diff --git a/CHANGELOG b/CHANGELOG index e1615c91d..d9459ce9a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ * Fix the deprecation about omitting parentheses when calling a macro being reported twice for the same call * Add the macro name to the deprecation about omitting parentheses when calling a macro * Deprecate cloning a `Twig\Environment` instance + * Fix array access with a `Stringable` key on subclasses of `ArrayObject` and `ArrayIterator` # 3.29.0 (2026-09-18) diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 5fd56e9eb..30f841393 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -131,14 +131,6 @@ final class CoreExtension extends AbstractExtension 'SplStack', 'WeakMap', ]; - /** - * @internal - */ - public const STRINGABLE_KEY_ARRAY_ACCESS_CLASSES = [ - 'ArrayIterator', - 'ArrayObject', - 'RecursiveArrayIterator', - ]; private const DEFAULT_TRIM_CHARS = " \t\n\r\0\x0B"; @@ -1757,6 +1749,10 @@ final class CoreExtension extends AbstractExtension if (Template::METHOD_CALL !== $type) { $arrayItem = \is_bool($item) || \is_float($item) ? (int) $item : $item; + if ($arrayItem instanceof \Stringable && ($object instanceof \ArrayObject || $object instanceof \ArrayIterator)) { + $arrayItem = (string) $arrayItem; + } + if ($sandboxed && $object instanceof \ArrayAccess && !\in_array($object::class, self::ARRAY_LIKE_CLASSES, true)) { try { $env->getExtension(SandboxExtension::class)->getChecker()->checkPropertyAllowed($object, $arrayItem, $lineno, $source); @@ -1769,10 +1765,6 @@ final class CoreExtension extends AbstractExtension } } - if ($object instanceof \ArrayAccess && $arrayItem instanceof \Stringable && \in_array($object::class, self::STRINGABLE_KEY_ARRAY_ACCESS_CLASSES, true)) { - $arrayItem = (string) $arrayItem; - } - if (match (true) { \is_array($object) => \array_key_exists($arrayItem = (string) $arrayItem, $object), $object instanceof \ArrayAccess => $object->offsetExists($arrayItem), diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index f1502be42..665e4a465 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -195,7 +195,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest $compiler ->raw('(('.$key.' = ') ->subcompile($attribute) - ->raw(') instanceof \Stringable && (is_array('.$var.') || in_array('.$var.'::class, CoreExtension::STRINGABLE_KEY_ARRAY_ACCESS_CLASSES, true)) ? (string) '.$key.' : '.$key.')') + ->raw(') instanceof \Stringable && (is_array('.$var.') || '.$var.' instanceof \ArrayObject || '.$var.' instanceof \ArrayIterator) ? (string) '.$key.' : '.$key.')') ; } diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index 8db4b2113..fee648b24 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -352,6 +352,36 @@ class TemplateTest extends TestCase } } + /** + * @dataProvider getStringableKeySubclassArrayAccessContainers + */ + #[DataProvider('getStringableKeySubclassArrayAccessContainers')] + public function testStringableKeyIsCoercedForSubclassesOfInternalArrayAccess(bool $strict, bool $sandboxed, \ArrayAccess $data): void + { + $twig = new Environment(new ArrayLoader(['index' => '{{ data[key] }}']), [ + 'strict_variables' => $strict, + 'autoescape' => false, + ]); + $key = new TemplateStringableKey(); + if ($sandboxed) { + // subclasses are not part of CoreExtension::ARRAY_LIKE_CLASSES, so the sandbox checks the key as a property + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], [], [$key::class => ['__toString']], [$data::class => ['string']], []), true)); + } + + $this->assertSame('value', $twig->render('index', ['data' => $data, 'key' => $key])); + $this->assertSame(1, $key->toStringCalls); + } + + public static function getStringableKeySubclassArrayAccessContainers(): iterable + { + foreach (['lax' => false, 'strict' => true] as $mode => $strict) { + foreach (['unsandboxed' => false, 'sandboxed' => true] as $sandboxMode => $sandboxed) { + yield $mode.' '.$sandboxMode.' ArrayObject subclass' => [$strict, $sandboxed, new TemplateArrayObjectSubclass(['string' => 'value'])]; + yield $mode.' '.$sandboxMode.' ArrayIterator subclass' => [$strict, $sandboxed, new TemplateArrayIteratorSubclass(['string' => 'value'])]; + } + } + } + /** * @dataProvider getStrictVariablesModes */ @@ -829,6 +859,14 @@ class TemplateForTest extends Template } } +final class TemplateArrayObjectSubclass extends \ArrayObject +{ +} + +final class TemplateArrayIteratorSubclass extends \ArrayIterator +{ +} + final class TemplateStringableKey implements \Stringable { public int $toStringCalls = 0;