Preserve IteratorAggregate identity in sandbox __toString walker

This commit is contained in:
Fabien Potencier
2026-05-28 12:14:34 +02:00
parent 118938b191
commit d25f98f45b
3 changed files with 70 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# 3.27.1 (2026-XX-XX)
* n/a
* Fix sandbox replacing `IteratorAggregate` arguments (e.g. Symfony's `FormView`) by a plain array
# 3.27.0 (2026-05-27)
+15 -15
View File
@@ -172,29 +172,29 @@ final class SandboxExtension extends AbstractExtension
}
}
// A Traversable would later be materialised (e.g. by filters such as `join`
// or `replace`) and its elements coerced to string by PHP itself, bypassing
// the policy. Materialise it now and recursively check the contents. This
// also applies to objects that implement both `Stringable` and `Traversable`:
// the `__toString` check above only validates the container's own coercion,
// not the elements yielded by `getIterator()`.
// Elements yielded by a Traversable may be string-coerced downstream
// (e.g. by `join`/`replace`), bypassing the policy. Check them now.
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;
// IteratorAggregate::getIterator() is idempotent, so we can walk
// the elements and return the original object: host code typed
// against a specific class (e.g. FormView) keeps working.
if ($obj instanceof \IteratorAggregate) {
foreach ($obj as $v) {
$this->doEnsureToStringAllowed($v, $lineno, $source, $seen);
}
return $obj;
}
// Single-pass Iterator/Generator: materialise to validate.
$array = iterator_to_array($obj);
$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 }}`)
// keep working. Plain consumers of iterables (join, replace, ...) call
// `iterator_to_array()` again, so the extra materialisation is benign.
if (!$obj instanceof \Stringable) {
return $array;
}
+54
View File
@@ -1355,6 +1355,60 @@ EOF
}
}
public function testSandboxPreservesTraversableArgumentIdentity()
{
// Regression for https://github.com/twigphp/Twig/issues/4820:
// a typed Traversable argument (e.g. Symfony's FormView) must reach
// host code as-is, not as a plain array.
$twig = $this->getEnvironment(
true,
[],
['index' => '{{ render_traversable(obj) }}'],
[],
[],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
$twig->addFunction(new TwigFunction('render_traversable', static function ($obj) {
if (!$obj instanceof StringableTraversableObject) {
throw new \RuntimeException(\sprintf('Expected a StringableTraversableObject, got "%s".', get_debug_type($obj)));
}
return (string) $obj;
}));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['render_traversable']);
$params = ['obj' => new StringableTraversableObject(['a', 'b'])];
$this->assertSame('stringable-traversable', $twig->load('index')->render($params));
}
public function testSandboxStillBlocksDisallowedToStringInTraversableArgument()
{
// The container is returned as-is, but yielded elements must still
// be policy-checked since host code can string-coerce them.
$twig = $this->getEnvironment(
true,
[],
['index' => '{{ render_traversable(stringable_iterator) }}'],
[],
[],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
$twig->addFunction(new TwigFunction('render_traversable', static fn ($obj) => (string) $obj));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['render_traversable']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox should block __toString on objects yielded by a Traversable argument to a user function.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testColumnFilterUnaffectedOutsideSandbox()
{
$params = ['obj' => new ColumnObject()];