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