Skip to content

Commit 04aa3df

Browse files
mpdudefabpot
authored andcommitted
Add html_attr_relaxed escaping strategy
1 parent df89382 commit 04aa3df

File tree

7 files changed

+69
-5
lines changed

7 files changed

+69
-5
lines changed

CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# 3.24.0 (2026-XX-XX)
22

33
* Add support for renaming variables in object destructuring (`{name: userName} = user`)
4+
* Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names
45

56
# 3.23.0 (2026-01-23)
67

doc/api.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ The following options are available:
130130

131131
* ``autoescape`` *string*
132132

133-
Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``,
134-
``url``, ``html_attr``, or a PHP callback that takes the template "filename"
133+
Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, ``url``,
134+
``html_attr``, ``html_attr_relaxed``, or a PHP callback that takes the template "filename"
135135
and returns the escaping strategy to use -- the callback cannot be a function
136136
name to avoid collision with built-in escaping strategies); set it to
137137
``false`` to disable auto-escaping. The ``name`` escaping strategy determines

doc/filters/escape.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ documents:
5757
also when used as the value of an HTML attribute **without quotes**
5858
(e.g. ``data-attribute={{ some_value }}``).
5959

60+
* ``html_attr_relaxed``: like ``html_attr``, but **does not** escape the ``@``, ``:``,
61+
``[`` and ``]`` characters. You may want to use this in combination with front-end
62+
frameworks that use attribute names like ``v-bind:href`` or ``@click``. But, be
63+
aware that in some processing contexts like XML, characters like the colon ``:``
64+
may have meaning like for XML namespace separation.
65+
66+
.. versionadded:: 3.24
67+
68+
The ``html_attr_relaxed`` strategy has been added in 3.23.
69+
6070
Note that doing contextual escaping in HTML documents is hard and choosing the
6171
right escaping strategy depends on a lot of factors. Please, read related
6272
documentation like `the OWASP prevention cheat sheet

src/NodeVisitor/SafeAnalysisNodeVisitor.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ public function getSafe(Node $node)
5454

5555
if (\in_array('html_attr', $bucket['value'], true)) {
5656
$bucket['value'][] = 'html';
57+
$bucket['value'][] = 'html_attr_relaxed';
58+
}
59+
60+
if (\in_array('html_attr_relaxed', $bucket['value'], true)) {
61+
$bucket['value'][] = 'html';
5762
}
5863

5964
return $bucket['value'];

src/Runtime/EscaperRuntime.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu
124124
}
125125

126126
$string = (string) $string;
127-
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'], true)) {
127+
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'html_attr_relaxed', 'url'], true)) {
128128
// we return the input as is (which can be of any type)
129129
return $string;
130130
}
@@ -256,6 +256,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu
256256
return $string;
257257

258258
case 'html_attr':
259+
case 'html_attr_relaxed':
259260
if ('UTF-8' !== $charset) {
260261
$string = $this->convertEncoding($string, 'UTF-8', $charset);
261262
}
@@ -264,7 +265,12 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu
264265
throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
265266
}
266267

267-
$string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', static function ($matches) {
268+
$regex = match ($strategy) {
269+
'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su',
270+
'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su',
271+
};
272+
273+
$string = preg_replace_callback($regex, static function ($matches) {
268274
/**
269275
* This function is adapted from code coming from Zend Framework.
270276
*
@@ -323,7 +329,7 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu
323329
return $this->escapers[$strategy]($string, $charset);
324330
}
325331

326-
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($this->escapers)));
332+
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr', 'html_attr_relaxed'], array_keys($this->escapers)));
327333

328334
throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies));
329335
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
--TEST--
2+
"escape" filter does not additionally apply the html strategy when the html_attr_relaxed strategy has been applied
3+
"escape" filter does not additionally apply the html_attr_relaxed strategy when the html_attr strategy has been applied
4+
--TEMPLATE--
5+
{% autoescape 'html' %}
6+
{{ 'v:bind@click="foo"'|escape('html_attr_relaxed') }}
7+
{% endautoescape %}
8+
{% autoescape 'html_attr_relaxed' %}
9+
{{ 'v:bind@click="foo"' | escape('html_attr') }}
10+
{% endautoescape %}
11+
--DATA--
12+
return []
13+
--EXPECT--
14+
v:bind@click="foo"
15+
v:bind@click="foo"

tests/Runtime/EscaperRuntimeTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ public function testHtmlAttributeEscapingConvertsSpecialChars()
179179
}
180180
}
181181

182+
public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars()
183+
{
184+
foreach ($this->htmlAttrSpecialChars as $key => $value) {
185+
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key);
186+
}
187+
}
188+
182189
public function testJavascriptEscapingConvertsSpecialChars()
183190
{
184191
foreach ($this->jsSpecialChars as $key => $value) {
@@ -330,6 +337,26 @@ public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges()
330337
}
331338
}
332339

340+
public function testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges()
341+
{
342+
$immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges
343+
for ($chr = 0; $chr < 0xFF; ++$chr) {
344+
if ($chr >= 0x30 && $chr <= 0x39
345+
|| $chr >= 0x41 && $chr <= 0x5A
346+
|| $chr >= 0x61 && $chr <= 0x7A) {
347+
$literal = $this->codepointToUtf8($chr);
348+
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
349+
} else {
350+
$literal = $this->codepointToUtf8($chr);
351+
if (\in_array($literal, $immune)) {
352+
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
353+
} else {
354+
$this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!");
355+
}
356+
}
357+
}
358+
}
359+
333360
public function testCssEscapingEscapesOwaspRecommendedRanges()
334361
{
335362
// CSS has no exceptions to escaping ranges

0 commit comments

Comments
 (0)