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