Align runtime safe classes with the compile-time strategy subset rules

This commit is contained in:
Fabien Potencier
2026-09-18 23:01:16 +01:00
parent 070adde1b2
commit e0e04a1561
4 changed files with 63 additions and 3 deletions
+1
View File
@@ -14,6 +14,7 @@
* Speed up traversing nodes with node visitors
* Speed up loading compiled templates that OPcache already holds
* Speed up applying `html_cva()` recipes that resolve to classes already seen
* Fix a class marked safe for `html_attr` not being considered safe in `html` and `html_attr_relaxed` contexts
# 3.30.0 (2026-09-25)
+4
View File
@@ -554,6 +554,10 @@ The escaping rules are implemented as follows:
// mark objects of class "HtmlGenerator" as safe for all strategies
$escaper->addSafeClass('HtmlGenerator', ['all']);
A class marked safe for ``html_attr`` is also safe for ``html_attr_relaxed``
and ``html``, and a class marked safe for ``html_attr_relaxed`` is also safe
for ``html``, as the stricter escaping covers the looser contexts.
* Escaping is applied before printing, after any other filter is applied:
.. code-block:: twig
+23 -3
View File
@@ -17,6 +17,16 @@ use Twig\Markup;
final class EscaperRuntime implements RuntimeExtensionInterface
{
/**
* Strategies a value is also safe for when it is safe for the declared one.
*
* Mirrors the compile-time rules of Twig\NodeVisitor\SafeAnalysisNodeVisitor.
*/
private const IMPLIED_STRATEGIES = [
'html_attr' => ['html_attr_relaxed', 'html'],
'html_attr_relaxed' => ['html'],
];
/** @var array<string, callable(string, string): string> */
private $escapers = [];
@@ -76,8 +86,20 @@ final class EscaperRuntime implements RuntimeExtensionInterface
}
$this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies);
$this->addSafeLookup($class, $strategies);
}
/**
* @param string[] $strategies
*/
private function addSafeLookup(string $class, array $strategies): void
{
foreach ($strategies as $strategy) {
$this->safeLookup[$strategy][$class] = true;
foreach (self::IMPLIED_STRATEGIES[$strategy] ?? [] as $implied) {
$this->safeLookup[$implied][$class] = true;
}
}
}
@@ -106,9 +128,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
foreach (class_parents($string) + class_implements($string) as $class) {
if (isset($this->safeClasses[$class])) {
$this->safeClasses[$c] = array_unique(array_merge($this->safeClasses[$c], $this->safeClasses[$class]));
foreach ($this->safeClasses[$class] as $s) {
$this->safeLookup[$s][$c] = true;
}
$this->addSafeLookup($c, $this->safeClasses[$class]);
}
}
}
+35
View File
@@ -426,6 +426,41 @@ class EscaperRuntimeTest extends TestCase
['<br />', '<br />', ['\Twig\Tests\Runtime\Extension_SafeHtmlInterface' => ['all']]],
];
}
/**
* @dataProvider provideImpliedStrategies
*/
#[DataProvider('provideImpliedStrategies')]
public function testSafeClassesAreSafeForImpliedStrategies(bool $escaped, string $declaredStrategy, string $strategy): void
{
$escaper = new EscaperRuntime();
$escaper->addSafeClass(Extension_TestClass::class, [$declaredStrategy]);
$output = $escaper->escape(new Extension_TestClass(), $strategy, null, true);
$this->assertSame($escaped, '<br />' !== $output);
}
public static function provideImpliedStrategies()
{
return [
[false, 'html_attr', 'html'],
[false, 'html_attr', 'html_attr_relaxed'],
[false, 'html_attr_relaxed', 'html'],
[true, 'html', 'html_attr'],
[true, 'html', 'html_attr_relaxed'],
[true, 'html_attr_relaxed', 'html_attr'],
[true, 'html_attr', 'js'],
];
}
public function testInheritedSafeClassesAreSafeForImpliedStrategies(): void
{
$escaper = new EscaperRuntime();
$escaper->addSafeClass(Extension_SafeHtmlInterface::class, ['html_attr']);
$this->assertSame('<br />', $escaper->escape(new Extension_TestClass(), 'html', null, true));
}
}
function escaper($string, $charset)