From e235cae3a1d1e23edc24df4d675f18108c6b167b Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 23 May 2026 09:13:48 +0200 Subject: [PATCH 01/20] Fix sandbox bypass in deprecated internal wrappers --- CHANGELOG | 1 + src/Resources/core.php | 6 +- tests/Resources/LegacyCoreTest.php | 102 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 50cacca52..d86fef44e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ * Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation * Escape root profile name in `HtmlDumper` + * Fix sandbox bypass in deprecated internal wrappers `twig_array_some()`, `twig_array_every()`, and `twig_check_arrow_in_sandbox()` (`src/Resources/core.php`) * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement # 3.26.0 (2026-05-20) diff --git a/src/Resources/core.php b/src/Resources/core.php index 59bc57352..80f62cf69 100644 --- a/src/Resources/core.php +++ b/src/Resources/core.php @@ -516,7 +516,7 @@ function twig_array_some(Environment $env, $array, $arrow) { trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); - return CoreExtension::arraySome($env, $array, $arrow); + return CoreExtension::arraySome($env, $array, $arrow, twig_resolve_is_sandboxed($env)); } /** @@ -528,7 +528,7 @@ function twig_array_every(Environment $env, $array, $arrow) { trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); - return CoreExtension::arrayEvery($env, $array, $arrow); + return CoreExtension::arrayEvery($env, $array, $arrow, twig_resolve_is_sandboxed($env)); } /** @@ -540,7 +540,7 @@ function twig_check_arrow_in_sandbox(Environment $env, $arrow, $thing, $type) { trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); - CoreExtension::checkArrow($env, $arrow, $thing, $type); + CoreExtension::checkArrow(twig_resolve_is_sandboxed($env), $arrow, $thing, $type); } /** diff --git a/tests/Resources/LegacyCoreTest.php b/tests/Resources/LegacyCoreTest.php index dc58ae099..8eafca622 100644 --- a/tests/Resources/LegacyCoreTest.php +++ b/tests/Resources/LegacyCoreTest.php @@ -122,6 +122,93 @@ class LegacyCoreTest extends TestCase $template->callLegacyArrayReduce([1, 2], 'intval'); } + public function testTwigArraySomeEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArraySome(['a', 'b'], 'is_string'); + } + + public function testTwigArraySomeRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArraySome(['a', 'b'], 'is_string'); + } + + public function testTwigArrayEveryEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayEvery(['a', 'b'], 'is_string'); + } + + public function testTwigArrayEveryRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayEvery(['a', 'b'], 'is_string'); + } + + public function testTwigCheckArrowInSandboxEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + } + + public function testTwigCheckArrowInSandboxRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + } + + public function testTwigCheckArrowInSandboxIsNoopWhenNotSandboxed() + { + $env = $this->createSandboxedEnvironment(false); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + $this->expectNotToPerformAssertions(); + } + private function createSandboxedEnvironment(bool $globallySandboxed, ?SourcePolicyInterface $sourcePolicy = null): Environment { $env = new Environment(new ArrayLoader([]), ['cache' => false, 'autoescape' => false]); @@ -163,6 +250,21 @@ class LegacyCoreTestTemplate extends Template return twig_array_column($this->env, $array, $name, $index); } + public function callLegacyArraySome($array, $arrow) + { + return twig_array_some($this->env, $array, $arrow); + } + + public function callLegacyArrayEvery($array, $arrow) + { + return twig_array_every($this->env, $array, $arrow); + } + + public function callLegacyCheckArrowInSandbox($arrow, $thing, $type) + { + twig_check_arrow_in_sandbox($this->env, $arrow, $thing, $type); + } + public function getTemplateName(): string { return $this->name; From 09c6706407ed7b1cb2d7d1408004f811e47e0424 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 22 May 2026 10:06:46 +0200 Subject: [PATCH 02/20] Fix sandbox bypass in the "column" filter under SourcePolicyInterface --- CHANGELOG | 1 + src/Extension/CoreExtension.php | 9 ++-- tests/Extension/SandboxTest.php | 78 +++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3adfc5379..cd747b60a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,7 @@ * Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation * Escape root profile name in `HtmlDumper` * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement + * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` # 3.26.0 (2026-05-20) diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index f150ca71f..ea2f80011 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1960,12 +1960,15 @@ final class CoreExtension extends AbstractExtension } if ($isSandboxed) { - $sandbox = $env->getExtension(SandboxExtension::class); + // The sandbox might be enabled via a SourcePolicyInterface, in which case the SandboxExtension + // would not consider the sandbox active without the current Source: $isSandboxed is already + // computed against the call-site source, so check the policy directly to honor that decision. + $policy = $env->getExtension(SandboxExtension::class)->getSecurityPolicy(); foreach ($array as $item) { if (\is_object($item)) { - $sandbox->checkPropertyAllowed($item, (string) $name); + $policy->checkPropertyAllowed($item, (string) $name); if (null !== $index) { - $sandbox->checkPropertyAllowed($item, (string) $index); + $policy->checkPropertyAllowed($item, (string) $index); } } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 716dd5f80..fe4397cda 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1173,6 +1173,84 @@ EOF $this->assertSame('bar', $twig->load('index')->render($params)); } + public function testSourcePolicySandboxBlocksColumnFilterOnDisallowedProperty() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $params = ['obj' => new ColumnObject()]; + $twig = $this->getEnvironment(false, [], ['index' => "{{ [obj]|column('bar')|first }}"], [], ['column', 'first'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render($params); + $this->fail('Sandbox should reject the "column" filter when the requested property is not in allowedProperties (SourcePolicyInterface).'); + } catch (SecurityNotAllowedPropertyError $e) { + $this->assertSame('Twig\Tests\Extension\ColumnObject', $e->getClassName()); + $this->assertSame('bar', $e->getPropertyName()); + } + } + + public function testSourcePolicySandboxBlocksColumnFilterOnDisallowedIndex() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $params = ['obj' => new ColumnObject()]; + $twig = $this->getEnvironment(false, [], ['index' => "{{ [obj]|column('bar', 'foo')|keys|first }}"], [], ['column', 'first', 'keys'], [], ['Twig\Tests\Extension\ColumnObject' => ['bar']], [], $sourcePolicy); + + try { + $twig->load('index')->render($params); + $this->fail('Sandbox should reject the "column" filter when the index argument targets a disallowed property (SourcePolicyInterface).'); + } catch (SecurityNotAllowedPropertyError $e) { + $this->assertSame('Twig\Tests\Extension\ColumnObject', $e->getClassName()); + $this->assertSame('foo', $e->getPropertyName()); + } + } + + public function testSourcePolicySandboxAllowsColumnFilterOnAllowedProperty() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $params = ['obj' => new ColumnObject()]; + $twig = $this->getEnvironment(false, [], ['index' => "{{ [obj]|column('bar')|first }}"], [], ['column', 'first'], [], ['Twig\Tests\Extension\ColumnObject' => ['bar']], [], $sourcePolicy); + + $this->assertSame('bar', $twig->load('index')->render($params)); + } + + public function testSourcePolicySandboxBlocksColumnFilterOnMagicGetter() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $params = ['magic' => new MagicObject()]; + $twig = $this->getEnvironment(false, [], ['index' => "{{ [magic]|column('anything')|first }}"], [], ['column', 'first'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render($params); + $this->fail('Sandbox should reject the "column" filter before invoking __get on a non-allowlisted property (SourcePolicyInterface).'); + } catch (SecurityNotAllowedPropertyError $e) { + $this->assertSame('Twig\Tests\Extension\MagicObject', $e->getClassName()); + $this->assertSame('anything', $e->getPropertyName()); + } + } + protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [], $sourcePolicy = null, bool $strict = false) { $loader = new ArrayLoader($templates); From afbaa2a9daeb7943666cbfa876dcd751d7dfee17 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 11:16:20 +0200 Subject: [PATCH 03/20] Mark new SourcePolicyInterface column filter tests as @group legacy These tests pass a SourcePolicyInterface instance to SandboxExtension, which triggers the 3.27 deprecation. Mark them legacy and assert the deprecation to silence the PHPUnit "unhandled deprecation" report. --- tests/Extension/SandboxTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index fe4397cda..45770e25e 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1173,8 +1173,13 @@ EOF $this->assertSame('bar', $twig->load('index')->render($params)); } + /** + * @group legacy + */ public function testSourcePolicySandboxBlocksColumnFilterOnDisallowedProperty() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { @@ -1194,8 +1199,13 @@ EOF } } + /** + * @group legacy + */ public function testSourcePolicySandboxBlocksColumnFilterOnDisallowedIndex() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { @@ -1215,8 +1225,13 @@ EOF } } + /** + * @group legacy + */ public function testSourcePolicySandboxAllowsColumnFilterOnAllowedProperty() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { @@ -1230,8 +1245,13 @@ EOF $this->assertSame('bar', $twig->load('index')->render($params)); } + /** + * @group legacy + */ public function testSourcePolicySandboxBlocksColumnFilterOnMagicGetter() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { From cc1e21a2a29cfd626630a3426911546991206017 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 22 May 2026 11:55:19 +0200 Subject: [PATCH 04/20] Fix sandbox __toString bypass via Traversable in join/replace filters --- CHANGELOG | 1 + src/Extension/SandboxExtension.php | 10 +++++++ tests/Extension/SandboxTest.php | 45 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index cd747b60a..505a868e2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,7 @@ * Escape root profile name in `HtmlDumper` * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` + * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters # 3.26.0 (2026-05-20) diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index 91e8cb340..b105375e4 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -132,6 +132,16 @@ final class SandboxExtension extends AbstractExtension return $obj; } + // A non-Stringable Traversable would later be materialised (e.g. by filters such as + // `join` or `replace`) and its elements coerced to string by PHP itself, bypassing + // the policy. Materialise it now and recursively check the contents. + if ($obj instanceof \Traversable && !$obj instanceof \Stringable && $this->isSandboxed($source)) { + $obj = iterator_to_array($obj); + $this->ensureToStringAllowedForArray($obj, $lineno, $source); + + return $obj; + } + if ($obj instanceof \Stringable && $this->isSandboxed($source)) { try { $this->policy->checkMethodAllowed($obj, '__toString'); diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 45770e25e..9142565c2 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -62,6 +62,8 @@ class SandboxTest extends TestCase 'magic' => new MagicObject(), 'recursion' => [4], 'iterator' => new \ArrayIterator(['a', new FooObject()]), + 'iterator_map' => new \ArrayIterator(['__toString' => new FooObject()]), + 'iterator_nested' => new \ArrayIterator(['a', new \ArrayIterator(['b', new FooObject()])]), ]; self::$params['recursion'][] = &self::$params['recursion']; self::$params['recursion'][] = new FooObject(); @@ -589,6 +591,9 @@ class SandboxTest extends TestCase 'spread_array_operator' => ['{{ [1, 2, ...[5, 6, 7, obj]]|join(",") }}'], 'spread_array_operator_var' => ['{{ [1, 2, ...some_array]|join(",") }}'], 'spread_iterator_in_function_args' => ['{{ ["x", ...iterator]|join(",") }}'], + 'iterator_in_join' => ['{{ iterator|join(", ") }}'], + 'iterator_nested_in_join' => ['{{ iterator_nested|join(", ") }}'], + 'iterator_in_replace' => ['{{ "__toString"|replace(iterator_map) }}'], 'recursion' => ['{{ recursion|join(", ") }}'], 'ternary_print' => ['{{ true ? obj : "" }}'], 'ternary_filter_input' => ['{{ (true ? obj : "")|upper }}'], @@ -1165,6 +1170,46 @@ EOF } } + public function testSourcePolicySandboxBlocksToStringInTraversableJoin() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment(false, [], ['index' => '{{ iterator|join(", ") }}'], [], ['join'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects contained in a Traversable passed to the "join" filter (SourcePolicyInterface).'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSourcePolicySandboxBlocksToStringInTraversableReplace() + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment(false, [], ['index' => '{{ "__toString"|replace(iterator_map) }}'], [], ['replace'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects contained in a Traversable passed to the "replace" filter (SourcePolicyInterface).'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + public function testColumnFilterUnaffectedOutsideSandbox() { $params = ['obj' => new ColumnObject()]; From 8d6af0707b7edfda1aa3022193a26c5145a76e8c Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 08:29:59 +0200 Subject: [PATCH 05/20] Fix sandbox `__toString` bypass via the `in` and `not in` operators --- CHANGELOG | 1 + src/Node/Expression/Binary/InBinary.php | 8 +++++++- src/Node/Expression/Binary/NotInBinary.php | 8 +++++++- tests/Extension/SandboxTest.php | 6 ++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 505a868e2..d5222608b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,7 @@ * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters + * Fix sandbox `__toString` bypass via the `in` and `not in` operators # 3.26.0 (2026-05-20) diff --git a/src/Node/Expression/Binary/InBinary.php b/src/Node/Expression/Binary/InBinary.php index 31a21e7d6..2f11a54fd 100644 --- a/src/Node/Expression/Binary/InBinary.php +++ b/src/Node/Expression/Binary/InBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class InBinary extends AbstractBinary implements ReturnBoolInterface +class InBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -31,4 +32,9 @@ class InBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('in'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/NotInBinary.php b/src/Node/Expression/Binary/NotInBinary.php index 9fd27311f..f46c88ff8 100644 --- a/src/Node/Expression/Binary/NotInBinary.php +++ b/src/Node/Expression/Binary/NotInBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class NotInBinary extends AbstractBinary implements ReturnBoolInterface +class NotInBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -31,4 +32,9 @@ class NotInBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('not in'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 9142565c2..9fcfb4a70 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -614,6 +614,12 @@ class SandboxTest extends TestCase 'concat_right_in_if' => ['{% if "" ~ obj %}LEAK{% endif %}'], 'range_left' => ['{% for x in obj..1 %}LEAK{% endfor %}'], 'range_right' => ['{% for x in 1..obj %}LEAK{% endfor %}'], + 'in_array_right' => ['{% if "needle" in [obj] %}LEAK{% endif %}'], + 'in_array_left' => ['{% if obj in ["needle"] %}LEAK{% endif %}'], + 'notin_array_right' => ['{% if "needle" not in [obj] %}LEAK{% endif %}'], + 'notin_array_left' => ['{% if obj not in ["needle"] %}LEAK{% endif %}'], + 'in_iterator_right' => ['{% if "needle" in iterator %}LEAK{% endif %}'], + 'notin_iterator_right' => ['{% if "needle" not in iterator %}LEAK{% endif %}'], 'do_tag_function_arg' => ['{% do my_func(obj) %}'], 'do_tag_filter_input' => ['{% do obj|upper %}'], 'do_tag_concat' => ['{% do obj ~ "" %}'], From e9e818cbfc0fc16ef610d4b855e8814d3295f683 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 09:12:55 +0200 Subject: [PATCH 06/20] Fix sandbox `__toString` bypass via `Stringable` + `Traversable` containers --- CHANGELOG | 2 +- src/Extension/SandboxExtension.php | 29 +++++--- tests/Extension/SandboxTest.php | 105 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d5222608b..ddff4d367 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,7 +7,7 @@ * Escape root profile name in `HtmlDumper` * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` - * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters + * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`) * Fix sandbox `__toString` bypass via the `in` and `not in` operators # 3.26.0 (2026-05-20) diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index b105375e4..947f9f87e 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -132,17 +132,11 @@ final class SandboxExtension extends AbstractExtension return $obj; } - // A non-Stringable Traversable would later be materialised (e.g. by filters such as - // `join` or `replace`) and its elements coerced to string by PHP itself, bypassing - // the policy. Materialise it now and recursively check the contents. - if ($obj instanceof \Traversable && !$obj instanceof \Stringable && $this->isSandboxed($source)) { - $obj = iterator_to_array($obj); - $this->ensureToStringAllowedForArray($obj, $lineno, $source); - + if (!$this->isSandboxed($source)) { return $obj; } - if ($obj instanceof \Stringable && $this->isSandboxed($source)) { + if ($obj instanceof \Stringable) { try { $this->policy->checkMethodAllowed($obj, '__toString'); } catch (SecurityNotAllowedMethodError $e) { @@ -153,6 +147,25 @@ final class SandboxExtension extends AbstractExtension } } + // A Traversable would later be materialised (e.g. by filters such as `join` + // or `replace`) and its elements coerced to string by PHP itself, bypassing + // the policy. Materialise it now and recursively check the contents. This + // also applies to objects that implement both `Stringable` and `Traversable`: + // the `__toString` check above only validates the container's own coercion, + // not the elements yielded by `getIterator()`. + if ($obj instanceof \Traversable) { + $array = iterator_to_array($obj); + $this->ensureToStringAllowedForArray($array, $lineno, $source); + + // Return the materialised array only when the object is not also + // Stringable, so that callers that rely on `__toString` (e.g. `{{ obj }}`) + // keep working. Plain consumers of iterables (join, replace, ...) call + // `iterator_to_array()` again, so the extra materialisation is benign. + if (!$obj instanceof \Stringable) { + return $array; + } + } + return $obj; } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 9fcfb4a70..a6d13ed1f 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -64,6 +64,8 @@ class SandboxTest extends TestCase 'iterator' => new \ArrayIterator(['a', new FooObject()]), 'iterator_map' => new \ArrayIterator(['__toString' => new FooObject()]), 'iterator_nested' => new \ArrayIterator(['a', new \ArrayIterator(['b', new FooObject()])]), + 'stringable_iterator' => new StringableTraversableObject(['a', new FooObject()]), + 'stringable_iterator_map' => new StringableTraversableObject(['__toString' => new FooObject()]), ]; self::$params['recursion'][] = &self::$params['recursion']; self::$params['recursion'][] = new FooObject(); @@ -1176,6 +1178,88 @@ EOF } } + /** + * @dataProvider getStringableTraversableBypassTemplates + */ + public function testSandboxBlocksToStringInStringableTraversable(string $template) + { + $twig = $this->getEnvironment( + true, + [], + ['index' => $template], + [], + ['join', 'replace'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Stringable+Traversable container, even when the container\'s own __toString is allowed.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getStringableTraversableBypassTemplates(): iterable + { + yield 'join' => ['{{ stringable_iterator|join(", ") }}']; + yield 'replace' => ['{{ "__toString"|replace(stringable_iterator_map) }}']; + } + + /** + * @dataProvider getStringableTraversableBypassTemplates + */ + public function testSourcePolicySandboxBlocksToStringInStringableTraversable(string $template) + { + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment( + false, + [], + ['index' => $template], + [], + ['join', 'replace'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + [], + [], + $sourcePolicy, + ); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Stringable+Traversable container under a SourcePolicyInterface-only sandbox.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSandboxAllowsPrintingStringableTraversableWhenToStringAllowed() + { + // Printing the container itself yields its `__toString()` value. The + // sandbox materialises the iterable to also policy-check the elements + // (some consumers like `join`/`replace` would coerce them too), so the + // inner items must not contain anything that violates the policy. + $twig = $this->getEnvironment( + true, + ['autoescape' => 'html'], + ['index' => '{{ obj }}'], + [], + ['escape'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + + $params = ['obj' => new StringableTraversableObject(['a', 'b'])]; + + $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); + } + public function testSourcePolicySandboxBlocksToStringInTraversableJoin() { $sourcePolicy = new class implements SourcePolicyInterface { @@ -1722,3 +1806,24 @@ class ColumnObject { public $bar = 'bar'; } + +// Implements both Stringable and Traversable: a sandbox policy may legitimately +// allow the container's own `__toString`, but the elements yielded by +// `getIterator()` must still be policy-checked when consumers (`join`, `replace`, +// ...) materialise the iterable and coerce its contents to string. +class StringableTraversableObject implements \IteratorAggregate, \Stringable +{ + public function __construct(private array $items) + { + } + + public function __toString(): string + { + return 'stringable-traversable'; + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } +} From 475fb690ac6ad74b09887e7c5245334e1dcf305d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 09:38:10 +0200 Subject: [PATCH 07/20] Guard sandbox `__toString` walker against self-referencing iterables --- CHANGELOG | 1 + src/Extension/SandboxExtension.php | 62 +++++++++++++++++++----------- tests/Extension/SandboxTest.php | 41 ++++++++++++++++++++ 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index ddff4d367..178d9cf58 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -9,6 +9,7 @@ * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`) * Fix sandbox `__toString` bypass via the `in` and `not in` operators + * Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template # 3.26.0 (2026-05-20) diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index 947f9f87e..5ee4c4bed 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -125,9 +125,34 @@ final class SandboxExtension extends AbstractExtension * @throws SecurityNotAllowedMethodError */ public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null) + { + return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage()); + } + + /** + * Materialises a spread operand and runs the policy on every element. + * + * @internal + * + * @throws SecurityNotAllowedMethodError + */ + public function ensureSpreadAllowed(iterable $obj, int $lineno = -1, ?Source $source = null): array + { + $seen = new \SplObjectStorage(); + if ($obj instanceof \Traversable) { + $seen[$obj] = true; + $obj = iterator_to_array($obj); + } + + $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen); + + return $obj; + } + + private function doEnsureToStringAllowed($obj, int $lineno, ?Source $source, \SplObjectStorage $seen) { if (\is_array($obj)) { - $this->ensureToStringAllowedForArray($obj, $lineno, $source); + $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen); return $obj; } @@ -154,8 +179,17 @@ final class SandboxExtension extends AbstractExtension // the `__toString` check above only validates the container's own coercion, // not the elements yielded by `getIterator()`. if ($obj instanceof \Traversable) { + // Guard against self-referencing iterables (e.g. an IteratorAggregate + // whose getIterator() yields $this): without this check, materialising + // and recursing into the elements would overflow the stack. Mirrors + // the array-cycle guard in ensureToStringAllowedForArray(). + if (isset($seen[$obj])) { + return $obj; + } + + $seen[$obj] = true; $array = iterator_to_array($obj); - $this->ensureToStringAllowedForArray($array, $lineno, $source); + $this->ensureToStringAllowedForArray($array, $lineno, $source, $seen); // Return the materialised array only when the object is not also // Stringable, so that callers that rely on `__toString` (e.g. `{{ obj }}`) @@ -169,25 +203,7 @@ final class SandboxExtension extends AbstractExtension return $obj; } - /** - * Materialises a spread operand and runs the policy on every element. - * - * @internal - * - * @throws SecurityNotAllowedMethodError - */ - public function ensureSpreadAllowed(iterable $obj, int $lineno = -1, ?Source $source = null): array - { - if ($obj instanceof \Traversable) { - $obj = iterator_to_array($obj); - } - - $this->ensureToStringAllowedForArray($obj, $lineno, $source); - - return $obj; - } - - private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, array &$stack = []): void + private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, \SplObjectStorage $seen, array &$stack = []): void { foreach ($obj as $k => $v) { if (!$v) { @@ -195,7 +211,7 @@ final class SandboxExtension extends AbstractExtension } if (!\is_array($v)) { - $this->ensureToStringAllowed($v, $lineno, $source); + $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); continue; } @@ -207,7 +223,7 @@ final class SandboxExtension extends AbstractExtension $stack[$r->getId()] = true; } - $this->ensureToStringAllowedForArray($v, $lineno, $source, $stack); + $this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack); } } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index a6d13ed1f..477645139 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1260,6 +1260,35 @@ EOF $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); } + /** + * @dataProvider getCyclicTraversableTemplates + */ + public function testSandboxHandlesCyclicTraversableWithoutStackOverflow(string $template) + { + // A self-referencing IteratorAggregate must not cause the sandbox policy + // walker to recurse infinitely when materialising the iterable. PHP itself + // throws a clean error when the cyclic object reaches `implode()` / + // string coercion; the sandbox must NOT turn that into a stack overflow. + $twig = $this->getEnvironment( + true, + [], + ['index' => $template], + [], + ['join', 'replace'], + ); + + $this->expectException(RuntimeError::class); + + $twig->load('index')->render(['obj' => new CyclicTraversableObject()]); + } + + public static function getCyclicTraversableTemplates(): iterable + { + yield 'join' => ['{{ obj|join(",") }}']; + yield 'replace' => ['{{ "x"|replace(obj) }}']; + yield 'spread' => ['{{ ["a", ...obj]|join(",") }}']; + } + public function testSourcePolicySandboxBlocksToStringInTraversableJoin() { $sourcePolicy = new class implements SourcePolicyInterface { @@ -1827,3 +1856,15 @@ class StringableTraversableObject implements \IteratorAggregate, \Stringable yield from $this->items; } } + +// Self-referencing IteratorAggregate: getIterator() yields `$this`. Used to +// verify that the sandbox policy walker (which materialises Traversables to +// enforce the `__toString` policy on yielded elements) does not recurse +// infinitely. +class CyclicTraversableObject implements \IteratorAggregate +{ + public function getIterator(): \Traversable + { + yield $this; + } +} From e3f66654b815508c36f856759c5300645a487a16 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 09:47:09 +0200 Subject: [PATCH 08/20] Fix deprecation notices in tests --- tests/Extension/SandboxTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 477645139..85a2ff1ea 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1208,10 +1208,14 @@ EOF } /** + * @group legacy + * * @dataProvider getStringableTraversableBypassTemplates */ public function testSourcePolicySandboxBlocksToStringInStringableTraversable(string $template) { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { @@ -1289,8 +1293,13 @@ EOF yield 'spread' => ['{{ ["a", ...obj]|join(",") }}']; } + /** + * @group legacy + */ public function testSourcePolicySandboxBlocksToStringInTraversableJoin() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { @@ -1309,8 +1318,13 @@ EOF } } + /** + * @group legacy + */ public function testSourcePolicySandboxBlocksToStringInTraversableReplace() { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + $sourcePolicy = new class implements SourcePolicyInterface { public function enableSandbox(Source $source): bool { From 9ff41014639ef0e8eb50ac7669191c309d863105 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 23 May 2026 08:01:24 +0200 Subject: [PATCH 09/20] Fix sandbox __toString policy bypass via dynamic mapping keys --- CHANGELOG | 1 + src/Node/Expression/ArrayExpression.php | 30 ++++++++++++++++++++----- tests/Extension/SandboxTest.php | 3 +++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 178d9cf58..7dff6d304 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,7 @@ * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`) * Fix sandbox `__toString` bypass via the `in` and `not in` operators * Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template + * Fix sandbox `__toString` policy bypass via dynamic mapping keys # 3.26.0 (2026-05-20) diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php index f9f719dd9..189e02f7e 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -13,11 +13,11 @@ namespace Twig\Node\Expression; use Twig\Compiler; use Twig\Error\SyntaxError; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\Unary\SpreadUnary; use Twig\Node\Expression\Unary\StringCastUnary; -use Twig\Node\Expression\Variable\ContextVariable; -class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface +class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface, CoercesChildrenToStringInterface { use SupportDefinedTestTrait; @@ -95,6 +95,24 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn array_push($this->nodes, $key, $value); } + public function getStringCoercedChildNames(): array + { + // dynamic mapping keys (computed at runtime) are coerced to string; + // static keys (constants or sequence indexes) are emitted as PHP + // literals by compile() and never trigger a __toString() call + $names = []; + foreach (array_chunk($this->nodes, 2) as $i => $pair) { + $key = $pair[0]; + if ($key instanceof ConstantExpression || $key instanceof TempNameExpression) { + continue; + } + + $names[] = (string) ($i * 2); + } + + return $names; + } + public function compile(Compiler $compiler): void { if ($this->definedTest) { @@ -118,13 +136,15 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn } $key = null; - if ($pair['key'] instanceof ContextVariable) { - $pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine()); - } elseif ($pair['key'] instanceof TempNameExpression) { + if ($pair['key'] instanceof TempNameExpression) { $key = $pair['key']->getAttribute('name'); $pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine()); } elseif ($pair['key'] instanceof ConstantExpression) { $key = $pair['key']->getAttribute('value'); + } else { + // dynamic key: cast to string so PHP accepts it as an array offset + // (the sandbox visitor has already wrapped it with a __toString policy check) + $pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine()); } if ($key !== $i) { diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 85a2ff1ea..e02c44fda 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -627,6 +627,9 @@ class SandboxTest extends TestCase 'do_tag_concat' => ['{% do obj ~ "" %}'], 'set_tag_filter_input' => ['{% set _ = obj|upper %}'], 'set_tag_concat' => ['{% set _ = obj ~ "" %}'], + 'set_tag_array_dynamic_key' => ['{% set _ = {(obj): "v"} %}'], + 'set_tag_array_dynamic_key_nested' => ['{% set _ = {"foo": {(obj): "v"}} %}'], + 'set_tag_array_dynamic_key_object_chain' => ['{% set _ = {(obj.anotherFooObject): "v"} %}'], 'set_capture_print' => ['{% set _ %}{{ obj }}{% endset %}'], 'is_empty_in_if' => ['{% if obj is empty %}LEAK{% endif %}'], 'is_empty_in_print' => ['{{ obj is empty ? "1" : "0" }}'], From 635cea47895f74d3e2b10abce2346c6c8ca60ece Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 23 May 2026 08:06:17 +0200 Subject: [PATCH 10/20] Document new support for any expression as a dynamic mapping key --- CHANGELOG | 1 + doc/templates.rst | 36 +++++++++++ tests/Extension/SandboxTest.php | 9 +++ .../expressions/array_dynamic_keys.test | 59 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 tests/Fixtures/expressions/array_dynamic_keys.test diff --git a/CHANGELOG b/CHANGELOG index 7dff6d304..cee1e749d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,7 @@ * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`) * Fix sandbox `__toString` bypass via the `in` and `not in` operators * Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template + * Add support for any expression as a dynamic mapping key (attribute access, filters, ...) * Fix sandbox `__toString` policy bypass via dynamic mapping keys # 3.26.0 (2026-05-20) diff --git a/doc/templates.rst b/doc/templates.rst index 0c1b73f07..0b38d5bcc 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -639,6 +639,42 @@ exist: {% set key = 'name' %} {(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'} + Any expression is supported as a dynamic key. The result is coerced to + string, so objects implementing ``__toString()`` (PHP ``Stringable``) are + accepted: + + .. code-block:: twig + + {# attribute access #} + {(user.role): 'allowed'} + + {# method call #} + {(user.getRole()): 'allowed'} + + {# filter result #} + {(name|upper): 'Fabien'} + + {# function call #} + {(slug(title)): post} + + {# chained expression #} + {(user.email|lower): 'subscribed'} + + {# Stringable object (cast via __toString) #} + {(uuid): 'token'} + + .. versionadded:: 3.26.1 + + Support for arbitrary expressions as dynamic mapping keys + (attribute access, method calls, filter results, function calls, + and any ``Stringable`` object) was added in Twig 3.26.1. + + .. note:: + + Inside a sandbox, the ``__toString()`` coercion goes through the + ``SecurityPolicy`` method allowlist, the same way as ``{{ obj }}`` + or ``{{ obj|upper }}``. + * ``true`` / ``false``: ``true`` represents the true value, ``false`` represents the false value. diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index e02c44fda..ddb6ce79a 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -799,6 +799,15 @@ class SandboxTest extends TestCase $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once'); } + public function testSandboxAllowsArrayDynamicKeyWhenToStringAllowed() + { + $twig = $this->getEnvironment(true, [], [ + 'index' => '{% set arr = {(obj): "v", (obj.anotherFooObject): "v2"} %}{{ arr|keys|join(",") }}', + ], ['set'], ['join', 'keys'], ['Twig\Tests\Extension\FooObject' => ['__toString', 'getAnotherFooObject']]); + + $this->assertSame('foo', $twig->load('index')->render(self::$params)); + } + public function testSandboxAllowMethodToStringDisabled() { $twig = $this->getEnvironment(false, [], self::$templates); diff --git a/tests/Fixtures/expressions/array_dynamic_keys.test b/tests/Fixtures/expressions/array_dynamic_keys.test new file mode 100644 index 000000000..34cbed8bc --- /dev/null +++ b/tests/Fixtures/expressions/array_dynamic_keys.test @@ -0,0 +1,59 @@ +--TEST-- +Mapping keys can be any expression that evaluates to a scalar or Stringable +--TEMPLATE-- +{# context variable holding a Stringable object #} +{{ {(obj): 'a'}|keys|join(',') }} + +{# attribute access on an object yielding a Stringable #} +{{ {(holder.stringable): 'a'}|keys|join(',') }} + +{# attribute access yielding a string #} +{{ {(holder.name): 'a'}|keys|join(',') }} + +{# filter result yielding a Stringable #} +{{ {(obj|raw): 'a'}|keys|join(',') }} + +{# filter result yielding a string #} +{{ {('hello'|upper): 'a'}|keys|join(',') }} + +{# filter result yielding an integer (keys stay int) #} +{{ {(holder.name|length): 'a'}|keys|first == 4 ? 'ok' : 'ko' }} + +{# method call yielding a Stringable #} +{{ {(holder.getStringable()): 'a'}|keys|join(',') }} + +{# mixed static and dynamic keys #} +{{ {'static': 's', (obj): 'd', (holder.name): 'n'}|keys|join(',') }} + +{# nested mapping with a dynamic Stringable key #} +{{ {'outer': {(obj): 'inner'}} | json_encode | raw }} +--DATA-- +class TwigTestStringy implements \Stringable { + public function __construct(private string $v) {} + public function __toString(): string { return $this->v; } +} +class TwigTestHolder { + public string $name = 'attr'; + public TwigTestStringy $stringable; + public function __construct() { $this->stringable = new TwigTestStringy('attr_obj'); } + public function getStringable(): TwigTestStringy { return new TwigTestStringy('method_obj'); } +} +return ['obj' => new TwigTestStringy('ctx_obj'), 'holder' => new TwigTestHolder()] +--EXPECT-- +ctx_obj + +attr_obj + +attr + +ctx_obj + +HELLO + +ok + +method_obj + +static,ctx_obj,attr + +{"outer":{"ctx_obj":"inner"}} From 23eb6eb1267cb0d303b91eb5cff9b0c559c538a4 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 24 May 2026 20:08:38 +0200 Subject: [PATCH 11/20] Fix sandbox filter/tag/function allow-list bypass when sandbox state changes between renders --- CHANGELOG | 1 + phpstan-baseline.neon | 6 - src/Extension/CoreExtension.php | 4 - src/Node/CheckSecurityCallNode.php | 1 - src/Node/CheckSecurityNode.php | 11 + src/Node/IncludeNode.php | 18 - src/Template.php | 23 ++ tests/Extension/SandboxStateChangeTest.php | 428 +++++++++++++++++++++ 8 files changed, 463 insertions(+), 29 deletions(-) create mode 100644 tests/Extension/SandboxStateChangeTest.php diff --git a/CHANGELOG b/CHANGELOG index bf04ed547..3665617b2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ * Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox * Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template + * Fix sandbox filter/tag/function allow-list bypass when the sandbox state changed between renders of a cached `Template` instance * 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/phpstan-baseline.neon b/phpstan-baseline.neon index 131ed97b4..9b8c8d863 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - # The method is dynamically generated by the CheckSecurityNode - message: '#^Call to an undefined method Twig\\Template\:\:checkSecurity\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Extension/CoreExtension.php - - # 2 parameters will be required message: '#^Method Twig\\Node\\IncludeNode\:\:addGetTemplate\(\) invoked with 2 parameters, 1 required\.$#' identifier: arguments.count diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index ea2f80011..092cd4777 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1519,10 +1519,6 @@ final class CoreExtension extends AbstractExtension return ''; } - if ($isSandboxed) { - $loaded->unwrap()->checkSecurity(); - } - return $loaded->render($variables); } finally { if ($isSandboxed && !$alreadySandboxed) { diff --git a/src/Node/CheckSecurityCallNode.php b/src/Node/CheckSecurityCallNode.php index bb8783bc3..0667bd0fd 100644 --- a/src/Node/CheckSecurityCallNode.php +++ b/src/Node/CheckSecurityCallNode.php @@ -27,7 +27,6 @@ class CheckSecurityCallNode extends Node { $compiler ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") - ->write("\$this->checkSecurity();\n") ; } } diff --git a/src/Node/CheckSecurityNode.php b/src/Node/CheckSecurityNode.php index 9f2c75324..d2aa5a864 100644 --- a/src/Node/CheckSecurityNode.php +++ b/src/Node/CheckSecurityNode.php @@ -41,6 +41,17 @@ class CheckSecurityNode extends Node public function compile(Compiler $compiler): void { $compiler + ->write("\n") + ->write("public function ensureSecurityChecked(): void\n") + ->write("{\n") + ->indent() + ->write("if (\$this->sandbox->isSandboxed(\$this->source)) {\n") + ->indent() + ->write("\$this->checkSecurity();\n") + ->outdent() + ->write("}\n") + ->outdent() + ->write("}\n") ->write("\n") ->write("public function checkSecurity()\n") ->write("{\n") diff --git a/src/Node/IncludeNode.php b/src/Node/IncludeNode.php index 2e5e94f8d..3adf415e1 100644 --- a/src/Node/IncludeNode.php +++ b/src/Node/IncludeNode.php @@ -38,8 +38,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo { $compiler->addDebugInfo($this); - $sandboxed = $this->hasAttribute('sandboxed') && $this->getAttribute('sandboxed'); - if ($this->getAttribute('ignore_missing')) { $template = $compiler->getVarName(); @@ -64,10 +62,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo ->indent() ; - if ($sandboxed) { - $compiler->write(\sprintf("\$%s->unwrap()->checkSecurity();\n", $template)); - } - $compiler->write(\sprintf('yield from $%s->unwrap()->yield(', $template)); $this->addTemplateArguments($compiler); @@ -76,18 +70,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo ->outdent() ->write("}\n") ; - } elseif ($sandboxed) { - $template = $compiler->getVarName(); - - $compiler->write(\sprintf('$%s = ', $template)); - $this->addGetTemplate($compiler); - $compiler - ->raw(";\n") - ->write(\sprintf("\$%s->unwrap()->checkSecurity();\n", $template)) - ->write(\sprintf('yield from $%s->unwrap()->yield(', $template)) - ; - $this->addTemplateArguments($compiler); - $compiler->raw(");\n"); } else { $compiler->write('yield from '); $this->addGetTemplate($compiler); diff --git a/src/Template.php b/src/Template.php index 37f8a1f03..e32c85d0f 100644 --- a/src/Template.php +++ b/src/Template.php @@ -80,6 +80,14 @@ abstract class Template return $this->parent; } + // The compiled doGetParent() may evaluate user expressions (filters, + // functions, method calls) when the parent name is dynamic. Make sure + // the sandbox security check runs first so those expressions cannot + // bypass the allow-list when getParent() is reached before the first + // ensureSecurityChecked() call on this template (e.g. via + // getTemplateForMacro() or yieldBlock() into a pre-warmed instance). + $this->ensureSecurityChecked(); + if (!$parent = $this->doGetParent($context)) { return false; } @@ -399,6 +407,7 @@ abstract class Template $blocks = array_merge($this->blocks, $blocks); try { + $this->ensureSecurityChecked(); yield from $this->doDisplay($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { @@ -443,6 +452,7 @@ abstract class Template if (null !== $template) { try { + $template->ensureSecurityChecked(); yield from $template->$block($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { @@ -510,12 +520,16 @@ abstract class Template protected function getTemplateForMacro(string $name, array $context, int $line, Source $source): self { if (method_exists($this, $name)) { + $this->ensureSecurityChecked(); + return $this; } $parent = $this; while ($parent = $parent->getParent($context)) { if (method_exists($parent, $name)) { + $parent->ensureSecurityChecked(); + return $parent; } } @@ -523,6 +537,15 @@ abstract class Template throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($name, \strlen('macro_')), $this->getTemplateName()), $line, $source); } + /** + * Runs the sandbox security check against the current sandbox state. + * + * @internal + */ + public function ensureSecurityChecked(): void + { + } + /** * Auto-generated method to display the template with the given context. * diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php new file mode 100644 index 000000000..1e0256209 --- /dev/null +++ b/tests/Extension/SandboxStateChangeTest.php @@ -0,0 +1,428 @@ +build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false); + + $this->assertSame('FOO', $twig->render('t')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('t'); + } + + public function testDisableSandboxAfterFirstRender() + { + // Use enableSandbox() to toggle a non-global sandbox so disableSandbox() actually has an effect. + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false); + $sandbox->enableSandbox(); + + try { + $twig->render('t'); + $this->fail('Expected SecurityNotAllowedFilterError on first render'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('upper', $e->getFilterName()); + } + + $sandbox->disableSandbox(); + + $this->assertSame('FOO', $twig->render('t')); + } + + public function testSetSecurityPolicyTightening() + { + $permissive = new SecurityPolicy(allowedFilters: ['upper', 'escape']); + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $permissive, true); + + $this->assertSame('FOO', $twig->render('t')); + + $sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['escape'])); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('t'); + } + + public function testSetSecurityPolicyLoosening() + { + $strict = new SecurityPolicy(allowedFilters: []); + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $strict, true); + + try { + $twig->render('t'); + $this->fail('Expected SecurityNotAllowedFilterError on first render'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('upper', $e->getFilterName()); + } + + $sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper'])); + + $this->assertSame('FOO', $twig->render('t')); + } + + /** + * @group legacy + */ + public function testSourcePolicyDecisionFlip() + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + + $sourcePolicy = new class implements SourcePolicyInterface { + public array $sandboxFor = []; + + public function enableSandbox(Source $source): bool + { + return \in_array($source->getName(), $this->sandboxFor, true); + } + }; + + [$twig] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false, $sourcePolicy); + + $this->assertSame('FOO', $twig->render('t')); + + $sourcePolicy->sandboxFor = ['t']; + + $this->expectException(SecurityNotAllowedFilterError::class); + $twig->render('t'); + } + + public function testPreWarmedParentTemplateThroughExtends() + { + $templates = [ + 'parent.twig' => '{% block c %}default{% endblock %}{{ "hi"|upper }}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}child{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + // pre-warm with sandbox off: parent and child Template instances are now cached + $this->assertSame('childHI', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('child.twig'); + } + + public function testPreWarmedSharedViaSandboxedInclude() + { + $templates = [ + 'top' => '{{ include("user", sandboxed=true) }}', + 'user' => '{% extends "shared" %}{% block c %}user{% endblock %}', + 'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFunctions: ['include'], + allowedFilters: ['escape'], + ); + [$twig] = $this->build($templates, $policy, false); + + // legitimate pre-warm of the trusted layout with sandbox off + $this->assertSame('OK', $twig->render('shared')); + + // sandboxed include of user, which extends the pre-warmed shared: + // upper is not in the allow-list, so it must throw even though shared + // was loaded unsandboxed. + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('top'); + } + + public function testMacroFromPreWarmedTemplate() + { + $templates = [ + 'macros.twig' => '{% macro greet(name) %}{{ name|upper }}{% endmacro %}', + 'caller.twig' => '{% import "macros.twig" as m %}{{ m.greet("world") }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['import', 'macro'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + // pre-warm with sandbox off + $this->assertSame('WORLD', $twig->render('caller.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('caller.twig'); + } + + /** + * @group legacy + */ + public function testSandboxTagAroundIncludeOfPreWarmedTemplate() + { + $this->expectDeprecation('Since twig/twig 3.15: The "sandbox" tag is deprecated in "wrapper" at line 1.'); + + $templates = [ + 'wrapper' => '{% sandbox %}{% include "user" %}{% endsandbox %}', + 'user' => '{% extends "shared" %}{% block c %}user{% endblock %}', + 'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block', 'include'], + allowedFilters: ['escape'], + ); + [$twig] = $this->build($templates, $policy, false); + + $this->assertSame('OK', $twig->render('shared')); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('wrapper'); + } + + public function testTagBypassThroughPreWarmedParent() + { + $templates = [ + 'parent.twig' => '{% block c %}{% for i in 1..2 %}{{ i }}{% endfor %}{% endblock %}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: [], + allowedFunctions: ['range'], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + $this->assertSame('12', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedTagError::class); + $this->expectExceptionMessage('Tag "for" is not allowed'); + $twig->render('child.twig'); + } + + public function testFunctionBypassThroughPreWarmedParent() + { + $templates = [ + 'parent.twig' => '{% block c %}{{ range(1, 2)|first }}{% endblock %}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: ['first'], + allowedFunctions: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + $this->assertSame('1', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFunctionError::class); + $this->expectExceptionMessage('Function "range" is not allowed'); + $twig->render('child.twig'); + } + + public function testDynamicParentFilterRejectedWhenReachedViaMacroImport() + { + // Regression: getTemplateForMacro() walks getParent() to find the + // macro on a parent template. When the imported template has a + // dynamic {% extends %}, doGetParent() evaluates the user expression. + // The sandbox security check must run on the imported template + // *before* doGetParent() executes, otherwise a forbidden filter on + // the parent name escapes the allow-list. + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends parent_name|evil %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFilters: [], + ); + [$twig] = $this->build($templates, $policy, true); + $evilCalls = 0; + $twig->addFilter(new \Twig\TwigFilter('evil', function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('evil', $e->getFilterName()); + } + $this->assertSame(0, $evilCalls, 'The forbidden filter must not be invoked before the security check runs.'); + } + + public function testDynamicParentFunctionRejectedWhenReachedViaMacroImport() + { + // Same root cause as the filter case, but for functions called from + // within the dynamic parent expression. + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends evil(parent_name) %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFunctions: [], + ); + [$twig] = $this->build($templates, $policy, true); + $evilCalls = 0; + $twig->addFunction(new \Twig\TwigFunction('evil', function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFunctionError'); + } catch (SecurityNotAllowedFunctionError $e) { + $this->assertSame('evil', $e->getFunctionName()); + } + $this->assertSame(0, $evilCalls, 'The forbidden function must not be invoked before the security check runs.'); + } + + public function testDynamicParentFilterRejectedOnPreWarmedTemplate() + { + // Same bypass, but reached after the template has been pre-warmed + // outside the sandbox (which would have otherwise short-circuited + // ensureSecurityChecked() at yield() time). + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends parent_name|evil %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + $evilCalls = 0; + $twig->addFilter(new \Twig\TwigFilter('evil', function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + $this->assertSame('grand', $twig->render('caller.twig', ['parent_name' => 'grandparent.twig'])); + $this->assertSame(1, $evilCalls); + + $sandbox->enableSandbox(); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('evil', $e->getFilterName()); + } + $this->assertSame(1, $evilCalls, 'The forbidden filter must not be invoked after the sandbox is enabled.'); + } + + public function testRepeatedRendersInStableSandboxedStateRunCheckEachTime() + { + $counter = new CountingSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper'])); + [$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, true); + + for ($i = 0; $i < 5; ++$i) { + $this->assertSame('X', $twig->render('t')); + } + + // The per-render check is intentionally re-run on every yield(): the cached verdict was + // the source of the original bypass. Asserting >= 5 documents the new contract. + $this->assertGreaterThanOrEqual(5, $counter->callCount); + } + + public function testNoCheckWhenSandboxRemainsOff() + { + $counter = new CountingSecurityPolicy(new SecurityPolicy()); + [$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, false); + + for ($i = 0; $i < 5; ++$i) { + $this->assertSame('X', $twig->render('t')); + } + + $this->assertSame(0, $counter->callCount); + } + + /** + * @return array{0: Environment, 1: SandboxExtension} + */ + private function build(array $templates, SecurityPolicyInterface $policy, bool $sandboxed, ?SourcePolicyInterface $sourcePolicy = null): array + { + $twig = new Environment(new ArrayLoader($templates), ['cache' => false, 'autoescape' => false]); + $sandbox = new SandboxExtension($policy, $sandboxed, $sourcePolicy); + $twig->addExtension($sandbox); + + return [$twig, $sandbox]; + } +} + +class CountingSecurityPolicy implements SecurityPolicyInterface +{ + public int $callCount = 0; + + public function __construct(private SecurityPolicyInterface $inner) + { + } + + public function checkSecurity($tags, $filters, $functions): void + { + ++$this->callCount; + $this->inner->checkSecurity($tags, $filters, $functions); + } + + public function checkMethodAllowed($obj, $method): void + { + $this->inner->checkMethodAllowed($obj, $method); + } + + public function checkPropertyAllowed($obj, $property): void + { + $this->inner->checkPropertyAllowed($obj, $property); + } +} From 04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Wed, 27 May 2026 15:05:51 +0200 Subject: [PATCH 12/20] Prepare the 3.27.0 release --- CHANGELOG | 2 +- src/Environment.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3665617b2..4e4a7a2c3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -# 3.27.0 (2026-XX-XX) +# 3.27.0 (2026-05-27) * Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox * Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template diff --git a/src/Environment.php b/src/Environment.php index ab18ea53f..3678bcfbd 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.27.0-DEV'; + public const VERSION = '3.27.0'; public const VERSION_ID = 32700; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 27; public const RELEASE_VERSION = 0; - public const EXTRA_VERSION = 'DEV'; + public const EXTRA_VERSION = ''; private $charset; private $loader; From 86f3b3a1f2827013fcd5dafbaf9e76dc2de13c90 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Wed, 27 May 2026 15:06:12 +0200 Subject: [PATCH 13/20] Bump version --- CHANGELOG | 4 ++++ src/Environment.php | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4e4a7a2c3..3848c2198 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +# 3.27.1 (2026-XX-XX) + + * n/a + # 3.27.0 (2026-05-27) * Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox diff --git a/src/Environment.php b/src/Environment.php index 3678bcfbd..49cc5a7ff 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.27.0'; - public const VERSION_ID = 32700; + public const VERSION = '3.27.1-DEV'; + public const VERSION_ID = 32701; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 27; - public const RELEASE_VERSION = 0; - public const EXTRA_VERSION = ''; + public const RELEASE_VERSION = 1; + public const EXTRA_VERSION = 'DEV'; private $charset; private $loader; From 118938b191b039c2f064cb9e8a3cc89b78ac9504 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 29 May 2026 09:30:54 +0200 Subject: [PATCH 14/20] Fix tests --- tests/Extension/SandboxStateChangeTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php index 1e0256209..9080909a8 100644 --- a/tests/Extension/SandboxStateChangeTest.php +++ b/tests/Extension/SandboxStateChangeTest.php @@ -228,7 +228,7 @@ class SandboxStateChangeTest extends TestCase $policy = new SecurityPolicy( allowedTags: ['extends', 'block'], allowedFilters: [], - allowedFunctions: ['range'], + allowedFunctions: ['parent', 'range'], ); [$twig, $sandbox] = $this->build($templates, $policy, false); @@ -250,7 +250,7 @@ class SandboxStateChangeTest extends TestCase $policy = new SecurityPolicy( allowedTags: ['extends', 'block'], allowedFilters: ['first'], - allowedFunctions: [], + allowedFunctions: ['parent'], ); [$twig, $sandbox] = $this->build($templates, $policy, false); From d25f98f45b5a0928bedd82232817bd439f7711a1 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 28 May 2026 12:14:34 +0200 Subject: [PATCH 15/20] Preserve IteratorAggregate identity in sandbox __toString walker --- CHANGELOG | 2 +- src/Extension/SandboxExtension.php | 30 ++++++++--------- tests/Extension/SandboxTest.php | 54 ++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3848c2198..174b8e237 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.27.1 (2026-XX-XX) - * n/a + * Fix sandbox replacing `IteratorAggregate` arguments (e.g. Symfony's `FormView`) by a plain array # 3.27.0 (2026-05-27) diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index 5ee4c4bed..bdc3e7348 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -172,29 +172,29 @@ final class SandboxExtension extends AbstractExtension } } - // A Traversable would later be materialised (e.g. by filters such as `join` - // or `replace`) and its elements coerced to string by PHP itself, bypassing - // the policy. Materialise it now and recursively check the contents. This - // also applies to objects that implement both `Stringable` and `Traversable`: - // the `__toString` check above only validates the container's own coercion, - // not the elements yielded by `getIterator()`. + // Elements yielded by a Traversable may be string-coerced downstream + // (e.g. by `join`/`replace`), bypassing the policy. Check them now. if ($obj instanceof \Traversable) { - // Guard against self-referencing iterables (e.g. an IteratorAggregate - // whose getIterator() yields $this): without this check, materialising - // and recursing into the elements would overflow the stack. Mirrors - // the array-cycle guard in ensureToStringAllowedForArray(). if (isset($seen[$obj])) { return $obj; } - $seen[$obj] = true; + + // IteratorAggregate::getIterator() is idempotent, so we can walk + // the elements and return the original object: host code typed + // against a specific class (e.g. FormView) keeps working. + if ($obj instanceof \IteratorAggregate) { + foreach ($obj as $v) { + $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); + } + + return $obj; + } + + // Single-pass Iterator/Generator: materialise to validate. $array = iterator_to_array($obj); $this->ensureToStringAllowedForArray($array, $lineno, $source, $seen); - // Return the materialised array only when the object is not also - // Stringable, so that callers that rely on `__toString` (e.g. `{{ obj }}`) - // keep working. Plain consumers of iterables (join, replace, ...) call - // `iterator_to_array()` again, so the extra materialisation is benign. if (!$obj instanceof \Stringable) { return $array; } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index ddb6ce79a..de6594b6d 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1355,6 +1355,60 @@ EOF } } + public function testSandboxPreservesTraversableArgumentIdentity() + { + // Regression for https://github.com/twigphp/Twig/issues/4820: + // a typed Traversable argument (e.g. Symfony's FormView) must reach + // host code as-is, not as a plain array. + $twig = $this->getEnvironment( + true, + [], + ['index' => '{{ render_traversable(obj) }}'], + [], + [], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + $twig->addFunction(new TwigFunction('render_traversable', static function ($obj) { + if (!$obj instanceof StringableTraversableObject) { + throw new \RuntimeException(\sprintf('Expected a StringableTraversableObject, got "%s".', get_debug_type($obj))); + } + + return (string) $obj; + })); + + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['render_traversable']); + + $params = ['obj' => new StringableTraversableObject(['a', 'b'])]; + $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); + } + + public function testSandboxStillBlocksDisallowedToStringInTraversableArgument() + { + // The container is returned as-is, but yielded elements must still + // be policy-checked since host code can string-coerce them. + $twig = $this->getEnvironment( + true, + [], + ['index' => '{{ render_traversable(stringable_iterator) }}'], + [], + [], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + $twig->addFunction(new TwigFunction('render_traversable', static fn ($obj) => (string) $obj)); + + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['render_traversable']); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Traversable argument to a user function.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + public function testColumnFilterUnaffectedOutsideSandbox() { $params = ['obj' => new ColumnObject()]; From 8ec953073281638c4b809adb3b70a3157c9ff343 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 29 May 2026 09:47:56 +0200 Subject: [PATCH 16/20] Fix inconsistent array access with a Stringable key --- CHANGELOG | 1 + src/Node/Expression/GetAttrExpression.php | 45 +++++++++++++++++--- tests/TemplateTest.php | 52 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 174b8e237..bf8436801 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.27.1 (2026-XX-XX) + * Fix array access with a `Stringable` key to coerce the key to string consistently instead of throwing in the optimized path * Fix sandbox replacing `IteratorAggregate` arguments (e.g. Symfony's `FormView`) by a plain array # 3.27.0 (2026-05-27) diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index b9cb74fbd..3dcd88af5 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -73,9 +73,9 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ->raw(' instanceof ArrayAccess ? (') ->raw($var) ->raw('[') - ->subcompile($this->getNode('attribute')) - ->raw('] ?? null) : null)') ; + $this->compileArrayKey($compiler); + $compiler->raw('] ?? null) : null)'); return; } @@ -90,9 +90,9 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (') ->raw($var) ->raw('[') - ->subcompile($this->getNode('attribute')) - ->raw('] ?? null) : ') ; + $this->compileArrayKey($compiler); + $compiler->raw('] ?? null) : '); } if ($this->getAttribute('ignore_strict_check')) { @@ -160,8 +160,41 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest public function getStringCoercedChildNames(): array { - // for a method-like access, the host PHP method may coerce any of its arguments to string - return $this->hasNode('arguments') ? ['arguments'] : []; + $names = []; + + // the host PHP method may coerce any argument to string + if ($this->hasNode('arguments')) { + $names[] = 'arguments'; + } + + // compileArrayKey() coerces a Stringable key; expose it so the sandbox checks __toString() + if (Template::ARRAY_CALL === $this->getAttribute('type')) { + $names[] = 'attribute'; + } + + return $names; + } + + /** + * Coerces a Stringable array key to string so the optimized path matches + * CoreExtension::getAttribute(); scalars are left to PHP's native offset coercion. + */ + private function compileArrayKey(Compiler $compiler): void + { + $attribute = $this->getNode('attribute'); + + if ($attribute instanceof ConstantExpression) { + $compiler->subcompile($attribute); + + return; + } + + $key = '$'.$compiler->getVarName(); + $compiler + ->raw('(('.$key.' = ') + ->subcompile($attribute) + ->raw(') instanceof \Stringable ? (string) '.$key.' : '.$key.')') + ; } private function changeIgnoreStrictCheck(self $node): void diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index 7102db9a7..dcd8b4060 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -252,6 +252,58 @@ class TemplateTest extends TestCase $this->assertSame('EmptyString', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, null), 'null is treated as "" when accessing a sequence/mapping (equals PHP behavior)'); } + /** + * @dataProvider getStrictVariablesModes + */ + public function testArrayAccessWithStringableKeyIsConsistentAcrossStrictModes(bool $strict) + { + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), [ + 'strict_variables' => $strict, + 'autoescape' => false, + ]); + + $object = new class implements \Stringable { + public function __toString(): string + { + return 'string'; + } + }; + + $this->assertSame('value', $twig->render('index', ['array' => ['string' => 'value'], 'object' => $object])); + } + + public static function getStrictVariablesModes(): iterable + { + yield 'lax' => [false]; + yield 'strict' => [true]; + } + + public function testArrayAccessWithStringableKeyIsCheckedBySandbox() + { + $object = new class implements \Stringable { + public function __toString(): string + { + return 'string'; + } + }; + $data = ['array' => ['string' => 'value'], 'object' => $object]; + + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), ['autoescape' => false]); + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], [], [], [], []), true)); + + try { + $twig->render('index', $data); + $this->fail('The sandbox must reject the __toString() coercion of the array key.'); + } catch (SecurityError $e) { + $this->assertStringContainsStringIgnoringCase('__toString', $e->getMessage()); + } + + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), ['autoescape' => false]); + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], [], [$object::class => ['__toString']], [], []), true)); + + $this->assertSame('value', $twig->render('index', $data)); + } + /** * @dataProvider getGetAttributeTests */ From ae2071bffb38f04847fc0864d730c94b9cb8ab74 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 30 May 2026 19:09:26 +0200 Subject: [PATCH 17/20] Prepare the 3.27.1 release --- CHANGELOG | 2 +- src/Environment.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bf8436801..aab430d4b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -# 3.27.1 (2026-XX-XX) +# 3.27.1 (2026-05-30) * Fix array access with a `Stringable` key to coerce the key to string consistently instead of throwing in the optimized path * Fix sandbox replacing `IteratorAggregate` arguments (e.g. Symfony's `FormView`) by a plain array diff --git a/src/Environment.php b/src/Environment.php index 49cc5a7ff..ff100d2d7 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.27.1-DEV'; + public const VERSION = '3.27.1'; public const VERSION_ID = 32701; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 27; public const RELEASE_VERSION = 1; - public const EXTRA_VERSION = 'DEV'; + public const EXTRA_VERSION = ''; private $charset; private $loader; From dc1db9ef63250f0b7c8c4fc5768fc86d7f6d1d80 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 30 May 2026 19:09:51 +0200 Subject: [PATCH 18/20] Bump version --- CHANGELOG | 4 ++++ src/Environment.php | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index aab430d4b..511ba7475 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +# 3.27.2 (2026-XX-XX) + + * n/a + # 3.27.1 (2026-05-30) * Fix array access with a `Stringable` key to coerce the key to string consistently instead of throwing in the optimized path diff --git a/src/Environment.php b/src/Environment.php index ff100d2d7..ba9e8a18d 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.27.1'; - public const VERSION_ID = 32701; + public const VERSION = '3.27.2-DEV'; + public const VERSION_ID = 32702; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 27; - public const RELEASE_VERSION = 1; - public const EXTRA_VERSION = ''; + public const RELEASE_VERSION = 2; + public const EXTRA_VERSION = 'DEV'; private $charset; private $loader; From 4e58cb22e5af2b8680854c377ec0ab6dfd901ea1 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Mon, 1 Jun 2026 09:00:03 +0200 Subject: [PATCH 19/20] Fix CS --- tests/Extension/SandboxStateChangeTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php index 9080909a8..d948ac147 100644 --- a/tests/Extension/SandboxStateChangeTest.php +++ b/tests/Extension/SandboxStateChangeTest.php @@ -282,7 +282,7 @@ class SandboxStateChangeTest extends TestCase ); [$twig] = $this->build($templates, $policy, true); $evilCalls = 0; - $twig->addFilter(new \Twig\TwigFilter('evil', function ($v) use (&$evilCalls) { + $twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) { ++$evilCalls; return $v; @@ -312,7 +312,7 @@ class SandboxStateChangeTest extends TestCase ); [$twig] = $this->build($templates, $policy, true); $evilCalls = 0; - $twig->addFunction(new \Twig\TwigFunction('evil', function ($v) use (&$evilCalls) { + $twig->addFunction(new \Twig\TwigFunction('evil', static function ($v) use (&$evilCalls) { ++$evilCalls; return $v; @@ -343,7 +343,7 @@ class SandboxStateChangeTest extends TestCase ); [$twig, $sandbox] = $this->build($templates, $policy, false); $evilCalls = 0; - $twig->addFilter(new \Twig\TwigFilter('evil', function ($v) use (&$evilCalls) { + $twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) { ++$evilCalls; return $v; From 6d5ef30436dca7c9b7e769791cf2ca0bd67837d7 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Mon, 1 Jun 2026 09:31:30 +0200 Subject: [PATCH 20/20] Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string --- CHANGELOG | 2 +- src/Node/Expression/CallExpression.php | 21 +- src/NodeVisitor/SandboxNodeVisitor.php | 71 +++++++ src/Util/CallableArgumentsExtractor.php | 21 +- src/Util/CallableParameters.php | 161 ++++++++++++++++ src/Util/ReflectionCallable.php | 38 +++- tests/Extension/SandboxTest.php | 242 ++++++++++++++++++++++++ tests/Util/CallableParametersTest.php | 168 ++++++++++++++++ 8 files changed, 682 insertions(+), 42 deletions(-) create mode 100644 src/Util/CallableParameters.php create mode 100644 tests/Util/CallableParametersTest.php diff --git a/CHANGELOG b/CHANGELOG index 511ba7475..1ca2712c6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.27.2 (2026-XX-XX) - * n/a + * Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string # 3.27.1 (2026-05-30) diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index 70c56c3c6..ad6d3881a 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -282,28 +282,9 @@ abstract class CallExpression extends AbstractExpression { $twigCallable = $this->getAttribute('twig_callable'); $rc = $this->reflectCallable($twigCallable); - $r = $rc->getReflector(); $callableName = $rc->getName(); - $parameters = $r->getParameters(); - if ($this->hasNode('node')) { - array_shift($parameters); - } - if ($twigCallable->needsCharset()) { - array_shift($parameters); - } - if ($twigCallable->needsEnvironment()) { - array_shift($parameters); - } - if ($twigCallable->needsContext()) { - array_shift($parameters); - } - if (self::needsIsSandboxed($twigCallable)) { - array_shift($parameters); - } - foreach ($twigCallable->getArguments() as $argument) { - array_shift($parameters); - } + $parameters = $rc->getTwigParameters($this->hasNode('node')); $isPhpVariadic = false; if ($isVariadic) { diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index 65667c2ff..be39cc778 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -27,6 +27,7 @@ use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\Node\Nodes; +use Twig\Util\CallableParameters; /** * @author Fabien Potencier @@ -85,7 +86,22 @@ final class SandboxNodeVisitor implements NodeVisitorInterface // wrap children that the node itself will string-coerce at runtime; // applies to ModuleNode (`parent` slot for {% extends %}) too if ($this->inAModule && $node instanceof CoercesChildrenToStringInterface) { + $params = CallableParameters::fromNode($node, $env); foreach ($node->getStringCoercedChildNames() as $childName) { + // For Filter/Function/Test calls, consult the PHP callable + // signature: skip wrapping arguments whose param type cannot + // implicitly string-coerce (e.g. `int`, a `final` value object). + if (null !== $params && 'arguments' === $childName) { + $this->wrapArguments($node, $params); + + continue; + } + if (null !== $params && 'node' === $childName && $node instanceof FilterExpression) { + // The filter's input value maps to the first PHP parameter. + if (isset($params[0]) && CallableParameters::isStringCoercionSafe($params[0]->getType(), $params[0]->getDeclaringClass())) { + continue; + } + } $this->wrapNode($node, $childName); } } @@ -105,6 +121,61 @@ final class SandboxNodeVisitor implements NodeVisitorInterface return $node; } + /** + * Wraps each entry in the `arguments` slot only when the corresponding + * PHP parameter type can implicitly string-coerce. + * + * @param list<\ReflectionParameter> $params parameters relative to the + * first template argument (for + * filters and tests: starting + * after the `node`/input + * parameter) + */ + private function wrapArguments(Node $node, array $params): void + { + $arguments = $node->getNode('arguments'); + if (!$arguments instanceof Nodes && !$arguments instanceof ArrayExpression) { + $this->wrapNode($node, 'arguments'); + + return; + } + + // Filters and tests pass their input value (`node`) as the first PHP + // param, so their template arguments start at offset 1. + $positional = \array_slice($params, $node->hasNode('node') ? 1 : 0); + $variadic = null; + $byName = []; + foreach ($positional as $p) { + if ($p->isVariadic()) { + $variadic = $p; + break; + } + $byName[$this->normalizeName($p->getName())] ??= $p; + } + + $positionalIdx = 0; + foreach ($arguments as $key => $_) { + if (\is_int($key)) { + $param = $positional[$positionalIdx] ?? $variadic; + if (null !== $param && !$param->isVariadic()) { + ++$positionalIdx; + } + } else { + $param = $byName[$this->normalizeName($key)] ?? $variadic; + } + + if (null !== $param && CallableParameters::isStringCoercionSafe($param->getType(), $param->getDeclaringClass())) { + continue; + } + $this->wrapNode($arguments, (string) $key); + } + } + + private function normalizeName(string $name): string + { + return strtolower(str_replace('_', '', $name)); + } + private function wrapNode(Node $node, string $name): void { $expr = $node->getNode($name); diff --git a/src/Util/CallableArgumentsExtractor.php b/src/Util/CallableArgumentsExtractor.php index c7bf54088..e3e1f82bd 100644 --- a/src/Util/CallableArgumentsExtractor.php +++ b/src/Util/CallableArgumentsExtractor.php @@ -13,7 +13,6 @@ namespace Twig\Util; use Twig\Error\SyntaxError; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\CallExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\VariadicExpression; use Twig\Node\Node; @@ -184,25 +183,7 @@ final class CallableArgumentsExtractor private function getCallableParameters(): array { - $parameters = $this->rc->getReflector()->getParameters(); - if ($this->node->hasNode('node')) { - array_shift($parameters); - } - if ($this->twigCallable->needsCharset()) { - array_shift($parameters); - } - if ($this->twigCallable->needsEnvironment()) { - array_shift($parameters); - } - if ($this->twigCallable->needsContext()) { - array_shift($parameters); - } - if (CallExpression::needsIsSandboxed($this->twigCallable)) { - array_shift($parameters); - } - foreach ($this->twigCallable->getArguments() as $argument) { - array_shift($parameters); - } + $parameters = $this->rc->getTwigParameters($this->node->hasNode('node')); $isPhpVariadic = false; if ($this->twigCallable->isVariadic()) { diff --git a/src/Util/CallableParameters.php b/src/Util/CallableParameters.php new file mode 100644 index 000000000..4321bb291 --- /dev/null +++ b/src/Util/CallableParameters.php @@ -0,0 +1,161 @@ + + * + * @internal + */ +final class CallableParameters +{ + /** + * Returns the PHP parameters of a Filter/Function/Test call mapped to its + * template-level arguments. + * + * The parameters Twig injects automatically + * (`needs_charset/environment/context/is_sandboxed`) and the bound + * `arguments` are stripped. For a filter, the first returned parameter is + * the filter's input value. Returns null when reflection fails or the node + * is not a callable expression. + * + * @return list<\ReflectionParameter>|null + */ + public static function fromNode(Node $node, Environment $env): ?array + { + if (!$node instanceof FilterExpression && !$node instanceof FunctionExpression && !$node instanceof TestExpression) { + return null; + } + + $callable = self::resolveTwigCallable($node, $env); + if (null === $callable || null === $callable->getCallable()) { + return null; + } + + try { + return (new ReflectionCallable($callable))->getTwigParameters(); + } catch (\LogicException) { + return null; + } + } + + /** + * Returns true when a PHP parameter type proves the value reaching it + * cannot implicitly string-coerce (directly or by iterating it). + * + * Safe: `int`, `float`, `bool`, `null`, `false`, `true`, `void`, `never`, + * enums, and `final` class types that are neither `Stringable` nor + * `Traversable`. + * + * Unsafe: `string`, `array`, `iterable`, `mixed`, `object`, + * untyped, interfaces, non-final classes, and any type that is + * `Stringable` or `Traversable`. Interfaces and non-final classes are + * open: a subtype could add `Stringable`/`Traversable` and reach the host + * code, bypassing the `__toString` policy, so only a `final` class (enums + * included) is closed enough. + * + * @param \ReflectionClass|null $scope resolves the relative types `self`/`parent`/`static` (which + * older PHP versions report verbatim instead of the declaring class) + */ + public static function isStringCoercionSafe(?\ReflectionType $type, ?\ReflectionClass $scope = null): bool + { + if (null === $type) { + return false; + } + + // A union value is one of its members, so every member must be safe. + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $t) { + if (!self::isStringCoercionSafe($t, $scope)) { + return false; + } + } + + return true; + } + + // An intersection value satisfies all its members at once. A safe + // member is necessarily a final class, which pins the concrete class, + // so the value is that non-coercible class whatever the other members. + if ($type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if (self::isStringCoercionSafe($t, $scope)) { + return true; + } + } + + return false; + } + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $name = $type->getName(); + + if ($type->isBuiltin()) { + return match ($name) { + // `null` (e.g. as a union member) cannot have a __toString. + 'null', 'int', 'float', 'bool', 'true', 'false', 'void', 'never' => true, + default => false, // string, array, iterable, object, mixed, callable + }; + } + + // Resolve `self`/`parent`/`static` to the concrete class + // `static` is treated like `self` since a `final` class cannot be subclassed anyway + $class = match ($name) { + 'self', 'static' => $scope, + 'parent' => $scope ? ($scope->getParentClass() ?: null) : null, + default => class_exists($name, false) ? new \ReflectionClass($name) : null, + }; + + // Interfaces and non-final classes are open: a subtype could add + // Stringable/Traversable, so only a final non-coercible class is safe + if (null === $class) { + return false; + } + if (is_a($class->getName(), \Stringable::class, true) || is_a($class->getName(), \Traversable::class, true)) { + return false; + } + + return $class->isFinal(); + } + + private static function resolveTwigCallable(Node $node, Environment $env): ?TwigCallableInterface + { + if ($node->hasAttribute('twig_callable')) { + return $node->getAttribute('twig_callable'); + } + if (!$node->hasAttribute('name')) { + return null; + } + $name = $node->getAttribute('name'); + try { + return match (true) { + $node instanceof FilterExpression => $env->getFilter($name), + $node instanceof FunctionExpression => $env->getFunction($name), + $node instanceof TestExpression => $env->getTest($name), + }; + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Util/ReflectionCallable.php b/src/Util/ReflectionCallable.php index 0298e291d..9616e3b29 100644 --- a/src/Util/ReflectionCallable.php +++ b/src/Util/ReflectionCallable.php @@ -11,6 +11,7 @@ namespace Twig\Util; +use Twig\Node\Expression\CallExpression; use Twig\TwigCallableInterface; /** @@ -25,7 +26,7 @@ final class ReflectionCallable private $name; public function __construct( - TwigCallableInterface $twigCallable, + private TwigCallableInterface $twigCallable, ) { $callable = $twigCallable->getCallable(); if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { @@ -80,6 +81,41 @@ final class ReflectionCallable return $this->reflector; } + /** + * Returns the PHP parameters that map to the callable's template-level + * arguments. + * + * The parameters Twig injects automatically (the piped input value when + * $stripInput is true, then needs_charset/environment/context/is_sandboxed) + * and the bound arguments are stripped. + * + * @return list<\ReflectionParameter> + */ + public function getTwigParameters(bool $stripInput = false): array + { + $parameters = $this->reflector->getParameters(); + if ($stripInput) { + array_shift($parameters); + } + if ($this->twigCallable->needsCharset()) { + array_shift($parameters); + } + if ($this->twigCallable->needsEnvironment()) { + array_shift($parameters); + } + if ($this->twigCallable->needsContext()) { + array_shift($parameters); + } + if (CallExpression::needsIsSandboxed($this->twigCallable)) { + array_shift($parameters); + } + foreach ($this->twigCallable->getArguments() as $argument) { + array_shift($parameters); + } + + return array_values($parameters); + } + /** * @return callable */ diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index de6594b6d..25b71f6ce 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1409,6 +1409,228 @@ EOF } } + /** + * @dataProvider getSafePhpTypesSkipToStringWrap + */ + public function testSafePhpParamTypesSkipToStringWrap(string $template, callable $func, array $params): void + { + // The sandbox visitor must not wrap arguments whose target PHP + // parameter type cannot implicitly coerce to string (int, float, + // bool, non-Stringable/non-Traversable classes, ...). We observe + // the optimization by passing values whose `__toString` is NOT in + // the policy: with the wrap, the render throws; without it, it + // succeeds. + $twig = $this->getEnvironment(true, [], ['index' => $template]); + $twig->addFunction(new TwigFunction('safe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['safe_fn']); + + $this->assertSame('ok', $twig->load('index')->render($params)); + } + + public static function getSafePhpTypesSkipToStringWrap(): iterable + { + yield 'int param' => [ + '{{ safe_fn(n) }}', + static fn (int $n) => 'ok', + ['n' => 42], + ]; + yield 'float param' => [ + '{{ safe_fn(n) }}', + static fn (float $n) => 'ok', + ['n' => 3.14], + ]; + yield 'bool param' => [ + '{{ safe_fn(b) }}', + static fn (bool $b) => 'ok', + ['b' => true], + ]; + yield 'non-stringable class param' => [ + '{{ safe_fn(obj) }}', + static fn (ColumnObject $o) => 'ok', + ['obj' => new ColumnObject()], + ]; + yield 'nullable int param with null value' => [ + '{{ safe_fn(n) }}', + static fn (?int $n) => 'ok', + ['n' => null], + ]; + yield 'int|float union param' => [ + '{{ safe_fn(n) }}', + static fn (int|float $n) => 'ok', + ['n' => 7], + ]; + } + + /** + * @dataProvider getUnsafePhpTypesStillWrap + */ + public function testUnsafePhpParamTypesStillWrap(string $template, callable $func, array $params): void + { + // Conversely, an unsafe parameter type (`mixed`, untyped, `string`, + // `iterable`, `Stringable`, ...) must keep wrapping arguments so the + // sandbox can still block disallowed `__toString` calls. + $twig = $this->getEnvironment(true, [], ['index' => $template]); + $twig->addFunction(new TwigFunction('unsafe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render($params); + $this->fail('Sandbox should still check __toString when the PHP parameter type can implicitly coerce to string.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getUnsafePhpTypesStillWrap(): iterable + { + $params = ['obj' => new FooObject()]; + yield 'untyped param' => ['{{ unsafe_fn(obj) }}', static fn ($x) => (string) $x, $params]; + yield 'mixed param' => ['{{ unsafe_fn(obj) }}', static fn (mixed $x) => (string) $x, $params]; + yield 'string param' => ['{{ unsafe_fn(obj) }}', static fn (string $x) => $x, $params]; + yield 'object param' => ['{{ unsafe_fn(obj) }}', static fn (object $x) => (string) $x, $params]; + yield 'Stringable param' => ['{{ unsafe_fn(obj) }}', static fn (\Stringable $x) => (string) $x, $params]; + } + + /** + * @dataProvider getOpenPhpTypesStillWrap + */ + public function testOpenPhpParamTypesStillWrap(callable $func, object $obj, string $class): void + { + // Interfaces and non-final classes are "open": a Stringable subtype + // can satisfy them, so the sandbox must keep gating __toString. + // Skipping the wrap on these would bypass the policy. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(obj) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['obj' => $obj]); + $this->fail('Sandbox must still check __toString for an interface or non-final class parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame($class, $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getOpenPhpTypesStillWrap(): iterable + { + yield 'interface param' => [static fn (\Countable $x) => (string) $x, new CountableFooObject(), CountableFooObject::class]; + yield 'non-final class param' => [static fn (PlainBaseObject $x) => (string) $x, new StringablePlainObject(), StringablePlainObject::class]; + } + + public function testTestArgumentsMapAfterTheTestedValueParameter(): void + { + // A test's tested value is its first PHP parameter, so its template + // arguments must be mapped to the parameters *after* it. Mapping the + // first argument to the (safe-typed) value parameter would skip its + // __toString wrap and bypass the policy. + $twig = $this->getEnvironment(true, [], ['index' => '{{ 5 is my_test(obj) }}']); + $twig->addTest(new TwigTest('my_test', static fn (int $value, $arg) => 'x' === (string) $arg)); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on a test argument bound to an unsafe parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSafeVariadicPhpTypeSkipsToStringWrap(): void + { + // PHP-variadic with a safe type: all spilled arguments skip the wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ safe_fn(1, 2, 3) }}']); + $twig->addFunction(new TwigFunction('safe_fn', static fn (int ...$x) => 'ok')); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['safe_fn']); + + $this->assertSame('ok', $twig->load('index')->render()); + } + + public function testSpreadIntoUnsafeVariadicStillWraps(): void + { + // A spread fills an unsafe (untyped) variadic param, so every spilled + // element must keep its __toString wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(...args) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', static fn (...$x) => (string) $x[0])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['args' => [new FooObject()]]); + $this->fail('Sandbox must check __toString on spread elements bound to an unsafe variadic parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedArgumentDoesNotFallThroughToSafeVariadic(): void + { + // The compiler normalizes named arguments (`foo_bar` maps to + // `$fooBar`). The sandbox visitor must use the same mapping and not + // fall back to the safe typed variadic tail, or it would skip the + // __toString check on `$fooBar`. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(foo_bar: obj) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', static fn ($fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedFilterArgumentDoesNotFallThroughToSafeVariadic(): void + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ 1|unsafe_filter(foo_bar: obj) }}']); + $twig->addFilter(new TwigFilter('unsafe_filter', static fn ($value, $fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFilters(['unsafe_filter']); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named filter arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedTestArgumentDoesNotFallThroughToSafeVariadic(): void + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is unsafe_test(foo_bar: obj) ? "yes" : "no" }}']); + $twig->addTest(new TwigTest('unsafe_test', static fn ($value, $fooBar, int ...$rest) => 'x' === (string) $fooBar, ['is_variadic' => true])); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named test arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testFilterInputTypeSkipsToStringWrap(): void + { + // A filter whose first PHP param has a safe type also skips the + // input (`node`) wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ n|safe_filter }}']); + $twig->addFilter(new TwigFilter('safe_filter', static fn (int $n) => 'ok')); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFilters(['safe_filter']); + + $this->assertSame('ok', $twig->load('index')->render(['n' => 42])); + } + public function testColumnFilterUnaffectedOutsideSandbox() { $params = ['obj' => new ColumnObject()]; @@ -1916,6 +2138,26 @@ class ColumnObject public $bar = 'bar'; } +class CountableFooObject extends FooObject implements \Countable +{ + public function count(): int + { + return 0; + } +} + +class PlainBaseObject +{ +} + +class StringablePlainObject extends PlainBaseObject implements \Stringable +{ + public function __toString(): string + { + return 'plain'; + } +} + // Implements both Stringable and Traversable: a sandbox policy may legitimately // allow the container's own `__toString`, but the elements yielded by // `getIterator()` must still be policy-checked when consumers (`join`, `replace`, diff --git a/tests/Util/CallableParametersTest.php b/tests/Util/CallableParametersTest.php new file mode 100644 index 000000000..92e61a578 --- /dev/null +++ b/tests/Util/CallableParametersTest.php @@ -0,0 +1,168 @@ +assertSame($expected, CallableParameters::isStringCoercionSafe($type, $scope)); + } + + public function testIsStringCoercionSafeDoesNotAutoloadUnknownClasses(): void + { + $autoloaded = false; + $autoload = static function () use (&$autoloaded): void { + $autoloaded = true; + }; + spl_autoload_register($autoload); + + try { + $type = (new \ReflectionFunction(eval('return static fn (CallableParametersAutoloadProbe $x) => null;')))->getParameters()[0]->getType(); + + $this->assertFalse(CallableParameters::isStringCoercionSafe($type)); + $this->assertFalse($autoloaded); + } finally { + spl_autoload_unregister($autoload); + } + } + + public static function provideTypes(): iterable + { + $param = static fn (\Closure $c): ?\ReflectionType => (new \ReflectionFunction($c))->getParameters()[0]->getType(); + $mParam = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getParameters()[0]->getType(); + $mReturn = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getReturnType(); + + // No type information: must keep the check. + yield 'untyped' => [$param(static fn ($x) => null), false]; + + // Builtin scalars that cannot hold a string-coercible value. + yield 'int' => [$param(static fn (int $x) => null), true]; + yield 'float' => [$param(static fn (float $x) => null), true]; + yield 'bool' => [$param(static fn (bool $x) => null), true]; + yield 'never (return)' => [(new \ReflectionFunction(static function (): never { throw new \LogicException(); }))->getReturnType(), true]; + + // Builtins that can carry or coerce to a string. + yield 'string' => [$param(static fn (string $x) => null), false]; + yield 'array' => [$param(static fn (array $x) => null), false]; + yield 'iterable' => [$param(static fn (iterable $x) => null), false]; + yield 'object' => [$param(static fn (object $x) => null), false]; + yield 'mixed' => [$param(static fn (mixed $x) => null), false]; + yield 'callable' => [$param(static fn (callable $x) => null), false]; + + // Class types: safe only when final and neither Stringable nor + // Traversable. Interfaces and non-final classes are open (a subtype + // could add Stringable/Traversable), so they must stay unsafe. + yield 'final plain class' => [$param(static fn (FinalPlainObject $x) => null), true]; + yield 'nullable final class' => [$param(static fn (?FinalPlainObject $x) => null), true]; + yield 'non-final class' => [$param(static fn (\stdClass $x) => null), false]; + yield 'final Stringable class' => [$param(static fn (FinalStringableObject $x) => null), false]; + yield 'Countable interface' => [$param(static fn (\Countable $x) => null), false]; + yield 'Stringable interface' => [$param(static fn (\Stringable $x) => null), false]; + yield 'Traversable interface' => [$param(static fn (\Iterator $x) => null), false]; + yield 'Traversable class' => [$param(static fn (\ArrayIterator $x) => null), false]; + + // Enums are implicitly final and cannot be Stringable (no __toString). + yield 'backed enum' => [$param(static fn (SampleBackedEnum $x) => null), true]; + yield 'pure enum' => [$param(static fn (SamplePureEnum $x) => null), true]; + + // `self`/`parent`/`static` resolve to the declaring class (passed as + // scope) and are then judged by the class rules. PHP < 8.4 reports + // them verbatim, recent versions resolve them in getName(); both paths + // must yield the same verdict. + yield 'self -> Stringable class' => [$mParam(CoercionFixture::class, 'selfParam'), false, new \ReflectionClass(CoercionFixture::class)]; + yield 'self -> final class' => [$mParam(FinalSelfFixture::class, 'selfParam'), true, new \ReflectionClass(FinalSelfFixture::class)]; + yield 'parent -> non-final class' => [$mParam(CoercionFixture::class, 'parentParam'), false, new \ReflectionClass(CoercionFixture::class)]; + yield 'static (return) -> self' => [$mReturn(CoercionFixture::class, 'staticReturn'), false, new \ReflectionClass(CoercionFixture::class)]; + + // Union: the value is one of the members, so all must be safe. + yield 'int|float union' => [$param(static fn (int|float $x) => null), true]; + yield 'int|null union' => [$param(static fn (?int $x) => null), true]; + yield 'int|float|null union' => [$param(static fn (int|float|null $x) => null), true]; + yield 'int|string union' => [$param(static fn (int|string $x) => null), false]; + + // Intersection: safe as soon as one member is a final, non-coercible + // class (it pins the concrete class); otherwise open and unsafe. + yield 'final class intersection' => [$param(static fn (FinalMarkedObject&SampleMarker $x) => null), true]; + yield 'interface intersection' => [$param(static fn (\Countable&\ArrayAccess $x) => null), false]; + yield 'Stringable intersection' => [$param(static fn (\Stringable&\Countable $x) => null), false]; + yield 'Traversable intersection' => [$param(static fn (\Traversable&\Countable $x) => null), false]; + } +} + +enum SampleBackedEnum: string +{ + case A = 'a'; +} + +enum SamplePureEnum +{ + case A; +} + +interface SampleMarker +{ +} + +final class FinalMarkedObject implements SampleMarker +{ +} + +final class FinalPlainObject +{ +} + +final class FinalStringableObject implements \Stringable +{ + public function __toString(): string + { + return ''; + } +} + +final class FinalSelfFixture +{ + public function selfParam(self $x): void + { + } +} + +class CoercionFixtureParent +{ +} + +class CoercionFixture extends CoercionFixtureParent implements \Stringable +{ + public function selfParam(self $x): void + { + } + + public function parentParam(parent $x): void + { + } + + public function staticReturn(): static + { + return $this; + } + + public function __toString(): string + { + return ''; + } +}