Skip to content

Commit bd1a88d

Browse files
committed
[SECURITY] Mitigate raw-text bypass with ALLOW_INSECURE_RAW_TEXT
Escape whitespace-variant closing tags (e.g. </style\t>) in serialised raw text content to prevent browser-parser differentials where browsers exit raw text mode early but the sanitizer does not. Deny raw text passthrough inside <noscript>, where browsers with JavaScript enabled parse the content as raw text, making inner closing tags invisible and allowing payloads to escape the raw text context. Security-References: CVE-2026-47344
1 parent 988caa3 commit bd1a88d

5 files changed

Lines changed: 221 additions & 5 deletions

File tree

src/Parser/Html5.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under the terms
9+
* of the MIT License (MIT). For the full copyright and license information,
10+
* please read the LICENSE file that was distributed with this source code.
11+
*
12+
* The TYPO3 project - inspiring people to share!
13+
*/
14+
15+
namespace TYPO3\HtmlSanitizer\Parser;
16+
17+
use DOMDocument;
18+
use DOMDocumentFragment;
19+
use Masterminds\HTML5 as MastermindsHTML5;
20+
use Masterminds\HTML5\Parser\DOMTreeBuilder;
21+
use Masterminds\HTML5\Parser\Scanner;
22+
23+
/**
24+
* Extends the Masterminds HTML5 parser to substitute the local Tokenizer
25+
* subclass, which fixes raw-text end-tag handling for whitespace variants.
26+
*/
27+
class Html5 extends MastermindsHTML5
28+
{
29+
#[\Override]
30+
public function parse($input, array $options = []): DOMDocument
31+
{
32+
$this->errors = [];
33+
$options = array_merge($this->getOptions(), $options);
34+
$events = new DOMTreeBuilder(false, $options);
35+
$scanner = new Scanner($input, !empty($options['encoding']) ? $options['encoding'] : 'UTF-8');
36+
$parser = new Tokenizer(
37+
$scanner,
38+
$events,
39+
!empty($options['xmlNamespaces']) ? Tokenizer::CONFORMANT_XML : Tokenizer::CONFORMANT_HTML
40+
);
41+
42+
$parser->parse();
43+
$this->errors = $events->getErrors();
44+
45+
return $events->document();
46+
}
47+
48+
#[\Override]
49+
public function parseFragment($input, array $options = []): DOMDocumentFragment
50+
{
51+
$options = array_merge($this->getOptions(), $options);
52+
$events = new DOMTreeBuilder(true, $options);
53+
$scanner = new Scanner($input, !empty($options['encoding']) ? $options['encoding'] : 'UTF-8');
54+
$parser = new Tokenizer(
55+
$scanner,
56+
$events,
57+
!empty($options['xmlNamespaces']) ? Tokenizer::CONFORMANT_XML : Tokenizer::CONFORMANT_HTML
58+
);
59+
60+
$parser->parse();
61+
$this->errors = $events->getErrors();
62+
63+
return $events->fragment();
64+
}
65+
}

src/Parser/Tokenizer.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under the terms
9+
* of the MIT License (MIT). For the full copyright and license information,
10+
* please read the LICENSE file that was distributed with this source code.
11+
*
12+
* The TYPO3 project - inspiring people to share!
13+
*/
14+
15+
namespace TYPO3\HtmlSanitizer\Parser;
16+
17+
use Masterminds\HTML5\Elements;
18+
use Masterminds\HTML5\Parser\Tokenizer as MastermindsTokenizer;
19+
20+
/**
21+
* Extends the Masterminds tokenizer to fix rawText() so that it recognises
22+
* whitespace-variant closing tags (e.g. </style\t>) as valid end tags per
23+
* HTML5 spec § 8.2.6.1, aligning it with the existing rcdata() behaviour.
24+
*/
25+
class Tokenizer extends MastermindsTokenizer
26+
{
27+
#[\Override]
28+
protected function rawText($tok): bool
29+
{
30+
if ($this->untilTag === null) {
31+
return $this->text($tok);
32+
}
33+
34+
// Search for `'</' . $untilTag` without the trailing '>' so that
35+
// optional whitespace before '>' is handled correctly, matching
36+
// the behaviour of rcdata() and the HTML5 spec (§ 8.2.6.1).
37+
// Entity references are NOT decoded in raw text (unlike rcdata).
38+
$sequence = '</' . $this->untilTag;
39+
$txt = '';
40+
41+
$caseSensitive = !Elements::isHtml5Element($this->untilTag);
42+
while ($tok !== false &&
43+
($tok !== '<' || !$this->scanner->sequenceMatches($sequence, $caseSensitive))
44+
) {
45+
$txt .= $tok;
46+
$tok = $this->scanner->next();
47+
}
48+
49+
if ($tok === false) {
50+
$this->parseError('Unexpected EOF during raw text read.');
51+
$this->events->text($txt);
52+
$this->setTextMode(0);
53+
return false;
54+
}
55+
56+
$len = strlen($sequence);
57+
$this->scanner->consume($len);
58+
$len += $this->scanner->whitespace();
59+
if ($this->scanner->current() !== '>') {
60+
$this->parseError('Unclosed raw text end tag');
61+
}
62+
63+
$this->scanner->unconsume($len);
64+
$this->events->text($txt);
65+
$this->setTextMode(0);
66+
67+
return $this->endTag();
68+
}
69+
}

