Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
21 changes: 1 addition & 20 deletions src/Node/Expression/CallExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -282,28 +282,9 @@ private function getCallableParameters($callable, bool $isVariadic): array
{
$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) {
Expand Down
71 changes: 71 additions & 0 deletions src/NodeVisitor/SandboxNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Util\CallableParameters;

/**
* @author Fabien Potencier <fabien@symfony.com>
Expand Down Expand Up @@ -85,7 +86,22 @@ public function enterNode(Node $node, Environment $env): Node
// 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);
}
}
Expand All @@ -105,6 +121,61 @@ public function leaveNode(Node $node, Environment $env): ?Node
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);
Expand Down
21 changes: 1 addition & 20 deletions src/Util/CallableArgumentsExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

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;
Expand Down Expand Up @@ -184,25 +183,7 @@ private function toSnakeCase(string $name): string

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()) {
Expand Down
161 changes: 161 additions & 0 deletions src/Util/CallableParameters.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
38 changes: 37 additions & 1 deletion src/Util/ReflectionCallable.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Twig\Util;

use Twig\Node\Expression\CallExpression;
use Twig\TwigCallableInterface;

/**
Expand All @@ -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, '::')) {
Expand Down Expand Up @@ -80,6 +81,41 @@ public function getReflector(): \ReflectionFunctionAbstract
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
*/
Expand Down
Loading
Loading