diff --git a/CHANGELOG b/CHANGELOG index 6cbe452cd..cbb302895 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,7 @@ * Cast printed expressions to string so values that cannot be converted to a string (arrays, non-`Stringable` objects, ...) report a usable stack trace at the print location * Make the `include()` function return a `Markup` object so an assigned result is not re-escaped when printed * Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string + * Add an `always_allowed_in_sandbox` option for filters and functions, and an `isAlwaysAllowedInSandbox()` method for token parsers, to let authors mark callables and tags that are always allowed in sandbox mode without explicit allow-listing # 3.27.1 (2026-05-30) diff --git a/doc/deprecated.rst b/doc/deprecated.rst index 83ee114fb..5b6c186db 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -312,6 +312,17 @@ Sandbox on ``Twig\Sandbox\SecurityPolicy`` opts-in to the 4.0 behavior for these functions too. +* Not implementing the ``isAlwaysAllowedInSandbox()`` method in + ``Twig\TwigCallableInterface`` implementations (``TwigFilter``, + ``TwigFunction``, ``TwigTest``) and in + ``Twig\TokenParser\TokenParserInterface`` implementations is deprecated as + of Twig 3.27. This method will be added to both interfaces in Twig 4.0. It + returns ``true`` when the filter, function, test, or tag is always allowed + in a sandboxed template, regardless of the security policy allow-list. + Custom callables extending ``Twig\AbstractTwigCallable`` and custom token + parsers extending ``Twig\TokenParser\AbstractTokenParser`` inherit a + default implementation that returns ``false``. + * The ``Twig\Sandbox\SourcePolicyInterface`` interface is deprecated as of Twig 3.27.0 with no replacement. Passing an instance to the ``Twig\Extension\SandboxExtension`` constructor triggers a deprecation. diff --git a/doc/sandbox.rst b/doc/sandbox.rst index b6ab08dd0..cd0e0445a 100644 --- a/doc/sandbox.rst +++ b/doc/sandbox.rst @@ -57,6 +57,52 @@ allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception. $policy->setStrict(true); +Marking Filters, Functions, and Tags as Always Allowed +------------------------------------------------------ + +.. versionadded:: 3.27 + + The ``always_allowed_in_sandbox`` option for filters and functions, and + the ``isAlwaysAllowedInSandbox()`` method for token parsers, were added in + Twig 3.27. + +Some filters, functions, and tags are inherently safe and should always be +usable in sandboxed templates without forcing every policy to allow-list them. +Mark such callables by setting the ``always_allowed_in_sandbox`` option to +``true``:: + + $twig->addFilter(new \Twig\TwigFilter('upper', 'strtoupper', [ + 'always_allowed_in_sandbox' => true, + ])); + + $twig->addFunction(new \Twig\TwigFunction('max', 'max', [ + 'always_allowed_in_sandbox' => true, + ])); + +For tags, override ``isAlwaysAllowedInSandbox()`` on your token parser to +return ``true``:: + + final class MyTagTokenParser extends \Twig\TokenParser\AbstractTokenParser + { + public function isAlwaysAllowedInSandbox(): bool + { + return true; + } + + // ... + } + +Marked filters, functions, and tags are skipped by the sandbox security check +entirely, so they incur no runtime overhead, and they do not need to be +listed in the ``SecurityPolicy`` allow-lists. + +.. caution:: + + Only mark callables and tags as always allowed when their behavior is + safe regardless of the arguments they receive. A callable that can read + arbitrary PHP constants, access object internals, perform I/O, or trigger + side effects must not be marked: keep it under the allow-list. + Enabling the Sandbox -------------------- diff --git a/src/AbstractTwigCallable.php b/src/AbstractTwigCallable.php index 008d4d395..4ea9f6695 100644 --- a/src/AbstractTwigCallable.php +++ b/src/AbstractTwigCallable.php @@ -34,6 +34,7 @@ abstract class AbstractTwigCallable implements TwigCallableInterface 'needs_charset' => false, 'needs_is_sandboxed' => false, 'is_variadic' => false, + 'always_allowed_in_sandbox' => false, 'deprecation_info' => null, 'deprecated' => false, 'deprecating_package' => '', @@ -113,6 +114,11 @@ abstract class AbstractTwigCallable implements TwigCallableInterface return $this->options['needs_is_sandboxed']; } + public function isAlwaysAllowedInSandbox(): bool + { + return $this->options['always_allowed_in_sandbox']; + } + /** * @return static */ diff --git a/src/Attribute/AsTwigFilter.php b/src/Attribute/AsTwigFilter.php index 59c4726de..5689c9dfb 100644 --- a/src/Attribute/AsTwigFilter.php +++ b/src/Attribute/AsTwigFilter.php @@ -31,16 +31,17 @@ use Twig\TwigFilter; final class AsTwigFilter { /** - * @param non-empty-string $name The name of the filter in Twig - * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument - * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset - * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment - * @param bool|null $needsIsSandboxed Whether the filter needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context - * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped - * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe - * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe - * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to - * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param non-empty-string $name The name of the filter in Twig + * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument + * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset + * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment + * @param bool|null $needsIsSandboxed Whether the filter needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context + * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped + * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe + * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe + * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to + * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param bool|null $alwaysAllowedInSandbox Whether the filter is always allowed in sandbox mode, even when not explicitly allow-listed */ public function __construct( public string $name, @@ -53,6 +54,7 @@ final class AsTwigFilter public ?string $preEscape = null, public ?array $preservesSafety = null, public ?DeprecatedCallableInfo $deprecationInfo = null, + public ?bool $alwaysAllowedInSandbox = null, ) { } } diff --git a/src/Attribute/AsTwigFunction.php b/src/Attribute/AsTwigFunction.php index 119918085..a0118c378 100644 --- a/src/Attribute/AsTwigFunction.php +++ b/src/Attribute/AsTwigFunction.php @@ -31,14 +31,15 @@ use Twig\TwigFunction; final class AsTwigFunction { /** - * @param non-empty-string $name The name of the function in Twig - * @param bool|null $needsCharset Whether the function needs the charset passed as the first argument - * @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset - * @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment - * @param bool|null $needsIsSandboxed Whether the function needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context - * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped - * @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe - * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param non-empty-string $name The name of the function in Twig + * @param bool|null $needsCharset Whether the function needs the charset passed as the first argument + * @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset + * @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment + * @param bool|null $needsIsSandboxed Whether the function needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context + * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped + * @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe + * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param bool|null $alwaysAllowedInSandbox Whether the function is always allowed in sandbox mode, even when not explicitly allow-listed */ public function __construct( public string $name, @@ -49,6 +50,7 @@ final class AsTwigFunction public ?array $isSafe = null, public string|array|null $isSafeCallback = null, public ?DeprecatedCallableInfo $deprecationInfo = null, + public ?bool $alwaysAllowedInSandbox = null, ) { } } diff --git a/src/Attribute/AsTwigTest.php b/src/Attribute/AsTwigTest.php index a5f32529b..707424df7 100644 --- a/src/Attribute/AsTwigTest.php +++ b/src/Attribute/AsTwigTest.php @@ -31,12 +31,13 @@ use Twig\TwigTest; final class AsTwigTest { /** - * @param non-empty-string $name The name of the test in Twig - * @param bool|null $needsCharset Whether the test needs the charset passed as the first argument - * @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset - * @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment - * @param bool|null $needsIsSandboxed Whether the test needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context - * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param non-empty-string $name The name of the test in Twig + * @param bool|null $needsCharset Whether the test needs the charset passed as the first argument + * @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset + * @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment + * @param bool|null $needsIsSandboxed Whether the test needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context + * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + * @param bool|null $alwaysAllowedInSandbox Whether the test is always allowed in sandbox mode, even when not explicitly allow-listed */ public function __construct( public string $name, @@ -45,6 +46,7 @@ final class AsTwigTest public ?bool $needsContext = null, public ?bool $needsIsSandboxed = null, public ?DeprecatedCallableInfo $deprecationInfo = null, + public ?bool $alwaysAllowedInSandbox = null, ) { } } diff --git a/src/ExpressionParser/Infix/FunctionExpressionParser.php b/src/ExpressionParser/Infix/FunctionExpressionParser.php index b0ad8af1c..c8851823d 100644 --- a/src/ExpressionParser/Infix/FunctionExpressionParser.php +++ b/src/ExpressionParser/Infix/FunctionExpressionParser.php @@ -59,6 +59,7 @@ final class FunctionExpressionParser extends AbstractExpressionParser implements // the `allowedFunctions` allow-list even though the parser callable // returned a specialized node (e.g. `parent`, `block`, `attribute`). $node->setAttribute('sandboxed_function_name', $name); + $node->setAttribute('sandboxed_function', $function); return $node; } diff --git a/src/Extension/AttributeExtension.php b/src/Extension/AttributeExtension.php index 3b51f8dfc..623fd4f8e 100644 --- a/src/Extension/AttributeExtension.php +++ b/src/Extension/AttributeExtension.php @@ -101,6 +101,7 @@ final class AttributeExtension extends AbstractExtension 'is_safe_callback' => $attribute->isSafeCallback, 'pre_escape' => $attribute->preEscape, 'preserves_safety' => $attribute->preservesSafety, + 'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false, 'deprecation_info' => $attribute->deprecationInfo, ]); @@ -123,6 +124,7 @@ final class AttributeExtension extends AbstractExtension 'is_variadic' => $method->isVariadic(), 'is_safe' => $attribute->isSafe, 'is_safe_callback' => $attribute->isSafeCallback, + 'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false, 'deprecation_info' => $attribute->deprecationInfo, ]); @@ -143,6 +145,7 @@ final class AttributeExtension extends AbstractExtension 'needs_charset' => $attribute->needsCharset ?? false, 'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false, 'is_variadic' => $method->isVariadic(), + 'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false, 'deprecation_info' => $attribute->deprecationInfo, ]); diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index be39cc778..4300eae4a 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -27,6 +27,8 @@ use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\Node\Nodes; +use Twig\TokenParser\TokenParserInterface; +use Twig\TwigCallableInterface; use Twig\Util\CallableParameters; /** @@ -53,18 +55,18 @@ final class SandboxNodeVisitor implements NodeVisitorInterface $this->functions = []; } elseif ($this->inAModule) { // look for tags - if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()])) { + if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()]) && !$this->isTagAlwaysAllowedInSandbox($env, $node->getNodeTag())) { $this->tags[$node->getNodeTag()] = $node->getTemplateLine(); } // look for filters - if ($node instanceof FilterExpression && !isset($this->filters[$node->getAttribute('name')])) { - $this->filters[$node->getAttribute('name')] = $node->getTemplateLine(); + if ($node instanceof FilterExpression && !isset($this->filters[$name = $node->getAttribute('name')]) && !$this->isFilterAlwaysAllowedInSandbox($env, $node)) { + $this->filters[$name] = $node->getTemplateLine(); } // look for functions - if ($node instanceof FunctionExpression && !isset($this->functions[$node->getAttribute('name')])) { - $this->functions[$node->getAttribute('name')] = $node->getTemplateLine(); + if ($node instanceof FunctionExpression && !isset($this->functions[$name = $node->getAttribute('name')]) && !$this->isFunctionAlwaysAllowedInSandbox($env, $node)) { + $this->functions[$name] = $node->getTemplateLine(); } // look for functions whose parser callable replaced the FunctionExpression @@ -72,13 +74,13 @@ final class SandboxNodeVisitor implements NodeVisitorInterface // original function name was stashed by FunctionExpressionParser. if ($node->hasAttribute('sandboxed_function_name')) { $name = $node->getAttribute('sandboxed_function_name'); - if (!isset($this->functions[$name])) { + if (!isset($this->functions[$name]) && !$this->isSandboxedFunctionAlwaysAllowedInSandbox($env, $node, $name)) { $this->functions[$name] = $node->getTemplateLine(); } } // the .. operator is equivalent to the range() function - if ($node instanceof RangeBinary && !isset($this->functions['range'])) { + if ($node instanceof RangeBinary && !isset($this->functions['range']) && !$this->isFunctionNameAlwaysAllowedInSandbox($env, 'range')) { $this->functions['range'] = $node->getTemplateLine(); } } @@ -200,6 +202,72 @@ final class SandboxNodeVisitor implements NodeVisitorInterface } } + private function isTagAlwaysAllowedInSandbox(Environment $env, string $name): bool + { + if (null === $parser = $env->getTokenParser($name)) { + return false; + } + + return self::isAlwaysAllowedInSandbox($parser); + } + + private function isFilterAlwaysAllowedInSandbox(Environment $env, FilterExpression $node): bool + { + if ($node->hasAttribute('twig_callable')) { + $filter = $node->getAttribute('twig_callable'); + } elseif (null === $filter = $env->getFilter($node->getAttribute('name'))) { + return false; + } + + return self::isAlwaysAllowedInSandbox($filter); + } + + private function isFunctionAlwaysAllowedInSandbox(Environment $env, FunctionExpression $node): bool + { + if ($node->hasAttribute('twig_callable')) { + $function = $node->getAttribute('twig_callable'); + } elseif (null === $function = $env->getFunction($node->getAttribute('name'))) { + return false; + } + + return self::isAlwaysAllowedInSandbox($function); + } + + private function isSandboxedFunctionAlwaysAllowedInSandbox(Environment $env, Node $node, string $name): bool + { + if ($node->hasAttribute('sandboxed_function')) { + $function = $node->getAttribute('sandboxed_function'); + } elseif (null === $function = $env->getFunction($name)) { + return false; + } + + return self::isAlwaysAllowedInSandbox($function); + } + + private function isFunctionNameAlwaysAllowedInSandbox(Environment $env, string $name): bool + { + if (null === $function = $env->getFunction($name)) { + return false; + } + + return self::isAlwaysAllowedInSandbox($function); + } + + /** + * @param TwigCallableInterface|TokenParserInterface $subject + */ + private static function isAlwaysAllowedInSandbox($subject): bool + { + if (method_exists($subject, 'isAlwaysAllowedInSandbox')) { + return $subject->isAlwaysAllowedInSandbox(); + } + + $interface = $subject instanceof TokenParserInterface ? TokenParserInterface::class : TwigCallableInterface::class; + trigger_deprecation('twig/twig', '3.27', 'Not implementing the "isAlwaysAllowedInSandbox()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $subject::class, $interface); + + return false; + } + public function getPriority(): int { return 0; diff --git a/src/TokenParser/AbstractTokenParser.php b/src/TokenParser/AbstractTokenParser.php index 8acaa6f56..8c07f6029 100644 --- a/src/TokenParser/AbstractTokenParser.php +++ b/src/TokenParser/AbstractTokenParser.php @@ -34,6 +34,11 @@ abstract class AbstractTokenParser implements TokenParserInterface $this->parser = $parser; } + public function isAlwaysAllowedInSandbox(): bool + { + return false; + } + /** * Parses an assignment expression like "a, b". */ diff --git a/src/TokenParser/TokenParserInterface.php b/src/TokenParser/TokenParserInterface.php index bb8db3e5c..7514c67db 100644 --- a/src/TokenParser/TokenParserInterface.php +++ b/src/TokenParser/TokenParserInterface.php @@ -20,6 +20,8 @@ use Twig\Token; * Interface implemented by token parsers. * * @author Fabien Potencier + * + * @method bool isAlwaysAllowedInSandbox() Whether the tag is always allowed in sandbox mode, even when not explicitly allow-listed. Not implementing this method is deprecated since Twig 3.27, it will be required in 4.0. */ interface TokenParserInterface { diff --git a/src/TwigCallableInterface.php b/src/TwigCallableInterface.php index 4279b6430..742ff15e4 100644 --- a/src/TwigCallableInterface.php +++ b/src/TwigCallableInterface.php @@ -14,7 +14,8 @@ namespace Twig; /** * @author Fabien Potencier * - * @method bool needsIsSandboxed() Whether the callable needs the current sandbox state passed as an argument. Not implementing this method is deprecated since Twig 3.25, it will be required in 4.0. + * @method bool needsIsSandboxed() Whether the callable needs the current sandbox state passed as an argument. Not implementing this method is deprecated since Twig 3.25, it will be required in 4.0. + * @method bool isAlwaysAllowedInSandbox() Whether the callable is always allowed in sandbox mode, even when not explicitly allow-listed. Not implementing this method is deprecated since Twig 3.27, it will be required in 4.0. */ interface TwigCallableInterface extends \Stringable { diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php index f6b6d9242..c5d9ddd39 100644 --- a/tests/Extension/AttributeExtensionTest.php +++ b/tests/Extension/AttributeExtensionTest.php @@ -51,8 +51,10 @@ class AttributeExtensionTest extends TestCase yield 'with context' => ['with_context_filter', 'withContextFilter', ['needs_context' => true]]; yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]]; yield 'with sandbox' => ['with_sandbox_filter', 'withSandboxFilter', ['needs_is_sandboxed' => true]]; + yield 'always allowed' => ['always_allowed_filter', 'alwaysAllowedFilter', ['always_allowed_in_sandbox' => true]]; yield 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]]; yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + yield 'deprecated positional' => ['deprecated_positional_filter', 'deprecatedPositionalFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; yield 'pattern' => ['pattern_*_filter', 'patternFilter', []]; } @@ -81,9 +83,11 @@ class AttributeExtensionTest extends TestCase yield 'with context' => ['with_context_function', 'withContextFunction', ['needs_context' => true]]; yield 'with env and context' => ['with_env_and_context_function', 'withEnvAndContextFunction', ['needs_environment' => true, 'needs_context' => true]]; yield 'with sandbox' => ['with_sandbox_function', 'withSandboxFunction', ['needs_is_sandboxed' => true]]; + yield 'always allowed' => ['always_allowed_function', 'alwaysAllowedFunction', ['always_allowed_in_sandbox' => true]]; yield 'no argument' => ['no_arg_function', 'noArgFunction', []]; yield 'variadic' => ['variadic_function', 'variadicFunction', ['is_variadic' => true]]; yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + yield 'deprecated positional' => ['deprecated_positional_function', 'deprecatedPositionalFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; } /** @@ -111,8 +115,10 @@ class AttributeExtensionTest extends TestCase yield 'with context' => ['with_context_test', 'withContextTest', ['needs_context' => true]]; yield 'with env and context' => ['with_env_and_context_test', 'withEnvAndContextTest', ['needs_environment' => true, 'needs_context' => true]]; yield 'with sandbox' => ['with_sandbox_test', 'withSandboxTest', ['needs_is_sandboxed' => true]]; + yield 'always allowed' => ['always_allowed_test', 'alwaysAllowedTest', ['always_allowed_in_sandbox' => true]]; yield 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]]; yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + yield 'deprecated positional' => ['deprecated_positional_test', 'deprecatedPositionalTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; } public function testFilterRequireOneArgument() diff --git a/tests/Extension/Fixtures/ExtensionWithAttributes.php b/tests/Extension/Fixtures/ExtensionWithAttributes.php index 181029ce8..64b1088fb 100644 --- a/tests/Extension/Fixtures/ExtensionWithAttributes.php +++ b/tests/Extension/Fixtures/ExtensionWithAttributes.php @@ -44,6 +44,11 @@ class ExtensionWithAttributes { } + #[AsTwigFilter('always_allowed_filter', alwaysAllowedInSandbox: true)] + public function alwaysAllowedFilter(string $string) + { + } + #[AsTwigFilter('variadic_filter')] public function variadicFilter(string ...$strings) { @@ -54,6 +59,11 @@ class ExtensionWithAttributes { } + #[AsTwigFilter('deprecated_positional_filter', null, null, null, null, null, null, null, null, new DeprecatedCallableInfo('foo/bar', '1.2'))] + public function deprecatedPositionalFilter(string $string) + { + } + #[AsTwigFilter('pattern_*_filter')] public function patternFilter(string $string) { @@ -84,6 +94,11 @@ class ExtensionWithAttributes { } + #[AsTwigFunction('always_allowed_function', alwaysAllowedInSandbox: true)] + public function alwaysAllowedFunction(string $string) + { + } + #[AsTwigFunction('no_arg_function')] public function noArgFunction() { @@ -99,6 +114,11 @@ class ExtensionWithAttributes { } + #[AsTwigFunction('deprecated_positional_function', null, null, null, null, null, null, new DeprecatedCallableInfo('foo/bar', '1.2'))] + public function deprecatedPositionalFunction(string $string) + { + } + #[AsTwigTest(name: 'foo')] public function fooTest(string|int $value) { @@ -129,8 +149,18 @@ class ExtensionWithAttributes { } + #[AsTwigTest('always_allowed_test', alwaysAllowedInSandbox: true)] + public function alwaysAllowedTest($value) + { + } + #[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))] public function deprecatedTest($value, $argument) { } + + #[AsTwigTest('deprecated_positional_test', null, null, null, null, new DeprecatedCallableInfo('foo/bar', '1.2'))] + public function deprecatedPositionalTest($value, $argument) + { + } } diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 0b94fb7aa..1cc126364 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -31,6 +31,11 @@ use Twig\Extension\SandboxExtension; use Twig\Extension\StringLoaderExtension; use Twig\Loader\ArrayLoader; use Twig\Node\Expression\CallExpression; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Node; +use Twig\Node\Nodes; +use Twig\Node\TextNode; +use Twig\Parser; use Twig\Sandbox\SecurityError; use Twig\Sandbox\SecurityNotAllowedFilterError; use Twig\Sandbox\SecurityNotAllowedFunctionError; @@ -40,6 +45,9 @@ use Twig\Sandbox\SecurityNotAllowedTagError; use Twig\Sandbox\SecurityPolicy; use Twig\Sandbox\SourcePolicyInterface; use Twig\Source; +use Twig\Token; +use Twig\TokenParser\AbstractTokenParser; +use Twig\TokenParser\TokenParserInterface; use Twig\TwigCallableInterface; use Twig\TwigFilter; use Twig\TwigFunction; @@ -1989,6 +1997,134 @@ EOF $this->assertFalse(CallExpression::needsIsSandboxed($callable)); } + + public function testAlwaysAllowedInSandboxFilterBypassesAllowList() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|safe_upper }}']); + $twig->addFilter(new TwigFilter('safe_upper', 'strtoupper', ['always_allowed_in_sandbox' => true])); + + $this->assertSame('FABIEN', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxFilterStillEnforcedWhenFlagNotSet() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|gated_upper }}']); + $twig->addFilter(new TwigFilter('gated_upper', 'strtoupper')); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "gated_upper" is not allowed'); + $twig->load('index')->render([]); + } + + public function testAlwaysAllowedInSandboxFunctionBypassesAllowList() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ safe_greet("fabien") }}']); + $twig->addFunction(new TwigFunction('safe_greet', static fn (string $name) => "hi $name", ['always_allowed_in_sandbox' => true])); + + $this->assertSame('hi fabien', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxFunctionStillEnforcedWhenFlagNotSet() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ gated_greet("fabien") }}']); + $twig->addFunction(new TwigFunction('gated_greet', static fn (string $name) => "hi $name")); + + $this->expectException(SecurityNotAllowedFunctionError::class); + $this->expectExceptionMessage('Function "gated_greet" is not allowed'); + $twig->load('index')->render([]); + } + + public function testAlwaysAllowedInSandboxFunctionAlsoCoversRangeOperator() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ (1..2)[0] }}']); + // override the built-in `range` function with one that is always allowed + $twig->addFunction(new TwigFunction('range', 'range', ['always_allowed_in_sandbox' => true])); + + $this->assertSame('1', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxFilterFromUndefinedCallbackUsesParsedCallable() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|callback_upper }}']); + $callbackCalled = false; + $twig->registerUndefinedFilterCallback(static function (string $name) use (&$callbackCalled) { + if ($callbackCalled || 'callback_upper' !== $name) { + return false; + } + $callbackCalled = true; + + return new TwigFilter('callback_upper', 'strtoupper', ['always_allowed_in_sandbox' => true]); + }); + + $this->assertSame('FABIEN', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxFunctionFromUndefinedCallbackUsesParsedCallable() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ callback_upper("fabien") }}']); + $callbackCalled = false; + $twig->registerUndefinedFunctionCallback(static function (string $name) use (&$callbackCalled) { + if ($callbackCalled || 'callback_upper' !== $name) { + return false; + } + $callbackCalled = true; + + return new TwigFunction('callback_upper', 'strtoupper', ['always_allowed_in_sandbox' => true]); + }); + + $this->assertSame('FABIEN', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxParserCallableFunctionFromUndefinedCallbackUsesParsedCallable() + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ callback_literal() }}']); + $callbackCalled = false; + $twig->registerUndefinedFunctionCallback(static function (string $name) use (&$callbackCalled) { + if ($callbackCalled || 'callback_literal' !== $name) { + return false; + } + $callbackCalled = true; + + return new TwigFunction('callback_literal', null, [ + 'always_allowed_in_sandbox' => true, + 'parser_callable' => static fn (Parser $parser, Node $node, Nodes $arguments, int $line): ConstantExpression => new ConstantExpression('literal', $line), + ]); + }); + + $this->assertSame('literal', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxTagBypassesAllowList() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% always_allowed_tag %}']); + $twig->addTokenParser(new AlwaysAllowedSandboxTokenParser()); + + $this->assertSame('always-allowed', $twig->load('index')->render([])); + } + + public function testAlwaysAllowedInSandboxTagStillEnforcedWhenFlagNotSet() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% gated_tag %}']); + $twig->addTokenParser(new GatedSandboxTokenParser()); + + $this->expectException(SecurityNotAllowedTagError::class); + $this->expectExceptionMessage('Tag "gated_tag" is not allowed'); + $twig->load('index')->render([]); + } + + /** + * @group legacy + */ + public function testCustomTokenParserWithoutIsAlwaysAllowedInSandboxTriggersDeprecation() + { + $twig = $this->getEnvironment(true, [], ['index' => '{% legacy_tag %}'], tags: ['legacy_tag']); + $twig->addTokenParser(new LegacyTokenParserWithoutIsAlwaysAllowedInSandbox()); + + $this->expectDeprecation(\sprintf('Since twig/twig 3.27: Not implementing the "isAlwaysAllowedInSandbox()" method in "%s" is deprecated. This method will be part of the "Twig\TokenParser\TokenParserInterface" interface in 4.0.', LegacyTokenParserWithoutIsAlwaysAllowedInSandbox::class)); + + // tag is allow-listed, so the render itself succeeds; the deprecation fires from the sandbox visitor while compiling + $this->assertSame('', $twig->load('index')->render([])); + } } class LegacyTwigCallableWithoutNeedsIsSandboxed implements TwigCallableInterface @@ -2224,3 +2360,60 @@ class CyclicTraversableObject implements \IteratorAggregate yield $this; } } + +class AlwaysAllowedSandboxTokenParser extends AbstractTokenParser +{ + public function parse(Token $token): Node + { + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + + return new TextNode('always-allowed', $token->getLine()); + } + + public function getTag(): string + { + return 'always_allowed_tag'; + } + + public function isAlwaysAllowedInSandbox(): bool + { + return true; + } +} + +class GatedSandboxTokenParser extends AbstractTokenParser +{ + public function parse(Token $token): Node + { + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + + return new TextNode('gated', $token->getLine()); + } + + public function getTag(): string + { + return 'gated_tag'; + } +} + +class LegacyTokenParserWithoutIsAlwaysAllowedInSandbox implements TokenParserInterface +{ + private Parser $parser; + + public function setParser(Parser $parser): void + { + $this->parser = $parser; + } + + public function parse(Token $token): Node + { + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + + return new TextNode('', $token->getLine()); + } + + public function getTag(): string + { + return 'legacy_tag'; + } +}