diff --git a/classes/mapworker.php b/classes/mapworker.php index d7bf557..c4f151b 100644 --- a/classes/mapworker.php +++ b/classes/mapworker.php @@ -329,6 +329,15 @@ public function get_svgcode(): string { return $this->svgmap->get_svgcode(); } + /** + * Checks whether the processed map has a valid SVG root element. + * + * @return bool + */ + public function has_valid_root(): bool { + return $this->svgmap->has_valid_root(); + } + /** * Get attribute value (for unit testing) * diff --git a/classes/svgmap.php b/classes/svgmap.php index 14f9794..56fc7ae 100644 --- a/classes/svgmap.php +++ b/classes/svgmap.php @@ -30,6 +30,51 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class svgmap { + /** + * Namespace of SVG elements. + * @var string + */ + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + /** + * Elements that are removed from the SVG (compared case-insensitively by local name, regardless of prefix). + * A learning map never contains any of them. + * + * - Scripting and event handling: script, handler, listener. + * - Foreign content that the HTML parser treats as HTML or MathML: foreignobject, math. + * - Embedding of other documents: iframe, frame, frameset, embed, object, applet. + * - Document level elements: base, link, meta. + * - Animation elements that can set attributes (e.g. href) at runtime: animate, animatecolor, animatemotion, + * animatetransform, set. + * - HTML elements that break out of SVG when the map is printed inline into HTML (view.php). + * @var array + */ + const DENIED_ELEMENTS = [ + 'script', 'handler', 'listener', + 'foreignobject', 'math', + 'iframe', 'frame', 'frameset', 'embed', 'object', 'applet', + 'base', 'link', 'meta', + 'animate', 'animatecolor', 'animatemotion', 'animatetransform', 'set', + 'b', 'big', 'blockquote', 'body', 'br', 'center', 'code', 'dd', 'div', 'dl', 'dt', 'em', 'font', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'hr', 'i', 'img', 'li', 'listing', 'menu', 'nobr', 'ol', 'p', + 'pre', 'ruby', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tt', 'u', 'ul', 'var', + ]; + /** + * Attributes that contain a URL (compared case-insensitively by local name, regardless of prefix and namespace). + * Their values are checked by is_allowed_url(). + * + * - href: links, images and use elements (also xlink:href or any other prefix bound to XLink). + * - base: xml:base, which changes the base of relative URLs. + * - src, data, action, formaction: HTML attributes that load or submit to a URL. + * - from, to, values: animation values that can contain a URL. + * @var array + */ + const URL_ATTRIBUTES = ['href', 'base', 'src', 'data', 'action', 'formaction', 'from', 'to', 'values']; + /** + * URL schemes that are allowed in URL attributes. URLs without scheme (relative URLs, fragments like #checkmark + * and @@PLUGINFILE@@ placeholders) are always allowed, data URLs only for raster images (see is_allowed_url()). + * @var array + */ + const ALLOWED_URL_SCHEMES = ['http', 'https']; /** * DOMDocument for parsing the SVG * @var DOMDocument @@ -65,22 +110,221 @@ public function __construct(string $svgcode, array $placestore) { $this->svgcode = $svgcode; $this->placestore = $placestore; + $this->reset_dom(); + $this->load_dom(); + } + + /** + * Replaces the DOM by a new empty document. + * + * @return void + */ + protected function reset_dom(): void { $this->dom = new \DOMDocument('1.0', 'UTF-8'); $this->dom->preserveWhiteSpace = false; $this->dom->formatOutput = true; - - $this->load_dom(); $this->xpath = new \DOMXPath($this->dom); } /** - * Loads the code from svgcode attribute for DOM processing + * Loads the code from svgcode attribute for DOM processing and removes unsafe content. + * + * If the code cannot be parsed, svgcode and DOM are emptied (fail closed), so unparsed code is never returned. * * @return void */ public function load_dom(): void { $this->remove_tags_before_svg(); - $this->dom->loadXML($this->svgcode); + $loaded = false; + if (trim($this->svgcode) !== '') { + $loaded = $this->dom->loadXML($this->svgcode); + } + if ($loaded === false) { + // Fail closed: never keep unparsed code, as it would be returned unsanitized. + $this->svgcode = ''; + $this->reset_dom(); + } else { + // An existing DOMXPath does not see the newly loaded document. + $this->xpath = new \DOMXPath($this->dom); + $changed = $this->sanitize_dom(); + // Write back only if something was removed, so clean maps stay byte-identical. + if ($changed) { + $this->svgcode = $this->dom->saveXML(); + $this->remove_tags_before_svg(); + } + } + } + + /** + * Removes content from the DOM that could execute scripts in the browser. + * + * The map is rendered by an XML parser (course page) as well as by the HTML parser (inline in view.php), + * so names are compared case-insensitively and nodes that are interpreted differently by both parsers + * (comments, processing instructions, doctype) are removed and elements inside title and desc are unwrapped. + * + * @return bool true if the DOM was changed + */ + public function sanitize_dom(): bool { + $changed = false; + + // Collect nodes first and remove them afterwards, so the iteration is not affected by the removal. + $nodestoremove = []; + if ($this->dom->doctype !== null) { + $nodestoremove[] = $this->dom->doctype; + } + // Comments and processing instructions are ignored by the XML parser, but may contain markup for the HTML parser. + foreach ($this->xpath->query('//comment() | //processing-instruction()') as $node) { + $nodestoremove[] = $node; + } + foreach ($this->xpath->query('//*') as $element) { + // Compare the local name in lower case, as the HTML parser ignores case and prefix. + $name = strtolower($element->localName); + if (in_array($name, self::DENIED_ELEMENTS, true)) { + $nodestoremove[] = $element; + } + } + foreach ($nodestoremove as $node) { + if ($node->parentNode !== null) { + $node->parentNode->removeChild($node); + $changed = true; + } + } + + // Query again, so removed elements are not processed. + $elements = []; + foreach ($this->xpath->query('//*') as $element) { + $elements[] = $element; + } + foreach ($elements as $element) { + $name = strtolower($element->localName); + if ($name === 'title' || $name === 'desc') { + $textchanged = $this->unwrap_child_elements($element); + if ($textchanged) { + $changed = true; + } + } + $attributeschanged = $this->sanitize_attributes($element); + if ($attributeschanged) { + $changed = true; + } + } + return $changed; + } + + /** + * Unwraps all child elements of a title or desc element (recursively), so only their text remains. + * + * The HTML parser treats title and desc as HTML integration points, so elements inside them could be + * interpreted as HTML. Text and CDATA nodes are kept, CDATA sections never reach the browser, because + * mapworker::get_svgcode() converts them to escaped text. Comments and denied elements are already removed + * by sanitize_dom(). + * + * @param DOMElement $element title or desc element (or one of its child elements) + * @return bool true if the element was changed + */ + protected function unwrap_child_elements(DOMElement $element): bool { + $childelements = []; + foreach ($element->childNodes as $child) { + if ($child->nodeType === XML_ELEMENT_NODE) { + $childelements[] = $child; + } + } + foreach ($childelements as $child) { + // Unwrap nested elements first, then move the remaining text and CDATA nodes in front of the child. + $this->unwrap_child_elements($child); + while ($child->firstChild !== null) { + $element->insertBefore($child->firstChild, $child); + } + $element->removeChild($child); + } + return count($childelements) > 0; + } + + /** + * Removes event handler attributes and URL attributes with a URL that is not allowed. + * + * @param DOMElement $element + * @return bool true if an attribute was removed + */ + protected function sanitize_attributes(DOMElement $element): bool { + $attributestoremove = []; + foreach ($element->attributes as $attribute) { + // The local name ignores the prefix (e.g. any prefix bound to XLink), the node name is what the HTML parser sees. + $localname = strtolower($attribute->localName); + $nodename = strtolower($attribute->nodeName); + $iseventhandler = false; + if (str_starts_with($localname, 'on')) { + $iseventhandler = true; + } + if (str_starts_with($nodename, 'on')) { + $iseventhandler = true; + } + if ($iseventhandler) { + $attributestoremove[] = $attribute; + } else if (in_array($localname, self::URL_ATTRIBUTES, true)) { + $isallowed = self::is_allowed_url($attribute->value); + if (!$isallowed) { + $attributestoremove[] = $attribute; + } + } + } + foreach ($attributestoremove as $attribute) { + $element->removeAttributeNode($attribute); + } + return count($attributestoremove) > 0; + } + + /** + * Checks whether a URL is allowed in the map. + * + * Allowed are URLs without scheme (relative URLs, fragments, @@PLUGINFILE@@ placeholders), URLs with a scheme + * from ALLOWED_URL_SCHEMES and data URLs of raster images. Whitespace and control characters are removed before + * the check, as browsers ignore them inside the scheme. + * + * @param string $url + * @return bool + */ + public static function is_allowed_url(string $url): bool { + $allowed = false; + // Browsers ignore whitespace and control characters inside the scheme, so remove them before checking. + $withoutcontrolchars = preg_replace('/[\x00-\x20\x7F]+/', '', $url); + $normalized = strtolower($withoutcontrolchars); + $matches = []; + $hasscheme = preg_match('/^([a-z][a-z0-9+.\-]*):/', $normalized, $matches); + if ($hasscheme !== 1) { + // No scheme: relative URL, fragment or @@PLUGINFILE@@ placeholder. + $allowed = true; + } else { + $scheme = $matches[1]; + if (in_array($scheme, self::ALLOWED_URL_SCHEMES, true)) { + $allowed = true; + } else if ($scheme === 'data') { + $israsterimage = preg_match('/^data:image\/(png|jpeg|gif|webp)[;,]/', $normalized); + if ($israsterimage === 1) { + $allowed = true; + } + } + } + return $allowed; + } + + /** + * Checks whether the document has a valid SVG root element (svg element in SVG namespace without prefix). + * + * @return bool + */ + public function has_valid_root(): bool { + $valid = false; + $root = $this->dom->documentElement; + if ($root !== null) { + $isnamesvg = $root->localName === 'svg'; + $isnamespacesvg = $root->namespaceURI === self::SVG_NAMESPACE; + $hasprefix = !empty($root->prefix); + if ($isnamesvg && $isnamespacesvg && !$hasprefix) { + $valid = true; + } + } + return $valid; } /** @@ -128,6 +372,9 @@ public function remove_tags_before_svg(): void { /** * Returns the current svg code * + * The code may still contain CDATA sections. Output it only via mapworker::get_svgcode(), which converts them + * to escaped text. + * * @return string */ public function get_svgcode(): string { diff --git a/mod_form.php b/mod_form.php index ad9c789..959484f 100644 --- a/mod_form.php +++ b/mod_form.php @@ -326,6 +326,10 @@ public function data_postprocessing($data): void { ); $mapworker->replace_stylesheet(); $data->svgcode = $mapworker->get_svgcode(); + // Unparsable code is emptied by svgmap, don't save an empty map. + if (!$mapworker->has_valid_root()) { + throw new moodle_exception('invaliddata', 'error'); + } $data->svgcode = file_rewrite_urls_to_pluginfile( $data->svgcode, diff --git a/tests/mod_learningmap_svgmap_test.php b/tests/mod_learningmap_svgmap_test.php new file mode 100644 index 0000000..45f0944 --- /dev/null +++ b/tests/mod_learningmap_svgmap_test.php @@ -0,0 +1,607 @@ +. + +namespace mod_learningmap; + +use mod_learningmap\completion\custom_completion; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\DataProvider; + +#[CoversClass(svgmap::class)] +#[CoversClass(mapworker::class)] +#[CoversClass(\mod_learningmap_mod_form::class)] +#[CoversFunction('learningmap_get_learningmap')] +/** + * Unit tests for the sanitizing of the SVG code in mod_learningmap + * + * @package mod_learningmap + * @copyright 2026, ISB Bayern + * @author Thomas Schönlein + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_learningmap\svgmap + * @covers \mod_learningmap\mapworker + * @covers \mod_learningmap_mod_form + * @covers ::learningmap_get_learningmap + */ +final class mod_learningmap_svgmap_test extends \advanced_testcase { + /** + * Opening tag of the SVG root used in the tests + * @var string + */ + const SVG_OPEN = ''; + + /** + * Previous state of libxml_use_internal_errors() + * @var bool + */ + protected bool $previouslibxmlerrors; + + /** + * Collect libxml errors instead of emitting warnings (some tests use malformed XML on purpose). + */ + public function setUp(): void { + parent::setUp(); + $this->previouslibxmlerrors = libxml_use_internal_errors(true); + } + + /** + * Restore libxml error handling. + */ + public function tearDown(): void { + libxml_clear_errors(); + libxml_use_internal_errors($this->previouslibxmlerrors); + parent::tearDown(); + } + + /** + * Wraps content in an SVG root element. + * + * @param string $content + * @return string + */ + protected function wrap(string $content): string { + return self::SVG_OPEN . $content . ''; + } + + /** + * Parses SVG code for assertions. + * + * @param string $svgcode + * @return \DOMXPath + */ + protected function parse(string $svgcode): \DOMXPath { + $dom = new \DOMDocument(); + $dom->loadXML($svgcode); + return new \DOMXPath($dom); + } + + /** + * Returns the names of all elements in lower case. + * + * @param \DOMXPath $xpath + * @return array + */ + protected function get_element_names(\DOMXPath $xpath): array { + $names = []; + foreach ($xpath->query('//*') as $element) { + $names[] = strtolower($element->localName); + } + return $names; + } + + /** + * Returns the test fixture with injected unsafe content. + * + * @return string + */ + protected function get_unsafe_fixture(): string { + global $CFG; + $fixture = file_get_contents($CFG->dirroot . '/mod/learningmap/tests/generator/test.svg'); + $unsafe = '' . + '' . + '<style><!--</style><g id="hidden"/>--></style>'; + $position = strrpos($fixture, ''); + return substr($fixture, 0, $position) . $unsafe . substr($fixture, $position); + } + + /** + * Asserts that the SVG code contains no unsafe content of get_unsafe_fixture(). + * + * @param string $code + * @return void + */ + protected function assert_safe_output(string $code): void { + $lowercode = strtolower($code); + $this->assertStringNotContainsString('onerror', $lowercode); + $this->assertStringNotContainsString('assertStringNotContainsString('' . + $this->wrap( + '' . + '' + ); + $svgmap = new svgmap($svgcode, []); + $result = $svgmap->get_svgcode(); + $xpath = $this->parse($result); + $this->assertNull($xpath->document->doctype); + $this->assertEquals(0, $xpath->query('//comment()')->length); + $this->assertEquals(0, $xpath->query('//processing-instruction()')->length); + $this->assertStringNotContainsString('ENTITY', $result); + $this->assertEquals(1, $xpath->query('//*[@id="keep"]')->length); + } + + /** + * Data provider for test_title_desc. + * + * @return array + */ + public static function title_desc_provider(): array { + return [ + 'empty title stays empty' => ['', '', false], + 'empty self closing desc' => ['', '', false], + 'single text node stays' => ['Place 1', 'Place 1', false], + 'single cdata stays' => ['<![CDATA[Place & <b>1</b>]]>', 'Place & 1', false], + 'text and cdata stay' => ['a<![CDATA[<b>]]>', 'a', false], + 'element child is unwrapped' => ['a<tspan>b</tspan>c', 'abc', true], + 'nested elements are unwrapped' => ['a<tspan>x & <tspan>y</tspan></tspan>z', 'ax & yz', true], + 'cdata in child element is kept' => ['a<tspan><![CDATA[<b>]]></tspan>', 'a', true], + 'comment child is removed' => ['ab', 'ab', true], + 'empty element child is unwrapped' => ['<tspan></tspan>', '', true], + 'uppercase TITLE' => ['a<tspan>b</tspan>', 'ab', true], + 'mixed case Desc' => ['', 'a', true], + 'denied element inside is removed completely' => ['a<script>b</script>c', 'ac', true], + ]; + } + + #[DataProvider('title_desc_provider')] + /** + * Tests that title and desc contain no elements, text and CDATA of unwrapped elements are kept. + * + * @param string $markup + * @param string $expectedtext Expected text content + * @param bool $expectchange Whether the SVG code is expected to change + * @return void + * @dataProvider title_desc_provider + */ + public function test_title_desc(string $markup, string $expectedtext, bool $expectchange): void { + $svgcode = $this->wrap($markup); + $svgmap = new svgmap($svgcode, []); + $result = $svgmap->get_svgcode(); + $xpath = $this->parse($result); + $element = $xpath->query('//*[@id="t"]')->item(0); + $this->assertNotNull($element); + $this->assertEquals(0, $xpath->query('//*[@id="t"]/*')->length); + $this->assertEquals($expectedtext, $element->textContent); + if ($expectchange) { + $this->assertNotEquals($svgcode, $result); + } else { + $this->assertEquals($svgcode, $result); + } + } + + /** + * Data provider for test_title_desc_pipeline. Expected texts are the output of the pipeline before the fix. + * + * @return array + */ + public static function title_desc_pipeline_provider(): array { + return [ + 'mixed text and cdata' => ["before\n<![CDATA[A & B A <]]>", "before\nA & B A <"], + 'cdata in nested element' => ["a\n<tspan><![CDATA[x & y]]></tspan>", "a\nx & y"], + 'desc with quotes in cdata' => [ + "a\n b", + "a\n\"q\" 's' > b", + ], + 'markup in cdata stays text' => ["a\n<![CDATA[<b>x</b> & y]]>", "a\nx & y"], + 'single cdata' => ['<![CDATA[A & B A]]>', 'A & B A'], + 'plain text' => ['A & B', 'A & B'], + ]; + } + + #[DataProvider('title_desc_pipeline_provider')] + /** + * Tests that the text of title and desc is shown as before through the complete mapworker pipeline. + * + * @param string $markup + * @param string $expectedtext + * @return void + * @dataProvider title_desc_pipeline_provider + */ + public function test_title_desc_pipeline(string $markup, string $expectedtext): void { + $this->resetAfterTest(); + $mapworker = new mapworker($this->wrap($markup), []); + $xpath = $this->parse($mapworker->get_svgcode()); + $element = $xpath->query('//*[@id="t"]')->item(0); + $this->assertNotNull($element); + $this->assertEquals($expectedtext, $element->textContent); + $this->assertEquals(0, $xpath->query('//*[@id="t"]/*')->length); + } + + /** + * Tests that a second sanitizing run does not change an already sanitized document. + * + * @return void + */ + public function test_sanitizing_is_idempotent(): void { + $svgmap = new svgmap($this->wrap('a<tspan>b</tspan>'), []); + $this->assertFalse($svgmap->sanitize_dom()); + } + + /** + * Data provider for test_event_handlers. + * + * @return array + */ + public static function event_handler_provider(): array { + return [ + 'onload' => ['onload="marker()"'], + 'ONLOAD' => ['ONLOAD="marker()"'], + 'OnError' => ['OnError="marker()"'], + 'onclick' => ['onclick="marker()"'], + 'prefixed onclick' => ['xmlns:x="urn:marker" x:onclick="marker()"'], + 'prefix starting with on' => ['xmlns:onx="urn:marker" onx:attribute="marker()"'], + ]; + } + + #[DataProvider('event_handler_provider')] + /** + * Tests that event handler attributes are removed and other attributes are kept. + * + * @param string $attribute + * @return void + * @dataProvider event_handler_provider + */ + public function test_event_handlers(string $attribute): void { + $svgmap = new svgmap($this->wrap(''), []); + $element = $this->parse($svgmap->get_svgcode())->query('//*[@id="target"]')->item(0); + $names = []; + foreach ($element->attributes as $attr) { + $names[] = $attr->nodeName; + } + sort($names); + $this->assertEquals(['class', 'd', 'id'], $names); + } + + /** + * Data provider for test_url_attributes. Attribute names are listed explicitly (not taken from the constant). + * + * @return array + */ + public static function url_attribute_provider(): array { + $alias = 'xmlns:alias="http://www.w3.org/1999/xlink" '; + return [ + // Completeness: one blocked case per URL attribute. + 'href blocked' => ['href="javascript:void(0)"', false], + 'src blocked' => ['src="javascript:void(0)"', false], + 'data blocked' => ['data="javascript:void(0)"', false], + 'action blocked' => ['action="javascript:void(0)"', false], + 'formaction blocked' => ['formaction="javascript:void(0)"', false], + 'from blocked' => ['from="javascript:void(0)"', false], + 'to blocked' => ['to="javascript:void(0)"', false], + 'values blocked' => ['values="javascript:void(0)"', false], + // Prefix, namespace and case of the attribute name. + 'xlink:href blocked' => ['xlink:href="javascript:void(0)"', false], + 'alias prefix for xlink blocked' => [$alias . 'alias:href="javascript:void(0)"', false], + 'alias prefix for xlink allowed' => [$alias . 'alias:href="https://example.org/bg.png"', true], + 'xml:base blocked' => ['xml:base="javascript:void(0)"', false], + 'xml:base allowed' => ['xml:base="https://example.org/"', true], + 'HREF blocked' => ['HREF="javascript:void(0)"', false], + 'Src blocked' => ['Src="javascript:void(0)"', false], + // Normalization of the value. + 'uppercase scheme' => ['href="JAVASCRIPT:void(0)"', false], + 'mixed case scheme' => ['xlink:href="JavaScript:void(0)"', false], + 'leading space' => ['href=" javascript:void(0)"', false], + 'raw tab in scheme' => ["href=\"java\tscript:void(0)\"", false], + 'raw newline in scheme' => ["xlink:href=\"java\nscript:void(0)\"", false], + 'tab entity in scheme' => ['href="java script:void(0)"', false], + 'newline entity before scheme' => ['xlink:href=" javascript:void(0)"', false], + 'carriage return entity in scheme' => ['href="java script:void(0)"', false], + 'other scheme' => ['href="vbscript:marker"', false], + // Data URLs. + 'data svg' => ['xlink:href="data:image/svg+xml,%3Csvg/%3E"', false], + 'data svg base64' => ['xlink:href="data:image/svg+xml;base64,PHN2Zy8+"', false], + 'data svg uppercase' => ['xlink:href="DATA:IMAGE/SVG+XML,%3Csvg/%3E"', false], + 'data html' => ['href="data:text/html,marker"', false], + 'data png' => ['xlink:href="data:image/png;base64,iVBORw0KGgo="', true], + 'data jpeg' => ['xlink:href="data:image/jpeg;base64,/9j/4AAQ"', true], + 'data gif' => ['xlink:href="data:image/gif;base64,R0lGODlh"', true], + 'data webp' => ['xlink:href="data:image/webp;base64,UklGRg=="', true], + // Allowed URLs. + 'pluginfile placeholder' => ['xlink:href="@@PLUGINFILE@@/bg.png"', true], + 'pluginfile url' => [ + 'xlink:href="https://example.org/pluginfile.php/1/mod_learningmap/background/0/bg.png"', + true, + ], + 'fragment' => ['xlink:href="#checkmark"', true], + 'relative' => ['href="bg.png"', true], + 'relative with colon in path' => ['href="dir/a:b.png"', true], + 'http' => ['href="http://example.org/"', true], + 'https' => ['href="https://example.org/"', true], + 'scheme-relative' => ['href="//example.org/bg.png"', true], + 'scheme-relative xlink' => ['xlink:href="//example.org/bg.png"', true], + 'numeric values' => ['values="1 0 0 0 0"', true], + ]; + } + + #[DataProvider('url_attribute_provider')] + /** + * Tests the URL check of URL attributes. + * + * @param string $attribute + * @param bool $kept Whether the attribute is expected to be kept + * @return void + * @dataProvider url_attribute_provider + */ + public function test_url_attributes(string $attribute, bool $kept): void { + $svgcode = $this->wrap(''); + $svgmap = new svgmap($svgcode, []); + $result = $svgmap->get_svgcode(); + $element = $this->parse($result)->query('//*[@id="target"]')->item(0); + $this->assertNotNull($element); + if ($kept) { + $this->assertEquals(2, $element->attributes->length); + $this->assertEquals($svgcode, $result); + } else { + $this->assertEquals(1, $element->attributes->length); + } + } + + /** + * Tests that malformed code leads to an empty map and that following operations do not fail. + * + * @return void + */ + public function test_fail_closed(): void { + $this->resetAfterTest(); + $svgmap = new svgmap($this->wrap(''), []); + $this->assertFalse($svgmap->has_valid_root()); + $this->assertEquals('', $svgmap->get_svgcode()); + $svgmap->replace_stylesheet(); + $svgmap->replace_cdata(); + $svgmap->save_svg_data(); + $svgmap->load_dom(); + $this->assertFalse($svgmap->has_valid_root()); + $this->assertStringNotContainsString('get_svgcode()); + + $mapworker = new mapworker($this->wrap(''), []); + $mapworker->replace_stylesheet(); + $this->assertStringNotContainsString('get_svgcode()); + $this->assertFalse($mapworker->has_valid_root()); + } + + /** + * Data provider for test_has_valid_root. + * + * @return array + */ + public static function root_provider(): array { + return [ + 'empty' => ['', false], + 'only whitespace' => ["\n ", false], + 'only xml declaration' => ['', false], + 'svgjunk element' => ['', false], + 'wrapper with nested svg' => [ + '', + false, + ], + 'svg without namespace' => ['', false], + 'svg with wrong namespace' => ['', false], + 'prefixed svg root' => ['', false], + 'uppercase SVG root' => ['', false], + 'forbidden control character' => [ + '', + false, + ], + 'valid root' => ['', true], + 'valid root with declaration' => ['', true], + ]; + } + + #[DataProvider('root_provider')] + /** + * Tests the structural check of the SVG root. + * + * @param string $svgcode + * @param bool $expected + * @return void + * @dataProvider root_provider + */ + public function test_has_valid_root(string $svgcode, bool $expected): void { + $svgmap = new svgmap($svgcode, []); + $this->assertEquals($expected, $svgmap->has_valid_root()); + } + + /** + * Tests that legitimate maps (test fixture and fresh skeleton) are not changed. + * + * @return void + */ + public function test_legitimate_maps_unchanged(): void { + global $CFG, $OUTPUT; + $fixture = file_get_contents($CFG->dirroot . '/mod/learningmap/tests/generator/test.svg'); + $svgmap = new svgmap($fixture, []); + $this->assertEquals($fixture, $svgmap->get_svgcode()); + $this->assertTrue($svgmap->has_valid_root()); + $this->assertFalse($svgmap->sanitize_dom()); + + $skeleton = $OUTPUT->render_from_template('mod_learningmap/svgskeleton', ['editmode' => true, 'mapid' => 'abc']); + $svgmap = new svgmap($skeleton, []); + $this->assertEquals($skeleton, $svgmap->get_svgcode()); + $this->assertTrue($svgmap->has_valid_root()); + } + + /** + * Tests that unsafe content already stored in the database is removed when rendering the map, when rendering + * a legacy map from the intro and when preparing the editing form defaults (data_preprocessing()). + * + * @return void + */ + public function test_existing_data(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + [$course, $learningmap, $cm] = $this->create_learningmap(); + $unsafe = $this->get_unsafe_fixture(); + + $DB->set_field('learningmap', 'svgcode', $unsafe, ['id' => $learningmap->id]); + $output = learningmap_get_learningmap($cm); + $this->assertStringContainsString('assert_safe_output($output); + + $placestore = json_decode($learningmap->placestore, true); + $mapworker = new mapworker($unsafe, $placestore, $cm, true); + $mapworker->process_map_objects(); + $mapworker->replace_stylesheet(); + $code = $mapworker->get_svgcode(); + $this->assertTrue($mapworker->has_valid_root()); + $this->assert_safe_output($code); + $this->assertStringContainsString('id="injected"', $code); + + $DB->set_field('learningmap', 'svgcode', '', ['id' => $learningmap->id]); + $DB->set_field('learningmap', 'intro', $unsafe, ['id' => $learningmap->id]); + $output = learningmap_get_learningmap($cm); + $this->assertStringContainsString('assert_safe_output($output); + } + + /** + * Tests that data_postprocessing() stores sanitized code and does not save a map that cannot be processed. + * + * @return void + */ + public function test_data_postprocessing(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + [$course, $learningmap, $cm] = $this->create_learningmap(); + $form = $this->create_form($cm, $course); + + $data = new \stdClass(); + $data->svgcode = $this->get_unsafe_fixture(); + $data->placestore = json_encode(['mapid' => 'abc']); + $data->backgroundfile = 0; + $form->data_postprocessing($data); + $this->assertStringContainsString('svgcode); + $this->assert_safe_output($data->svgcode); + + // Valid XML that is broken by the greedy regex in replace_stylesheet(). + $data = new \stdClass(); + $data->svgcode = $this->wrap('a style> b'); + $data->placestore = json_encode(['mapid' => 'abc']); + $data->backgroundfile = 0; + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('invaliddata', 'error')); + $form->data_postprocessing($data); + } +}