Skip to content

[HtmlSanitizer] Add support for configuring the default action #57399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Symfony/Component/HtmlSanitizer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down
16 changes: 14 additions & 2 deletions src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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;
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/Symfony/Component/HtmlSanitizer/HtmlSanitizerAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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';
}
47 changes: 42 additions & 5 deletions src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
*/
class HtmlSanitizerConfig
{
private HtmlSanitizerAction $defaultAction = HtmlSanitizerAction::Drop;

/**
* Elements that should be removed.
*
* @var array<string, true>
*/
private array $droppedElements = [];

/**
* Elements that should be removed but their children should be retained.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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] = [];

Expand All @@ -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;

Expand All @@ -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;
}

Expand Down Expand Up @@ -426,6 +450,11 @@ public function getMaxInputLength(): int
return $this->maxInputLength;
}

public function getDefaultAction(): HtmlSanitizerAction
{
return $this->defaultAction;
}

/**
* @return array<string, array<string, true>>
*/
Expand All @@ -442,6 +471,14 @@ public function getBlockedElements(): array
return $this->blockedElements;
}

/**
* @return array<string, true>
*/
public function getDroppedElements(): array
{
return $this->droppedElements;
}

/**
* @return array<string, array<string, string>>
*/
Expand Down
22 changes: 22 additions & 0 deletions src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('<p>Hello</p>', $sanitizer->sanitize('<foo><div><p><a target="_blank">Hello</a></p></div></foo>'));
}

public function testAllowByDefault()
{
$config = (new HtmlSanitizerConfig())
->defaultAction(HtmlSanitizerAction::Allow)
->allowElement('p')
->dropElement('span');

$sanitizer = new HtmlSanitizer($config);
self::assertSame('<foo><div><p><a>Hello</a></p></div></foo>', $sanitizer->sanitize('<foo data-attr="value"><div class="foo"><p><a target="_blank">Hello<span> World</span></a></p></div></foo>'));
}
}
54 changes: 37 additions & 17 deletions src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand All @@ -49,11 +52,11 @@ final class DomVisitor
private array $attributeSanitizers = [];

/**
* @param array<string, false|array<string, bool>> $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<string, HtmlSanitizerAction|array<string, bool>> $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,
Expand All @@ -68,6 +71,8 @@ public function __construct(
}
}
}

$this->defaultAction = $config->getDefaultAction();
}

public function visit(\DOMDocumentFragment $domNode): ?NodeInterface
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Loading