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
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
4 changes: 2 additions & 2 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions doc/filters/escape.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/NodeVisitor/SafeAnalysisNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
12 changes: 9 additions & 3 deletions src/Runtime/EscaperRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -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));
}
Expand Down
15 changes: 15 additions & 0 deletions tests/Fixtures/filters/escape_html_attr_relaxed.test
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions tests/Runtime/EscaperRuntimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ public function testHtmlAttributeEscapingConvertsSpecialChars()
}
}

public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars()
{
foreach ($this->htmlAttrSpecialChars as $key => $value) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't we test :, @, [, or ] as well here (they are not part of $this->htmlAttrSpecialChars)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Short: I don't think we need to.

I don't fully understand why the tests are written and organized the way they are. It seems that has been inherited from old Zend Framework Escaper tests.

testHtmlAttributeRelaxedEscapingConvertsSpecialChars and testHtmlAttributeEscapingConvertsSpecialChars use $htmlAttrSpecialChars to test for a few samples that

  • alnums are not escaped
  • a few "immune" chars (common to both html_attr and html_attr_relaxed) are not escaped
  • two examples beyond ASCII 0xFF are escaped
  • other examples like <>&" are being escaped.

In addition to that, we have testHtmlAttributeEscapingEscapesOwaspRecommendedRanges and testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges. Those tests will fully iterate the ASCII 0x01 to 0xFF range and test every single character from it. The test expects escaping to happen for all cases except the 0-9, a-z, A-Z ranges and explicitly given whitelists of characters.

So, I think we're safe as-is.

$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key);
}
}

public function testJavascriptEscapingConvertsSpecialChars()
{
foreach ($this->jsSpecialChars as $key => $value) {
Expand Down Expand Up @@ -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
Expand Down