From 8ec953073281638c4b809adb3b70a3157c9ff343 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 29 May 2026 09:47:56 +0200 Subject: [PATCH] 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 174b8e237a2..bf8436801d7 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 b9cb74fbdaa..3dcd88af526 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -73,9 +73,9 @@ public function compile(Compiler $compiler): void ->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 @@ public function compile(Compiler $compiler): void ->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 @@ public function compile(Compiler $compiler): void 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 7102db9a72b..dcd8b406048 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -252,6 +252,58 @@ public function testGetAttributeOnArrayWithConfusableKey() $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 */