Skip to content

Commit a36fc54

Browse files
committed
[HtmlSanitizer] Add support for configuring the default action to block or allow unconfigured elements instead of dropping them
1 parent e0ad00c commit a36fc54

File tree

6 files changed

+150
-24
lines changed

6 files changed

+150
-24
lines changed

src/Symfony/Component/HtmlSanitizer/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Add support for configuring the default action to block or allow unconfigured elements instead of dropping them
8+
49
6.4
510
---
611

src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php

+14-2
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,13 @@ private function createDomVisitorForContext(string $context): DomVisitor
103103

104104
foreach ($this->config->getBlockedElements() as $blockedElement => $v) {
105105
if (\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) {
106-
$elementsConfig[$blockedElement] = false;
106+
$elementsConfig[$blockedElement] = HtmlSanitizerAction::Block;
107+
}
108+
}
109+
110+
foreach ($this->config->getDroppedElements() as $droppedElement => $v) {
111+
if (\array_key_exists($droppedElement, W3CReference::HEAD_ELEMENTS)) {
112+
$elementsConfig[$droppedElement] = HtmlSanitizerAction::Drop;
107113
}
108114
}
109115

@@ -119,7 +125,13 @@ private function createDomVisitorForContext(string $context): DomVisitor
119125

120126
foreach ($this->config->getBlockedElements() as $blockedElement => $v) {
121127
if (!\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) {
122-
$elementsConfig[$blockedElement] = false;
128+
$elementsConfig[$blockedElement] = HtmlSanitizerAction::Block;
129+
}
130+
}
131+
132+
foreach ($this->config->getDroppedElements() as $droppedElement => $v) {
133+
if (!\array_key_exists($droppedElement, W3CReference::HEAD_ELEMENTS)) {
134+
$elementsConfig[$droppedElement] = HtmlSanitizerAction::Drop;
123135
}
124136
}
125137

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HtmlSanitizer;
13+
14+
enum HtmlSanitizerAction: string
15+
{
16+
/**
17+
* Dropped elements are elements the sanitizer should remove from the input, including their children.
18+
*/
19+
case Drop = 'drop';
20+
21+
/**
22+
* Blocked elements are elements the sanitizer should remove from the input, but retain their children.
23+
*/
24+
case Block = 'block';
25+
26+
/**
27+
* Allowed elements are elements the sanitizer should retain from the input.
28+
*/
29+
case Allow = 'allow';
30+
}

src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php

+42-5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
*/
2020
class HtmlSanitizerConfig
2121
{
22+
private HtmlSanitizerAction $defaultAction = HtmlSanitizerAction::Drop;
23+
24+
/**
25+
* Elements that should be removed.
26+
*
27+
* @var array<string, true>
28+
*/
29+
private array $droppedElements = [];
30+
2231
/**
2332
* Elements that should be removed but their children should be retained.
2433
*
@@ -99,6 +108,19 @@ public function __construct()
99108
];
100109
}
101110

111+
/**
112+
* Sets the default action for elements which are not otherwise specifically allowed or blocked.
113+
*
114+
* Note that a default action of Allow will allow all tags but they will not have any attributes.
115+
*/
116+
public function defaultAction(HtmlSanitizerAction $action): static
117+
{
118+
$clone = clone $this;
119+
$clone->defaultAction = $action;
120+
121+
return $clone;
122+
}
123+
102124
/**
103125
* Allows all static elements and attributes from the W3C Sanitizer API standard.
104126
*
@@ -261,8 +283,8 @@ public function allowElement(string $element, array|string $allowedAttributes =
261283
{
262284
$clone = clone $this;
263285

264-
// Unblock the element is necessary
265-
unset($clone->blockedElements[$element]);
286+
// Unblock/undrop the element if necessary
287+
unset($clone->blockedElements[$element], $clone->droppedElements[$element]);
266288

267289
$clone->allowedElements[$element] = [];
268290

@@ -284,8 +306,8 @@ public function blockElement(string $element): static
284306
{
285307
$clone = clone $this;
286308

287-
// Disallow the element is necessary
288-
unset($clone->allowedElements[$element]);
309+
// Disallow/undrop the element if necessary
310+
unset($clone->allowedElements[$element], $clone->droppedElements[$element]);
289311

290312
$clone->blockedElements[$element] = true;
291313

@@ -300,13 +322,15 @@ public function blockElement(string $element): static
300322
*
301323
* Note: when using an empty configuration, all unknown elements are dropped
302324
* automatically. This method let you drop elements that were allowed earlier
303-
* in the configuration.
325+
* in the configuration, or explicitly drop some if you changed the default action.
304326
*/
305327
public function dropElement(string $element): static
306328
{
307329
$clone = clone $this;
308330
unset($clone->allowedElements[$element], $clone->blockedElements[$element]);
309331

332+
$clone->droppedElements[$element] = true;
333+
310334
return $clone;
311335
}
312336

@@ -426,6 +450,11 @@ public function getMaxInputLength(): int
426450
return $this->maxInputLength;
427451
}
428452

453+
public function getDefaultAction(): HtmlSanitizerAction
454+
{
455+
return $this->defaultAction;
456+
}
457+
429458
/**
430459
* @return array<string, array<string, true>>
431460
*/
@@ -442,6 +471,14 @@ public function getBlockedElements(): array
442471
return $this->blockedElements;
443472
}
444473

474+
/**
475+
* @return array<string, true>
476+
*/
477+
public function getDroppedElements(): array
478+
{
479+
return $this->droppedElements;
480+
}
481+
445482
/**
446483
* @return array<string, array<string, string>>
447484
*/

