Guard sandbox __toString walker against self-referencing iterables

This commit is contained in:
Fabien Potencier
2026-05-24 09:38:10 +02:00
parent e9e818cbfc
commit 475fb690ac
3 changed files with 81 additions and 23 deletions
+1
View File
@@ -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)
+39 -23
View File
@@ -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);
}
}
}
+41
View File
@@ -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;
}
}