Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/psalm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-24.04

env:
php-version: '8.2'
php-version: '8.4'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
12 changes: 10 additions & 2 deletions UPGRADE-7.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Cache
Console
-------

* Deprecate `Symfony\Component\Console\Application::add()` in favor of `Symfony\Component\Console\Application::addCommand()`
* Deprecate `Symfony\Component\Console\Application::add()` in favor of `addCommand()`

DependencyInjection
-------------------
Expand All @@ -32,7 +32,15 @@ DoctrineBridge
FrameworkBundle
---------------

* Deprecate `Symfony\Bundle\FrameworkBundle\Console\Application::add()` in favor of `Symfony\Bundle\FrameworkBundle\Console\Application::addCommand()`
* Deprecate `Symfony\Bundle\FrameworkBundle\Console\Application::add()` in favor of `addCommand()`

HtmlSanitizer
-------------

* Use the native HTML5 parser when using PHP 8.4+
* Deprecate `MastermindsParser`; use `NativeParser` instead
* [BC BREAK] `ParserInterface::parse()` can now return `\Dom\Node|\DOMNode|null` instead of just `\DOMNode|null`
* Add argument `$context` to `ParserInterface::parse()`

HttpClient
----------
Expand Down
8 changes: 8 additions & 0 deletions src/Symfony/Component/HtmlSanitizer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
CHANGELOG
=========

7.4
---

* Use the native HTML5 parser when using PHP 8.4+
* Deprecate `MastermindsParser`; use `NativeParser` instead
* [BC BREAK] `ParserInterface::parse()` can now return `\Dom\Node|\DOMNode|null` instead of just `\DOMNode|null`
* Add argument `$context` to `ParserInterface::parse()`

7.2
---

Expand Down
21 changes: 9 additions & 12 deletions src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\HtmlSanitizer;

use Symfony\Component\HtmlSanitizer\Parser\MastermindsParser;
use Symfony\Component\HtmlSanitizer\Parser\NativeParser;
use Symfony\Component\HtmlSanitizer\Parser\ParserInterface;
use Symfony\Component\HtmlSanitizer\Reference\W3CReference;
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
Expand All @@ -34,24 +35,20 @@ public function __construct(
?ParserInterface $parser = null,
) {
$this->config = $config;
$this->parser = $parser ?? new MastermindsParser();
$this->parser = $parser ?? (\PHP_VERSION_ID < 80400 ? new MastermindsParser() : new NativeParser());
}

public function sanitize(string $input): string
{
return $this->sanitizeWithContext(W3CReference::CONTEXT_BODY, $input);
return $this->sanitizeFor(W3CReference::CONTEXT_BODY, $input);
}

public function sanitizeFor(string $element, string $input): string
{
return $this->sanitizeWithContext(
W3CReference::CONTEXTS_MAP[StringSanitizer::htmlLower($element)] ?? W3CReference::CONTEXT_BODY,
$input
);
}
$element = StringSanitizer::htmlLower($element);
$context = W3CReference::CONTEXTS_MAP[$element] ?? W3CReference::CONTEXT_BODY;
$element = isset(W3CReference::BODY_ELEMENTS[$element]) ? $element : $context;

