feature #4743 Add html_attr_relaxed escaping strategy (mpdude)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Add `html_attr_relaxed` escaping strategy

This adds `html_attr_relaxed`, a relaxed variant of the `html_attr` escaping strategy. The difference is that `html_attr_relaxed` does not escape the `:`, `@`, `[` and `]` characters. These are used by some front-end frameworks in attribute names to wire special handling/value binding. See https://v2.vuejs.org/v2/guide/syntax.html#v-bind-Shorthand for an example.

The HTML 5 spec does not exclude all those characters from attribute names ([html.spec.whatwg.org/multipage/syntax.html#attributes-2](https://html.spec.whatwg.org/multipage/syntax.html#attributes-2)).

However, at least XML processors will treat the colon as the XML namespace separator.

HTML 5 allows XML only on SVG and MathML elements, and only for pre-defined namespace-prefixes ([developer.mozilla.org/en-US/docs/Web/API/Attr/localName#:~:text=That means that the local,different from the qualified name](https://developer.mozilla.org/en-US/docs/Web/API/Attr/localName#:~:text=That%20means%20that%20the%20local,different%20from%20the%20qualified%20name)). For other something: prefixes, these will simply be passed on as part of the local attribute name.

According to [engine.sygnal.com/research/html5-attribute-names](https://engine.sygnal.com/research/html5-attribute-names), all current browser implementations handle at least the colon fine, and the aforementioned Vue.js documentation suggests that this is also the case for @.

Note also that Symfony UX only conditionally escapes attribute names, and it has `:` and `@` in its safe list:
https://github.com/symfony/ux/blob/c9a3e66b8ac53e870097e8a828913e57204398e7/src/TwigComponent/src/ComponentAttributes.php#L82

Closes #3614.

Commits
-------

04aa3df49f Add `html_attr_relaxed` escaping strategy
This commit is contained in:
Fabien Potencier
2026-02-08 18:59:10 +01:00
7 changed files with 69 additions and 5 deletions
+1
View File
@@ -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)
+2 -2
View File
@@ -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
+10
View File
@@ -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
@@ -54,6 +54,11 @@ final class SafeAnalysisNodeVisitor implements NodeVisitorInterface
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'];
+9 -3
View File
@@ -124,7 +124,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
}
$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 @@ final class EscaperRuntime implements RuntimeExtensionInterface
return $string;
case 'html_attr':
case 'html_attr_relaxed':
if ('UTF-8' !== $charset) {
$string = $this->convertEncoding($string, 'UTF-8', $charset);
}
@@ -264,7 +265,12 @@ final class EscaperRuntime implements RuntimeExtensionInterface
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 @@ final class EscaperRuntime implements RuntimeExtensionInterface
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));
}
@@ -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"
+27
View File
@@ -179,6 +179,13 @@ class EscaperRuntimeTest extends TestCase
}
}
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 @@ class EscaperRuntimeTest extends TestCase
}
}
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