mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-17 11:31:38 +00:00
feature #4823 Skip the sandbox __toString check on arguments whose PHP parameter type cannot implicitly coerce to string (fabpot)
This PR was squashed before being merged into the 3.x branch.
Discussion
----------
Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string
The sandbox visitor currently wraps every argument of every Twig callable with `CheckToStringNode.
As an optimization, we are now only wrapping when needed (based on the callable type hints). This is a conservative approach (untyped, mixed, string, array, iterable, object, Stringable, Traversable, self/static/parent and unknown class names all keep wrapping).
Here is a concrete before/after for template `{{ demo(a, b) }}` under the sandbox, with the following signature on the PHP side `demo(int $a, string $b)`:
**Before**:
```php
yield $this->sandbox->ensureToStringAllowed(
$this->env->getFunction('demo')->getCallable()(
$this->sandbox->ensureToStringAllowed(($context["a"] ?? null), 1, $this->source),
$this->sandbox->ensureToStringAllowed(($context["b"] ?? null), 1, $this->source),
),
1, $this->source,
);
```
**After**
```php
yield $this->sandbox->ensureToStringAllowed(
$this->env->getFunction('demo')->getCallable()(
($context["a"] ?? null), // int: bare, skipped
$this->sandbox->ensureToStringAllowed(($context["b"] ?? null), 1, $this->source), // string: still wrapped
),
1, $this->source,
);
```
Commits
-------
6d5ef30436 Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 3.27.2 (2026-XX-XX)
|
||||
|
||||
* n/a
|
||||
* Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string
|
||||
|
||||
# 3.27.1 (2026-05-30)
|
||||
|
||||
|
||||
@@ -282,28 +282,9 @@ abstract class CallExpression extends AbstractExpression
|
||||
{
|
||||
$twigCallable = $this->getAttribute('twig_callable');
|
||||
$rc = $this->reflectCallable($twigCallable);
|
||||
$r = $rc->getReflector();
|
||||
$callableName = $rc->getName();
|
||||
|
||||
$parameters = $r->getParameters();
|
||||
if ($this->hasNode('node')) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($twigCallable->needsCharset()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($twigCallable->needsEnvironment()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($twigCallable->needsContext()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if (self::needsIsSandboxed($twigCallable)) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
foreach ($twigCallable->getArguments() as $argument) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
$parameters = $rc->getTwigParameters($this->hasNode('node'));
|
||||
|
||||
$isPhpVariadic = false;
|
||||
if ($isVariadic) {
|
||||
|
||||
@@ -27,6 +27,7 @@ use Twig\Node\Expression\Variable\ContextVariable;
|
||||
use Twig\Node\ModuleNode;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Node\Nodes;
|
||||
use Twig\Util\CallableParameters;
|
||||
|
||||
/**
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
@@ -85,7 +86,22 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
|
||||
// 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) {
|
||||
$params = CallableParameters::fromNode($node, $env);
|
||||
foreach ($node->getStringCoercedChildNames() as $childName) {
|
||||
// For Filter/Function/Test calls, consult the PHP callable
|
||||
// signature: skip wrapping arguments whose param type cannot
|
||||
// implicitly string-coerce (e.g. `int`, a `final` value object).
|
||||
if (null !== $params && 'arguments' === $childName) {
|
||||
$this->wrapArguments($node, $params);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (null !== $params && 'node' === $childName && $node instanceof FilterExpression) {
|
||||
// The filter's input value maps to the first PHP parameter.
|
||||
if (isset($params[0]) && CallableParameters::isStringCoercionSafe($params[0]->getType(), $params[0]->getDeclaringClass())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->wrapNode($node, $childName);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +121,61 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps each entry in the `arguments` slot only when the corresponding
|
||||
* PHP parameter type can implicitly string-coerce.
|
||||
*
|
||||
* @param list<\ReflectionParameter> $params parameters relative to the
|
||||
* first template argument (for
|
||||
* filters and tests: starting
|
||||
* after the `node`/input
|
||||
* parameter)
|
||||
*/
|
||||
private function wrapArguments(Node $node, array $params): void
|
||||
{
|
||||
$arguments = $node->getNode('arguments');
|
||||
if (!$arguments instanceof Nodes && !$arguments instanceof ArrayExpression) {
|
||||
$this->wrapNode($node, 'arguments');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Filters and tests pass their input value (`node`) as the first PHP
|
||||
// param, so their template arguments start at offset 1.
|
||||
$positional = \array_slice($params, $node->hasNode('node') ? 1 : 0);
|
||||
$variadic = null;
|
||||
$byName = [];
|
||||
foreach ($positional as $p) {
|
||||
if ($p->isVariadic()) {
|
||||
$variadic = $p;
|
||||
break;
|
||||
}
|
||||
$byName[$this->normalizeName($p->getName())] ??= $p;
|
||||
}
|
||||
|
||||
$positionalIdx = 0;
|
||||
foreach ($arguments as $key => $_) {
|
||||
if (\is_int($key)) {
|
||||
$param = $positional[$positionalIdx] ?? $variadic;
|
||||
if (null !== $param && !$param->isVariadic()) {
|
||||
++$positionalIdx;
|
||||
}
|
||||
} else {
|
||||
$param = $byName[$this->normalizeName($key)] ?? $variadic;
|
||||
}
|
||||
|
||||
if (null !== $param && CallableParameters::isStringCoercionSafe($param->getType(), $param->getDeclaringClass())) {
|
||||
continue;
|
||||
}
|
||||
$this->wrapNode($arguments, (string) $key);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeName(string $name): string
|
||||
{
|
||||
return strtolower(str_replace('_', '', $name));
|
||||
}
|
||||
|
||||
private function wrapNode(Node $node, string $name): void
|
||||
{
|
||||
$expr = $node->getNode($name);
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace Twig\Util;
|
||||
|
||||
use Twig\Error\SyntaxError;
|
||||
use Twig\Node\Expression\ArrayExpression;
|
||||
use Twig\Node\Expression\CallExpression;
|
||||
use Twig\Node\Expression\ConstantExpression;
|
||||
use Twig\Node\Expression\VariadicExpression;
|
||||
use Twig\Node\Node;
|
||||
@@ -184,25 +183,7 @@ final class CallableArgumentsExtractor
|
||||
|
||||
private function getCallableParameters(): array
|
||||
{
|
||||
$parameters = $this->rc->getReflector()->getParameters();
|
||||
if ($this->node->hasNode('node')) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsCharset()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsEnvironment()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsContext()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if (CallExpression::needsIsSandboxed($this->twigCallable)) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
foreach ($this->twigCallable->getArguments() as $argument) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
$parameters = $this->rc->getTwigParameters($this->node->hasNode('node'));
|
||||
|
||||
$isPhpVariadic = false;
|
||||
if ($this->twigCallable->isVariadic()) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?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\Util;
|
||||
|
||||
use Twig\Environment;
|
||||
use Twig\Node\Expression\FilterExpression;
|
||||
use Twig\Node\Expression\FunctionExpression;
|
||||
use Twig\Node\Expression\TestExpression;
|
||||
use Twig\Node\Node;
|
||||
use Twig\TwigCallableInterface;
|
||||
|
||||
/**
|
||||
* Reflects the PHP parameters backing a Twig callable expression.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CallableParameters
|
||||
{
|
||||
/**
|
||||
* Returns the PHP parameters of a Filter/Function/Test call mapped to its
|
||||
* template-level arguments.
|
||||
*
|
||||
* The parameters Twig injects automatically
|
||||
* (`needs_charset/environment/context/is_sandboxed`) and the bound
|
||||
* `arguments` are stripped. For a filter, the first returned parameter is
|
||||
* the filter's input value. Returns null when reflection fails or the node
|
||||
* is not a callable expression.
|
||||
*
|
||||
* @return list<\ReflectionParameter>|null
|
||||
*/
|
||||
public static function fromNode(Node $node, Environment $env): ?array
|
||||
{
|
||||
if (!$node instanceof FilterExpression && !$node instanceof FunctionExpression && !$node instanceof TestExpression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$callable = self::resolveTwigCallable($node, $env);
|
||||
if (null === $callable || null === $callable->getCallable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new ReflectionCallable($callable))->getTwigParameters();
|
||||
} catch (\LogicException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a PHP parameter type proves the value reaching it
|
||||
* cannot implicitly string-coerce (directly or by iterating it).
|
||||
*
|
||||
* Safe: `int`, `float`, `bool`, `null`, `false`, `true`, `void`, `never`,
|
||||
* enums, and `final` class types that are neither `Stringable` nor
|
||||
* `Traversable`.
|
||||
*
|
||||
* Unsafe: `string`, `array`, `iterable`, `mixed`, `object`,
|
||||
* untyped, interfaces, non-final classes, and any type that is
|
||||
* `Stringable` or `Traversable`. Interfaces and non-final classes are
|
||||
* open: a subtype could add `Stringable`/`Traversable` and reach the host
|
||||
* code, bypassing the `__toString` policy, so only a `final` class (enums
|
||||
* included) is closed enough.
|
||||
*
|
||||
* @param \ReflectionClass|null $scope resolves the relative types `self`/`parent`/`static` (which
|
||||
* older PHP versions report verbatim instead of the declaring class)
|
||||
*/
|
||||
public static function isStringCoercionSafe(?\ReflectionType $type, ?\ReflectionClass $scope = null): bool
|
||||
{
|
||||
if (null === $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A union value is one of its members, so every member must be safe.
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
foreach ($type->getTypes() as $t) {
|
||||
if (!self::isStringCoercionSafe($t, $scope)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// An intersection value satisfies all its members at once. A safe
|
||||
// member is necessarily a final class, which pins the concrete class,
|
||||
// so the value is that non-coercible class whatever the other members.
|
||||
if ($type instanceof \ReflectionIntersectionType) {
|
||||
foreach ($type->getTypes() as $t) {
|
||||
if (self::isStringCoercionSafe($t, $scope)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$type instanceof \ReflectionNamedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$name = $type->getName();
|
||||
|
||||
if ($type->isBuiltin()) {
|
||||
return match ($name) {
|
||||
// `null` (e.g. as a union member) cannot have a __toString.
|
||||
'null', 'int', 'float', 'bool', 'true', 'false', 'void', 'never' => true,
|
||||
default => false, // string, array, iterable, object, mixed, callable
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve `self`/`parent`/`static` to the concrete class
|
||||
// `static` is treated like `self` since a `final` class cannot be subclassed anyway
|
||||
$class = match ($name) {
|
||||
'self', 'static' => $scope,
|
||||
'parent' => $scope ? ($scope->getParentClass() ?: null) : null,
|
||||
default => class_exists($name, false) ? new \ReflectionClass($name) : null,
|
||||
};
|
||||
|
||||
// Interfaces and non-final classes are open: a subtype could add
|
||||
// Stringable/Traversable, so only a final non-coercible class is safe
|
||||
if (null === $class) {
|
||||
return false;
|
||||
}
|
||||
if (is_a($class->getName(), \Stringable::class, true) || is_a($class->getName(), \Traversable::class, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $class->isFinal();
|
||||
}
|
||||
|
||||
private static function resolveTwigCallable(Node $node, Environment $env): ?TwigCallableInterface
|
||||
{
|
||||
if ($node->hasAttribute('twig_callable')) {
|
||||
return $node->getAttribute('twig_callable');
|
||||
}
|
||||
if (!$node->hasAttribute('name')) {
|
||||
return null;
|
||||
}
|
||||
$name = $node->getAttribute('name');
|
||||
try {
|
||||
return match (true) {
|
||||
$node instanceof FilterExpression => $env->getFilter($name),
|
||||
$node instanceof FunctionExpression => $env->getFunction($name),
|
||||
$node instanceof TestExpression => $env->getTest($name),
|
||||
};
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
namespace Twig\Util;
|
||||
|
||||
use Twig\Node\Expression\CallExpression;
|
||||
use Twig\TwigCallableInterface;
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ final class ReflectionCallable
|
||||
private $name;
|
||||
|
||||
public function __construct(
|
||||
TwigCallableInterface $twigCallable,
|
||||
private TwigCallableInterface $twigCallable,
|
||||
) {
|
||||
$callable = $twigCallable->getCallable();
|
||||
if (\is_string($callable) && false !== $pos = strpos($callable, '::')) {
|
||||
@@ -80,6 +81,41 @@ final class ReflectionCallable
|
||||
return $this->reflector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PHP parameters that map to the callable's template-level
|
||||
* arguments.
|
||||
*
|
||||
* The parameters Twig injects automatically (the piped input value when
|
||||
* $stripInput is true, then needs_charset/environment/context/is_sandboxed)
|
||||
* and the bound arguments are stripped.
|
||||
*
|
||||
* @return list<\ReflectionParameter>
|
||||
*/
|
||||
public function getTwigParameters(bool $stripInput = false): array
|
||||
{
|
||||
$parameters = $this->reflector->getParameters();
|
||||
if ($stripInput) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsCharset()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsEnvironment()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if ($this->twigCallable->needsContext()) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
if (CallExpression::needsIsSandboxed($this->twigCallable)) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
foreach ($this->twigCallable->getArguments() as $argument) {
|
||||
array_shift($parameters);
|
||||
}
|
||||
|
||||
return array_values($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return callable
|
||||
*/
|
||||
|
||||
@@ -1409,6 +1409,228 @@ EOF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getSafePhpTypesSkipToStringWrap
|
||||
*/
|
||||
public function testSafePhpParamTypesSkipToStringWrap(string $template, callable $func, array $params): void
|
||||
{
|
||||
// The sandbox visitor must not wrap arguments whose target PHP
|
||||
// parameter type cannot implicitly coerce to string (int, float,
|
||||
// bool, non-Stringable/non-Traversable classes, ...). We observe
|
||||
// the optimization by passing values whose `__toString` is NOT in
|
||||
// the policy: with the wrap, the render throws; without it, it
|
||||
// succeeds.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => $template]);
|
||||
$twig->addFunction(new TwigFunction('safe_fn', $func));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['safe_fn']);
|
||||
|
||||
$this->assertSame('ok', $twig->load('index')->render($params));
|
||||
}
|
||||
|
||||
public static function getSafePhpTypesSkipToStringWrap(): iterable
|
||||
{
|
||||
yield 'int param' => [
|
||||
'{{ safe_fn(n) }}',
|
||||
static fn (int $n) => 'ok',
|
||||
['n' => 42],
|
||||
];
|
||||
yield 'float param' => [
|
||||
'{{ safe_fn(n) }}',
|
||||
static fn (float $n) => 'ok',
|
||||
['n' => 3.14],
|
||||
];
|
||||
yield 'bool param' => [
|
||||
'{{ safe_fn(b) }}',
|
||||
static fn (bool $b) => 'ok',
|
||||
['b' => true],
|
||||
];
|
||||
yield 'non-stringable class param' => [
|
||||
'{{ safe_fn(obj) }}',
|
||||
static fn (ColumnObject $o) => 'ok',
|
||||
['obj' => new ColumnObject()],
|
||||
];
|
||||
yield 'nullable int param with null value' => [
|
||||
'{{ safe_fn(n) }}',
|
||||
static fn (?int $n) => 'ok',
|
||||
['n' => null],
|
||||
];
|
||||
yield 'int|float union param' => [
|
||||
'{{ safe_fn(n) }}',
|
||||
static fn (int|float $n) => 'ok',
|
||||
['n' => 7],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getUnsafePhpTypesStillWrap
|
||||
*/
|
||||
public function testUnsafePhpParamTypesStillWrap(string $template, callable $func, array $params): void
|
||||
{
|
||||
// Conversely, an unsafe parameter type (`mixed`, untyped, `string`,
|
||||
// `iterable`, `Stringable`, ...) must keep wrapping arguments so the
|
||||
// sandbox can still block disallowed `__toString` calls.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => $template]);
|
||||
$twig->addFunction(new TwigFunction('unsafe_fn', $func));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['unsafe_fn']);
|
||||
|
||||
try {
|
||||
$twig->load('index')->render($params);
|
||||
$this->fail('Sandbox should still check __toString when the PHP parameter type can implicitly coerce to string.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public static function getUnsafePhpTypesStillWrap(): iterable
|
||||
{
|
||||
$params = ['obj' => new FooObject()];
|
||||
yield 'untyped param' => ['{{ unsafe_fn(obj) }}', static fn ($x) => (string) $x, $params];
|
||||
yield 'mixed param' => ['{{ unsafe_fn(obj) }}', static fn (mixed $x) => (string) $x, $params];
|
||||
yield 'string param' => ['{{ unsafe_fn(obj) }}', static fn (string $x) => $x, $params];
|
||||
yield 'object param' => ['{{ unsafe_fn(obj) }}', static fn (object $x) => (string) $x, $params];
|
||||
yield 'Stringable param' => ['{{ unsafe_fn(obj) }}', static fn (\Stringable $x) => (string) $x, $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getOpenPhpTypesStillWrap
|
||||
*/
|
||||
public function testOpenPhpParamTypesStillWrap(callable $func, object $obj, string $class): void
|
||||
{
|
||||
// Interfaces and non-final classes are "open": a Stringable subtype
|
||||
// can satisfy them, so the sandbox must keep gating __toString.
|
||||
// Skipping the wrap on these would bypass the policy.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(obj) }}']);
|
||||
$twig->addFunction(new TwigFunction('unsafe_fn', $func));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['unsafe_fn']);
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['obj' => $obj]);
|
||||
$this->fail('Sandbox must still check __toString for an interface or non-final class parameter.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame($class, $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public static function getOpenPhpTypesStillWrap(): iterable
|
||||
{
|
||||
yield 'interface param' => [static fn (\Countable $x) => (string) $x, new CountableFooObject(), CountableFooObject::class];
|
||||
yield 'non-final class param' => [static fn (PlainBaseObject $x) => (string) $x, new StringablePlainObject(), StringablePlainObject::class];
|
||||
}
|
||||
|
||||
public function testTestArgumentsMapAfterTheTestedValueParameter(): void
|
||||
{
|
||||
// A test's tested value is its first PHP parameter, so its template
|
||||
// arguments must be mapped to the parameters *after* it. Mapping the
|
||||
// first argument to the (safe-typed) value parameter would skip its
|
||||
// __toString wrap and bypass the policy.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ 5 is my_test(obj) }}']);
|
||||
$twig->addTest(new TwigTest('my_test', static fn (int $value, $arg) => 'x' === (string) $arg));
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['obj' => new FooObject()]);
|
||||
$this->fail('Sandbox must check __toString on a test argument bound to an unsafe parameter.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testSafeVariadicPhpTypeSkipsToStringWrap(): void
|
||||
{
|
||||
// PHP-variadic with a safe type: all spilled arguments skip the wrap.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ safe_fn(1, 2, 3) }}']);
|
||||
$twig->addFunction(new TwigFunction('safe_fn', static fn (int ...$x) => 'ok'));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['safe_fn']);
|
||||
|
||||
$this->assertSame('ok', $twig->load('index')->render());
|
||||
}
|
||||
|
||||
public function testSpreadIntoUnsafeVariadicStillWraps(): void
|
||||
{
|
||||
// A spread fills an unsafe (untyped) variadic param, so every spilled
|
||||
// element must keep its __toString wrap.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(...args) }}']);
|
||||
$twig->addFunction(new TwigFunction('unsafe_fn', static fn (...$x) => (string) $x[0]));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['unsafe_fn']);
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['args' => [new FooObject()]]);
|
||||
$this->fail('Sandbox must check __toString on spread elements bound to an unsafe variadic parameter.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testNormalizedNamedArgumentDoesNotFallThroughToSafeVariadic(): void
|
||||
{
|
||||
// The compiler normalizes named arguments (`foo_bar` maps to
|
||||
// `$fooBar`). The sandbox visitor must use the same mapping and not
|
||||
// fall back to the safe typed variadic tail, or it would skip the
|
||||
// __toString check on `$fooBar`.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(foo_bar: obj) }}']);
|
||||
$twig->addFunction(new TwigFunction('unsafe_fn', static fn ($fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true]));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFunctions(['unsafe_fn']);
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['obj' => new FooObject()]);
|
||||
$this->fail('Sandbox must check __toString on normalized named arguments before considering the variadic tail.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testNormalizedNamedFilterArgumentDoesNotFallThroughToSafeVariadic(): void
|
||||
{
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1|unsafe_filter(foo_bar: obj) }}']);
|
||||
$twig->addFilter(new TwigFilter('unsafe_filter', static fn ($value, $fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true]));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFilters(['unsafe_filter']);
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['obj' => new FooObject()]);
|
||||
$this->fail('Sandbox must check __toString on normalized named filter arguments before considering the variadic tail.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testNormalizedNamedTestArgumentDoesNotFallThroughToSafeVariadic(): void
|
||||
{
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is unsafe_test(foo_bar: obj) ? "yes" : "no" }}']);
|
||||
$twig->addTest(new TwigTest('unsafe_test', static fn ($value, $fooBar, int ...$rest) => 'x' === (string) $fooBar, ['is_variadic' => true]));
|
||||
|
||||
try {
|
||||
$twig->load('index')->render(['obj' => new FooObject()]);
|
||||
$this->fail('Sandbox must check __toString on normalized named test arguments before considering the variadic tail.');
|
||||
} catch (SecurityNotAllowedMethodError $e) {
|
||||
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
|
||||
$this->assertSame('__tostring', $e->getMethodName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testFilterInputTypeSkipsToStringWrap(): void
|
||||
{
|
||||
// A filter whose first PHP param has a safe type also skips the
|
||||
// input (`node`) wrap.
|
||||
$twig = $this->getEnvironment(true, [], ['index' => '{{ n|safe_filter }}']);
|
||||
$twig->addFilter(new TwigFilter('safe_filter', static fn (int $n) => 'ok'));
|
||||
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
$policy->setAllowedFilters(['safe_filter']);
|
||||
|
||||
$this->assertSame('ok', $twig->load('index')->render(['n' => 42]));
|
||||
}
|
||||
|
||||
public function testColumnFilterUnaffectedOutsideSandbox()
|
||||
{
|
||||
$params = ['obj' => new ColumnObject()];
|
||||
@@ -1916,6 +2138,26 @@ class ColumnObject
|
||||
public $bar = 'bar';
|
||||
}
|
||||
|
||||
class CountableFooObject extends FooObject implements \Countable
|
||||
{
|
||||
public function count(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PlainBaseObject
|
||||
{
|
||||
}
|
||||
|
||||
class StringablePlainObject extends PlainBaseObject implements \Stringable
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'plain';
|
||||
}
|
||||
}
|
||||
|
||||
// Implements both Stringable and Traversable: a sandbox policy may legitimately
|
||||
// allow the container's own `__toString`, but the elements yielded by
|
||||
// `getIterator()` must still be policy-checked when consumers (`join`, `replace`,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?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\Tests\Util;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Util\CallableParameters;
|
||||
|
||||
class CallableParametersTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideTypes
|
||||
*/
|
||||
public function testIsStringCoercionSafe(?\ReflectionType $type, bool $expected, ?\ReflectionClass $scope = null): void
|
||||
{
|
||||
$this->assertSame($expected, CallableParameters::isStringCoercionSafe($type, $scope));
|
||||
}
|
||||
|
||||
public function testIsStringCoercionSafeDoesNotAutoloadUnknownClasses(): void
|
||||
{
|
||||
$autoloaded = false;
|
||||
$autoload = static function () use (&$autoloaded): void {
|
||||
$autoloaded = true;
|
||||
};
|
||||
spl_autoload_register($autoload);
|
||||
|
||||
try {
|
||||
$type = (new \ReflectionFunction(eval('return static fn (CallableParametersAutoloadProbe $x) => null;')))->getParameters()[0]->getType();
|
||||
|
||||
$this->assertFalse(CallableParameters::isStringCoercionSafe($type));
|
||||
$this->assertFalse($autoloaded);
|
||||
} finally {
|
||||
spl_autoload_unregister($autoload);
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideTypes(): iterable
|
||||
{
|
||||
$param = static fn (\Closure $c): ?\ReflectionType => (new \ReflectionFunction($c))->getParameters()[0]->getType();
|
||||
$mParam = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getParameters()[0]->getType();
|
||||
$mReturn = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getReturnType();
|
||||
|
||||
// No type information: must keep the check.
|
||||
yield 'untyped' => [$param(static fn ($x) => null), false];
|
||||
|
||||
// Builtin scalars that cannot hold a string-coercible value.
|
||||
yield 'int' => [$param(static fn (int $x) => null), true];
|
||||
yield 'float' => [$param(static fn (float $x) => null), true];
|
||||
yield 'bool' => [$param(static fn (bool $x) => null), true];
|
||||
yield 'never (return)' => [(new \ReflectionFunction(static function (): never { throw new \LogicException(); }))->getReturnType(), true];
|
||||
|
||||
// Builtins that can carry or coerce to a string.
|
||||
yield 'string' => [$param(static fn (string $x) => null), false];
|
||||
yield 'array' => [$param(static fn (array $x) => null), false];
|
||||
yield 'iterable' => [$param(static fn (iterable $x) => null), false];
|
||||
yield 'object' => [$param(static fn (object $x) => null), false];
|
||||
yield 'mixed' => [$param(static fn (mixed $x) => null), false];
|
||||
yield 'callable' => [$param(static fn (callable $x) => null), false];
|
||||
|
||||
// Class types: safe only when final and neither Stringable nor
|
||||
// Traversable. Interfaces and non-final classes are open (a subtype
|
||||
// could add Stringable/Traversable), so they must stay unsafe.
|
||||
yield 'final plain class' => [$param(static fn (FinalPlainObject $x) => null), true];
|
||||
yield 'nullable final class' => [$param(static fn (?FinalPlainObject $x) => null), true];
|
||||
yield 'non-final class' => [$param(static fn (\stdClass $x) => null), false];
|
||||
yield 'final Stringable class' => [$param(static fn (FinalStringableObject $x) => null), false];
|
||||
yield 'Countable interface' => [$param(static fn (\Countable $x) => null), false];
|
||||
yield 'Stringable interface' => [$param(static fn (\Stringable $x) => null), false];
|
||||
yield 'Traversable interface' => [$param(static fn (\Iterator $x) => null), false];
|
||||
yield 'Traversable class' => [$param(static fn (\ArrayIterator $x) => null), false];
|
||||
|
||||
// Enums are implicitly final and cannot be Stringable (no __toString).
|
||||
yield 'backed enum' => [$param(static fn (SampleBackedEnum $x) => null), true];
|
||||
yield 'pure enum' => [$param(static fn (SamplePureEnum $x) => null), true];
|
||||
|
||||
// `self`/`parent`/`static` resolve to the declaring class (passed as
|
||||
// scope) and are then judged by the class rules. PHP < 8.4 reports
|
||||
// them verbatim, recent versions resolve them in getName(); both paths
|
||||
// must yield the same verdict.
|
||||
yield 'self -> Stringable class' => [$mParam(CoercionFixture::class, 'selfParam'), false, new \ReflectionClass(CoercionFixture::class)];
|
||||
yield 'self -> final class' => [$mParam(FinalSelfFixture::class, 'selfParam'), true, new \ReflectionClass(FinalSelfFixture::class)];
|
||||
yield 'parent -> non-final class' => [$mParam(CoercionFixture::class, 'parentParam'), false, new \ReflectionClass(CoercionFixture::class)];
|
||||
yield 'static (return) -> self' => [$mReturn(CoercionFixture::class, 'staticReturn'), false, new \ReflectionClass(CoercionFixture::class)];
|
||||
|
||||
// Union: the value is one of the members, so all must be safe.
|
||||
yield 'int|float union' => [$param(static fn (int|float $x) => null), true];
|
||||
yield 'int|null union' => [$param(static fn (?int $x) => null), true];
|
||||
yield 'int|float|null union' => [$param(static fn (int|float|null $x) => null), true];
|
||||
yield 'int|string union' => [$param(static fn (int|string $x) => null), false];
|
||||
|
||||
// Intersection: safe as soon as one member is a final, non-coercible
|
||||
// class (it pins the concrete class); otherwise open and unsafe.
|
||||
yield 'final class intersection' => [$param(static fn (FinalMarkedObject&SampleMarker $x) => null), true];
|
||||
yield 'interface intersection' => [$param(static fn (\Countable&\ArrayAccess $x) => null), false];
|
||||
yield 'Stringable intersection' => [$param(static fn (\Stringable&\Countable $x) => null), false];
|
||||
yield 'Traversable intersection' => [$param(static fn (\Traversable&\Countable $x) => null), false];
|
||||
}
|
||||
}
|
||||
|
||||
enum SampleBackedEnum: string
|
||||
{
|
||||
case A = 'a';
|
||||
}
|
||||
|
||||
enum SamplePureEnum
|
||||
{
|
||||
case A;
|
||||
}
|
||||
|
||||
interface SampleMarker
|
||||
{
|
||||
}
|
||||
|
||||
final class FinalMarkedObject implements SampleMarker
|
||||
{
|
||||
}
|
||||
|
||||
final class FinalPlainObject
|
||||
{
|
||||
}
|
||||
|
||||
final class FinalStringableObject implements \Stringable
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
final class FinalSelfFixture
|
||||
{
|
||||
public function selfParam(self $x): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class CoercionFixtureParent
|
||||
{
|
||||
}
|
||||
|
||||
class CoercionFixture extends CoercionFixtureParent implements \Stringable
|
||||
{
|
||||
public function selfParam(self $x): void
|
||||
{
|
||||
}
|
||||
|
||||
public function parentParam(parent $x): void
|
||||
{
|
||||
}
|
||||
|
||||
public function staticReturn(): static
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user