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"}}