private function sanitizeWithContext(string $context, string $input): string
{
// Text context: early return with HTML encoding
if (W3CReference::CONTEXT_TEXT === $context) {
return StringSanitizer::encodeHtmlEntities($input);
Expand All @@ -71,11 +68,11 @@ private function sanitizeWithContext(string $context, string $input): string
return '';
}

// Remove NULL character
$input = str_replace(\chr(0), '', $input);
// Remove NULL character and HTML entities for null byte
$input = str_replace([\chr(0), '&#0;', '&#x00;', '&#X00;', '&#000;'], '', $input);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$input = str_replace([\chr(0), '&#0;', '&#x00;', '&#X00;', '&#000;'], '', $input);

Why not like this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The native parser already turns variants of &#0; into . We cannot really hook there. I think this is what the spec says to do.
Also: removing characters is a know vector for attacks, because it breaks content scanners.


// Parse as HTML
if (!$parsed = $this->parser->parse($input)) {
if ('' === trim($input) || !$parsed = $this->parser->parse($input, $element)) {
return '';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
use Masterminds\HTML5;

/**
* @deprecated since Symfony 7.4, use `NativeParser` instead
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class MastermindsParser implements ParserInterface
{
public function __construct(private array $defaultOptions = [])
{
if (\PHP_VERSION_ID >= 80400) {
trigger_deprecation('symfony/html-sanitizer', '7.4', '"%s" is deprecated since Symfony 7.4 and will be removed in 8.0. Use the "NativeParser" instead.', self::class);
}
}

public function parse(string $html): ?\DOMNode
public function parse(string $html, string $context = 'body'): ?\DOMNode
{
return (new HTML5($this->defaultOptions))->loadHTMLFragment($html);
}
Expand Down
33 changes: 33 additions & 0 deletions src/Symfony/Component/HtmlSanitizer/Parser/NativeParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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\Parser;

/**
* Parser using PHP 8.4's new Dom API.
*/
final class NativeParser implements ParserInterface
{
public function __construct()
{
if (\PHP_VERSION_ID < 80400) {
throw new \LogicException(self::class.' requires PHP 8.4 or higher.');
}
}

public function parse(string $html, string $context = 'body'): ?\Dom\Node
{
$document = @\Dom\HTMLDocument::createFromString(\sprintf('<!DOCTYPE html><%s>%s</%1$s>', $context, $html));
$element = $document->getElementsByTagName($context)->item(0);

return $element->hasChildNodes() ? $element : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface ParserInterface
* Parse a given string and returns a DOMNode tree.
*
* This method must return null if the string cannot be parsed as HTML.
*
* @param string $context The name of the context element in which the HTML is parsed
*/
public function parse(string $html): ?\DOMNode;
public function parse(string $html/* , string $context = 'body' */): \Dom\Node|\DOMNode|null;
}
74 changes: 59 additions & 15 deletions src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ public static function provideSanitizeHead()
}

#[DataProvider('provideSanitizeBody')]
public function testSanitizeBody(string $input, string $expected)
public function testSanitizeBody(string $input, string $expected, ?string $legacyExpected = null)
{
if (\PHP_VERSION_ID < 80400) {
$expected = $legacyExpected ?? $expected;
}
$this->assertSame($expected, $this->createSanitizer()->sanitize($input));
}

Expand All @@ -83,6 +86,7 @@ public static function provideSanitizeBody()
],
[
'< Hello',
'&lt; Hello',
' Hello',
],
[
Expand Down Expand Up @@ -127,6 +131,7 @@ public static function provideSanitizeBody()
],
[
'<<a href="javascript:evil"/>a href="javascript:evil"/>',
'&lt;<a>a href&#61;&#34;javascript:evil&#34;/&gt;</a>',
'<a>a href&#61;&#34;javascript:evil&#34;/&gt;</a>',
],
[
Expand Down Expand Up @@ -163,10 +168,12 @@ public static function provideSanitizeBody()
],
[
'<<img src="javascript:evil"/>iframe src="javascript:evil"/>',
'&lt;<img />iframe src&#61;&#34;javascript:evil&#34;/&gt;',
'<img />iframe src&#61;&#34;javascript:evil&#34;/&gt;',
],
[
'<<img src="javascript:evil"/>img src="javascript:evil"/>',
'&lt;<img />img src&#61;&#34;javascript:evil&#34;/&gt;',
'<img />img src&#61;&#34;javascript:evil&#34;/&gt;',
],
[
Expand Down Expand Up @@ -211,10 +218,12 @@ public static function provideSanitizeBody()
],
[
'<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>',
'<img />',
'<img src="&amp;#0000106&amp;#0000097&amp;#0000118&amp;#0000097&amp;#0000115&amp;#0000099&amp;#0000114&amp;#0000105&amp;#0000112&amp;#0000116&amp;#0000058&amp;#0000097&amp;#0000108&amp;#0000101&amp;#0000114&amp;#0000116&amp;#0000040&amp;#0000039&amp;#0000088&amp;#0000083&amp;#0000083&amp;#0000039&amp;#0000041" />',
],
[
'<IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>',
'<img />',
'<img src="&amp;#x6A&amp;#x61&amp;#x76&amp;#x61&amp;#x73&amp;#x63&amp;#x72&amp;#x69&amp;#x70&amp;#x74&amp;#x3A&amp;#x61&amp;#x6C&amp;#x65&amp;#x72&amp;#x74&amp;#x28&amp;#x27&amp;#x58&amp;#x53&amp;#x53&amp;#x27&amp;#x29" />',
],
[
Expand All @@ -233,10 +242,6 @@ public static function provideSanitizeBody()
'<svg/onload=alert(\'XSS\')>',
'',
],
[
'<BODY BACKGROUND="javascript:alert(\'XSS\')">',
'<body></body>',
],
[
'<BGSOUND SRC="javascript:alert(\'XSS\');">',
'<bgsound></bgsound>',
Expand Down Expand Up @@ -350,10 +355,6 @@ public static function provideSanitizeBody()
'Lorem ipsum <br>dolor sit amet <br />consectetur adipisicing.',
'Lorem ipsum <br />dolor sit amet <br />consectetur adipisicing.',
],
[
'<caption>Lorem ipsum</caption>',
'<caption>Lorem ipsum</caption>',
],
[
'<code>Lorem ipsum</code>',
'<code>Lorem ipsum</code>',
Expand Down Expand Up @@ -529,41 +530,84 @@ public static function provideSanitizeBody()
],
[
'<table>Lorem ipsum</table>',
'Lorem ipsum<table></table>',
'<table>Lorem ipsum</table>',
],
[
'<ul>Lorem ipsum</ul>',
'<ul>Lorem ipsum</ul>',
],
];

foreach ($cases as $case) {
yield $case[0] => $case;
}
}