src/Sanitizer.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use DOMDocumentFragment;
1818
use DOMNode;
1919
use DOMNodeList;
20-
use Masterminds\HTML5;
20+
use TYPO3\HtmlSanitizer\Parser\Html5;
2121
use TYPO3\HtmlSanitizer\Serializer\Rules;
2222
use TYPO3\HtmlSanitizer\Serializer\RulesInterface;
2323
use TYPO3\HtmlSanitizer\Visitor\VisitorInterface;
@@ -196,8 +196,8 @@ protected function closeRulesStream(RulesInterface $rules): bool
196196
return fclose($rules->getStream());
197197
}
198198

199-
protected function createParser(): HTML5
199+
protected function createParser(): Html5
200200
{
201-
return new HTML5(self::mastermindsDefaultOptions);
201+
return new Html5(self::mastermindsDefaultOptions);
202202
}
203203
}

src/Serializer/Rules.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,8 @@ public function text($domNode): void
154154
if (!$domNode instanceof DOMNode) {
155155
return;
156156
}
157-
// @todo if allowed as text raw element
158157
$parentDomNode = $domNode->parentNode ?? null;
159-
if (!$this->isRawText($parentDomNode) || !$this->shallAllowInsecureRawText($parentDomNode)) {
158+
if (!$this->shallAllowInsecureRawText($parentDomNode)) {
160159
$this->wr($this->enc($domNode->data));
161160
return;
162161
}
@@ -200,6 +199,10 @@ protected function shallAllowInsecureRawText(?DOMNode $domNode): bool
200199
if ($domNode === null || !$this->behavior instanceof Behavior || !$this->isRawText($domNode)) {
201200
return false;
202201
}
202+
// allowing raw-text in elements nested in `<noscript>` is denied per default
203+
if ($this->hasAncestorWithName($domNode, 'noscript')) {
204+
return false;
205+
}
203206
$tag = $this->behavior->getTag($domNode->nodeName);
204207
return $tag instanceof Behavior\Tag && $tag->shallAllowInsecureRawText();
205208
}
@@ -217,4 +220,19 @@ protected function isVoid(?DOMNode $domNode): bool
217220
&& !empty($domNode->tagName)
218221
&& Elements::isA($domNode->localName, Elements::VOID_TAG);
219222
}
223+
224+
protected function hasAncestorWithName(?DOMNode $domNode, string $ancestorName): bool
225+
{
226+
if (!$domNode instanceof DOMNode) {
227+
return false;
228+
}
229+
$ancestor = $domNode->parentNode;
230+
while ($ancestor instanceof DOMNode) {
231+
if ($ancestor->localName === $ancestorName) {
232+
return true;
233+
}
234+
$ancestor = $ancestor->parentNode;
235+
}
236+
return false;
237+
}
220238
}

tests/ScenarioTest.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ public function attributesAreEncoded(string $payload, string $expectation): void
616616

