diff --git a/src/Control/HTTP.php b/src/Control/HTTP.php index b576bd56ce6..2ac576e61a0 100644 --- a/src/Control/HTTP.php +++ b/src/Control/HTTP.php @@ -20,6 +20,7 @@ class HTTP * Set to true to disable all deprecated HTTP Cache settings * * @var bool + * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it. */ private static $ignoreDeprecatedCaching = false; diff --git a/src/Core/XssSanitiser.php b/src/Core/XssSanitiser.php new file mode 100644 index 00000000000..5ffdb224b95 --- /dev/null +++ b/src/Core/XssSanitiser.php @@ -0,0 +1,213 @@ +sanitiseHtmlValue($htmlValue); + return $htmlValue->getContent(); + } + + /** + * Remove XSS attack vectors from HTMLValue content + */ + public function sanitiseHtmlValue(HTMLValue $html): void + { + foreach ($html->query('//*') as $element) { + if (!is_a($element, DOMElement::class)) { + continue; + } + $this->sanitiseElement($element); + } + } + + /** + * Remove XSS attack vectors from a DOMElement + */ + public function sanitiseElement(DOMElement $element): void + { + // Remove elements first - if we remove the element, we don't have any attributes to check so exit early + $removed = $this->stripElement($element); + if ($removed) { + return; + } + $this->stripAttributes($element); + $this->stripAttributeContents($element); + } + + /** + * Get the names of elements which will be removed. + */ + public function getElementsToRemove(): array + { + return $this->elementsToRemove; + } + + /** + * Set the names of elements which will be removed. + * Note that allowing the elements which are included in the default list could result in XSS vulnerabilities. + */ + public function setElementsToRemove(array $elements): static + { + $this->elementsToRemove = $elements; + return $this; + } + + /** + * Get the names of attributes which will be removed from any elements that have them. + */ + public function getAttributesToRemove(): array + { + return $this->attributesToRemove; + } + + /** + * Set the names of attributes which will be removed from any elements that have them. + * Note that allowing the attributes which are included in the default list could result in XSS vulnerabilities. + */ + public function setAttributesToRemove(array $attributes): static + { + $this->attributesToRemove = $attributes; + return $this; + } + + /** + * Get whether the inner contents of an element will be kept for elements that get removed. + */ + public function getKeepInnerHtmlOnRemoveElement(): bool + { + return $this->keepInnerHtmlOnRemoveElement; + } + + /** + * Set whether to keep the inner contents of an element if it gets removed. + */ + public function setKeepInnerHtmlOnRemoveElement(bool $keep): static + { + $this->keepInnerHtmlOnRemoveElement = $keep; + return $this; + } + + /** + * If $element is one of the elements in $elementsToRemove, replace it + * with a text node. + */ + private function stripElement(DOMElement $element): bool + { + if (!in_array($element->tagName, $this->getElementsToRemove())) { + return false; + } + // Make sure we don't remove any child nodes + $parentNode = $element->parentNode; + if ($this->getKeepInnerHtmlOnRemoveElement() && $parentNode && $element->hasChildNodes()) { + // We can't just iterate through $node->childNodes because that seems to skip some children + while ($element->hasChildNodes()) { + $parentNode->insertBefore($element->firstChild, $element); + } + } + $element->remove(); + return true; + } + + /** + * Remove all attributes in $attributesToRemove from the element. + */ + private function stripAttributes(DOMElement $element): void + { + $attributesToRemove = $this->getAttributesToRemove(); + if (empty($attributesToRemove)) { + return; + } + $attributes = $element->attributes; + for ($i = count($attributes) - 1; $i >= 0; $i--) { + /** @var DOMAttr $attr */ + $attr = $attributes->item($i); + foreach ($attributesToRemove as $toRemove) { + if (str_starts_with($toRemove, '*') && str_ends_with($attr->name, str_replace('*', '', $toRemove))) { + $element->removeAttributeNode($attr); + } elseif (str_ends_with($toRemove, '*') && str_starts_with($attr->name, str_replace('*', '', $toRemove))) { + $element->removeAttributeNode($attr); + } elseif (!str_contains($toRemove, '*') && $attr->name === $toRemove) { + $element->removeAttributeNode($attr); + } + } + } + } + + /** + * Strip out attributes which have dangerous content which might otherwise execute javascript. + * This is content that we will always remove regardless of whether the attributes and elements in question + * are otherwise allowed, e.g. via WYSIWYG configuration. + */ + private function stripAttributeContents(DOMElement $element): void + { + $regex = $this->getStripAttributeContentsRegex(); + foreach (['lowsrc', 'src', 'href', 'data'] as $dangerAttribute) { + if ($element->hasAttribute($dangerAttribute)) { + $attrContent = $element->getAttribute($dangerAttribute); + if (preg_match($regex, $attrContent)) { + $element->removeAttribute($dangerAttribute); + } + } + } + } + + private function getStripAttributeContentsRegex(): string + { + $regexes = [ + $this->splitWithWhitespaceRegex('javascript:'), + $this->splitWithWhitespaceRegex('data:text/html'), + $this->splitWithWhitespaceRegex('vbscript:'), + ]; + // Regex is "starts with any of these, with optional whitespace at the start, case insensitive" + return '#^\s*(' . implode('|', $regexes) . ')#iu'; + } + + private function splitWithWhitespaceRegex(string $string): string + { + // Note that `\s` explicitly includes ALL invisible characters when used with the `u` modifier. + // That includes unicode characters like the non-breaking space. + return implode('\s*', str_split($string)); + } +} diff --git a/src/Dev/Debug.php b/src/Dev/Debug.php index d1db47671dc..939bd2de5b5 100644 --- a/src/Dev/Debug.php +++ b/src/Dev/Debug.php @@ -164,16 +164,20 @@ protected static function supportsHTML(?HTTPRequest $request = null) if (Director::is_cli()) { return false; } + $accepted = []; // Get current request if registered if (!$request && Injector::inst()->has(HTTPRequest::class)) { $request = Injector::inst()->get(HTTPRequest::class); } - if (!$request) { - return false; + if ($request) { + $accepted = $request->getAcceptMimetypes(false); + } elseif (isset($_SERVER['HTTP_ACCEPT'])) { + // If there's no request object available, fallback to global $_SERVER + // This can happen in some circumstances when a PHP error is triggered + // during a regular HTTP request + $accepted = preg_split('#\s*,\s*#', $_SERVER['HTTP_ACCEPT']); } - // Request must include text/html - $accepted = $request->getAcceptMimetypes(false); // Explicit opt in if (in_array('text/html', $accepted ?? [])) { diff --git a/src/Forms/FormMessage.php b/src/Forms/FormMessage.php index 2ac83c9fd33..92c6dfa81bd 100644 --- a/src/Forms/FormMessage.php +++ b/src/Forms/FormMessage.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Model\ModelData; +use SilverStripe\Core\XssSanitiser; /** * Form component which contains a castable message @@ -33,6 +34,7 @@ trait FormMessage /** * Returns the field message, used by form validation. + * If the current cast is ValidationResult::CAST_HTML, the message will be sanitised. * * Use {@link setError()} to set this property. * @@ -40,7 +42,11 @@ trait FormMessage */ public function getMessage() { - return $this->message; + $message = $this->message; + if ($this->getMessageCast() === ValidationResult::CAST_HTML) { + $message = XssSanitiser::create()->sanitiseString($message); + } + return $message; } /** diff --git a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php index 61b1d323605..34e060412f4 100644 --- a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php +++ b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php @@ -568,13 +568,13 @@ public function doSave($data, $form) $this->saveFormIntoRecord($data, $form); $link = '"' - . htmlspecialchars($this->record->Title ?? '', ENT_QUOTES) + . Convert::raw2xml($this->record->Title ?? '', ENT_QUOTES) . '"'; $message = _t( 'SilverStripe\\Forms\\GridField\\GridFieldDetailForm.Saved', 'Saved {name} {link}', [ - 'name' => $this->getModelName(), + 'name' => Convert::raw2xml($this->getModelName()), 'link' => $link ] ); @@ -835,8 +835,8 @@ public function doDelete($data, $form) 'SilverStripe\\Forms\\GridField\\GridFieldDetailForm.Deleted', 'Deleted {type} "{name}"', [ - 'type' => $this->getModelName(), - 'name' => $this->record->Title + 'type' => Convert::raw2xml($this->getModelName()), + 'name' => Convert::raw2xml($this->record->Title) ] ); diff --git a/src/Forms/HTMLEditor/HTMLEditorSanitiser.php b/src/Forms/HTMLEditor/HTMLEditorSanitiser.php index c056bca10f3..49eda400afa 100644 --- a/src/Forms/HTMLEditor/HTMLEditorSanitiser.php +++ b/src/Forms/HTMLEditor/HTMLEditorSanitiser.php @@ -6,6 +6,7 @@ use DOMElement; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; +use SilverStripe\Core\XssSanitiser; use SilverStripe\View\Parsers\HTMLValue; use stdClass; @@ -289,6 +290,10 @@ public function sanitise(HTMLValue $html) { $linkRelValue = $this->config()->get('link_rel_value'); $doc = $html->getDocument(); + // Get a sanitiser but don't deny any specific attributes or elements, since that's + // handled as part of the element rules. + $xssSanitiser = XssSanitiser::create(); + $xssSanitiser->setElementsToRemove([])->setAttributesToRemove([]); /** @var DOMElement $el */ foreach ($html->query('//body//*') as $el) { @@ -342,16 +347,8 @@ public function sanitise(HTMLValue $html) $el->setAttribute($attr, $forced); } - // Matches "javascript:" with any arbitrary linebreaks inbetween the characters. - $regex = '#^\s*(' . implode('\s*', str_split('javascript:')) . '|' . implode('\s*', str_split('data:text/html;')) . ')#i'; - // Strip out javascript execution in href or src attributes. - foreach (['src', 'href', 'data'] as $dangerAttribute) { - if ($el->hasAttribute($dangerAttribute)) { - if (preg_match($regex, $el->getAttribute($dangerAttribute))) { - $el->removeAttribute($dangerAttribute); - } - } - } + // Explicit XSS sanitisation for anything that there's really no sensible use case for in a WYSIWYG + $xssSanitiser->sanitiseElement($el); } if ($el->tagName === 'a' && $linkRelValue !== null) { diff --git a/src/View/Shortcodes/EmbedShortcodeProvider.php b/src/View/Shortcodes/EmbedShortcodeProvider.php index ed23d778cae..53170e5654a 100644 --- a/src/View/Shortcodes/EmbedShortcodeProvider.php +++ b/src/View/Shortcodes/EmbedShortcodeProvider.php @@ -6,6 +6,7 @@ use Embed\Http\RequestException; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException; +use RuntimeException; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; use SilverStripe\Model\List\ArrayList; @@ -28,6 +29,31 @@ class EmbedShortcodeProvider implements ShortcodeHandler { use Configurable; + /** + * Domains to exclude from sandboxing content in an iframe + * This will also exclude any subdomains + * e.g. if 'example.com' is excluded then 'www.example.com' will also be excluded + * Do not include the protocol in the domain i.e. exclude the leading https:// + */ + private static array $domains_excluded_from_sandboxing = []; + + /** + * Attributes to add to the iframe when sandboxing + * Note that the 'src' attribute cannot be set via config + * If a style attribute is set via config, width and height values will be overriden by + * any shortcode width and height arguments + */ + private static array $sandboxed_iframe_attributes = []; + + /** + * The url of the last extractor used + * This is used instead of adding a new param to an existing method + * which would be backwards incompatible + * + * @internal + */ + private static string $extractorUrl = ''; + /** * Gets the list of shortcodes provided by this handler * @@ -140,6 +166,7 @@ public static function embeddableToHtml(Embeddable $embeddable, array $arguments return ''; } $extractor = $embeddable->getExtractor(); + EmbedShortcodeProvider::$extractorUrl = (string) $extractor->url; $type = $embeddable->getType(); if ($type === 'video' || $type === 'rich') { // Attempt to inherit width (but leave height auto) @@ -194,6 +221,7 @@ protected static function videoEmbed($arguments, $content) ])); } + $content = EmbedShortcodeProvider::sandboxHtml($content, $arguments); $data = [ 'Arguments' => $arguments, 'Attributes' => $attributes, @@ -342,4 +370,72 @@ private static function cleanKeySegment(string $str): string { return preg_replace('/[^a-zA-Z0-9\-]/', '', $str ?? ''); } + + /** + * Wrap potentially dangerous html embeds in an iframe to sandbox them + * Potentially dangerous html embeds would could be those that contain + // and + // If there's more than 2 HTML tags then sandbox it + if (substr_count($html, '<') <= 2) { + return $html; + } + } + // Sandbox the html in an iframe + $style = ''; + if (!empty($arguments['width'])) { + $style .= 'width:' . intval($arguments['width']) . 'px;'; + } + if (!empty($arguments['height'])) { + $style .= 'height:' . intval($arguments['height']) . 'px;'; + } + $attrs = array_merge([ + 'frameborder' => '0', + ], static::config()->get('sandboxed_iframe_attributes')); + $attrs['src'] = 'data:text/html;charset=utf-8,' . rawurlencode($html); + if (array_key_exists('style', $attrs)) { + $attrs['style'] .= ";$style"; + $attrs['style'] = ltrim($attrs['style'], ';'); + } else { + $attrs['style'] = $style; + } + $html = HTML::createTag('iframe', $attrs); + return $html; + } + + /** + * Check if the domain is excluded from sandboxing based on config + */ + private static function domainIsExcludedFromSandboxing(): bool + { + $domain = (string) parse_url(EmbedShortcodeProvider::$extractorUrl, PHP_URL_HOST); + $config = static::config()->get('domains_excluded_from_sandboxing'); + foreach ($config as $excluded) { + if (!is_string($excluded)) { + throw new RuntimeException('domains_excluded_from_sandboxing must be an array of strings'); + } + $excludedDomain = parse_url($excluded, PHP_URL_HOST); + if (!$excludedDomain) { + // Try adding a protocol so that parse_url can parse it + $excludedDomain = parse_url('http://' . $excluded, PHP_URL_HOST); + } + if (!$excludedDomain) { + throw new RuntimeException('Not a valid domain: ' . $excluded); + } + if (str_ends_with($domain, $excludedDomain)) { + return true; + } + } + return false; + } } diff --git a/tests/php/Core/XssSanitiserTest.php b/tests/php/Core/XssSanitiserTest.php new file mode 100644 index 00000000000..0d1ef959447 --- /dev/null +++ b/tests/php/Core/XssSanitiserTest.php @@ -0,0 +1,463 @@ + '', + 'expected' => '', + ], + [ + 'input' => 'hello world', + 'expected' => 'hello world', + ], + [ + 'input' => '<hello world>', + 'expected' => '<hello world>', + ], + [ + 'input' => '< Hello', + 'expected' => ' Hello', + ], + [ + 'input' => 'Lorem & Ipsum', + 'expected' => 'Lorem & Ipsum', + ], + // Unknown tag + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + // Scripts + [ + 'input' => '', + 'expected' => 'alert(\'ok\');', + ], + [ + 'input' => 'javascript:/*-->', + 'expected' => 'javascript:/*-->', + ], + [ + // Not exploitable XSS + 'input' => 'ipt>alert(1)', + 'expected' => 'ipt>alert(1)', + ], + [ + // Not exploitable XSS + 'input' => 'ipt>alert(1)', + 'expected' => 'ipt>alert(1)', + ], + [ + // Not exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
', + 'expected' => '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.alert(\'ok\');
', + ], + [ + 'input' => 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', + 'expected' => 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', + ], + [ + // Not exploitable XSS + 'input' => '<a href="javascript:evil"/>', + 'expected' => 'a href="javascript:evil"/>', + ], + [ + 'input' => 'Test', + 'expected' => 'Test', + ], + [ + 'input' => 'Test', + 'expected' => 'Test', + ], + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + // Note this includes U+200A, U+202F, U+205F, U+2000, U+2001, U+2002, U+2003, U+2004, U+2005, U+2006, U+2007, U+2008, U+2009, U+3000 + 'input' => "Lorem ipsum", + 'expected' => 'Lorem ipsum', + ], + [ + // Not exploitable XSS + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + 'input' => 'Test', + 'expected' => 'Test', + ], + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + // Not exploitable XSS + 'input' => '
', + 'expected' => '
', + ], + [ + // Not exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '<iframe src="javascript:evil"/>', + 'expected' => 'iframe src="javascript:evil"/>', + ], + [ + // Not exploitable XSS + 'input' => '<img src="javascript:evil"/>', + 'expected' => 'img src="javascript:evil"/>', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '"\>', + 'expected' => 'alert("XSS")"\>', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + // decodes to `javascript:alert('XSS')` + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + // Decodes to a SVG with `` inside it + // But that's not actually exploitable XSS + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => 'Image alternative text', + 'expected' => 'Image alternative text', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '
', + 'expected' => '
', + ], + [ + 'input' => '

', + 'expected' => '

', + ], + + [ + 'input' => '', + 'expected' => '', + ], + [ + // Decodes to a SVG with `` inside it + 'input' => '', + 'expected' => '', + ], + [ + // Not exploitable XSS + 'input' => '!!', + 'expected' => '!!', + ], + [ + 'input' => '!!', + 'expected' => '!!', + ], + [ + 'input' => '">"@x.y', + 'expected' => '">"@x.y', + ], + [ + 'input' => '
onetwothree
', + 'expected' => '
one2twothree
', + ], + // Styles + [ + 'input' => '', + 'expected' => 'body { background: red; }', + ], + [ + 'input' => '
Lorem ipsum dolor sit amet, consectetur.
', + 'expected' => '
Lorem ipsum dolor sit amet, consectetur.body { background: red; }
', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => 'Lorem ipsum dolor sit amet, consectetur.', + 'expected' => 'Lorem ipsum dolor sit amet, consectetur.', + ], + // Comments + [ + // Not exploitable XSS + 'input' => 'Lorem ipsum dolor sit amet, consectetur', + 'expected' => 'Lorem ipsum dolor sit amet, consectetur', + ], + [ + 'input' => 'Lorem ipsum ', + 'expected' => 'Lorem ipsum ', + ], + // Normal tags (just checking they don't get mangled) + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + 'input' => 'Lorem ipsum', + 'expected' => 'Lorem ipsum', + ], + [ + 'input' => 'Image alternative text', + 'expected' => 'Image alternative text', + ], + [ + 'input' => 'Image alternative text', + 'expected' => 'Image alternative text', + ], + [ + 'input' => '', + 'expected' => '', + ], + [ + 'input' => '
onetwothree
', + 'expected' => '
onetwothree
', + ], + ]; + } + + /** + * @dataProvider provideSanitise + */ + public function testSanitiseString(string $input, string $expected): void + { + $sanitiser = new XssSanitiser(); + $this->assertSame($expected, $sanitiser->sanitiseString($input)); + } + + /** + * @dataProvider provideSanitise + */ + public function testSanitiseHtmlValue(string $input, string $expected): void + { + $sanitiser = new XssSanitiser(); + $htmlValue = new HTMLValue($input); + $sanitiser->sanitiseHtmlValue($htmlValue); + $this->assertSame($expected, $htmlValue->getContent()); + } + + /** + * @dataProvider provideSanitise + */ + public function testSanitiseElement(string $input, string $expected): void + { + $sanitiser = new XssSanitiser(); + $htmlValue = new HTMLValue($input); + foreach ($htmlValue->query('//*') as $element) { + if (!is_a($element, DOMElement::class)) { + continue; + } + $element = $sanitiser->sanitiseElement($element); + } + $this->assertSame($expected, $htmlValue->getContent()); + } + + public function provideSanitiseElementsAllowed(): array + { + return [ + 'disallow these by default' => [ + 'input' => '', + 'removeElements' => null, + 'expected' => 'alert("one");', + ], + 'allow all' => [ + 'input' => '', + 'removeElements' => [], + 'expected' => '', + ], + 'disallow circle' => [ + 'input' => '', + 'removeElements' => ['circle'], + 'expected' => '', + ], + ]; + } + + /** + * @dataProvider provideSanitiseElementsAllowed + */ + public function testSanitiseElementsAllowed(string $input, ?array $removeElements, string $expected): void + { + $sanitiser = new XssSanitiser(); + if ($removeElements !== null) { + $sanitiser->setElementsToRemove($removeElements); + } + $this->assertSame($expected, $sanitiser->sanitiseString($input)); + } + + public function provideSanitiseAttributesAllowed(): array + { + return [ + 'disallow these by default' => [ + 'input' => 'abcd', + 'removeAttributes' => null, + 'expected' => 'abcd', + ], + 'allow all' => [ + 'input' => 'abcd', + 'removeAttributes' => [], + 'expected' => 'abcd', + ], + 'disallow class' => [ + 'input' => 'abcd', + 'removeAttributes' => ['class'], + 'expected' => 'abcd', + ], + 'wildcard attributes' => [ + 'input' => 'abcd', + 'removeAttributes' => [ + 'cla*', + '*tle', + // this one specifically won't do anything + 'di*ed', + ], + 'expected' => 'abcd', + ], + // Not sure why you'd do this, but this functionality is a natural consequence of how `*something` and `something*` are implemented. + 'remove all attributes' => [ + 'input' => 'abcd', + 'removeAttributes' => [ + '*', + ], + 'expected' => 'abcd', + ], + ]; + } + + /** + * @dataProvider provideSanitiseAttributesAllowed + */ + public function testSanitiseAttributesAllowed(string $input, ?array $removeAttributes, string $expected): void + { + $sanitiser = new XssSanitiser(); + if ($removeAttributes !== null) { + $sanitiser->setAttributesToRemove($removeAttributes); + } + $this->assertSame($expected, $sanitiser->sanitiseString($input)); + } + + public function provideSanitiseNoKeepInnerHtml(): array + { + return [ + 'keeps inner html' => [ + 'input' => '
something first
Keep thisand this
something last
', + 'keepInnerHtml' => true, + 'expected' => '
something firstKeep thisand thissomething last
', + ], + 'discards inner html' => [ + 'input' => '
something first
Keep thisand this
something last
', + 'keepInnerHtml' => false, + 'expected' => '
something firstsomething last
', + ], + 'multiple and nested disallowed elements (keep inner html)' => [ + 'input' => '
something
nested
nested2
last
', + 'keepInnerHtml' => true, + 'expected' => '
somethingnested nested2last
', + ], + 'multiple and nested disallowed elements (discard inner html)' => [ + 'input' => '
something
nested
nested2
last
', + 'keepInnerHtml' => false, + 'expected' => '
somethinglast
', + ], + ]; + } + + /** + * @dataProvider provideSanitiseNoKeepInnerHtml + */ + public function testSanitiseNoKeepInnerHtml(string $input, bool $keepInnerHtml, string $expected): void + { + $sanitiser = new XssSanitiser(); + $sanitiser->setElementsToRemove(['div'])->setKeepInnerHtmlOnRemoveElement($keepInnerHtml); + $this->assertSame($expected, $sanitiser->sanitiseString($input)); + } +} diff --git a/tests/php/Forms/FormMessageTest.php b/tests/php/Forms/FormMessageTest.php new file mode 100644 index 00000000000..02f689a7378 --- /dev/null +++ b/tests/php/Forms/FormMessageTest.php @@ -0,0 +1,100 @@ + [ + 'message' => '', + 'type' => '', + 'casting' => ValidationResult::CAST_HTML, + 'expected' => '', + ], + 'empty plain text' => [ + 'message' => '', + 'type' => '', + 'casting' => ValidationResult::CAST_TEXT, + 'expected' => '', + ], + 'plain HTML' => [ + 'message' => 'just some text', + 'type' => '', + 'casting' => ValidationResult::CAST_HTML, + 'expected' => 'just some text', + ], + 'plain plain text' => [ + 'message' => 'just some text', + 'type' => '', + 'casting' => ValidationResult::CAST_TEXT, + 'expected' => 'just some text', + ], + 'HTML in HTML' => [ + 'message' => '
link
', + 'type' => '', + 'casting' => ValidationResult::CAST_HTML, + 'expected' => '
link
', + ], + 'HTML in plain text' => [ + 'message' => '
link
', + 'type' => '', + 'casting' => ValidationResult::CAST_TEXT, + 'expected' => '
link
', + ], + 'Type doesnt matter HTML' => [ + 'message' => '
link
', + 'type' => 'an arbitrary string here', + 'casting' => ValidationResult::CAST_HTML, + 'expected' => '
link
', + ], + 'Type doesnt matter text' => [ + 'message' => '
link
', + 'type' => 'an arbitrary string here', + 'casting' => ValidationResult::CAST_TEXT, + 'expected' => '
link
', + ], + ]; + } + + /** + * Test that getMessage() generally works and calls the sanitiser as appropriate. + * Note we don't actually test the sanitisation here, as that is handled by the sanitiser's unit tests. + * @dataProvider provideGetMessage + */ + public function testGetMessage(string $message, string $type, string $casting, string $expected): void + { + $mockSanitiserClass = get_class(new class extends XssSanitiser { + public static int $called = 0; + public function sanitiseString(string $html): string + { + static::$called++; + return parent::sanitiseString($html); + } + }); + Injector::inst()->load([ + XssSanitiser::class => [ + 'class' => $mockSanitiserClass, + ], + ]); + $expectedSanitisationCount = $casting === ValidationResult::CAST_HTML ? 1 : 0; + + try { + $formMessage = new TestFormMessage(); + $formMessage->setMessage($message, $type, $casting); + $this->assertSame($expected, $formMessage->getMessage()); + $this->assertSame($expectedSanitisationCount, $mockSanitiserClass::$called); + } finally { + $mockSanitiserClass::$called = 0; + } + } +} diff --git a/tests/php/Forms/FormMessageTest/TestFormMessage.php b/tests/php/Forms/FormMessageTest/TestFormMessage.php new file mode 100644 index 00000000000..a77dfcd347b --- /dev/null +++ b/tests/php/Forms/FormMessageTest/TestFormMessage.php @@ -0,0 +1,14 @@ +assertEqualIgnoringWhitespace( <<bird

Birdy

+

Birdy

EOT, $html ); @@ -217,4 +221,211 @@ public function testOnlyWhitelistedAttributesAllowed() $html ); } + + public function provideSandboxHtml(): array + { + return [ + 'normal' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '', + ], + 'normal-with-attrs' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => 'Some content', + 'attrs' => [ + 'frameborder' => '1', + 'style' => 'width:200px;height:200px', + 'data-something' => 'lorem' + ], + 'exception' => false, + 'expected' => '
', + ], + 'excluded' => [ + 'url' => 'http://example.com/embed', + 'excluded' => ['example.com'], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '
Some content
', + ], + 'subdomain-excluded' => [ + 'url' => 'http://sub.example.com/embed', + 'excluded' => ['example.com'], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '
Some content
', + ], + 'config-includes-protocol' => [ + 'url' => 'http://example.com/embed', + 'excluded' => ['http://example.com'], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '
Some content
', + ], + 'config-includes-wrong-protocol' => [ + 'url' => 'https://example.com/embed', + 'excluded' => ['http://example.com'], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '
Some content
', + ], + 'umatched-config' => [ + 'url' => 'https://example.com/embed', + 'excluded' => ['somewhere.com'], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => false, + 'expected' => '
', + ], + 'invalid-config' => [ + 'url' => 'https://example.com/embed', + 'excluded' => [123], + 'html' => 'Some content', + 'attrs' => [], + 'exception' => true, + 'expected' => '', + ], + 'iframe' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => '', + 'attrs' => [], + 'exception' => false, + 'expected' => '
', + ], + 'iframe-short' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => '', + ], + 'iframe-whitespace-in-tags' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => '', + 'attrs' => [], + 'exception' => false, + 'expected' => '
', + ], + 'iframe-with-content-inside' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => '', + 'attrs' => [], + 'exception' => false, + 'expected' => '', + ], + 'closed-iframe' => [ + 'url' => 'http://example.com/embed', + 'excluded' => [], + 'html' => '', + 'attrs' => [], + 'exception' => false, + 'expected' => '
', + ], + 'malicious-iframe-1' => [ + 'url' => 'https://example.com/embed', + 'excluded' => [], + 'html' => 'bad', + 'attrs' => [], + 'exception' => false, + 'expected' => '', + ], + 'malicious-iframe-2' => [ + 'url' => 'https://example.com/embed', + 'excluded' => [], + 'html' => 'bad', + 'attrs' => [], + 'exception' => false, + 'expected' => '
', + ], + ]; + } + + /** + * @dataProvider provideSandboxHtml + */ + public function testSandboxHtml( + string $url, + array $excluded, + string $html, + array $attrs, + bool $exception, + string $expected + ): void { + if ($exception) { + $this->expectException(RuntimeException::class); + } + $embeddable = $this->getEmbeddable($url, $html); + $attributes = ['width' => 100]; + EmbedShortcodeProvider::config()->set('domains_excluded_from_sandboxing', $excluded); + EmbedShortcodeProvider::config()->set('sandboxed_iframe_attributes', $attrs); + $actual = EmbedShortcodeProvider::embeddableToHtml($embeddable, $attributes); + if (!$exception) { + $this->assertEqualIgnoringWhitespace($expected, $actual); + } + } + + private function getEmbeddable(string $url, string $html) + { + return new class($url, $html) extends EmbedContainer { + private $_url; + private $_html; + public function __construct($url, $html) + { + $this->_url = $url; + $this->_html = $html; + parent::__construct($url); + } + public function getType() + { + return 'rich'; + } + public function getExtractor(): Extractor + { + return new class($this->_url, $this->_html) extends Extractor { + protected $_url; + private $_html; + public function __construct($url, $html) + { + $this->_url = $url; + $this->_html = $html; + } + public function __get($name) + { + $code = new stdClass; + $code->html = $this->_html; + return match ($name) { + 'code' => $code, + 'url' => $this->_url, + default => null, + }; + } + }; + } + }; + } }