From 447d0b2331e01b8fc6e08119ac984e1ef50caef9 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 19 May 2026 16:49:38 +0200 Subject: [PATCH] Fix sandbox `__toString` bypasses --- CHANGELOG | 2 + src/Extension/CoreExtension.php | 3 + src/Extension/SandboxExtension.php | 18 ++ src/Node/CheckToStringNode.php | 7 +- src/Node/CoercesChildrenToStringInterface.php | 40 +++++ src/Node/DeprecatedNode.php | 16 +- src/Node/Expression/Binary/ConcatBinary.php | 8 +- src/Node/Expression/Binary/EqualBinary.php | 8 +- src/Node/Expression/Binary/GreaterBinary.php | 8 +- .../Expression/Binary/GreaterEqualBinary.php | 8 +- src/Node/Expression/Binary/LessBinary.php | 8 +- .../Expression/Binary/LessEqualBinary.php | 8 +- src/Node/Expression/Binary/MatchesBinary.php | 8 +- src/Node/Expression/Binary/NotEqualBinary.php | 8 +- src/Node/Expression/Binary/RangeBinary.php | 8 +- .../Expression/Binary/SpaceshipBinary.php | 8 +- .../Expression/BlockReferenceExpression.php | 9 +- src/Node/Expression/FilterExpression.php | 9 +- src/Node/Expression/FunctionExpression.php | 9 +- src/Node/Expression/GetAttrExpression.php | 9 +- src/Node/Expression/Test/DefinedTest.php | 6 + src/Node/Expression/Test/DivisiblebyTest.php | 6 + src/Node/Expression/Test/EvenTest.php | 6 + src/Node/Expression/Test/NullTest.php | 6 + src/Node/Expression/Test/OddTest.php | 6 + src/Node/Expression/Test/SameasTest.php | 6 + src/Node/Expression/Test/TrueTest.php | 6 + src/Node/Expression/TestExpression.php | 20 ++- src/Node/ImportNode.php | 8 +- src/Node/IncludeNode.php | 8 +- src/Node/ModuleNode.php | 8 +- src/Node/PrintNode.php | 7 +- src/NodeVisitor/SandboxNodeVisitor.php | 62 +++---- tests/Extension/SandboxTest.php | 163 +++++++++++++++++- tests/NodeVisitor/SandboxTest.php | 63 +++++++ 35 files changed, 516 insertions(+), 67 deletions(-) create mode 100644 src/Node/CoercesChildrenToStringInterface.php diff --git a/CHANGELOG b/CHANGELOG index 4abc524c4..84d4dfd91 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ # 3.26.0 (2026-XX-XX) * Encode single quotes as `\x27` in `Compiler::string()` as a defense-in-depth measure + * Fix sandbox `__toString` bypasses + * Add `Twig\Node\CoercesChildrenToStringInterface` to let nodes declare which of their child nodes will be string-coerced at runtime so the sandbox wraps them with a `__toString` check # 3.25.0 (2026-05-17) diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index ede3d636a..02628ce85 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1686,6 +1686,9 @@ final class CoreExtension extends AbstractExtension public static function getAttribute(Environment $env, Source $source, $object, $item, array $arguments = [], $type = Template::ANY_CALL, $isDefinedTest = false, $ignoreStrictCheck = false, $sandboxed = false, int $lineno = -1) { $propertyNotAllowedError = null; + if ($sandboxed && $item instanceof \Stringable) { + $env->getExtension(SandboxExtension::class)->ensureToStringAllowed($item, $lineno, $source); + } // array if (Template::METHOD_CALL !== $type) { diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index 5d0f64443..52f096702 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -142,6 +142,24 @@ 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 { foreach ($obj as $k => $v) { diff --git a/src/Node/CheckToStringNode.php b/src/Node/CheckToStringNode.php index 937240c1d..11aec9cc0 100644 --- a/src/Node/CheckToStringNode.php +++ b/src/Node/CheckToStringNode.php @@ -28,16 +28,17 @@ use Twig\Node\Expression\AbstractExpression; #[YieldReady] class CheckToStringNode extends AbstractExpression { - public function __construct(AbstractExpression $expr) + public function __construct(AbstractExpression $expr, bool $spread = false) { - parent::__construct(['expr' => $expr], [], $expr->getTemplateLine()); + parent::__construct(['expr' => $expr], ['spread' => $spread], $expr->getTemplateLine()); } public function compile(Compiler $compiler): void { $expr = $this->getNode('expr'); + $method = $this->getAttribute('spread') ? 'ensureSpreadAllowed' : 'ensureToStringAllowed'; $compiler - ->raw('$this->sandbox->ensureToStringAllowed(') + ->raw('$this->sandbox->'.$method.'(') ->subcompile($expr) ->raw(', ') ->repr($expr->getTemplateLine()) diff --git a/src/Node/CoercesChildrenToStringInterface.php b/src/Node/CoercesChildrenToStringInterface.php new file mode 100644 index 000000000..e213d1b61 --- /dev/null +++ b/src/Node/CoercesChildrenToStringInterface.php @@ -0,0 +1,40 @@ + + */ +interface CoercesChildrenToStringInterface +{ + /** + * Returns the names of the child nodes that will be coerced to + * string when this node is evaluated. + * + * @return string[] + */ + public function getStringCoercedChildNames(): array; +} diff --git a/src/Node/DeprecatedNode.php b/src/Node/DeprecatedNode.php index 0772adfc3..fea8d355b 100644 --- a/src/Node/DeprecatedNode.php +++ b/src/Node/DeprecatedNode.php @@ -22,7 +22,7 @@ use Twig\Node\Expression\ConstantExpression; * @author Yonel Ceruto */ #[YieldReady] -class DeprecatedNode extends Node +class DeprecatedNode extends Node implements CoercesChildrenToStringInterface { public function __construct(AbstractExpression $expr, int $lineno) { @@ -70,4 +70,18 @@ class DeprecatedNode extends Node ->raw(");\n") ; } + + public function getStringCoercedChildNames(): array + { + // the message is concatenated with `.`, and `package` / `version` are typed `string` on trigger_deprecation() + $names = ['expr']; + if ($this->hasNode('package')) { + $names[] = 'package'; + } + if ($this->hasNode('version')) { + $names[] = 'version'; + } + + return $names; + } } diff --git a/src/Node/Expression/Binary/ConcatBinary.php b/src/Node/Expression/Binary/ConcatBinary.php index 75ee65473..e4ec135dd 100644 --- a/src/Node/Expression/Binary/ConcatBinary.php +++ b/src/Node/Expression/Binary/ConcatBinary.php @@ -13,12 +13,18 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnStringInterface; -class ConcatBinary extends AbstractBinary implements ReturnStringInterface +class ConcatBinary extends AbstractBinary implements ReturnStringInterface, CoercesChildrenToStringInterface { public function operator(Compiler $compiler): Compiler { return $compiler->raw('.'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/EqualBinary.php b/src/Node/Expression/Binary/EqualBinary.php index 8c3650355..d438e2b5d 100644 --- a/src/Node/Expression/Binary/EqualBinary.php +++ b/src/Node/Expression/Binary/EqualBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class EqualBinary extends AbstractBinary implements ReturnBoolInterface +class EqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class EqualBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('=='); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/GreaterBinary.php b/src/Node/Expression/Binary/GreaterBinary.php index 71a980b3e..318c3019d 100644 --- a/src/Node/Expression/Binary/GreaterBinary.php +++ b/src/Node/Expression/Binary/GreaterBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class GreaterBinary extends AbstractBinary implements ReturnBoolInterface +class GreaterBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class GreaterBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('>'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/GreaterEqualBinary.php b/src/Node/Expression/Binary/GreaterEqualBinary.php index c92e61b37..2dacd9f0f 100644 --- a/src/Node/Expression/Binary/GreaterEqualBinary.php +++ b/src/Node/Expression/Binary/GreaterEqualBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface +class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('>='); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/LessBinary.php b/src/Node/Expression/Binary/LessBinary.php index 293d98d51..3bb629122 100644 --- a/src/Node/Expression/Binary/LessBinary.php +++ b/src/Node/Expression/Binary/LessBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class LessBinary extends AbstractBinary implements ReturnBoolInterface +class LessBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class LessBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('<'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/LessEqualBinary.php b/src/Node/Expression/Binary/LessEqualBinary.php index 239d9fdfe..30217454f 100644 --- a/src/Node/Expression/Binary/LessEqualBinary.php +++ b/src/Node/Expression/Binary/LessEqualBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface +class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('<='); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/MatchesBinary.php b/src/Node/Expression/Binary/MatchesBinary.php index bd1f24f0c..5dd3dbeb0 100644 --- a/src/Node/Expression/Binary/MatchesBinary.php +++ b/src/Node/Expression/Binary/MatchesBinary.php @@ -13,12 +13,13 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; use Twig\Error\SyntaxError; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\ReturnBoolInterface; use Twig\Node\Node; -class MatchesBinary extends AbstractBinary implements ReturnBoolInterface +class MatchesBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function __construct(Node $left, Node $right, int $lineno) { @@ -57,4 +58,9 @@ class MatchesBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw(''); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/NotEqualBinary.php b/src/Node/Expression/Binary/NotEqualBinary.php index fd24ef911..c63e32517 100644 --- a/src/Node/Expression/Binary/NotEqualBinary.php +++ b/src/Node/Expression/Binary/NotEqualBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface +class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -37,4 +38,9 @@ class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('!='); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/RangeBinary.php b/src/Node/Expression/Binary/RangeBinary.php index f318d8e55..2d0d2eb5b 100644 --- a/src/Node/Expression/Binary/RangeBinary.php +++ b/src/Node/Expression/Binary/RangeBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnArrayInterface; -class RangeBinary extends AbstractBinary implements ReturnArrayInterface +class RangeBinary extends AbstractBinary implements ReturnArrayInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -31,4 +32,9 @@ class RangeBinary extends AbstractBinary implements ReturnArrayInterface { return $compiler->raw('..'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/SpaceshipBinary.php b/src/Node/Expression/Binary/SpaceshipBinary.php index c0a28b0a8..2fb5ddf9e 100644 --- a/src/Node/Expression/Binary/SpaceshipBinary.php +++ b/src/Node/Expression/Binary/SpaceshipBinary.php @@ -12,12 +12,18 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnNumberInterface; -class SpaceshipBinary extends AbstractBinary implements ReturnNumberInterface +class SpaceshipBinary extends AbstractBinary implements ReturnNumberInterface, CoercesChildrenToStringInterface { public function operator(Compiler $compiler): Compiler { return $compiler->raw('<=>'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/BlockReferenceExpression.php b/src/Node/Expression/BlockReferenceExpression.php index cb7d38c57..657a63719 100644 --- a/src/Node/Expression/BlockReferenceExpression.php +++ b/src/Node/Expression/BlockReferenceExpression.php @@ -13,6 +13,7 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Node; /** @@ -20,7 +21,7 @@ use Twig\Node\Node; * * @author Fabien Potencier */ -class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface +class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface { use SupportDefinedTestDeprecationTrait; use SupportDefinedTestTrait; @@ -60,6 +61,12 @@ class BlockReferenceExpression extends AbstractExpression implements SupportDefi } } + public function getStringCoercedChildNames(): array + { + // the template expression is resolved through the loader, which coerces it to a string + return $this->hasNode('template') ? ['template'] : []; + } + private function compileTemplateCall(Compiler $compiler, string $method): Compiler { if (!$this->hasNode('template')) { diff --git a/src/Node/Expression/FilterExpression.php b/src/Node/Expression/FilterExpression.php index a66b0266d..eb947201d 100644 --- a/src/Node/Expression/FilterExpression.php +++ b/src/Node/Expression/FilterExpression.php @@ -14,11 +14,12 @@ namespace Twig\Node\Expression; use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\NameDeprecation; use Twig\Node\Node; use Twig\TwigFilter; -class FilterExpression extends CallExpression +class FilterExpression extends CallExpression implements CoercesChildrenToStringInterface { /** * @param AbstractExpression $node @@ -77,4 +78,10 @@ class FilterExpression extends CallExpression $this->compileCallable($compiler); } + + public function getStringCoercedChildNames(): array + { + // a filter may coerce its input and arguments to string (e.g. `upper`, `replace`) + return ['node', 'arguments']; + } } diff --git a/src/Node/Expression/FunctionExpression.php b/src/Node/Expression/FunctionExpression.php index 183145c41..5983962dc 100644 --- a/src/Node/Expression/FunctionExpression.php +++ b/src/Node/Expression/FunctionExpression.php @@ -13,11 +13,12 @@ namespace Twig\Node\Expression; use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\NameDeprecation; use Twig\Node\Node; use Twig\TwigFunction; -class FunctionExpression extends CallExpression implements SupportDefinedTestInterface +class FunctionExpression extends CallExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface { use SupportDefinedTestDeprecationTrait; use SupportDefinedTestTrait; @@ -78,4 +79,10 @@ class FunctionExpression extends CallExpression implements SupportDefinedTestInt $this->compileCallable($compiler); } + + public function getStringCoercedChildNames(): array + { + // a function may coerce its arguments to string (the host PHP code is opaque to Twig) + return ['arguments']; + } } diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index cf6239517..b9cb74fbd 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -14,10 +14,11 @@ namespace Twig\Node\Expression; use Twig\Compiler; use Twig\Extension\SandboxExtension; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Template; -class GetAttrExpression extends AbstractExpression implements SupportDefinedTestInterface +class GetAttrExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface { use SupportDefinedTestDeprecationTrait; use SupportDefinedTestTrait; @@ -157,6 +158,12 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest } } + public function getStringCoercedChildNames(): array + { + // for a method-like access, the host PHP method may coerce any of its arguments to string + return $this->hasNode('arguments') ? ['arguments'] : []; + } + private function changeIgnoreStrictCheck(self $node): void { $node->setAttribute('optimizable', false); diff --git a/src/Node/Expression/Test/DefinedTest.php b/src/Node/Expression/Test/DefinedTest.php index d73502990..73e43720f 100644 --- a/src/Node/Expression/Test/DefinedTest.php +++ b/src/Node/Expression/Test/DefinedTest.php @@ -59,4 +59,10 @@ class DefinedTest extends TestExpression { $compiler->subcompile($this->getNode('node')); } + + public function getStringCoercedChildNames(): array + { + // the `defined` test does not coerce its node to string (it only inspects existence) + return []; + } } diff --git a/src/Node/Expression/Test/DivisiblebyTest.php b/src/Node/Expression/Test/DivisiblebyTest.php index 90d58a49a..ab9dd416b 100644 --- a/src/Node/Expression/Test/DivisiblebyTest.php +++ b/src/Node/Expression/Test/DivisiblebyTest.php @@ -33,4 +33,10 @@ class DivisiblebyTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/src/Node/Expression/Test/EvenTest.php b/src/Node/Expression/Test/EvenTest.php index a0e3ed62c..81a06aa69 100644 --- a/src/Node/Expression/Test/EvenTest.php +++ b/src/Node/Expression/Test/EvenTest.php @@ -32,4 +32,10 @@ class EvenTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/src/Node/Expression/Test/NullTest.php b/src/Node/Expression/Test/NullTest.php index be5d38891..904aef927 100644 --- a/src/Node/Expression/Test/NullTest.php +++ b/src/Node/Expression/Test/NullTest.php @@ -31,4 +31,10 @@ class NullTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // `=== null` is strict, no coercion + return []; + } } diff --git a/src/Node/Expression/Test/OddTest.php b/src/Node/Expression/Test/OddTest.php index d56c71116..967c35311 100644 --- a/src/Node/Expression/Test/OddTest.php +++ b/src/Node/Expression/Test/OddTest.php @@ -32,4 +32,10 @@ class OddTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/src/Node/Expression/Test/SameasTest.php b/src/Node/Expression/Test/SameasTest.php index f1e24db6f..cc4d723d0 100644 --- a/src/Node/Expression/Test/SameasTest.php +++ b/src/Node/Expression/Test/SameasTest.php @@ -31,4 +31,10 @@ class SameasTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // `===` is strict, no coercion + return []; + } } diff --git a/src/Node/Expression/Test/TrueTest.php b/src/Node/Expression/Test/TrueTest.php index 22186a689..f830b8f2e 100644 --- a/src/Node/Expression/Test/TrueTest.php +++ b/src/Node/Expression/Test/TrueTest.php @@ -31,4 +31,10 @@ class TrueTest extends TestExpression ->raw(') && $tmp instanceof Markup ? (string) $tmp : $tmp)') ; } + + public function getStringCoercedChildNames(): array + { + // the `(string)` cast only fires for Markup instances, whose __toString is always allowed + return []; + } } diff --git a/src/Node/Expression/TestExpression.php b/src/Node/Expression/TestExpression.php index 3b51dd320..4d8123941 100644 --- a/src/Node/Expression/TestExpression.php +++ b/src/Node/Expression/TestExpression.php @@ -13,11 +13,12 @@ namespace Twig\Node\Expression; use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\NameDeprecation; use Twig\Node\Node; use Twig\TwigTest; -class TestExpression extends CallExpression implements ReturnBoolInterface +class TestExpression extends CallExpression implements ReturnBoolInterface, CoercesChildrenToStringInterface { #[FirstClassTwigCallableReady] /** @@ -70,4 +71,21 @@ class TestExpression extends CallExpression implements ReturnBoolInterface $this->compileCallable($compiler); } + + public function getStringCoercedChildNames(): array + { + $names = []; + + // the `empty` test triggers an implicit string coercion through `CoreExtension::testEmpty()` + if ('empty' === $this->getAttribute('name')) { + $names[] = 'node'; + } + + // a test may coerce its arguments to string (the host PHP code is opaque to Twig) + if ($this->hasNode('arguments')) { + $names[] = 'arguments'; + } + + return $names; + } } diff --git a/src/Node/ImportNode.php b/src/Node/ImportNode.php index 92bdd5ebf..ab7fe0ff5 100644 --- a/src/Node/ImportNode.php +++ b/src/Node/ImportNode.php @@ -23,7 +23,7 @@ use Twig\Node\Expression\Variable\ContextVariable; * @author Fabien Potencier */ #[YieldReady] -class ImportNode extends Node +class ImportNode extends Node implements CoercesChildrenToStringInterface { public function __construct(AbstractExpression $expr, AbstractExpression|AssignTemplateVariable $var, int $lineno) { @@ -58,4 +58,10 @@ class ImportNode extends Node $compiler->raw(";\n"); } + + public function getStringCoercedChildNames(): array + { + // the loader resolves the template-name expression by coercing it to a string + return ['expr']; + } } diff --git a/src/Node/IncludeNode.php b/src/Node/IncludeNode.php index f34a5da06..2e5e94f8d 100644 --- a/src/Node/IncludeNode.php +++ b/src/Node/IncludeNode.php @@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression; * @author Fabien Potencier */ #[YieldReady] -class IncludeNode extends Node implements NodeOutputInterface +class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenToStringInterface { public function __construct(AbstractExpression $expr, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno) { @@ -130,4 +130,10 @@ class IncludeNode extends Node implements NodeOutputInterface $compiler->raw(')'); } } + + public function getStringCoercedChildNames(): array + { + // the loader resolves the template-name expression by coercing it to a string + return ['expr']; + } } diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index a3f66827f..b2529e9fc 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -28,7 +28,7 @@ use Twig\Source; * @author Fabien Potencier */ #[YieldReady] -final class ModuleNode extends Node +final class ModuleNode extends Node implements CoercesChildrenToStringInterface { /** * @param BodyNode $body @@ -90,6 +90,12 @@ final class ModuleNode extends Node } } + public function getStringCoercedChildNames(): array + { + // the parent expression is resolved through the loader, which coerces it to a string + return $this->hasNode('parent') ? ['parent'] : []; + } + /** * @return void */ diff --git a/src/Node/PrintNode.php b/src/Node/PrintNode.php index e3c23bbfa..da94ab468 100644 --- a/src/Node/PrintNode.php +++ b/src/Node/PrintNode.php @@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression; * @author Fabien Potencier */ #[YieldReady] -class PrintNode extends Node implements NodeOutputInterface +class PrintNode extends Node implements NodeOutputInterface, CoercesChildrenToStringInterface { public function __construct(AbstractExpression $expr, int $lineno) { @@ -41,4 +41,9 @@ class PrintNode extends Node implements NodeOutputInterface ->raw(";\n") ; } + + public function getStringCoercedChildNames(): array + { + return ['expr']; + } } diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index 9dd48f5be..5d459247b 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -15,19 +15,18 @@ use Twig\Environment; use Twig\Node\CheckSecurityCallNode; use Twig\Node\CheckSecurityNode; use Twig\Node\CheckToStringNode; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\Binary\RangeBinary; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\FunctionExpression; use Twig\Node\Expression\GetAttrExpression; +use Twig\Node\Expression\OperatorEscapeInterface; use Twig\Node\Expression\Unary\SpreadUnary; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\Node\Nodes; -use Twig\Node\PrintNode; -use Twig\Node\SetNode; /** * @author Fabien Potencier @@ -43,7 +42,6 @@ final class SandboxNodeVisitor implements NodeVisitorInterface private $filters; /** @var array */ private $functions; - private $needsToStringWrap = false; public function enterNode(Node $node, Environment $env): Node { @@ -52,8 +50,6 @@ final class SandboxNodeVisitor implements NodeVisitorInterface $this->tags = []; $this->filters = []; $this->functions = []; - - return $node; } elseif ($this->inAModule) { // look for tags if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()])) { @@ -74,29 +70,13 @@ final class SandboxNodeVisitor implements NodeVisitorInterface if ($node instanceof RangeBinary && !isset($this->functions['range'])) { $this->functions['range'] = $node->getTemplateLine(); } + } - if ($node instanceof PrintNode) { - $this->needsToStringWrap = true; - $this->wrapNode($node, 'expr'); - } - - if ($node instanceof SetNode && !$node->getAttribute('capture')) { - $this->needsToStringWrap = true; - } - - // wrap outer nodes that can implicitly call __toString() - if ($this->needsToStringWrap) { - if ($node instanceof ConcatBinary) { - $this->wrapNode($node, 'left'); - $this->wrapNode($node, 'right'); - } - if ($node instanceof FilterExpression) { - $this->wrapNode($node, 'node'); - $this->wrapArrayNode($node, 'arguments'); - } - if ($node instanceof FunctionExpression) { - $this->wrapArrayNode($node, 'arguments'); - } + // wrap children that the node itself will string-coerce at runtime; + // applies to ModuleNode (`parent` slot for {% extends %}) too + if ($this->inAModule && $node instanceof CoercesChildrenToStringInterface) { + foreach ($node->getStringCoercedChildNames() as $childName) { + $this->wrapNode($node, $childName); } } @@ -110,10 +90,6 @@ final class SandboxNodeVisitor implements NodeVisitorInterface $node->setNode('constructor_end', new Nodes([new CheckSecurityCallNode(), $node->getNode('constructor_end')])); $node->setNode('class_end', new Nodes([new CheckSecurityNode($this->filters, $this->tags, $this->functions), $node->getNode('class_end')])); - } elseif ($this->inAModule) { - if ($node instanceof PrintNode || $node instanceof SetNode) { - $this->needsToStringWrap = false; - } } return $node; @@ -122,22 +98,24 @@ final class SandboxNodeVisitor implements NodeVisitorInterface private function wrapNode(Node $node, string $name): void { $expr = $node->getNode($name); + // `_self` is internal: it compiles to `$this->getTemplateName()` and is always a string + if ($expr instanceof ContextVariable && '_self' === $expr->getAttribute('name')) { + return; + } if (($expr instanceof ContextVariable || $expr instanceof GetAttrExpression) && !$expr->isGenerator()) { $node->setNode($name, new CheckToStringNode($expr)); } elseif ($expr instanceof SpreadUnary) { - $this->wrapNode($expr, 'node'); - } elseif ($expr instanceof ArrayExpression) { + $expr->setNode('node', new CheckToStringNode($expr->getNode('node'), true)); + } elseif ($expr instanceof ArrayExpression || $expr instanceof Nodes) { foreach ($expr as $name => $_) { $this->wrapNode($expr, $name); } - } - } - - private function wrapArrayNode(Node $node, string $name): void - { - $args = $node->getNode($name); - foreach ($args as $name => $_) { - $this->wrapNode($args, $name); + } elseif ($expr instanceof OperatorEscapeInterface) { + foreach ($expr->getOperandNamesToEscape() as $operandName) { + $this->wrapNode($expr, $operandName); + } + } elseif ($expr instanceof FilterExpression || $expr instanceof FunctionExpression) { + $node->setNode($name, new CheckToStringNode($expr)); } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 6d8db8044..f83472d4d 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -55,6 +55,7 @@ class SandboxTest extends TestCase 'array_like' => new ArrayLikeObject(), 'magic' => new MagicObject(), 'recursion' => [4], + 'iterator' => new \ArrayIterator(['a', new FooObject()]), ]; self::$params['recursion'][] = &self::$params['recursion']; self::$params['recursion'][] = new FooObject(); @@ -293,7 +294,8 @@ class SandboxTest extends TestCase */ public function testSandboxUnallowedToString($template) { - $twig = $this->getEnvironment(true, [], ['index' => $template], [], ['upper', 'join', 'replace'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random']); + $twig = $this->getEnvironment(true, [], ['index' => $template], ['if', 'do', 'for', 'set'], ['upper', 'join', 'replace', 'format', 'split'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random', 'range', 'my_func']); + $twig->addFunction(new \Twig\TwigFunction('my_func', fn ($a) => (string) $a)); try { $twig->load('index')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed method "__toString()" method is called in the template'); @@ -329,16 +331,171 @@ class SandboxTest extends TestCase 'context' => ['{{ _context|join(", ") }}'], 'spread_array_operator' => ['{{ [1, 2, ...[5, 6, 7, obj]]|join(",") }}'], 'spread_array_operator_var' => ['{{ [1, 2, ...some_array]|join(",") }}'], + 'spread_iterator_in_function_args' => ['{{ ["x", ...iterator]|join(",") }}'], 'recursion' => ['{{ recursion|join(", ") }}'], + 'ternary_print' => ['{{ true ? obj : "" }}'], + 'ternary_filter_input' => ['{{ (true ? obj : "")|upper }}'], + 'elvis_filter_input' => ['{{ (obj ?: "")|upper }}'], + 'nullcoalesce_filter_input' => ['{{ (obj ?? "")|upper }}'], + 'function_arg_with_ternary' => ['{{ random(true ? obj : "") }}'], + 'filter_arg_with_ternary' => ['{{ "%s"|format(true ? obj : "") }}'], + 'matches_in_print' => ['{{ obj matches "/foo/" ? "1" : "0" }}'], + 'equal_in_print' => ['{{ obj == "x" ? "1" : "0" }}'], + 'equal_in_if' => ['{% if obj == "x" %}LEAK{% endif %}'], + 'notequal_in_if' => ['{% if obj != "x" %}LEAK{% endif %}'], + 'spaceship_in_if' => ['{% if (obj <=> "x") == 0 %}LEAK{% endif %}'], + 'less_in_if' => ['{% if obj < "B" %}LEAK{% endif %}'], + 'greater_in_if' => ['{% if obj > "A" %}LEAK{% endif %}'], + 'lessequal_in_if' => ['{% if obj <= "z" %}LEAK{% endif %}'], + 'greaterequal_in_if' => ['{% if obj >= "a" %}LEAK{% endif %}'], + 'concat_left_in_if' => ['{% if obj ~ "" %}LEAK{% endif %}'], + 'concat_right_in_if' => ['{% if "" ~ obj %}LEAK{% endif %}'], + 'range_left' => ['{% for x in obj..1 %}LEAK{% endfor %}'], + 'range_right' => ['{% for x in 1..obj %}LEAK{% endfor %}'], + 'do_tag_function_arg' => ['{% do my_func(obj) %}'], + 'do_tag_filter_input' => ['{% do obj|upper %}'], + 'do_tag_concat' => ['{% do obj ~ "" %}'], + 'set_tag_filter_input' => ['{% set _ = obj|upper %}'], + 'set_tag_concat' => ['{% set _ = obj ~ "" %}'], + '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" }}'], + 'method_argument' => ['{{ obj.foo(obj.anotherFooObject) }}'], + 'filter_input_in_if' => ['{% if obj|upper == "X" %}LEAK{% endif %}'], + 'filter_arg_in_if' => ['{% if "x"|replace({"x": obj}) == "y" %}LEAK{% endif %}'], + 'function_arg_in_if' => ['{% if not random(obj) %}LEAK{% endif %}'], + 'filter_input_in_for' => ['{% for x in (obj|split(",")) %}LEAK{% endfor %}'], + 'function_arg_in_for' => ['{% for x in [random(obj)] %}LEAK{% endfor %}'], ]; } + public function testSandboxBlocksToStringOnFunctionReturn() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ make_obj() }}'], [], [], [], [], ['make_obj']); + $twig->addFunction(new \Twig\TwigFunction('make_obj', fn () => new FooObject())); + try { + $twig->load('index')->render([]); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on the return of an allowed function'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnFilterReturn() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ "x"|to_obj }}'], [], ['to_obj']); + $twig->addFilter(new \Twig\TwigFilter('to_obj', fn () => new FooObject())); + try { + $twig->load('index')->render([]); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on the return of an allowed filter'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnDynamicAttributeName() + { + $twig = $this->getEnvironment(true, ['strict_variables' => true], ['index' => '{{ arr[obj] }}'], [], [], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on a dynamic attribute name'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnIncludeTemplateName() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% include obj %}'], ['include']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on an include template name'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnExtendsTemplateName() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% extends obj %}'], ['extends']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on an extends template name'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnBlockFunctionTemplateName() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ block("content", obj) }}'], [], [], [], [], ['block']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on a block() template argument'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnEmbedTemplateName() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% embed obj %}{% endembed %}'], ['embed', 'extends']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on an embed template name'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnIsConstantTestArgument() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% if "x" is constant(obj) %}LEAK{% endif %}'], ['if']); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on a constant test argument'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } + } + + public function testSandboxBlocksToStringOnDeprecatedMessage() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% deprecated obj %}'], ['deprecated']); + $previous = set_error_handler(static fn () => true, \E_USER_DEPRECATED); + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if __toString is called on a deprecated tag message'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertEquals('__tostring', $e->getMethodName()); + } finally { + restore_error_handler(); + } + } + + public function testSandboxKeepsSelfImportShortcut() + { + $tpl = "{% macro local_lower(s) %}{{ s|lower }}{% endmacro %}{% from _self import local_lower %}{{ local_lower('A') }}"; + $twig = $this->getEnvironment(true, [], ['index' => $tpl], ['from', 'macro', 'import'], ['lower']); + + $this->assertSame('a', $twig->load('index')->render([])); + } + /** * @dataProvider getSandboxAllowedToStringTests */ public function testSandboxAllowedToString($template, $output) { - $twig = $this->getEnvironment(true, [], ['index' => $template], ['set'], [], ['Twig\Tests\Extension\FooObject' => ['foo', 'getAnotherFooObject']]); + $twig = $this->getEnvironment(true, [], ['index' => $template], ['set', 'do'], [], ['Twig\Tests\Extension\FooObject' => ['foo', 'getAnotherFooObject']]); $this->assertEquals($output, $twig->load('index')->render(self::$params)); } @@ -347,6 +504,8 @@ class SandboxTest extends TestCase return [ 'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''], 'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'], + 'do_object_discarded' => ['{% do obj %}', ''], + 'set_object_assigned' => ['{% set a = obj %}{{ a is defined ? "1" : "0" }}', '1'], 'is_defined1' => ['{{ obj.anotherFooObject is defined }}', '1'], 'is_defined2' => ['{{ magic.foo is defined }}', ''], 'is_null' => ['{{ obj is null }}', ''], diff --git a/tests/NodeVisitor/SandboxTest.php b/tests/NodeVisitor/SandboxTest.php index e7efcc874..8165464ad 100644 --- a/tests/NodeVisitor/SandboxTest.php +++ b/tests/NodeVisitor/SandboxTest.php @@ -25,9 +25,12 @@ use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\Node\BodyNode; use Twig\Node\CheckToStringNode; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\EmptyNode; +use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; +use Twig\Node\Node; use Twig\Node\PrintNode; use Twig\NodeTraverser; use Twig\NodeVisitor\SandboxNodeVisitor; @@ -47,4 +50,64 @@ class SandboxTest extends TestCase $this->assertNotInstanceOf(CheckToStringNode::class, $node->getNode('body')->getNode(0)->getNode('expr')); $this->assertSame("// line 1\nyield from (\$context[\"foo\"] ?? null);\n", $env->compile($node->getNode('body'))); } + + public function testCustomNodeImplementingCoercesChildrenToStringInterfaceIsWrapped() + { + $env = new Environment(new ArrayLoader()); + $custom = new CustomCoercingExpression(new ContextVariable('foo', 1), new ContextVariable('bar', 1), 1); + // wrap inside a PrintNode so it lives in a module; the wrapping must happen on the + // custom node itself regardless of the print context + $node = new ModuleNode(new BodyNode([new PrintNode($custom, 1)]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $custom = $node->getNode('body')->getNode(0)->getNode('expr'); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('left')); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('right')); + } + + public function testCustomNonExpressionNodeImplementingCoercesChildrenToStringInterfaceIsWrapped() + { + $env = new Environment(new ArrayLoader()); + $custom = new CustomCoercingNode(['expr' => new ContextVariable('foo', 1)], [], 1); + $node = new ModuleNode(new BodyNode([$custom]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $custom = $node->getNode('body')->getNode(0); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('expr')); + } + + public function testSelfIsNeverWrapped() + { + $env = new Environment(new ArrayLoader()); + $self = new ContextVariable('_self', 1); + $custom = new CustomCoercingNode(['expr' => $self], [], 1); + $node = new ModuleNode(new BodyNode([$custom]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $this->assertNotInstanceOf(CheckToStringNode::class, $node->getNode('body')->getNode(0)->getNode('expr')); + } +} + +class CustomCoercingExpression extends AbstractExpression implements CoercesChildrenToStringInterface +{ + public function __construct(AbstractExpression $left, AbstractExpression $right, int $lineno) + { + parent::__construct(['left' => $left, 'right' => $right], [], $lineno); + } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } +} + +class CustomCoercingNode extends Node implements CoercesChildrenToStringInterface +{ + public function getStringCoercedChildNames(): array + { + return ['expr']; + } }