#[DataProvider('provideSanitizeTable')]
public function testSanitizeTable(string $input, string $expected, ?string $legacyExpected = null)
{
if (\PHP_VERSION_ID < 80400) {
$expected = $legacyExpected ?? $expected;
}

$this->assertSame($expected, $this->createSanitizer()->sanitizeFor('table', $input));
}

public static function provideSanitizeTable(): iterable
{
return [
[
'<caption>Lorem ipsum</caption>',
'<caption>Lorem ipsum</caption>',
],
[
'<tbody>Lorem ipsum</tbody>',
'<tbody></tbody>',
'<tbody>Lorem ipsum</tbody>',
],
[
'<td>Lorem ipsum</td>',
'<tbody><tr><td>Lorem ipsum</td></tr></tbody>',
'<td>Lorem ipsum</td>',
],
[
'<tfoot>Lorem ipsum</tfoot>',
'<tfoot></tfoot>',
'<tfoot>Lorem ipsum</tfoot>',
],
[
'<thead>Lorem ipsum</thead>',
'<thead></thead>',
'<thead>Lorem ipsum</thead>',
],
[
'<th>Lorem ipsum</th>',
'<tbody><tr><th>Lorem ipsum</th></tr></tbody>',
'<th>Lorem ipsum</th>',
],
[
'<tr>Lorem ipsum</tr>',
'<tbody><tr></tr></tbody>',
'<tr>Lorem ipsum</tr>',
],
];
}

#[DataProvider('provideSanitizeHtml')]
public function testSanitizeHtml(string $input, string $expected)
{
$this->assertSame($expected, $this->createSanitizer()->sanitizeFor('html', $input));
}

public static function provideSanitizeHtml(): iterable
{
return [
[
'<ul>Lorem ipsum</ul>',
'<ul>Lorem ipsum</ul>',
'<BODY BACKGROUND="javascript:alert(\'XSS\')">',
'<body></body>',
],
];

foreach ($cases as $case) {
yield $case[0] => $case;
}
}

public function testUnlimitedLength()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ public function testSanitizeForHead()
;

$this->assertSame(
' world',
(new HtmlSanitizer($config))->sanitizeFor('head', '<div style="width: 100px">Hello</div> world')
'',
(new HtmlSanitizer($config))->sanitizeFor('head', '<div style="width: 100px">Hello world</div>')
);
}

Expand Down Expand Up @@ -65,8 +65,8 @@ public function testSanitizeDeepNestedString()

public function testSanitizeNullByte()
{
$this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), "Null byte\0"));
$this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), 'Null byte&#0;'));
$this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), "Null byte\0"));
$this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), 'Null byte&#0;'));
}

public function testSanitizeDefaultBody()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@

namespace Symfony\Component\HtmlSanitizer\Tests\Parser;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HtmlSanitizer\Parser\MastermindsParser;

#[IgnoreDeprecations]
#[Group('legacy')]
class MastermindsParserTest extends TestCase
{
public function testParseValid()
Expand Down
Loading
Loading