617617
public static function specialTagsAreHandledDataProvider(): iterable
618618
{
619+
yield 'noscript valid' => [
620+
'<noscript><p id="info">This site requires JavaScript.</p></noscript>',
621+
'<noscript><p id="info">This site requires JavaScript.</p></noscript>',
622+
];
619623
yield 'noscript attribute' => [
620624
'<noscript><p id="</noscript><script>alert(1)</script>"></p>',
621625
'<noscript><p id="&lt;/noscript&gt;&lt;script&gt;alert(1)&lt;/script&gt;"></p></noscript>',
@@ -662,4 +666,64 @@ public function specialTagsAreHandled(string $payload, string $expectation): voi
662666
);
663667
self::assertSame($expectation, $sanitizer->sanitize($payload));
664668
}
669+
670+
public static function insecureRawTextIsSanitizedDataProvider(): \Generator
671+
{
672+
$noscript = new Behavior\Tag('noscript', Behavior\Tag::ALLOW_CHILDREN);
673+
$styleDefault = new Behavior\Tag('style', Behavior\Tag::ALLOW_CHILDREN);
674+
$styleInsecureRawText = new Behavior\Tag('style', Behavior\Tag::ALLOW_CHILDREN | Behavior\Tag::ALLOW_INSECURE_RAW_TEXT);
675+
$iframeDefault = new Behavior\Tag('iframe', Behavior\Tag::ALLOW_CHILDREN);
676+
$iframeInsecureRawText = new Behavior\Tag('iframe', Behavior\Tag::ALLOW_CHILDREN | Behavior\Tag::ALLOW_INSECURE_RAW_TEXT);
677+
678+
yield 'style whitespace closing tag is recognized (img is removed - default)' => [
679+
[$styleDefault],
680+
"<style>div::after{content:'<'}</style\t><img src=x onerror=alert(1)>",
681+
'<style>div::after{content:\'&lt;\'}</style>',
682+
];
683+
yield 'style whitespace closing tag is recognised (img is removed - insecure raw text allowed)' => [
684+
[$styleInsecureRawText],
685+
"<style>div::after{content:'<'}</style\t><img src=x onerror=alert(1)>",
686+
'<style>div::after{content:\'<\'}</style>',
687+
];
688+
689+
yield 'iframe & style detect raw-text part (img is removed - insecure raw text allowed)' => [
690+
[$iframeInsecureRawText, $styleInsecureRawText],
691+
'<iframe><style></iframe><img src="x" onerror="alert(1)"></style></iframe>',
692+
'<iframe><style></iframe>',
693+
];
694+
yield 'iframe & style detect raw-text part (img is removed - default)' => [
695+
[$iframeDefault, $styleDefault],
696+
'<iframe><style></iframe><img src="x" onerror="alert(1)"></style></iframe>',
697+
'<iframe>&lt;style&gt;</iframe>',
698+
];
699+
700+
yield 'noscript nesting another raw-text element is denied (content is encoded - default)' => [
701+
[$noscript, $styleDefault],
702+
'<noscript><style></noscript><img src=x onerror=alert(2)></style></noscript>',
703+
'<noscript><style>&lt;/noscript&gt;&lt;img src=x onerror=alert(2)&gt;</style></noscript>',
704+
];
705+
yield 'noscript nesting another raw-text element is denied (content is encoded - insecure raw text allowed)' => [
706+
[$noscript, $styleInsecureRawText],
707+
'<noscript><style></noscript><img src=x onerror=alert(2)></style></noscript>',
708+
'<noscript><style>&lt;/noscript&gt;&lt;img src=x onerror=alert(2)&gt;</style></noscript>',
709+
];
710+
}
711+
712+
/**
713+
* @test
714+
* @dataProvider insecureRawTextIsSanitizedDataProvider
715+
*/
716+
public function insecureRawTextIsSanitized(array $tags, string $payload, string $expectation): void
717+
{
718+
$behavior = (new Behavior())
719+
->withFlags(Behavior::REMOVE_UNEXPECTED_CHILDREN)
720+
->withName('scenario-test')
721+
->withTags(...$tags);
722+
723+
$sanitizer = new Sanitizer(
724+
$behavior,
725+
new CommonVisitor($behavior)
726+
);
727+
self::assertSame($expectation, $sanitizer->sanitize($payload));
728+
}
665729
}

0 commit comments

Comments
 (0)