Fix sandbox __toString policy bypass via dynamic mapping keys

This commit is contained in:
Fabien Potencier
2026-05-23 08:01:24 +02:00
parent baebc46b67
commit 9ff4101463
3 changed files with 29 additions and 5 deletions
+1
View File
@@ -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)
+25 -5
View File
@@ -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) {
+3
View File
@@ -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" }}'],