src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php

+22
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
16+
use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction;
1617
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
1718

1819
class HtmlSanitizerAllTest extends TestCase
@@ -578,4 +579,25 @@ public function testUnlimitedLength()
578579

579580
$this->assertSame(\strlen($input), \strlen($sanitized));
580581
}
582+
583+
public function testBlockByDefault()
584+
{
585+
$config = (new HtmlSanitizerConfig())
586+
->defaultAction(HtmlSanitizerAction::Block)
587+
->allowElement('p');
588+
589+
$sanitizer = new HtmlSanitizer($config);
590+
self::assertSame('<p>Hello</p>', $sanitizer->sanitize('<foo><div><p><a target="_blank">Hello</a></p></div></foo>'));
591+
}
592+
593+
public function testAllowByDefault()
594+
{
595+
$config = (new HtmlSanitizerConfig())
596+
->defaultAction(HtmlSanitizerAction::Allow)
597+
->allowElement('p')
598+
->dropElement('span');
599+
600+
$sanitizer = new HtmlSanitizer($config);
601+
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>'));
602+
}
581603
}

src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php

+37-17
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\HtmlSanitizer\Visitor;
1313

14+
use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction;
1415
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
1516
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
1617
use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
@@ -33,6 +34,8 @@
3334
*/
3435
final class DomVisitor
3536
{
37+
private HtmlSanitizerAction $defaultAction = HtmlSanitizerAction::Drop;
38+
3639
/**
3740
* Registry of attributes to forcefully set on nodes, index by element and attribute.
3841
*
@@ -49,11 +52,11 @@ final class DomVisitor
4952
private array $attributeSanitizers = [];
5053

5154
/**
52-
* @param array<string, false|array<string, bool>> $elementsConfig Registry of allowed/blocked elements:
53-
* * If an element is present as a key and contains an array, the element should be allowed
54-
* and the array is the list of allowed attributes.
55-
* * If an element is present as a key and contains "false", the element should be blocked.
56-
* * If an element is not present as a key, the element should be dropped.
55+
* @param array<string, HtmlSanitizerAction|array<string, bool>> $elementsConfig Registry of allowed/blocked elements:
56+
* * If an element is present as a key and contains an array, the element should be allowed
57+
* and the array is the list of allowed attributes.
58+
* * If an element is present as a key and contains an HtmlSanitizerAction, that action applies.
59+
* * If an element is not present as a key, the default action applies.
5760
*/
5861
public function __construct(
5962
private HtmlSanitizerConfig $config,
@@ -68,6 +71,8 @@ public function __construct(
6871
}
6972
}
7073
}
74+
75+
$this->defaultAction = $config->getDefaultAction();
7176
}
7277

7378
public function visit(\DOMDocumentFragment $domNode): ?NodeInterface
@@ -82,32 +87,45 @@ private function visitNode(\DOMNode $domNode, Cursor $cursor): void
8287
{
8388
$nodeName = StringSanitizer::htmlLower($domNode->nodeName);
8489

85-
// Element should be dropped, including its children
86-
if (!\array_key_exists($nodeName, $this->elementsConfig)) {
87-
return;
90+
// Visit recursively if the node was not dropped
91+
if ($this->enterNode($nodeName, $domNode, $cursor)) {
92+
$this->visitChildren($domNode, $cursor);
93+
$cursor->node = $cursor->node->getParent();
8894
}
89-
90-
// Otherwise, visit recursively
91-
$this->enterNode($nodeName, $domNode, $cursor);
92-
$this->visitChildren($domNode, $cursor);
93-
$cursor->node = $cursor->node->getParent();
9495
}
9596

96-
private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): void
97+
private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): bool
9798
{
99+
if (!\array_key_exists($domNodeName, $this->elementsConfig)) {
100+
$action = $this->defaultAction;
101+
$allowedAttributes = [];
102+
} else {
103+
if (\is_array($this->elementsConfig[$domNodeName])) {
104+
$action = HtmlSanitizerAction::Allow;
105+
$allowedAttributes = $this->elementsConfig[$domNodeName];
106+
} else {
107+
$action = $this->elementsConfig[$domNodeName];
108+
$allowedAttributes = [];
109+
}
110+
}
111+
112+
if (HtmlSanitizerAction::Drop === $action) {
113+
return false;
114+
}
115+
98116
// Element should be blocked, retaining its children
99-
if (false === $this->elementsConfig[$domNodeName]) {
117+
if (HtmlSanitizerAction::Block === $action) {
100118
$node = new BlockedNode($cursor->node);
101119

102120
$cursor->node->addChild($node);
103121
$cursor->node = $node;
104122

105-
return;
123+
return true;
106124
}
107125

108126
// Otherwise create the node
109127
$node = new Node($cursor->node, $domNodeName);
110-
$this->setAttributes($domNodeName, $domNode, $node, $this->elementsConfig[$domNodeName]);
128+
$this->setAttributes($domNodeName, $domNode, $node, $allowedAttributes);
111129

112130
// Force configured attributes
113131
foreach ($this->forcedAttributes[$domNodeName] ?? [] as $attribute => $value) {
@@ -116,6 +134,8 @@ private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $curso
116134

117135
$cursor->node->addChild($node);
118136
$cursor->node = $node;
137+
138+
return true;
119139
}
120140

121141
private function visitChildren(\DOMNode $domNode, Cursor $cursor): void

0 commit comments

Comments
 (0)