diff --git a/CHANGELOG b/CHANGELOG index 282b1891fe0..f032ab5cd9e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ # 3.24.0 (2026-XX-XX) * Add support for renaming variables in object destructuring (`{name: userName} = user`) + * Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names # 3.23.0 (2026-01-23) diff --git a/doc/api.rst b/doc/api.rst index 5f1f02775c4..d2f5285cf49 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -130,8 +130,8 @@ The following options are available: * ``autoescape`` *string* - Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, - ``url``, ``html_attr``, or a PHP callback that takes the template "filename" + Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, ``url``, + ``html_attr``, ``html_attr_relaxed``, or a PHP callback that takes the template "filename" and returns the escaping strategy to use -- the callback cannot be a function name to avoid collision with built-in escaping strategies); set it to ``false`` to disable auto-escaping. The ``name`` escaping strategy determines diff --git a/doc/filters/escape.rst b/doc/filters/escape.rst index ed1b7c42944..fc305a1293d 100644 --- a/doc/filters/escape.rst +++ b/doc/filters/escape.rst @@ -57,6 +57,16 @@ documents: also when used as the value of an HTML attribute **without quotes** (e.g. ``data-attribute={{ some_value }}``). +* ``html_attr_relaxed``: like ``html_attr``, but **does not** escape the ``@``, ``:``, + ``[`` and ``]`` characters. You may want to use this in combination with front-end + frameworks that use attribute names like ``v-bind:href`` or ``@click``. But, be + aware that in some processing contexts like XML, characters like the colon ``:`` + may have meaning like for XML namespace separation. + +.. versionadded:: 3.24 + + The ``html_attr_relaxed`` strategy has been added in 3.23. + Note that doing contextual escaping in HTML documents is hard and choosing the right escaping strategy depends on a lot of factors. Please, read related documentation like `the OWASP prevention cheat sheet diff --git a/src/NodeVisitor/SafeAnalysisNodeVisitor.php b/src/NodeVisitor/SafeAnalysisNodeVisitor.php index 8cb5f7a39a5..b1aea0f361d 100644 --- a/src/NodeVisitor/SafeAnalysisNodeVisitor.php +++ b/src/NodeVisitor/SafeAnalysisNodeVisitor.php @@ -54,6 +54,11 @@ public function getSafe(Node $node) if (\in_array('html_attr', $bucket['value'], true)) { $bucket['value'][] = 'html'; + $bucket['value'][] = 'html_attr_relaxed'; + } + + if (\in_array('html_attr_relaxed', $bucket['value'], true)) { + $bucket['value'][] = 'html'; } return $bucket['value']; diff --git a/src/Runtime/EscaperRuntime.php b/src/Runtime/EscaperRuntime.php index fe7473bde33..f4a7023c7a7 100644 --- a/src/Runtime/EscaperRuntime.php +++ b/src/Runtime/EscaperRuntime.php @@ -124,7 +124,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu } $string = (string) $string; - } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'], true)) { + } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'html_attr_relaxed', 'url'], true)) { // we return the input as is (which can be of any type) return $string; } @@ -256,6 +256,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu return $string; case 'html_attr': + case 'html_attr_relaxed': if ('UTF-8' !== $charset) { $string = $this->convertEncoding($string, 'UTF-8', $charset); } @@ -264,7 +265,12 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); } - $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', static function ($matches) { + $regex = match ($strategy) { + 'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su', + 'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su', + }; + + $string = preg_replace_callback($regex, static function ($matches) { /** * This function is adapted from code coming from Zend Framework. * @@ -323,7 +329,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu return $this->escapers[$strategy]($string, $charset); } - $validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($this->escapers))); + $validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr', 'html_attr_relaxed'], array_keys($this->escapers))); throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies)); } diff --git a/tests/Fixtures/filters/escape_html_attr_relaxed.test b/tests/Fixtures/filters/escape_html_attr_relaxed.test new file mode 100644 index 00000000000..4719516a68a --- /dev/null +++ b/tests/Fixtures/filters/escape_html_attr_relaxed.test @@ -0,0 +1,15 @@ +--TEST-- +"escape" filter does not additionally apply the html strategy when the html_attr_relaxed strategy has been applied +"escape" filter does not additionally apply the html_attr_relaxed strategy when the html_attr strategy has been applied +--TEMPLATE-- +{% autoescape 'html' %} +{{ 'v:bind@click="foo"'|escape('html_attr_relaxed') }} +{% endautoescape %} +{% autoescape 'html_attr_relaxed' %} +{{ 'v:bind@click="foo"' | escape('html_attr') }} +{% endautoescape %} +--DATA-- +return [] +--EXPECT-- +v:bind@click="foo" +v:bind@click="foo" diff --git a/tests/Runtime/EscaperRuntimeTest.php b/tests/Runtime/EscaperRuntimeTest.php index 606ca297921..7d02249396f 100644 --- a/tests/Runtime/EscaperRuntimeTest.php +++ b/tests/Runtime/EscaperRuntimeTest.php @@ -179,6 +179,13 @@ public function testHtmlAttributeEscapingConvertsSpecialChars() } } + public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars() + { + foreach ($this->htmlAttrSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key); + } + } + public function testJavascriptEscapingConvertsSpecialChars() { foreach ($this->jsSpecialChars as $key => $value) { @@ -330,6 +337,26 @@ public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges() } } + public function testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges() + { + $immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges + for ($chr = 0; $chr < 0xFF; ++$chr) { + if ($chr >= 0x30 && $chr <= 0x39 + || $chr >= 0x41 && $chr <= 0x5A + || $chr >= 0x61 && $chr <= 0x7A) { + $literal = $this->codepointToUtf8($chr); + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); + } else { + $literal = $this->codepointToUtf8($chr); + if (\in_array($literal, $immune)) { + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); + } else { + $this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!"); + } + } + } + } + public function testCssEscapingEscapesOwaspRecommendedRanges() { // CSS has no exceptions to escaping ranges