From 4fd1c4c8b047495c5fb28dee1555e6932dd820a3 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 14 Jun 2024 15:30:16 +0200 Subject: [PATCH] [HtmlSanitizer] Add support for configuring the default action to block or allow unconfigured elements instead of dropping them --- .../Component/HtmlSanitizer/CHANGELOG.md | 5 ++ .../Component/HtmlSanitizer/HtmlSanitizer.php | 16 +++++- .../HtmlSanitizer/HtmlSanitizerAction.php | 30 +++++++++++ .../HtmlSanitizer/HtmlSanitizerConfig.php | 47 ++++++++++++++-- .../Tests/HtmlSanitizerAllTest.php | 22 ++++++++ .../HtmlSanitizer/Visitor/DomVisitor.php | 54 +++++++++++++------ 6 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 src/Symfony/Component/HtmlSanitizer/HtmlSanitizerAction.php diff --git a/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md b/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md index c5d32f929a689..b853e3c925353 100644 --- a/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md +++ b/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add support for configuring the default action to block or allow unconfigured elements instead of dropping them + 6.4 --- diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php index 0220727dcb27c..430960edcb86f 100644 --- a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php @@ -103,7 +103,13 @@ private function createDomVisitorForContext(string $context): DomVisitor foreach ($this->config->getBlockedElements() as $blockedElement => $v) { if (\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) { - $elementsConfig[$blockedElement] = false; + $elementsConfig[$blockedElement] = HtmlSanitizerAction::Block; + } + } + + foreach ($this->config->getDroppedElements() as $droppedElement => $v) { + if (\array_key_exists($droppedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$droppedElement] = HtmlSanitizerAction::Drop; } } @@ -119,7 +125,13 @@ private function createDomVisitorForContext(string $context): DomVisitor foreach ($this->config->getBlockedElements() as $blockedElement => $v) { if (!\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) { - $elementsConfig[$blockedElement] = false; + $elementsConfig[$blockedElement] = HtmlSanitizerAction::Block; + } + } + + foreach ($this->config->getDroppedElements() as $droppedElement => $v) { + if (!\array_key_exists($droppedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$droppedElement] = HtmlSanitizerAction::Drop; } } diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerAction.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerAction.php new file mode 100644 index 0000000000000..6352533c31bfc --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerAction.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer; + +enum HtmlSanitizerAction: string +{ + /** + * Dropped elements are elements the sanitizer should remove from the input, including their children. + */ + case Drop = 'drop'; + + /** + * Blocked elements are elements the sanitizer should remove from the input, but retain their children. + */ + case Block = 'block'; + + /** + * Allowed elements are elements the sanitizer should retain from the input. + */ + case Allow = 'allow'; +} diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php index 57965fdefb112..f7b0b0523bc43 100644 --- a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php @@ -19,6 +19,15 @@ */ class HtmlSanitizerConfig { + private HtmlSanitizerAction $defaultAction = HtmlSanitizerAction::Drop; + + /** + * Elements that should be removed. + * + * @var array + */ + private array $droppedElements = []; + /** * Elements that should be removed but their children should be retained. * @@ -99,6 +108,19 @@ public function __construct() ]; } + /** + * Sets the default action for elements which are not otherwise specifically allowed or blocked. + * + * Note that a default action of Allow will allow all tags but they will not have any attributes. + */ + public function defaultAction(HtmlSanitizerAction $action): static + { + $clone = clone $this; + $clone->defaultAction = $action; + + return $clone; + } + /** * Allows all static elements and attributes from the W3C Sanitizer API standard. * @@ -261,8 +283,8 @@ public function allowElement(string $element, array|string $allowedAttributes = { $clone = clone $this; - // Unblock the element is necessary - unset($clone->blockedElements[$element]); + // Unblock/undrop the element if necessary + unset($clone->blockedElements[$element], $clone->droppedElements[$element]); $clone->allowedElements[$element] = []; @@ -284,8 +306,8 @@ public function blockElement(string $element): static { $clone = clone $this; - // Disallow the element is necessary - unset($clone->allowedElements[$element]); + // Disallow/undrop the element if necessary + unset($clone->allowedElements[$element], $clone->droppedElements[$element]); $clone->blockedElements[$element] = true; @@ -300,13 +322,15 @@ public function blockElement(string $element): static * * Note: when using an empty configuration, all unknown elements are dropped * automatically. This method let you drop elements that were allowed earlier - * in the configuration. + * in the configuration, or explicitly drop some if you changed the default action. */ public function dropElement(string $element): static { $clone = clone $this; unset($clone->allowedElements[$element], $clone->blockedElements[$element]); + $clone->droppedElements[$element] = true; + return $clone; } @@ -426,6 +450,11 @@ public function getMaxInputLength(): int return $this->maxInputLength; } + public function getDefaultAction(): HtmlSanitizerAction + { + return $this->defaultAction; + } + /** * @return array> */ @@ -442,6 +471,14 @@ public function getBlockedElements(): array return $this->blockedElements; } + /** + * @return array + */ + public function getDroppedElements(): array + { + return $this->droppedElements; + } + /** * @return array> */ diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php index 90436cae631a7..8699879f67bfd 100644 --- a/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php +++ b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; class HtmlSanitizerAllTest extends TestCase @@ -578,4 +579,25 @@ public function testUnlimitedLength() $this->assertSame(\strlen($input), \strlen($sanitized)); } + + public function testBlockByDefault() + { + $config = (new HtmlSanitizerConfig()) + ->defaultAction(HtmlSanitizerAction::Block) + ->allowElement('p'); + + $sanitizer = new HtmlSanitizer($config); + self::assertSame('

Hello

', $sanitizer->sanitize('')); + } + + public function testAllowByDefault() + { + $config = (new HtmlSanitizerConfig()) + ->defaultAction(HtmlSanitizerAction::Allow) + ->allowElement('p') + ->dropElement('span'); + + $sanitizer = new HtmlSanitizer($config); + self::assertSame('', $sanitizer->sanitize('')); + } } diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php b/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php index 458635599f7ac..e6d34a0967b79 100644 --- a/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php +++ b/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HtmlSanitizer\Visitor; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer; use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface; @@ -33,6 +34,8 @@ */ final class DomVisitor { + private HtmlSanitizerAction $defaultAction = HtmlSanitizerAction::Drop; + /** * Registry of attributes to forcefully set on nodes, index by element and attribute. * @@ -49,11 +52,11 @@ final class DomVisitor private array $attributeSanitizers = []; /** - * @param array> $elementsConfig Registry of allowed/blocked elements: - * * If an element is present as a key and contains an array, the element should be allowed - * and the array is the list of allowed attributes. - * * If an element is present as a key and contains "false", the element should be blocked. - * * If an element is not present as a key, the element should be dropped. + * @param array> $elementsConfig Registry of allowed/blocked elements: + * * If an element is present as a key and contains an array, the element should be allowed + * and the array is the list of allowed attributes. + * * If an element is present as a key and contains an HtmlSanitizerAction, that action applies. + * * If an element is not present as a key, the default action applies. */ public function __construct( private HtmlSanitizerConfig $config, @@ -68,6 +71,8 @@ public function __construct( } } } + + $this->defaultAction = $config->getDefaultAction(); } public function visit(\DOMDocumentFragment $domNode): ?NodeInterface @@ -82,32 +87,45 @@ private function visitNode(\DOMNode $domNode, Cursor $cursor): void { $nodeName = StringSanitizer::htmlLower($domNode->nodeName); - // Element should be dropped, including its children - if (!\array_key_exists($nodeName, $this->elementsConfig)) { - return; + // Visit recursively if the node was not dropped + if ($this->enterNode($nodeName, $domNode, $cursor)) { + $this->visitChildren($domNode, $cursor); + $cursor->node = $cursor->node->getParent(); } - - // Otherwise, visit recursively - $this->enterNode($nodeName, $domNode, $cursor); - $this->visitChildren($domNode, $cursor); - $cursor->node = $cursor->node->getParent(); } - private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): void + private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): bool { + if (!\array_key_exists($domNodeName, $this->elementsConfig)) { + $action = $this->defaultAction; + $allowedAttributes = []; + } else { + if (\is_array($this->elementsConfig[$domNodeName])) { + $action = HtmlSanitizerAction::Allow; + $allowedAttributes = $this->elementsConfig[$domNodeName]; + } else { + $action = $this->elementsConfig[$domNodeName]; + $allowedAttributes = []; + } + } + + if (HtmlSanitizerAction::Drop === $action) { + return false; + } + // Element should be blocked, retaining its children - if (false === $this->elementsConfig[$domNodeName]) { + if (HtmlSanitizerAction::Block === $action) { $node = new BlockedNode($cursor->node); $cursor->node->addChild($node); $cursor->node = $node; - return; + return true; } // Otherwise create the node $node = new Node($cursor->node, $domNodeName); - $this->setAttributes($domNodeName, $domNode, $node, $this->elementsConfig[$domNodeName]); + $this->setAttributes($domNodeName, $domNode, $node, $allowedAttributes); // Force configured attributes foreach ($this->forcedAttributes[$domNodeName] ?? [] as $attribute => $value) { @@ -116,6 +134,8 @@ private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $curso $cursor->node->addChild($node); $cursor->node = $node; + + return true; } private function visitChildren(\DOMNode $domNode, Cursor $cursor): void pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy