Fix sandbox __toString bypasses

This commit is contained in:
Fabien Potencier
2026-05-19 16:49:38 +02:00
parent 7f3056a28e
commit 447d0b2331
35 changed files with 516 additions and 67 deletions
+2
View File
@@ -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)
+3
View File
@@ -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) {
+18
View File
@@ -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) {
+4 -3
View File
@@ -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())
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Node;
use Twig\Node\Expression\OperatorEscapeInterface;
/**
* Implemented by nodes that implicitly coerce one or more of their child
* nodes to string at runtime (PHP string casts, regex matching, comparisons,
* range bounds, template-name resolution by the loader, etc.).
*
* The sandbox node visitor wraps the listed children with a CheckToStringNode
* so that an implicit `__toString()` call goes through the sandbox policy
* check, independently of where this node's result is used.
*
* This is distinct from {@see OperatorEscapeInterface}, which describes
* operands whose value becomes this expression's value (passthrough operators
* like ternaries) and is consumed by the escaper.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
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;
}
+15 -1
View File
@@ -22,7 +22,7 @@ use Twig\Node\Expression\ConstantExpression;
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
#[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;
}
}
+7 -1
View File
@@ -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'];
}
}
+7 -1
View File
@@ -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'];
}
}
+7 -1
View File
@@ -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'];
}
}
@@ -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'];
}
}
+7 -1
View File
@@ -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'];
}
}
@@ -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'];
}
}
+7 -1
View File
@@ -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'];
}
}
@@ -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'];
}
}
+7 -1
View File
@@ -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'];
}
}
@@ -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'];
}
}
@@ -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 <fabien@symfony.com>
*/
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')) {
+8 -1
View File
@@ -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'];
}
}
+8 -1
View File
@@ -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'];
}
}
+8 -1
View File
@@ -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);
+6
View File
@@ -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 [];
}
}
@@ -33,4 +33,10 @@ class DivisiblebyTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}
+6
View File
@@ -32,4 +32,10 @@ class EvenTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}
+6
View File
@@ -31,4 +31,10 @@ class NullTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// `=== null` is strict, no coercion
return [];
}
}
+6
View File
@@ -32,4 +32,10 @@ class OddTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}
+6
View File
@@ -31,4 +31,10 @@ class SameasTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// `===` is strict, no coercion
return [];
}
}
+6
View File
@@ -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 [];
}
}
+19 -1
View File
@@ -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;
}
}
+7 -1
View File
@@ -23,7 +23,7 @@ use Twig\Node\Expression\Variable\ContextVariable;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[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'];
}
}
+7 -1
View File
@@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[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'];
}
}
+7 -1
View File
@@ -28,7 +28,7 @@ use Twig\Source;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[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
*/
+6 -1
View File
@@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[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'];
}
}
+20 -42
View File
@@ -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 <fabien@symfony.com>
@@ -43,7 +42,6 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
private $filters;
/** @var array<string, int> */
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));
}
}
+161 -2
View File
@@ -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 }}', ''],
+63
View File
@@ -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'];
}
}