From 3417f483bf4c7be5d0f0b78e706779d145fc4697 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 08:18:29 +0200 Subject: [PATCH] Fetch the escaper runtime once in the constructor of templates that escape --- CHANGELOG | 1 + src/Extension/EscaperExtension.php | 5 +- src/Node/Expression/Filter/EscapeFilter.php | 51 +++++++++++++ src/Node/ModuleNode.php | 13 +++- src/NodeVisitor/EscaperNodeVisitor.php | 22 +++++- tests/Node/Expression/Filter/EscapeTest.php | 35 +++++++++ tests/NodeVisitor/EscaperTest.php | 83 +++++++++++++++++++++ 7 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/Node/Expression/Filter/EscapeFilter.php create mode 100644 tests/Node/Expression/Filter/EscapeTest.php create mode 100644 tests/NodeVisitor/EscaperTest.php diff --git a/CHANGELOG b/CHANGELOG index ef8f11937..7a2218ba7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ * Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string * Speed up splitting a string into characters in `split`, `random`, and `shuffle` + * Speed up escaping by fetching the escaper runtime once per template * Speed up adding extensions to an environment * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index f666e3fdb..f044dffff 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -14,6 +14,7 @@ namespace Twig\Extension; use Twig\Environment; use Twig\FileExtensionEscapingStrategy; use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Filter\EscapeFilter; use Twig\Node\Expression\Filter\RawFilter; use Twig\Node\Node; use Twig\NodeVisitor\EscaperNodeVisitor; @@ -51,8 +52,8 @@ final class EscaperExtension extends AbstractExtension public function getFilters(): array { return [ - new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), - new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), + new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]), + new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]), new TwigFilter('raw', null, ['is_safe' => ['all'], 'node_class' => RawFilter::class]), ]; } diff --git a/src/Node/Expression/Filter/EscapeFilter.php b/src/Node/Expression/Filter/EscapeFilter.php new file mode 100644 index 000000000..de29830a0 --- /dev/null +++ b/src/Node/Expression/Filter/EscapeFilter.php @@ -0,0 +1,51 @@ +setAttribute('template_escaper', false); + } + + protected function compileCallable(Compiler $compiler): void + { + if (!$this->getAttribute('template_escaper')) { + parent::compileCallable($compiler); + + return; + } + + $compiler->raw('$this->escaper->escape'); + $this->compileArguments($compiler); + } +} diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index 8f2283475..c39e3bcb1 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -74,6 +74,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface 'index' => null, 'embedded_templates' => $embeddedTemplates, 'strategy' => false, + 'escaper' => false, ], 1); // populate the template name of all node children @@ -201,8 +202,14 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ->write("/**\n") ->write(" * @var array\n") ->write(" */\n") - ->write("private array \$macros = [];\n\n") + ->write("private array \$macros = [];\n") ; + + if ($this->getAttribute('escaper')) { + $compiler->write("private \\Twig\\Runtime\\EscaperRuntime \$escaper;\n"); + } + + $compiler->raw("\n"); } protected function compileConstructor(Compiler $compiler): void @@ -215,6 +222,10 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ->write("\$this->source = \$this->getSourceContext();\n\n") ; + if ($this->getAttribute('escaper')) { + $compiler->write("\$this->escaper = \$env->getRuntime('Twig\\Runtime\\EscaperRuntime');\n\n"); + } + // parent if (!$this->hasNode('parent')) { $compiler->write("\$this->parent = false;\n\n"); diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index 09a04b446..6063d8586 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -18,6 +18,7 @@ use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Filter\EscapeFilter; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\OperatorEscapeInterface; use Twig\Node\ImportNode; @@ -40,6 +41,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface private $traverser; private $defaultStrategy = false; private $safeVars = []; + private bool $usesEscaper = false; public function __construct() { @@ -55,6 +57,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface $node->setAttribute('strategy', \is_string($this->defaultStrategy) ? $this->defaultStrategy : false); $this->safeVars = []; $this->blocks = []; + $this->usesEscaper = false; } elseif ($node instanceof AutoEscapeNode) { $this->statusStack[] = $node->getAttribute('value'); } elseif ($node instanceof BlockNode) { @@ -69,10 +72,15 @@ final class EscaperNodeVisitor implements NodeVisitorInterface public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { + $node->setAttribute('escaper', $this->usesEscaper); $this->defaultStrategy = false; $this->safeVars = []; $this->blocks = []; } elseif ($node instanceof FilterExpression) { + if ($node instanceof EscapeFilter) { + $this->useTemplateEscaper($node); + } + return $this->preEscapeFilterNode($node, $env); } elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping()) { $expression = $node->getNode('expr'); @@ -175,8 +183,20 @@ final class EscaperNodeVisitor implements NodeVisitorInterface $line = $node->getTemplateLine(); $filter = $env->getFilter('escape'); $args = new Nodes([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]); + $class = $filter->getNodeClass(); + $expression = new $class($node, $filter, $args, $line); - return new FilterExpression($node, $filter, $args, $line); + if ($expression instanceof EscapeFilter) { + $this->useTemplateEscaper($expression); + } + + return $expression; + } + + private function useTemplateEscaper(EscapeFilter $filter): void + { + $filter->setAttribute('template_escaper', true); + $this->usesEscaper = true; } public function getPriority(): int diff --git a/tests/Node/Expression/Filter/EscapeTest.php b/tests/Node/Expression/Filter/EscapeTest.php new file mode 100644 index 000000000..a3bb28ddc --- /dev/null +++ b/tests/Node/Expression/Filter/EscapeTest.php @@ -0,0 +1,35 @@ +getFilter('escape'), $arguments, 1); + yield 'not flagged by the escaper node visitor' => [$node, '$this->env->getRuntime(\'Twig\Runtime\EscaperRuntime\')->escape("foo", "html")', $env]; + + $node = new EscapeFilter(new ConstantExpression('foo', 1), $env->getFilter('escape'), $arguments, 1); + $node->setAttribute('template_escaper', true); + yield 'flagged by the escaper node visitor' => [$node, '$this->escaper->escape("foo", "html")', $env]; + } +} diff --git a/tests/NodeVisitor/EscaperTest.php b/tests/NodeVisitor/EscaperTest.php new file mode 100644 index 000000000..35e9d071c --- /dev/null +++ b/tests/NodeVisitor/EscaperTest.php @@ -0,0 +1,83 @@ + $template, 'embedded' => '{% block content %}{% endblock %}']), ['autoescape' => $autoescape]); + + $this->assertSame($escaperClasses, substr_count($env->compileSource($env->getLoader()->getSourceContext('index')), 'private \Twig\Runtime\EscaperRuntime $escaper;')); + $this->assertSame($expected, $env->render('index', ['foo' => '
'])); + } + + public static function provideTemplates(): iterable + { + yield 'autoescaped print' => ['html', '{{ foo }}', 1, '<br>']; + yield 'print marked as safe' => ['html', '{{ foo|raw }}', 0, '
']; + yield 'autoescaping disabled' => [false, '{{ foo }}{# not autoescaped #}', 0, '
']; + yield 'explicit escape filter' => [false, '{{ foo|e }}', 1, '<br>']; + yield 'autoescape tag' => [false, '{% autoescape "html" %}{{ foo }}{% endautoescape %}', 1, '<br>']; + yield 'escaping in an embedded template only' => [false, '{% embed "embedded" %}{% block content %}{{ foo|e }}{% endblock %}{% endembed %}', 1, '<br>']; + } + + public function testEscapeFilterAddedByALaterVisitorStillEscapes(): void + { + $env = new Environment(new ArrayLoader(['index' => '{{ "
" }}']), ['autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getNodeVisitors(): array + { + return [new class implements NodeVisitorInterface { + public function enterNode(Node $node, Environment $env): Node + { + return $node; + } + + public function leaveNode(Node $node, Environment $env): ?Node + { + if (!$node instanceof PrintNode) { + return $node; + } + + $filter = $env->getFilter('escape'); + $class = $filter->getNodeClass(); + + return new PrintNode(new $class($node->getNode('expr'), $filter, new Nodes([new ConstantExpression('html', 1)]), 1), 1); + } + + public function getPriority(): int + { + return 10; + } + }]; + } + }); + + $this->assertSame('<br>', $env->render('index')); + } +}