From cfaa2fd030fc35b9e7e99a863ab0021204334d04 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 21:19:08 +0200 Subject: [PATCH] Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template --- CHANGELOG | 1 + doc/deprecated.rst | 4 + .../Infix/FunctionExpressionParser.php | 8 +- src/NodeVisitor/SandboxNodeVisitor.php | 10 ++ src/Sandbox/SecurityPolicy.php | 10 +- tests/Extension/SandboxTest.php | 101 ++++++++++++++++++ 6 files changed, 132 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d145b5fa3..400ab7413 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.27.0 (2026-XX-XX) + * Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template * Fix PHP 8.1+ implicit float-to-int deprecation triggered by sandboxed `ArrayAccess` attribute access with a float key * Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation * Escape root profile name in `HtmlDumper` diff --git a/doc/deprecated.rst b/doc/deprecated.rst index 50ed50c1e..08dbe08d8 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -304,6 +304,10 @@ Sandbox deprecated as of Twig 3.12. You will need to explicitly allow them if needed in 4.0. +* Having the ``parent``, ``block``, and ``attribute`` functions allowed by + default in a sandbox is deprecated as of Twig 3.27. You will need to + explicitly allow them if needed in 4.0. + * The ``Twig\Sandbox\SourcePolicyInterface`` interface is deprecated as of Twig 3.27.0 with no replacement. Passing an instance to the ``Twig\Extension\SandboxExtension`` constructor triggers a deprecation. diff --git a/src/ExpressionParser/Infix/FunctionExpressionParser.php b/src/ExpressionParser/Infix/FunctionExpressionParser.php index e9cd77517..b0ad8af1c 100644 --- a/src/ExpressionParser/Infix/FunctionExpressionParser.php +++ b/src/ExpressionParser/Infix/FunctionExpressionParser.php @@ -54,7 +54,13 @@ final class FunctionExpressionParser extends AbstractExpressionParser implements $fakeNode = new EmptyNode($line); $fakeNode->setSourceContext($parser->getStream()->getSourceContext()); - return ($function->getParserCallable())($parser, $fakeNode, $args, $line); + $node = ($function->getParserCallable())($parser, $fakeNode, $args, $line); + // remember the original function name so the sandbox can enforce + // the `allowedFunctions` allow-list even though the parser callable + // returned a specialized node (e.g. `parent`, `block`, `attribute`). + $node->setAttribute('sandboxed_function_name', $name); + + return $node; } if (!isset($this->readyNodes[$class = $function->getNodeClass()])) { diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index 5d459247b..65667c2ff 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -66,6 +66,16 @@ final class SandboxNodeVisitor implements NodeVisitorInterface $this->functions[$node->getAttribute('name')] = $node->getTemplateLine(); } + // look for functions whose parser callable replaced the FunctionExpression + // with a specialized node (e.g. `parent`, `block`, `attribute`); the + // original function name was stashed by FunctionExpressionParser. + if ($node->hasAttribute('sandboxed_function_name')) { + $name = $node->getAttribute('sandboxed_function_name'); + if (!isset($this->functions[$name])) { + $this->functions[$name] = $node->getTemplateLine(); + } + } + // the .. operator is equivalent to the range() function if ($node instanceof RangeBinary && !isset($this->functions['range'])) { $this->functions['range'] = $node->getTemplateLine(); diff --git a/src/Sandbox/SecurityPolicy.php b/src/Sandbox/SecurityPolicy.php index b2c83ee10..7c1d2a918 100644 --- a/src/Sandbox/SecurityPolicy.php +++ b/src/Sandbox/SecurityPolicy.php @@ -86,7 +86,15 @@ final class SecurityPolicy implements SecurityPolicyInterface foreach ($functions as $function) { if (!\in_array($function, $this->allowedFunctions, true)) { - throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function); + if ('parent' === $function) { + trigger_deprecation('twig/twig', '3.27', 'The "parent" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.'); + } elseif ('block' === $function) { + trigger_deprecation('twig/twig', '3.27', 'The "block" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.'); + } elseif ('attribute' === $function) { + trigger_deprecation('twig/twig', '3.27', 'The "attribute" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.'); + } else { + throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function); + } } } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 694ab4a0b..97236cf86 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -147,6 +147,107 @@ class SandboxTest extends TestCase yield ['use', '{% use "1_empty" %}']; } + /** + * @dataProvider getSandboxedForParserCallableFunctionsTests + * + * @group legacy + */ + public function testSandboxForParserCallableFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $context, string $expected) + { + $this->expectDeprecation(\sprintf('Since twig/twig 3.27: The "%s" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.', $function)); + + $twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], $allowedMethods, $allowedProperties, []); + $this->assertSame($expected, $twig->load($templateName)->render($context)); + } + + public static function getSandboxedForParserCallableFunctionsTests() + { + yield 'attribute on array' => [ + 'attribute', + 'index', + ['index' => '{{ attribute(data, "secret") }}'], + [], [], [], + ['data' => ['secret' => 'LEAK']], + 'LEAK', + ]; + + yield 'attribute on object property' => [ + 'attribute', + 'index', + ['index' => '{{ attribute(obj, "bar") }}'], + [], [], [FooObject::class => ['bar']], + ['obj' => new FooObject()], + 'bar', + ]; + + yield 'attribute on object method' => [ + 'attribute', + 'index', + ['index' => '{{ attribute(obj, "foo") }}'], + [], [FooObject::class => ['foo']], [], + ['obj' => new FooObject()], + 'foo', + ]; + + yield 'block from same template' => [ + 'block', + 'index', + ['index' => '{% block content %}B{% endblock %}{{ block("content") }}'], + ['block'], [], [], [], + 'BB', + ]; + + yield 'parent inside inherited block' => [ + 'parent', + 'child', + [ + 'base' => '{% block content %}PARENT{% endblock %}', + 'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD{% endblock %}', + ], + ['block'], [], [], [], + 'PARENT CHILD', + ]; + } + + /** + * @dataProvider getAllowedParserCallableFunctionsTests + */ + public function testSandboxWithAllowedParserCallableFunctions(string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $allowedFunctions, array $context, string $expected) + { + $twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], $allowedMethods, $allowedProperties, $allowedFunctions); + $this->assertSame($expected, $twig->load($templateName)->render($context)); + } + + public static function getAllowedParserCallableFunctionsTests() + { + yield 'attribute allowed' => [ + 'index', + ['index' => '{{ attribute(data, "x") }}'], + [], [], [], ['attribute'], + ['data' => ['x' => 'OK']], + 'OK', + ]; + + yield 'block allowed' => [ + 'index', + ['index' => '{% block content %}B{% endblock %}{{ block("content") }}'], + ['block'], [], [], ['block'], + [], + 'BB', + ]; + + yield 'parent allowed' => [ + 'child', + [ + 'base' => '{% block content %}PARENT{% endblock %}', + 'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD{% endblock %}', + ], + ['block', 'extends'], [], [], ['parent'], + [], + 'PARENT CHILD', + ]; + } + public function testSandboxWithInheritance() { $twig = $this->getEnvironment(true, [], self::$templates, ['extends', 'block']);