Add a needs_is_sandboxed option for filters, functions, and tests

This commit is contained in:
Fabien Potencier
2026-05-16 09:29:23 +01:00
parent c3f37a5195
commit 5462817da0
14 changed files with 287 additions and 1 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.25.0 (2026-XX-XX)
* Add a `needs_is_sandboxed` option for filters, functions, and tests
* Use deterministic suffixes for generated embed classes
# 3.24.0 (2026-03-17)
+28
View File
@@ -216,6 +216,34 @@ the first argument to the filter call (or the second one if
// ...
}, ['needs_context' => true, 'needs_environment' => true]);
Sandbox-aware Filters
~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 3.25
The ``needs_is_sandboxed`` option was added in Twig 3.25.
If you want to know whether the current template is sandboxed in your
filter, set the ``needs_is_sandboxed`` option to ``true``; Twig will pass the
current sandbox state as a boolean to the filter call (as the first
argument, or after the charset, the environment, and the context if they
are also requested)::
$filter = new \Twig\TwigFilter('rot13', function (bool $isSandboxed, $string) {
if ($isSandboxed) {
// adjust behavior when running in a sandboxed template
}
return str_rot13($string);
}, ['needs_is_sandboxed' => true]);
The sandbox state is resolved against the current template source, which
means it takes both the global sandbox state and any ``SourcePolicy`` into
account.
The same ``needs_is_sandboxed`` option is also available on functions and
tests.
Automatic Escaping
~~~~~~~~~~~~~~~~~~
+7 -1
View File
@@ -32,6 +32,7 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
'needs_environment' => false,
'needs_context' => false,
'needs_charset' => false,
'needs_is_sandboxed' => false,
'is_variadic' => false,
'deprecation_info' => null,
'deprecated' => false,
@@ -107,6 +108,11 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
return $this->options['needs_context'];
}
public function needsIsSandboxed(): bool
{
return $this->options['needs_is_sandboxed'];
}
/**
* @return static
*/
@@ -182,6 +188,6 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
public function getMinimalNumberOfRequiredArguments(): int
{
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + \count($this->arguments);
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + ($this->options['needs_is_sandboxed'] ? 1 : 0) + \count($this->arguments);
}
}
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigFilter
* @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
@@ -46,6 +47,7 @@ final class AsTwigFilter
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?string $preEscape = null,
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigFunction
* @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
@@ -44,6 +45,7 @@ final class AsTwigFunction
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigTest
* @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
*/
public function __construct(
@@ -42,6 +43,7 @@ final class AsTwigTest
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
) {
}
+3
View File
@@ -95,6 +95,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
@@ -118,6 +119,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
@@ -139,6 +141,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'deprecation_info' => $attribute->deprecationInfo,
]);
+30
View File
@@ -100,6 +100,14 @@ abstract class CallExpression extends AbstractExpression
$first = false;
}
if (self::needsIsSandboxed($twigCallable)) {
if (!$first) {
$compiler->raw(', ');
}
$compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source)');
$first = false;
}
foreach ($twigCallable->getArguments() as $argument) {
if (!$first) {
$compiler->raw(', ');
@@ -290,6 +298,9 @@ abstract class CallExpression extends AbstractExpression
if ($twigCallable->needsContext()) {
array_shift($parameters);
}
if (self::needsIsSandboxed($twigCallable)) {
array_shift($parameters);
}
foreach ($twigCallable->getArguments() as $argument) {
array_shift($parameters);
}
@@ -320,6 +331,22 @@ abstract class CallExpression extends AbstractExpression
return $this->reflector;
}
/**
* @internal
*
* To be removed in 4.0 and replaced by $twigCallable->needsIsSandboxed().
*/
public static function needsIsSandboxed(TwigCallableInterface $twigCallable): bool
{
if (method_exists($twigCallable, 'needsIsSandboxed')) {
return $twigCallable->needsIsSandboxed();
}
trigger_deprecation('twig/twig', '3.25', 'Not implementing the "needsIsSandboxed()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $twigCallable::class, TwigCallableInterface::class);
return false;
}
/**
* Overrides the Twig callable based on attributes (as potentially, attributes changed between the creation and the compilation of the node).
*
@@ -334,6 +361,7 @@ abstract class CallExpression extends AbstractExpression
$this->getAttribute('name'),
$this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(),
[
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
@@ -344,6 +372,7 @@ abstract class CallExpression extends AbstractExpression
'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
@@ -354,6 +383,7 @@ abstract class CallExpression extends AbstractExpression
'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
+2
View File
@@ -13,6 +13,8 @@ namespace Twig;
/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @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.
*/
interface TwigCallableInterface extends \Stringable
{
+4
View File
@@ -13,6 +13,7 @@ namespace Twig\Util;
use Twig\Error\SyntaxError;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\CallExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\VariadicExpression;
use Twig\Node\Node;
@@ -196,6 +197,9 @@ final class CallableArgumentsExtractor
if ($this->twigCallable->needsContext()) {
array_shift($parameters);
}
if (CallExpression::needsIsSandboxed($this->twigCallable)) {
array_shift($parameters);
}
foreach ($this->twigCallable->getArguments() as $argument) {
array_shift($parameters);
}
@@ -48,6 +48,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_filter', 'withEnvFilter', ['needs_environment' => true]];
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 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]];
yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
yield 'pattern' => ['pattern_*_filter', 'patternFilter', []];
@@ -76,6 +77,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_function', 'withEnvFunction', ['needs_environment' => true]];
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 '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')]];
@@ -104,6 +106,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_test', 'withEnvTest', ['needs_environment' => true]];
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 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]];
yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
}
@@ -39,6 +39,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigFilter('with_sandbox_filter', needsIsSandboxed: true)]
public function withSandboxFilter(bool $isSandboxed, string $string)
{
}
#[AsTwigFilter('variadic_filter')]
public function variadicFilter(string ...$strings)
{
@@ -74,6 +79,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigFunction('with_sandbox_function', needsIsSandboxed: true)]
public function withSandboxFunction(bool $isSandboxed, string $string)
{
}
#[AsTwigFunction('no_arg_function')]
public function noArgFunction()
{
@@ -114,6 +124,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigTest('with_sandbox_test', needsIsSandboxed: true)]
public function withSandboxTest(bool $isSandboxed, $argument)
{
}
#[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
public function deprecatedTest($value, $argument)
{
+170
View File
@@ -606,6 +606,176 @@ EOF
$this->expectException(SecurityError::class);
$twig->load('1_basic')->render([]);
}
public function testNeedsIsSandboxedFilterReceivesTrueWhenSandboxed()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo"|sandbox_aware }}'], [], ['sandbox_aware']);
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFilterReceivesFalseWhenNotSandboxed()
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo"|sandbox_aware }}']);
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFilterFollowsSourcePolicy()
{
$twig = $this->getEnvironment(false, [], [
'in' => '{{ "foo"|sandbox_aware }}',
'out' => '{{ "foo"|sandbox_aware }}',
], [], ['sandbox_aware'], [], [], [], new class implements \Twig\Sandbox\SourcePolicyInterface {
public function enableSandbox(Source $source): bool
{
return 'in' === $source->getName();
}
});
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:on', $twig->load('in')->render([]));
$this->assertSame('foo:off', $twig->load('out')->render([]));
}
public function testNeedsIsSandboxedFunctionWithoutSandboxExtension()
{
$loader = new ArrayLoader(['index' => '{{ sandbox_aware("foo") }}']);
$twig = new Environment($loader, ['debug' => true, 'cache' => false, 'autoescape' => false]);
$twig->addFunction(new \Twig\TwigFunction('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesTrueWhenSandboxed()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}']);
$twig->addTest(new \Twig\TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesFalseWhenNotSandboxed()
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}']);
$twig->addTest(new \Twig\TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return !$isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
/**
* @group legacy
*/
public function testNeedsIsSandboxedHelperTriggersDeprecationForCustomImplementation()
{
$callable = new LegacyTwigCallableWithoutNeedsIsSandboxed();
$this->expectDeprecation(\sprintf('Since twig/twig 3.25: Not implementing the "needsIsSandboxed()" method in "%s" is deprecated. This method will be part of the "Twig\TwigCallableInterface" interface in 4.0.', $callable::class));
$this->assertFalse(\Twig\Node\Expression\CallExpression::needsIsSandboxed($callable));
}
}
class LegacyTwigCallableWithoutNeedsIsSandboxed implements \Twig\TwigCallableInterface
{
public function getName(): string
{
return 'foo';
}
public function getType(): string
{
return 'filter';
}
public function getDynamicName(): string
{
return 'foo';
}
public function getCallable()
{
return null;
}
public function getNodeClass(): string
{
return '';
}
public function needsCharset(): bool
{
return false;
}
public function needsEnvironment(): bool
{
return false;
}
public function needsContext(): bool
{
return false;
}
public function withDynamicArguments(string $name, string $dynamicName, array $arguments): \Twig\TwigCallableInterface
{
return $this;
}
public function getArguments(): array
{
return [];
}
public function isVariadic(): bool
{
return false;
}
public function isDeprecated(): bool
{
return false;
}
public function getDeprecatingPackage(): string
{
return '';
}
public function getDeprecatedVersion(): string
{
return '';
}
public function getAlternative(): ?string
{
return null;
}
public function getMinimalNumberOfRequiredArguments(): int
{
return 0;
}
public function __toString(): string
{
return 'foo';
}
}
class ParentClass
+18
View File
@@ -87,6 +87,14 @@ class FilterTest extends NodeTestCase
$node = self::createFilter($environment, new ConstantExpression('foo', 1), 'anonymous');
$tests[] = [$node, '$this->env->getFilter(\'anonymous\')->getCallable()("foo")'];
// needs sandbox
$node = self::createFilter($environment, $string, 'bar_sandbox');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox($this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment];
// needs charset, environment, context, and sandbox
$node = self::createFilter($environment, $string, 'bar_all');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_all($this->env->getCharset(), $this->env, $context, $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment];
// needs environment
$node = self::createFilter($environment, $string, 'bar');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc")', $environment];
@@ -178,6 +186,8 @@ class FilterTest extends NodeTestCase
$env->addFilter(new TwigFilter('bar', 'Twig\Tests\Node\Expression\twig_tests_filter_dummy', ['needs_environment' => true]));
$env->addFilter(new TwigFilter('bar_closure', \Closure::fromCallable(twig_tests_filter_dummy::class), ['needs_environment' => true]));
$env->addFilter(new TwigFilter('barbar', 'Twig\Tests\Node\Expression\twig_tests_filter_barbar', ['needs_context' => true, 'is_variadic' => true]));
$env->addFilter(new TwigFilter('bar_sandbox', 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox', ['needs_is_sandboxed' => true]));
$env->addFilter(new TwigFilter('bar_all', 'Twig\Tests\Node\Expression\twig_tests_filter_all', ['needs_charset' => true, 'needs_environment' => true, 'needs_context' => true, 'needs_is_sandboxed' => true]));
$env->addFilter(new TwigFilter('magic_static', __NAMESPACE__.'\ChildMagicCallStub::magicStaticCall'));
if (\PHP_VERSION_ID >= 80111) {
$env->addExtension(new FilterTestExtension());
@@ -217,6 +227,14 @@ function twig_tests_filter_barbar($context, $string, $arg1 = null, $arg2 = null,
{
}
function twig_tests_filter_sandbox(bool $isSandboxed, $string)
{
}
function twig_tests_filter_all(string $charset, Environment $env, array $context, bool $isSandboxed, $string)
{
}
class ChildMagicCallStub extends ParentMagicCallStub
{
public static function identifier()