mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-02 13:37:41 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user