diff --git a/doc/templates.rst b/doc/templates.rst index 9b1d5c585..b09b625a9 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -625,6 +625,42 @@ exist: {% set key = 'name' %} {(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'} + Any expression is supported as a dynamic key. The result is coerced to + string, so objects implementing ``__toString()`` (PHP ``Stringable``) are + accepted: + + .. code-block:: twig + + {# attribute access #} + {(user.role): 'allowed'} + + {# method call #} + {(user.getRole()): 'allowed'} + + {# filter result #} + {(name|upper): 'Fabien'} + + {# function call #} + {(slug(title)): post} + + {# chained expression #} + {(user.email|lower): 'subscribed'} + + {# Stringable object (cast via __toString) #} + {(uuid): 'token'} + + .. versionadded:: 3.26.1 + + Support for arbitrary expressions as dynamic mapping keys + (attribute access, method calls, filter results, function calls, + and any ``Stringable`` object) was added in Twig 3.26.1. + + .. note:: + + Inside a sandbox, the ``__toString()`` coercion goes through the + ``SecurityPolicy`` method allowlist, the same way as ``{{ obj }}`` + or ``{{ obj|upper }}``. + * ``true`` / ``false``: ``true`` represents the true value, ``false`` represents the false value. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 29ac073c8..3511d717c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - # The method is dynamically generated by the CheckSecurityNode - message: '#^Call to an undefined method Twig\\Template\:\:checkSecurity\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Extension/CoreExtension.php - - # 2 parameters will be required message: '#^Method Twig\\Node\\IncludeNode\:\:addGetTemplate\(\) invoked with 2 parameters, 1 required\.$#' identifier: arguments.count diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index c87429283..f8b9b3d78 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1452,10 +1452,6 @@ final class CoreExtension extends AbstractExtension return ''; } - if ($isSandboxed) { - $loaded->unwrap()->checkSecurity(); - } - return $loaded->render($variables); } finally { if ($isSandboxed && !$alreadySandboxed) { @@ -1893,12 +1889,15 @@ final class CoreExtension extends AbstractExtension } if ($isSandboxed) { - $sandbox = $env->getExtension(SandboxExtension::class); + // The sandbox might be enabled via a SourcePolicyInterface, in which case the SandboxExtension + // would not consider the sandbox active without the current Source: $isSandboxed is already + // computed against the call-site source, so check the policy directly to honor that decision. + $policy = $env->getExtension(SandboxExtension::class)->getSecurityPolicy(); foreach ($array as $item) { if (\is_object($item)) { - $sandbox->checkPropertyAllowed($item, (string) $name); + $policy->checkPropertyAllowed($item, (string) $name); if (null !== $index) { - $sandbox->checkPropertyAllowed($item, (string) $index); + $policy->checkPropertyAllowed($item, (string) $index); } } } diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index d5e9e0d2f..b11f34c2a 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -104,24 +104,7 @@ final class SandboxExtension extends AbstractExtension */ public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null) { - if (\is_array($obj)) { - $this->ensureToStringAllowedForArray($obj, $lineno, $source); - - return $obj; - } - - if ($obj instanceof \Stringable && $this->isSandboxed($source)) { - try { - $this->policy->checkMethodAllowed($obj, '__toString'); - } catch (SecurityNotAllowedMethodError $e) { - $e->setSourceContext($source); - $e->setTemplateLine($lineno); - - throw $e; - } - } - - return $obj; + return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage()); } /** @@ -133,16 +116,72 @@ final class SandboxExtension extends AbstractExtension */ public function ensureSpreadAllowed(iterable $obj, int $lineno = -1, ?Source $source = null): array { + $seen = new \SplObjectStorage(); if ($obj instanceof \Traversable) { + $seen[$obj] = true; $obj = iterator_to_array($obj); } - $this->ensureToStringAllowedForArray($obj, $lineno, $source); + $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen); return $obj; } - private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, array &$stack = []): void + private function doEnsureToStringAllowed($obj, int $lineno, ?Source $source, \SplObjectStorage $seen) + { + if (\is_array($obj)) { + $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen); + + return $obj; + } + + if (!$this->isSandboxed($source)) { + return $obj; + } + + if ($obj instanceof \Stringable) { + try { + $this->policy->checkMethodAllowed($obj, '__toString'); + } catch (SecurityNotAllowedMethodError $e) { + $e->setSourceContext($source); + $e->setTemplateLine($lineno); + + throw $e; + } + } + + // Elements yielded by a Traversable may be string-coerced downstream + // (e.g. by `join`/`replace`), bypassing the policy. Check them now. + if ($obj instanceof \Traversable) { + if (isset($seen[$obj])) { + return $obj; + } + $seen[$obj] = true; + + // IteratorAggregate::getIterator() is idempotent, so we can walk + // the elements and return the original object: host code typed + // against a specific class (e.g. FormView) keeps working. + if ($obj instanceof \IteratorAggregate) { + foreach ($obj as $v) { + $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); + } + + return $obj; + } + + // Single-pass Iterator/Generator: materialise to validate. + $array = iterator_to_array($obj); + $this->ensureToStringAllowedForArray($array, $lineno, $source, $seen); + + if (!$obj instanceof \Stringable) { + return $array; + } + } + + return $obj; + } + + private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, \SplObjectStorage $seen, array &$stack = []): void { foreach ($obj as $k => $v) { if (!$v) { @@ -150,7 +189,7 @@ final class SandboxExtension extends AbstractExtension } if (!\is_array($v)) { - $this->ensureToStringAllowed($v, $lineno, $source); + $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); continue; } @@ -162,7 +201,7 @@ final class SandboxExtension extends AbstractExtension $stack[$r->getId()] = true; } - $this->ensureToStringAllowedForArray($v, $lineno, $source, $stack); + $this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack); } } } diff --git a/src/Node/CheckSecurityCallNode.php b/src/Node/CheckSecurityCallNode.php index 422bc1e1c..5a2f4b6fd 100644 --- a/src/Node/CheckSecurityCallNode.php +++ b/src/Node/CheckSecurityCallNode.php @@ -24,7 +24,6 @@ class CheckSecurityCallNode extends Node { $compiler ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") - ->write("\$this->checkSecurity();\n") ; } } diff --git a/src/Node/CheckSecurityNode.php b/src/Node/CheckSecurityNode.php index 9860de57e..9ac843ef2 100644 --- a/src/Node/CheckSecurityNode.php +++ b/src/Node/CheckSecurityNode.php @@ -41,6 +41,17 @@ class CheckSecurityNode extends Node public function compile(Compiler $compiler): void { $compiler + ->write("\n") + ->write("public function ensureSecurityChecked(): void\n") + ->write("{\n") + ->indent() + ->write("if (\$this->sandbox->isSandboxed(\$this->source)) {\n") + ->indent() + ->write("\$this->checkSecurity();\n") + ->outdent() + ->write("}\n") + ->outdent() + ->write("}\n") ->write("\n") ->write("public function checkSecurity()\n") ->write("{\n") diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php index f2d35e0a5..f8ee0b8e3 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -13,12 +13,13 @@ namespace Twig\Node\Expression; use Twig\Compiler; use Twig\Error\SyntaxError; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\Unary\SpreadUnary; use Twig\Node\Expression\Unary\StringCastUnary; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Expression\Variable\LocalVariable; -class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface +class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface, CoercesChildrenToStringInterface { use SupportDefinedTestTrait; @@ -96,6 +97,24 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn array_push($this->nodes, $key, $value); } + public function getStringCoercedChildNames(): array + { + // dynamic mapping keys (computed at runtime) are coerced to string; + // static keys (constants or sequence indexes) are emitted as PHP + // literals by compile() and never trigger a __toString() call + $names = []; + foreach (array_chunk($this->nodes, 2) as $i => $pair) { + $key = $pair[0]; + if ($key instanceof ConstantExpression || $key instanceof LocalVariable) { + continue; + } + + $names[] = (string) ($i * 2); + } + + return $names; + } + public function compile(Compiler $compiler): void { if ($this->definedTest) { @@ -126,6 +145,10 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn $pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine()); } elseif ($pair['key'] instanceof ConstantExpression) { $key = $pair['key']->getAttribute('value'); + } else { + // dynamic key: cast to string so PHP accepts it as an array offset + // (the sandbox visitor has already wrapped it with a __toString policy check) + $pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine()); } if ($key !== $i) { diff --git a/src/Node/Expression/Binary/InBinary.php b/src/Node/Expression/Binary/InBinary.php index 31a21e7d6..2f11a54fd 100644 --- a/src/Node/Expression/Binary/InBinary.php +++ b/src/Node/Expression/Binary/InBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class InBinary extends AbstractBinary implements ReturnBoolInterface +class InBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -31,4 +32,9 @@ class InBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('in'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/Binary/NotInBinary.php b/src/Node/Expression/Binary/NotInBinary.php index 9fd27311f..f46c88ff8 100644 --- a/src/Node/Expression/Binary/NotInBinary.php +++ b/src/Node/Expression/Binary/NotInBinary.php @@ -12,9 +12,10 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\CoercesChildrenToStringInterface; use Twig\Node\Expression\ReturnBoolInterface; -class NotInBinary extends AbstractBinary implements ReturnBoolInterface +class NotInBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface { public function compile(Compiler $compiler): void { @@ -31,4 +32,9 @@ class NotInBinary extends AbstractBinary implements ReturnBoolInterface { return $compiler->raw('not in'); } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } } diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 0b9b2a85c..0dcf81092 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -65,9 +65,9 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ->raw(' instanceof ArrayAccess ? (') ->raw($var) ->raw('[') - ->subcompile($this->getNode('attribute')) - ->raw('] ?? null) : null)') ; + $this->compileArrayKey($compiler); + $compiler->raw('] ?? null) : null)'); return; } @@ -82,9 +82,9 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (') ->raw($var) ->raw('[') - ->subcompile($this->getNode('attribute')) - ->raw('] ?? null) : ') ; + $this->compileArrayKey($compiler); + $compiler->raw('] ?? null) : '); } if ($this->getAttribute('ignore_strict_check')) { @@ -162,8 +162,41 @@ 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'] : []; + $names = []; + + // the host PHP method may coerce any argument to string + if ($this->hasNode('arguments')) { + $names[] = 'arguments'; + } + + // compileArrayKey() coerces a Stringable key; expose it so the sandbox checks __toString() + if (Template::ARRAY_CALL === $this->getAttribute('type')) { + $names[] = 'attribute'; + } + + return $names; + } + + /** + * Coerces a Stringable array key to string so the optimized path matches + * CoreExtension::getAttribute(); scalars are left to PHP's native offset coercion. + */ + private function compileArrayKey(Compiler $compiler): void + { + $attribute = $this->getNode('attribute'); + + if ($attribute instanceof ConstantExpression) { + $compiler->subcompile($attribute); + + return; + } + + $key = '$'.$compiler->getVarName(); + $compiler + ->raw('(('.$key.' = ') + ->subcompile($attribute) + ->raw(') instanceof \Stringable ? (string) '.$key.' : '.$key.')') + ; } private function changeIgnoreStrictCheck(self $node): void diff --git a/src/Node/IncludeNode.php b/src/Node/IncludeNode.php index 2e5e94f8d..3adf415e1 100644 --- a/src/Node/IncludeNode.php +++ b/src/Node/IncludeNode.php @@ -38,8 +38,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo { $compiler->addDebugInfo($this); - $sandboxed = $this->hasAttribute('sandboxed') && $this->getAttribute('sandboxed'); - if ($this->getAttribute('ignore_missing')) { $template = $compiler->getVarName(); @@ -64,10 +62,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo ->indent() ; - if ($sandboxed) { - $compiler->write(\sprintf("\$%s->unwrap()->checkSecurity();\n", $template)); - } - $compiler->write(\sprintf('yield from $%s->unwrap()->yield(', $template)); $this->addTemplateArguments($compiler); @@ -76,18 +70,6 @@ class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenTo ->outdent() ->write("}\n") ; - } elseif ($sandboxed) { - $template = $compiler->getVarName(); - - $compiler->write(\sprintf('$%s = ', $template)); - $this->addGetTemplate($compiler); - $compiler - ->raw(";\n") - ->write(\sprintf("\$%s->unwrap()->checkSecurity();\n", $template)) - ->write(\sprintf('yield from $%s->unwrap()->yield(', $template)) - ; - $this->addTemplateArguments($compiler); - $compiler->raw(");\n"); } else { $compiler->write('yield from '); $this->addGetTemplate($compiler); diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index d145c46f6..258d8694d 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -28,6 +28,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 @@ -86,7 +87,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); } } @@ -106,6 +122,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); diff --git a/src/Template.php b/src/Template.php index 0fc16a0bd..d3ebf26b5 100644 --- a/src/Template.php +++ b/src/Template.php @@ -86,6 +86,14 @@ abstract class Template return $this->parent; } + // The compiled doGetParent() may evaluate user expressions (filters, + // functions, method calls) when the parent name is dynamic. Make sure + // the sandbox security check runs first so those expressions cannot + // bypass the allow-list when getParent() is reached before the first + // ensureSecurityChecked() call on this template (e.g. via + // getTemplateForMacro() or yieldBlock() into a pre-warmed instance). + $this->ensureSecurityChecked(); + if (!$parent = $this->doGetParent($context)) { return false; } @@ -334,6 +342,7 @@ abstract class Template $blocks = array_merge($this->blocks, $blocks); try { + $this->ensureSecurityChecked(); yield from $this->doDisplay($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { @@ -378,6 +387,7 @@ abstract class Template if (null !== $template) { try { + $template->ensureSecurityChecked(); yield from $template->$block($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { @@ -445,16 +455,29 @@ abstract class Template protected function getTemplateForMacro(string $name, array $context, int $line, Source $source): self { if (method_exists($this, $name)) { + $this->ensureSecurityChecked(); + return $this; } $parent = $this; while ($parent = $parent->getParent($context)) { if (method_exists($parent, $name)) { + $parent->ensureSecurityChecked(); + return $parent; } } throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($name, \strlen('macro_')), $this->getTemplateName()), $line, $source); } + + /** + * Runs the sandbox security check against the current sandbox state. + * + * @internal + */ + public function ensureSecurityChecked(): void + { + } } diff --git a/src/Util/CallableArgumentsExtractor.php b/src/Util/CallableArgumentsExtractor.php index 55c19e63a..d922302bf 100644 --- a/src/Util/CallableArgumentsExtractor.php +++ b/src/Util/CallableArgumentsExtractor.php @@ -181,25 +181,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 ($this->twigCallable->needsIsSandboxed()) { - 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()) { diff --git a/src/Util/CallableParameters.php b/src/Util/CallableParameters.php new file mode 100644 index 000000000..4321bb291 --- /dev/null +++ b/src/Util/CallableParameters.php @@ -0,0 +1,161 @@ + + * + * @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; + } + } +} diff --git a/src/Util/ReflectionCallable.php b/src/Util/ReflectionCallable.php index 31207201b..3c7019bc0 100644 --- a/src/Util/ReflectionCallable.php +++ b/src/Util/ReflectionCallable.php @@ -25,7 +25,7 @@ final class ReflectionCallable private string $name; public function __construct( - TwigCallableInterface $twigCallable, + private TwigCallableInterface $twigCallable, ) { $callable = $twigCallable->getCallable(); if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { @@ -77,6 +77,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 ($this->twigCallable->needsIsSandboxed()) { + array_shift($parameters); + } + foreach ($this->twigCallable->getArguments() as $argument) { + array_shift($parameters); + } + + return array_values($parameters); + } + public function getCallable(): \Closure|string|array { return $this->callable; diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php new file mode 100644 index 000000000..d948ac147 --- /dev/null +++ b/tests/Extension/SandboxStateChangeTest.php @@ -0,0 +1,428 @@ +build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false); + + $this->assertSame('FOO', $twig->render('t')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('t'); + } + + public function testDisableSandboxAfterFirstRender() + { + // Use enableSandbox() to toggle a non-global sandbox so disableSandbox() actually has an effect. + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false); + $sandbox->enableSandbox(); + + try { + $twig->render('t'); + $this->fail('Expected SecurityNotAllowedFilterError on first render'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('upper', $e->getFilterName()); + } + + $sandbox->disableSandbox(); + + $this->assertSame('FOO', $twig->render('t')); + } + + public function testSetSecurityPolicyTightening() + { + $permissive = new SecurityPolicy(allowedFilters: ['upper', 'escape']); + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $permissive, true); + + $this->assertSame('FOO', $twig->render('t')); + + $sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['escape'])); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('t'); + } + + public function testSetSecurityPolicyLoosening() + { + $strict = new SecurityPolicy(allowedFilters: []); + [$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $strict, true); + + try { + $twig->render('t'); + $this->fail('Expected SecurityNotAllowedFilterError on first render'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('upper', $e->getFilterName()); + } + + $sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper'])); + + $this->assertSame('FOO', $twig->render('t')); + } + + /** + * @group legacy + */ + public function testSourcePolicyDecisionFlip() + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + + $sourcePolicy = new class implements SourcePolicyInterface { + public array $sandboxFor = []; + + public function enableSandbox(Source $source): bool + { + return \in_array($source->getName(), $this->sandboxFor, true); + } + }; + + [$twig] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false, $sourcePolicy); + + $this->assertSame('FOO', $twig->render('t')); + + $sourcePolicy->sandboxFor = ['t']; + + $this->expectException(SecurityNotAllowedFilterError::class); + $twig->render('t'); + } + + public function testPreWarmedParentTemplateThroughExtends() + { + $templates = [ + 'parent.twig' => '{% block c %}default{% endblock %}{{ "hi"|upper }}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}child{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + // pre-warm with sandbox off: parent and child Template instances are now cached + $this->assertSame('childHI', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('child.twig'); + } + + public function testPreWarmedSharedViaSandboxedInclude() + { + $templates = [ + 'top' => '{{ include("user", sandboxed=true) }}', + 'user' => '{% extends "shared" %}{% block c %}user{% endblock %}', + 'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFunctions: ['include'], + allowedFilters: ['escape'], + ); + [$twig] = $this->build($templates, $policy, false); + + // legitimate pre-warm of the trusted layout with sandbox off + $this->assertSame('OK', $twig->render('shared')); + + // sandboxed include of user, which extends the pre-warmed shared: + // upper is not in the allow-list, so it must throw even though shared + // was loaded unsandboxed. + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('top'); + } + + public function testMacroFromPreWarmedTemplate() + { + $templates = [ + 'macros.twig' => '{% macro greet(name) %}{{ name|upper }}{% endmacro %}', + 'caller.twig' => '{% import "macros.twig" as m %}{{ m.greet("world") }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['import', 'macro'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + // pre-warm with sandbox off + $this->assertSame('WORLD', $twig->render('caller.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('caller.twig'); + } + + /** + * @group legacy + */ + public function testSandboxTagAroundIncludeOfPreWarmedTemplate() + { + $this->expectDeprecation('Since twig/twig 3.15: The "sandbox" tag is deprecated in "wrapper" at line 1.'); + + $templates = [ + 'wrapper' => '{% sandbox %}{% include "user" %}{% endsandbox %}', + 'user' => '{% extends "shared" %}{% block c %}user{% endblock %}', + 'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block', 'include'], + allowedFilters: ['escape'], + ); + [$twig] = $this->build($templates, $policy, false); + + $this->assertSame('OK', $twig->render('shared')); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $twig->render('wrapper'); + } + + public function testTagBypassThroughPreWarmedParent() + { + $templates = [ + 'parent.twig' => '{% block c %}{% for i in 1..2 %}{{ i }}{% endfor %}{% endblock %}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: [], + allowedFunctions: ['parent', 'range'], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + $this->assertSame('12', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedTagError::class); + $this->expectExceptionMessage('Tag "for" is not allowed'); + $twig->render('child.twig'); + } + + public function testFunctionBypassThroughPreWarmedParent() + { + $templates = [ + 'parent.twig' => '{% block c %}{{ range(1, 2)|first }}{% endblock %}', + 'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'block'], + allowedFilters: ['first'], + allowedFunctions: ['parent'], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + + $this->assertSame('1', $twig->render('child.twig')); + + $sandbox->enableSandbox(); + + $this->expectException(SecurityNotAllowedFunctionError::class); + $this->expectExceptionMessage('Function "range" is not allowed'); + $twig->render('child.twig'); + } + + public function testDynamicParentFilterRejectedWhenReachedViaMacroImport() + { + // Regression: getTemplateForMacro() walks getParent() to find the + // macro on a parent template. When the imported template has a + // dynamic {% extends %}, doGetParent() evaluates the user expression. + // The sandbox security check must run on the imported template + // *before* doGetParent() executes, otherwise a forbidden filter on + // the parent name escapes the allow-list. + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends parent_name|evil %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFilters: [], + ); + [$twig] = $this->build($templates, $policy, true); + $evilCalls = 0; + $twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('evil', $e->getFilterName()); + } + $this->assertSame(0, $evilCalls, 'The forbidden filter must not be invoked before the security check runs.'); + } + + public function testDynamicParentFunctionRejectedWhenReachedViaMacroImport() + { + // Same root cause as the filter case, but for functions called from + // within the dynamic parent expression. + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends evil(parent_name) %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFunctions: [], + ); + [$twig] = $this->build($templates, $policy, true); + $evilCalls = 0; + $twig->addFunction(new \Twig\TwigFunction('evil', static function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFunctionError'); + } catch (SecurityNotAllowedFunctionError $e) { + $this->assertSame('evil', $e->getFunctionName()); + } + $this->assertSame(0, $evilCalls, 'The forbidden function must not be invoked before the security check runs.'); + } + + public function testDynamicParentFilterRejectedOnPreWarmedTemplate() + { + // Same bypass, but reached after the template has been pre-warmed + // outside the sandbox (which would have otherwise short-circuited + // ensureSecurityChecked() at yield() time). + $templates = [ + 'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}', + 'middle.twig' => '{% extends parent_name|evil %}', + 'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}', + ]; + $policy = new SecurityPolicy( + allowedTags: ['extends', 'import', 'macro'], + allowedFilters: [], + ); + [$twig, $sandbox] = $this->build($templates, $policy, false); + $evilCalls = 0; + $twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) { + ++$evilCalls; + + return $v; + })); + + $this->assertSame('grand', $twig->render('caller.twig', ['parent_name' => 'grandparent.twig'])); + $this->assertSame(1, $evilCalls); + + $sandbox->enableSandbox(); + + try { + $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']); + $this->fail('Expected SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertSame('evil', $e->getFilterName()); + } + $this->assertSame(1, $evilCalls, 'The forbidden filter must not be invoked after the sandbox is enabled.'); + } + + public function testRepeatedRendersInStableSandboxedStateRunCheckEachTime() + { + $counter = new CountingSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper'])); + [$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, true); + + for ($i = 0; $i < 5; ++$i) { + $this->assertSame('X', $twig->render('t')); + } + + // The per-render check is intentionally re-run on every yield(): the cached verdict was + // the source of the original bypass. Asserting >= 5 documents the new contract. + $this->assertGreaterThanOrEqual(5, $counter->callCount); + } + + public function testNoCheckWhenSandboxRemainsOff() + { + $counter = new CountingSecurityPolicy(new SecurityPolicy()); + [$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, false); + + for ($i = 0; $i < 5; ++$i) { + $this->assertSame('X', $twig->render('t')); + } + + $this->assertSame(0, $counter->callCount); + } + + /** + * @return array{0: Environment, 1: SandboxExtension} + */ + private function build(array $templates, SecurityPolicyInterface $policy, bool $sandboxed, ?SourcePolicyInterface $sourcePolicy = null): array + { + $twig = new Environment(new ArrayLoader($templates), ['cache' => false, 'autoescape' => false]); + $sandbox = new SandboxExtension($policy, $sandboxed, $sourcePolicy); + $twig->addExtension($sandbox); + + return [$twig, $sandbox]; + } +} + +class CountingSecurityPolicy implements SecurityPolicyInterface +{ + public int $callCount = 0; + + public function __construct(private SecurityPolicyInterface $inner) + { + } + + public function checkSecurity($tags, $filters, $functions): void + { + ++$this->callCount; + $this->inner->checkSecurity($tags, $filters, $functions); + } + + public function checkMethodAllowed($obj, $method): void + { + $this->inner->checkMethodAllowed($obj, $method); + } + + public function checkPropertyAllowed($obj, $property): void + { + $this->inner->checkPropertyAllowed($obj, $property); + } +} diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index d043856c7..820ce9434 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -56,6 +56,10 @@ class SandboxTest extends TestCase 'magic' => new MagicObject(), 'recursion' => [4], 'iterator' => new \ArrayIterator(['a', new FooObject()]), + 'iterator_map' => new \ArrayIterator(['__toString' => new FooObject()]), + 'iterator_nested' => new \ArrayIterator(['a', new \ArrayIterator(['b', new FooObject()])]), + 'stringable_iterator' => new StringableTraversableObject(['a', new FooObject()]), + 'stringable_iterator_map' => new StringableTraversableObject(['__toString' => new FooObject()]), ]; self::$params['recursion'][] = &self::$params['recursion']; self::$params['recursion'][] = new FooObject(); @@ -428,6 +432,9 @@ class SandboxTest extends TestCase '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(",") }}'], + 'iterator_in_join' => ['{{ iterator|join(", ") }}'], + 'iterator_nested_in_join' => ['{{ iterator_nested|join(", ") }}'], + 'iterator_in_replace' => ['{{ "__toString"|replace(iterator_map) }}'], 'recursion' => ['{{ recursion|join(", ") }}'], 'ternary_print' => ['{{ true ? obj : "" }}'], 'ternary_filter_input' => ['{{ (true ? obj : "")|upper }}'], @@ -448,11 +455,20 @@ class SandboxTest extends TestCase '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 %}'], + 'in_array_right' => ['{% if "needle" in [obj] %}LEAK{% endif %}'], + 'in_array_left' => ['{% if obj in ["needle"] %}LEAK{% endif %}'], + 'notin_array_right' => ['{% if "needle" not in [obj] %}LEAK{% endif %}'], + 'notin_array_left' => ['{% if obj not in ["needle"] %}LEAK{% endif %}'], + 'in_iterator_right' => ['{% if "needle" in iterator %}LEAK{% endif %}'], + 'notin_iterator_right' => ['{% if "needle" not in iterator %}LEAK{% endif %}'], '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_tag_array_dynamic_key' => ['{% set _ = {(obj): "v"} %}'], + 'set_tag_array_dynamic_key_nested' => ['{% set _ = {"foo": {(obj): "v"}} %}'], + 'set_tag_array_dynamic_key_object_chain' => ['{% set _ = {(obj.anotherFooObject): "v"} %}'], '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" }}'], @@ -620,6 +636,15 @@ class SandboxTest extends TestCase $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once'); } + public function testSandboxAllowsArrayDynamicKeyWhenToStringAllowed() + { + $twig = $this->getEnvironment(true, [], [ + 'index' => '{% set arr = {(obj): "v", (obj.anotherFooObject): "v2"} %}{{ arr|keys|join(",") }}', + ], ['set'], ['join', 'keys'], ['Twig\Tests\Extension\FooObject' => ['__toString', 'getAnotherFooObject']]); + + $this->assertSame('foo', $twig->load('index')->render(self::$params)); + } + public function testSandboxAllowMethodToStringDisabled() { $twig = $this->getEnvironment(false, [], self::$templates); @@ -955,6 +980,447 @@ EOF } } + /** + * @dataProvider getStringableTraversableBypassTemplates + */ + public function testSandboxBlocksToStringInStringableTraversable(string $template) + { + $twig = $this->getEnvironment( + true, + [], + ['index' => $template], + [], + ['join', 'replace'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Stringable+Traversable container, even when the container\'s own __toString is allowed.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getStringableTraversableBypassTemplates(): iterable + { + yield 'join' => ['{{ stringable_iterator|join(", ") }}']; + yield 'replace' => ['{{ "__toString"|replace(stringable_iterator_map) }}']; + } + + /** + * @group legacy + * + * @dataProvider getStringableTraversableBypassTemplates + */ + public function testSourcePolicySandboxBlocksToStringInStringableTraversable(string $template) + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment( + false, + [], + ['index' => $template], + [], + ['join', 'replace'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + [], + [], + $sourcePolicy, + ); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Stringable+Traversable container under a SourcePolicyInterface-only sandbox.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSandboxAllowsPrintingStringableTraversableWhenToStringAllowed() + { + // Printing the container itself yields its `__toString()` value. The + // sandbox materialises the iterable to also policy-check the elements + // (some consumers like `join`/`replace` would coerce them too), so the + // inner items must not contain anything that violates the policy. + $twig = $this->getEnvironment( + true, + ['autoescape' => 'html'], + ['index' => '{{ obj }}'], + [], + ['escape'], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + + $params = ['obj' => new StringableTraversableObject(['a', 'b'])]; + + $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); + } + + /** + * @dataProvider getCyclicTraversableTemplates + */ + public function testSandboxHandlesCyclicTraversableWithoutStackOverflow(string $template) + { + // A self-referencing IteratorAggregate must not cause the sandbox policy + // walker to recurse infinitely when materialising the iterable. PHP itself + // throws a clean error when the cyclic object reaches `implode()` / + // string coercion; the sandbox must NOT turn that into a stack overflow. + $twig = $this->getEnvironment( + true, + [], + ['index' => $template], + [], + ['join', 'replace'], + ); + + $this->expectException(RuntimeError::class); + + $twig->load('index')->render(['obj' => new CyclicTraversableObject()]); + } + + public static function getCyclicTraversableTemplates(): iterable + { + yield 'join' => ['{{ obj|join(",") }}']; + yield 'replace' => ['{{ "x"|replace(obj) }}']; + yield 'spread' => ['{{ ["a", ...obj]|join(",") }}']; + } + + /** + * @group legacy + */ + public function testSourcePolicySandboxBlocksToStringInTraversableJoin() + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment(false, [], ['index' => '{{ iterator|join(", ") }}'], [], ['join'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects contained in a Traversable passed to the "join" filter (SourcePolicyInterface).'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + /** + * @group legacy + */ + public function testSourcePolicySandboxBlocksToStringInTraversableReplace() + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); + + $sourcePolicy = new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return true; + } + }; + + $twig = $this->getEnvironment(false, [], ['index' => '{{ "__toString"|replace(iterator_map) }}'], [], ['replace'], [], [], [], $sourcePolicy); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects contained in a Traversable passed to the "replace" filter (SourcePolicyInterface).'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSandboxPreservesTraversableArgumentIdentity() + { + // Regression for https://github.com/twigphp/Twig/issues/4820: + // a typed Traversable argument (e.g. Symfony's FormView) must reach + // host code as-is, not as a plain array. + $twig = $this->getEnvironment( + true, + [], + ['index' => '{{ render_traversable(obj) }}'], + [], + [], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + $twig->addFunction(new TwigFunction('render_traversable', static function ($obj) { + if (!$obj instanceof StringableTraversableObject) { + throw new \RuntimeException(\sprintf('Expected a StringableTraversableObject, got "%s".', get_debug_type($obj))); + } + + return (string) $obj; + })); + + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['render_traversable']); + + $params = ['obj' => new StringableTraversableObject(['a', 'b'])]; + $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); + } + + public function testSandboxStillBlocksDisallowedToStringInTraversableArgument() + { + // The container is returned as-is, but yielded elements must still + // be policy-checked since host code can string-coerce them. + $twig = $this->getEnvironment( + true, + [], + ['index' => '{{ render_traversable(stringable_iterator) }}'], + [], + [], + ['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']], + ); + $twig->addFunction(new TwigFunction('render_traversable', static fn ($obj) => (string) $obj)); + + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['render_traversable']); + + try { + $twig->load('index')->render(self::$params); + $this->fail('Sandbox should block __toString on objects yielded by a Traversable argument to a user function.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + /** + * @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()]; @@ -1131,3 +1597,56 @@ 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`, +// ...) materialise the iterable and coerce its contents to string. +class StringableTraversableObject implements \IteratorAggregate, \Stringable +{ + public function __construct(private array $items) + { + } + + public function __toString(): string + { + return 'stringable-traversable'; + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } +} + +// Self-referencing IteratorAggregate: getIterator() yields `$this`. Used to +// verify that the sandbox policy walker (which materialises Traversables to +// enforce the `__toString` policy on yielded elements) does not recurse +// infinitely. +class CyclicTraversableObject implements \IteratorAggregate +{ + public function getIterator(): \Traversable + { + yield $this; + } +} diff --git a/tests/Fixtures/expressions/array_dynamic_keys.test b/tests/Fixtures/expressions/array_dynamic_keys.test new file mode 100644 index 000000000..34cbed8bc --- /dev/null +++ b/tests/Fixtures/expressions/array_dynamic_keys.test @@ -0,0 +1,59 @@ +--TEST-- +Mapping keys can be any expression that evaluates to a scalar or Stringable +--TEMPLATE-- +{# context variable holding a Stringable object #} +{{ {(obj): 'a'}|keys|join(',') }} + +{# attribute access on an object yielding a Stringable #} +{{ {(holder.stringable): 'a'}|keys|join(',') }} + +{# attribute access yielding a string #} +{{ {(holder.name): 'a'}|keys|join(',') }} + +{# filter result yielding a Stringable #} +{{ {(obj|raw): 'a'}|keys|join(',') }} + +{# filter result yielding a string #} +{{ {('hello'|upper): 'a'}|keys|join(',') }} + +{# filter result yielding an integer (keys stay int) #} +{{ {(holder.name|length): 'a'}|keys|first == 4 ? 'ok' : 'ko' }} + +{# method call yielding a Stringable #} +{{ {(holder.getStringable()): 'a'}|keys|join(',') }} + +{# mixed static and dynamic keys #} +{{ {'static': 's', (obj): 'd', (holder.name): 'n'}|keys|join(',') }} + +{# nested mapping with a dynamic Stringable key #} +{{ {'outer': {(obj): 'inner'}} | json_encode | raw }} +--DATA-- +class TwigTestStringy implements \Stringable { + public function __construct(private string $v) {} + public function __toString(): string { return $this->v; } +} +class TwigTestHolder { + public string $name = 'attr'; + public TwigTestStringy $stringable; + public function __construct() { $this->stringable = new TwigTestStringy('attr_obj'); } + public function getStringable(): TwigTestStringy { return new TwigTestStringy('method_obj'); } +} +return ['obj' => new TwigTestStringy('ctx_obj'), 'holder' => new TwigTestHolder()] +--EXPECT-- +ctx_obj + +attr_obj + +attr + +ctx_obj + +HELLO + +ok + +method_obj + +static,ctx_obj,attr + +{"outer":{"ctx_obj":"inner"}} diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index 99c24a657..cf875d591 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -225,6 +225,56 @@ class TemplateTest extends TestCase $this->assertSame('EmptyString', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, null), 'null is treated as "" when accessing a sequence/mapping (equals PHP behavior)'); } + #[DataProvider('getStrictVariablesModes')] + public function testArrayAccessWithStringableKeyIsConsistentAcrossStrictModes(bool $strict) + { + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), [ + 'strict_variables' => $strict, + 'autoescape' => false, + ]); + + $object = new class implements \Stringable { + public function __toString(): string + { + return 'string'; + } + }; + + $this->assertSame('value', $twig->render('index', ['array' => ['string' => 'value'], 'object' => $object])); + } + + public static function getStrictVariablesModes(): iterable + { + yield 'lax' => [false]; + yield 'strict' => [true]; + } + + public function testArrayAccessWithStringableKeyIsCheckedBySandbox() + { + $object = new class implements \Stringable { + public function __toString(): string + { + return 'string'; + } + }; + $data = ['array' => ['string' => 'value'], 'object' => $object]; + + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), ['autoescape' => false]); + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], [], [], [], []), true)); + + try { + $twig->render('index', $data); + $this->fail('The sandbox must reject the __toString() coercion of the array key.'); + } catch (SecurityError $e) { + $this->assertStringContainsStringIgnoringCase('__toString', $e->getMessage()); + } + + $twig = new Environment(new ArrayLoader(['index' => '{{ array[object] }}']), ['autoescape' => false]); + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], [], [$object::class => ['__toString']], [], []), true)); + + $this->assertSame('value', $twig->render('index', $data)); + } + #[DataProvider('getGetAttributeTests')] public function testGetAttribute($defined, $value, $object, $item, $arguments, $type) { diff --git a/tests/Util/CallableParametersTest.php b/tests/Util/CallableParametersTest.php new file mode 100644 index 000000000..92e61a578 --- /dev/null +++ b/tests/Util/CallableParametersTest.php @@ -0,0 +1,168 @@ +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 ''; + } +}