diff --git a/CHANGELOG b/CHANGELOG index c930ffc2be0..0e3dd497776 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +# 3.29.0 (2026-XX-XX) + + * Add the `Twig\Sandbox\Sandbox` class to render untrusted templates through a dedicated, always-sandboxed environment crafted for it + * Add `Twig\Sandbox\SecurityPolicy::isStrict()` + * Extract the sandbox runtime enforcement into a new internal `Twig\Sandbox\SecurityChecker` class used by compiled templates and `CoreExtension` + * Mark `SandboxExtension` as internal, use `Twig\Sandbox\Sandbox` instead + * Deprecate the `sandboxed` argument of the `include` function, use `Twig\Sandbox\Sandbox` instead + * Deprecate `SandboxExtension::enableSandbox()`, `disableSandbox()`, and `isSandboxedGlobally()` + # 3.28.1 (2026-XX-XX) * Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError` diff --git a/doc/api.rst b/doc/api.rst index d2f5285cf49..d6852bc6d5c 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -334,7 +334,7 @@ Using Extensions Twig extensions are packages that add new features to Twig. Register an extension via the ``addExtension()`` method:: - $twig->addExtension(new \Twig\Extension\SandboxExtension()); + $twig->addExtension(new \Twig\Extension\DebugExtension()); Twig comes bundled with the following extensions: diff --git a/doc/deprecated.rst b/doc/deprecated.rst index d8e548b5787..a06bcd27e3e 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -364,10 +364,12 @@ Sandbox 3.27.0 with no replacement. Passing an instance to the ``Twig\Extension\SandboxExtension`` constructor triggers a deprecation. -* Deprecate the ``sandbox`` tag, use the ``sandboxed`` option of the - ``include`` function instead: +* Deprecate the ``sandbox`` tag, use the ``Twig\Sandbox\Sandbox`` class + instead: - Before:: + Before: + + .. code-block:: twig {% sandbox %} {% include 'user_defined.html.twig' %} @@ -375,7 +377,22 @@ Sandbox After:: - {{ include('user_defined.html.twig', sandboxed: true) }} + echo $sandbox->render('user_defined.html.twig'); + +* The ``Twig\Extension\SandboxExtension`` class is marked as internal as of + Twig 3.29 and should not be used directly anymore (no runtime deprecation is + triggered); use the ``Twig\Sandbox\Sandbox`` class to render untrusted + templates instead. + +* The ``sandboxed`` argument of the ``include`` function is deprecated as of + Twig 3.29. Render the untrusted template with the ``Twig\Sandbox\Sandbox`` + class instead, for example by passing the ``Sandbox`` instance to the + trusted template and calling ``sandbox.render('user_defined.html.twig')`` + from it. + +* The ``enableSandbox()``, ``disableSandbox()``, and ``isSandboxedGlobally()`` + methods of ``Twig\Extension\SandboxExtension`` are deprecated as of Twig + 3.29 with no replacement: a ``Twig\Sandbox\Sandbox`` has no state to toggle. Testing Utilities ----------------- diff --git a/doc/functions/include.rst b/doc/functions/include.rst index dadac31cdd9..ba84814b79c 100644 --- a/doc/functions/include.rst +++ b/doc/functions/include.rst @@ -72,12 +72,16 @@ inclusion. The first template that exists will be rendered: If ``ignore_missing`` is set, it will fall back to rendering nothing if none of the templates exist, otherwise it will throw an exception. -When including a template created by an end user, you should consider -:doc:`sandboxing<../sandbox>` it: +When including a template created by an end user, you should +:doc:`sandbox<../sandbox>` it. -.. code-block:: twig +.. deprecated:: 3.29 - {{ include('page.html.twig', sandboxed: true) }} + Sandboxing the included template via the ``sandboxed`` argument is + deprecated as of Twig 3.29. Render the untrusted template with the + ``Twig\Sandbox\Sandbox`` class instead, for example by passing the + ``Sandbox`` instance to the trusted template and calling + ``sandbox.render('page.html.twig')`` from it. Arguments --------- @@ -86,4 +90,5 @@ Arguments * ``variables``: The variables to pass to the template * ``with_context``: Whether to pass the current context variables or not * ``ignore_missing``: Whether to ignore missing templates or not -* ``sandboxed``: Whether to sandbox the template or not +* ``sandboxed``: Whether to sandbox the template or not (deprecated as of + Twig 3.29) diff --git a/doc/sandbox.rst b/doc/sandbox.rst index 9799cd005e8..49d0b6d1caa 100644 --- a/doc/sandbox.rst +++ b/doc/sandbox.rst @@ -1,18 +1,126 @@ Twig Sandbox ============ -The ``sandbox`` extension can be used to evaluate untrusted code. +The sandbox can be used to evaluate untrusted code, restricting what template +authors can reach through explicit allow-lists. -Registering the Sandbox ------------------------ +Rendering Untrusted Templates +----------------------------- + +.. versionadded:: 3.29 + + The ``Twig\Sandbox\Sandbox`` class was added in Twig 3.29. + +The recommended way to render untrusted templates is the +``Twig\Sandbox\Sandbox`` class. A ``Sandbox`` takes ownership of an +environment crafted specifically for it and renders everything through it in +sandbox mode. Being in full control of that environment, you decide exactly +what untrusted templates can reach: its loader defines which templates exist, +the extensions, filters, functions, tests, and globals you register on it +define which capabilities exist, and the security policy defines what is +allowed to execute:: + + use Twig\Environment; + use Twig\Extra\Intl\IntlExtension; + use Twig\Loader\ArrayLoader; + use Twig\Sandbox\Sandbox; + use Twig\Sandbox\SecurityPolicy; + + // craft an environment dedicated to untrusted templates + $env = new Environment(new ArrayLoader($untrustedTemplates), [ + 'cache' => '/path/to/sandbox/cache', + ]); + // register the capabilities untrusted templates may use + $env->addExtension(new IntlExtension()); + + $policy = new SecurityPolicy( + allowedTags: ['if'], + allowedFilters: ['upper', 'escape'], + ); + $policy->setStrict(true); + + $sandbox = new Sandbox($env, $policy); + + // render a template known to the environment loader + echo $sandbox->render('newsletter.twig', ['name' => 'Fabien']); + + // render an untrusted template held as a string + echo $sandbox->createTemplate($userTemplate)->render(['name' => 'Fabien']); + +The environment must be dedicated to the sandbox: build a fresh environment +and pass it before its first use (the constructor throws a ``LogicException`` +otherwise). In particular, never pass your main application environment: all +your application templates would suddenly be rendered in sandbox mode. +Keeping the two environments separate also guarantees isolation in both +directions: the sandbox cannot load or affect application templates, and +application renders happening while a sandboxed render is in flight are not +sandboxed. + +A ``SecurityPolicy`` passed to the sandbox must be strict (call +``setStrict(true)``) so it behaves the same way in Twig 3.x and 4.0; the +constructor throws a ``LogicException`` otherwise. + +Everything rendered through a ``Sandbox`` is sandboxed: ``render()``, +``display()``, and ``stream()`` render a template from the environment loader +by name; ``renderBlock()``, ``displayBlock()``, and ``streamBlock()`` render a +single block of such a template; ``createTemplate()`` turns a string into a +sandboxed template. Templates included by a sandboxed template are sandboxed +as well. + +Data is passed through the render context (or registered as globals on the +environment you crafted); the policy governs any method or property access on +those values either way. + +.. note:: + + When auto-escaping is enabled (the default), the ``escape`` filter is + applied to every printed expression, so it must be part of the filter + allow-list for sandboxed templates to render. + +.. caution:: + + PHP code invoked during a sandboxed render (a filter, function, or + extension you registered on the sandbox environment) runs with its full + PHP capabilities: the sandbox only restricts what the template source can + express. Only register extensions and callables that are safe to call + with attacker-chosen arguments. -Register the ``SandboxExtension`` extension via the ``addExtension()`` method:: +Using the Sandbox Extension Directly +------------------------------------ + +.. deprecated:: 3.29 + + The ``SandboxExtension`` is internal as of Twig 3.29 and should not be + used directly anymore; the ``sandboxed`` argument of the ``include`` + function and the ``enableSandbox()``, ``disableSandbox()``, and + ``isSandboxedGlobally()`` methods are deprecated. Use the ``Sandbox`` + class instead. + +Before the ``Sandbox`` class existed, sandboxing was configured by registering +the ``SandboxExtension`` on the environment via the ``addExtension()`` +method:: $twig->addExtension(new \Twig\Extension\SandboxExtension($policy)); +By default, the sandbox mode is then disabled and gets enabled when including +untrusted template code by using the ``sandboxed`` option of the ``include`` +function: + +.. code-block:: twig + + {{ include('user.html.twig', sandboxed: true) }} + +You can also sandbox all templates by passing ``true`` as the second argument +of the extension constructor:: + + $twig->addExtension(new \Twig\Extension\SandboxExtension($policy, true)); + Configuring the Sandbox Policy ------------------------------ +The security policy is enforced the same way whether templates are rendered +through a ``Sandbox`` or through the ``SandboxExtension`` directly. + The sandbox security is managed by a policy instance, which must be passed to the ``SandboxExtension`` constructor. @@ -195,22 +303,6 @@ templates working unchanged. The ``constant`` test is the exception: it reaches into the PHP runtime, so it is not always allowed and must be allow-listed (it is still implicitly allowed in 3.x with a deprecation, and rejected in 4.0). -Enabling the Sandbox --------------------- - -By default, the sandbox mode is disabled and should be enabled when including -untrusted template code by using the ``sandboxed`` option of the ``include`` -function: - -.. code-block:: twig - - {{ include('user.html.twig', sandboxed: true) }} - -You can sandbox all templates by passing ``true`` as the second argument of -the extension constructor:: - - $twig->addExtension(new \Twig\Extension\SandboxExtension($policy, true)); - .. _allowed-operations-transitive: Allowed Operations Apply Transitively to Their Arguments diff --git a/doc/tags/sandbox.rst b/doc/tags/sandbox.rst index b9b9a8dd6c6..33844298cfc 100644 --- a/doc/tags/sandbox.rst +++ b/doc/tags/sandbox.rst @@ -4,7 +4,8 @@ .. warning:: The ``sandbox`` tag is deprecated as of Twig 3.15. - Use the ``sandboxed`` option of the ``include`` function instead. + Use the ``Twig\Sandbox\Sandbox`` class instead (see the + :doc:`sandbox<../sandbox>` chapter). The ``sandbox`` tag can be used to enable the sandboxing mode for an included template, when sandboxing is not enabled globally for the Twig environment: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a62d67492fb..5b157664962 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,6 +7,12 @@ parameters: path: src/Sandbox/SecurityPolicyInterface.php + - # The "$tests" argument will be part of the signature in 4.0; 3-parameter implementations are detected and called with 3 arguments + message: '#^Method Twig\\Sandbox\\SecurityPolicyInterface\:\:checkSecurity\(\) invoked with 4 parameters, 3 required\.$#' + identifier: arguments.count + count: 1 + path: src/Sandbox/SecurityChecker.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/Environment.php b/src/Environment.php index 71459e2108e..05098f0b703 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,11 +43,11 @@ */ class Environment { - public const VERSION = '3.28.1-DEV'; - public const VERSION_ID = 32801; + public const VERSION = '3.29.0-DEV'; + public const VERSION_ID = 32900; public const MAJOR_VERSION = 3; - public const MINOR_VERSION = 28; - public const RELEASE_VERSION = 1; + public const MINOR_VERSION = 29; + public const RELEASE_VERSION = 0; public const EXTRA_VERSION = 'DEV'; private $charset; diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 9ef620b6a70..c7e1005d023 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -87,6 +87,7 @@ use Twig\Node\Node; use Twig\NodeVisitor\CorrectnessNodeVisitor; use Twig\Parser; +use Twig\Sandbox\Sandbox; use Twig\Sandbox\SecurityNotAllowedMethodError; use Twig\Sandbox\SecurityNotAllowedPropertyError; use Twig\Source; @@ -1499,6 +1500,10 @@ public static function testMapping($value): bool */ public static function include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false) { + if ($sandboxed) { + trigger_deprecation('twig/twig', '3.29', 'The "sandboxed" argument of the "include" function is deprecated, use "%s" to render untrusted templates instead.', Sandbox::class); + } + $alreadySandboxed = false; $sandbox = null; if ($withContext) { @@ -1506,9 +1511,9 @@ public static function include(Environment $env, $context, $template, $variables } if ($isSandboxed = $sandboxed && $env->hasExtension(SandboxExtension::class)) { - $sandbox = $env->getExtension(SandboxExtension::class); + $sandbox = $env->getExtension(SandboxExtension::class)->getChecker(); if (!$alreadySandboxed = $sandbox->isSandboxed()) { - $sandbox->enableSandbox(); + $sandbox->setSandboxed(true); } } @@ -1529,7 +1534,7 @@ public static function include(Environment $env, $context, $template, $variables return '' === $rendered ? '' : new Markup($rendered, $env->getCharset()); } finally { if ($isSandboxed && !$alreadySandboxed) { - $sandbox->disableSandbox(); + $sandbox->setSandboxed(false); } } } @@ -1690,7 +1695,7 @@ public static function getAttribute(Environment $env, Source $source, $object, $ { $propertyNotAllowedError = null; if ($sandboxed && $item instanceof \Stringable) { - $env->getExtension(SandboxExtension::class)->ensureToStringAllowed($item, $lineno, $source); + $env->getExtension(SandboxExtension::class)->getChecker()->ensureToStringAllowed($item, $lineno, $source); } // array @@ -1699,7 +1704,7 @@ public static function getAttribute(Environment $env, Source $source, $object, $ if ($sandboxed && $object instanceof \ArrayAccess && !\in_array($object::class, self::ARRAY_LIKE_CLASSES, true)) { try { - $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $arrayItem, $lineno, $source); + $env->getExtension(SandboxExtension::class)->getChecker()->checkPropertyAllowed($object, $arrayItem, $lineno, $source); } catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) { // The methodCheck path expects $item to be a string; stringify it here // to avoid PHP 8.1+ implicit float-to-int deprecations on downstream @@ -1790,7 +1795,7 @@ public static function getAttribute(Environment $env, Source $source, $object, $ if (Template::METHOD_CALL !== $type) { if ($sandboxed) { try { - $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source); + $env->getExtension(SandboxExtension::class)->getChecker()->checkPropertyAllowed($object, $item, $lineno, $source); } catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) { goto methodCheck; } @@ -1904,7 +1909,7 @@ public static function getAttribute(Environment $env, Source $source, $object, $ if ($sandboxed) { try { - $env->getExtension(SandboxExtension::class)->checkMethodAllowed($object, $method, $lineno, $source); + $env->getExtension(SandboxExtension::class)->getChecker()->checkMethodAllowed($object, $method, $lineno, $source); } catch (SecurityNotAllowedMethodError $e) { if ($isDefinedTest) { return false; @@ -1969,7 +1974,7 @@ public static function column(Environment $env, bool $isSandboxed, $array, $name // 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(); + $policy = $env->getExtension(SandboxExtension::class)->getChecker()->getSecurityPolicy(); foreach ($array as $item) { if (\is_object($item)) { $policy->checkPropertyAllowed($item, (string) $name); diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index 181e58c9c8c..7b4f96fe0d8 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -12,19 +12,23 @@ namespace Twig\Extension; use Twig\NodeVisitor\SandboxNodeVisitor; +use Twig\Sandbox\Sandbox; +use Twig\Sandbox\SecurityChecker; use Twig\Sandbox\SecurityNotAllowedMethodError; -use Twig\Sandbox\SecurityNotAllowedPropertyError; use Twig\Sandbox\SecurityPolicyInterface; use Twig\Sandbox\SourcePolicyInterface; use Twig\Source; use Twig\TokenParser\SandboxTokenParser; +/** + * This extension is the wiring behind "Twig\Sandbox\Sandbox" and should not + * be used directly, use a "Sandbox" to render untrusted templates instead. + * + * @internal since Twig 3.29 + */ final class SandboxExtension extends AbstractExtension { - private $sandboxedGlobally; - private $sandboxed; - private $policy; - private $sourcePolicy; + private SecurityChecker $checker; public function __construct(SecurityPolicyInterface $policy, $sandboxed = false, ?SourcePolicyInterface $sourcePolicy = null) { @@ -32,9 +36,7 @@ public function __construct(SecurityPolicyInterface $policy, $sandboxed = false, trigger_deprecation('twig/twig', '3.27.0', 'The "%s" interface is deprecated with no replacement, do not pass an instance to "%s".', SourcePolicyInterface::class, self::class); } - $this->policy = $policy; - $this->sandboxedGlobally = $sandboxed; - $this->sourcePolicy = $sourcePolicy; + $this->checker = new SecurityChecker($policy, (bool) $sandboxed, $sourcePolicy); } public function getTokenParsers(): array @@ -47,43 +49,57 @@ public function getNodeVisitors(): array return [new SandboxNodeVisitor()]; } + /** + * @internal + */ + public function getChecker(): SecurityChecker + { + return $this->checker; + } + + /** + * @deprecated since Twig 3.29, use "Twig\Sandbox\Sandbox" to render untrusted templates instead + */ public function enableSandbox(): void { - $this->sandboxed = true; + trigger_deprecation('twig/twig', '3.29', 'The "%s()" method is deprecated, use "%s" to render untrusted templates instead.', __METHOD__, Sandbox::class); + + $this->checker->setSandboxed(true); } + /** + * @deprecated since Twig 3.29, use "Twig\Sandbox\Sandbox" to render untrusted templates instead + */ public function disableSandbox(): void { - $this->sandboxed = false; + trigger_deprecation('twig/twig', '3.29', 'The "%s()" method is deprecated, use "%s" to render untrusted templates instead.', __METHOD__, Sandbox::class); + + $this->checker->setSandboxed(false); } public function isSandboxed(?Source $source = null): bool { - return $this->sandboxedGlobally || $this->sandboxed || $this->isSourceSandboxed($source); + return $this->checker->isSandboxed($source); } + /** + * @deprecated since Twig 3.29, use "Twig\Sandbox\Sandbox" to render untrusted templates instead + */ public function isSandboxedGlobally(): bool { - return $this->sandboxedGlobally; - } - - private function isSourceSandboxed(?Source $source): bool - { - if (null === $source || null === $this->sourcePolicy) { - return false; - } + trigger_deprecation('twig/twig', '3.29', 'The "%s()" method is deprecated, use "%s" to render untrusted templates instead.', __METHOD__, Sandbox::class); - return $this->sourcePolicy->enableSandbox($source); + return $this->checker->isSandboxedGlobally(); } public function setSecurityPolicy(SecurityPolicyInterface $policy): void { - $this->policy = $policy; + $this->checker->setSecurityPolicy($policy); } public function getSecurityPolicy(): SecurityPolicyInterface { - return $this->policy; + return $this->checker->getSecurityPolicy(); } public function checkSecurity($tags, $filters, $functions, $tests = [], $source = null): void @@ -96,47 +112,17 @@ public function checkSecurity($tags, $filters, $functions, $tests = [], $source $tests = []; } - if (!$this->isSandboxed($source)) { - return; - } - - if ((new \ReflectionMethod($this->policy, 'checkSecurity'))->getNumberOfParameters() >= 4) { - $this->policy->checkSecurity($tags, $filters, $functions, $tests); - - return; - } - - trigger_deprecation('twig/twig', '3.28', 'The "%s::checkSecurity()" method will take a 4th "array $tests" argument in 4.0; not declaring it is deprecated.', $this->policy::class); - - $this->policy->checkSecurity($tags, $filters, $functions); + $this->checker->checkSecurity($tags, $filters, $functions, $tests, $source); } public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void { - if ($this->isSandboxed($source)) { - try { - $this->policy->checkMethodAllowed($obj, $method); - } catch (SecurityNotAllowedMethodError $e) { - $e->setSourceContext($source); - $e->setTemplateLine($lineno); - - throw $e; - } - } + $this->checker->checkMethodAllowed($obj, $method, $lineno, $source); } public function checkPropertyAllowed($obj, $property, int $lineno = -1, ?Source $source = null): void { - if ($this->isSandboxed($source)) { - try { - $this->policy->checkPropertyAllowed($obj, $property); - } catch (SecurityNotAllowedPropertyError $e) { - $e->setSourceContext($source); - $e->setTemplateLine($lineno); - - throw $e; - } - } + $this->checker->checkPropertyAllowed($obj, $property, $lineno, $source); } /** @@ -144,7 +130,7 @@ public function checkPropertyAllowed($obj, $property, int $lineno = -1, ?Source */ public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null) { - return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage()); + return $this->checker->ensureToStringAllowed($obj, $lineno, $source); } /** @@ -156,92 +142,6 @@ public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = */ 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, $seen); - - return $obj; - } - - 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) { - continue; - } - - if (!\is_array($v)) { - $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); - continue; - } - - if ($r = \ReflectionReference::fromArrayElement($obj, $k)) { - if (isset($stack[$r->getId()])) { - continue; - } - - $stack[$r->getId()] = true; - } - - $this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack); - } + return $this->checker->ensureSpreadAllowed($obj, $lineno, $source); } } diff --git a/src/Node/CheckSecurityCallNode.php b/src/Node/CheckSecurityCallNode.php index 0667bd0fdb8..a248040b15b 100644 --- a/src/Node/CheckSecurityCallNode.php +++ b/src/Node/CheckSecurityCallNode.php @@ -26,7 +26,7 @@ class CheckSecurityCallNode extends Node public function compile(Compiler $compiler) { $compiler - ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") + ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class]->getChecker();\n") ; } } diff --git a/src/Node/Expression/Binary/HasEveryBinary.php b/src/Node/Expression/Binary/HasEveryBinary.php index 9b107111a85..3ac11cd7eab 100644 --- a/src/Node/Expression/Binary/HasEveryBinary.php +++ b/src/Node/Expression/Binary/HasEveryBinary.php @@ -23,7 +23,7 @@ public function compile(Compiler $compiler): void ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) - ->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source))') + ->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->getChecker()->isSandboxed($this->source))') ; } diff --git a/src/Node/Expression/Binary/HasSomeBinary.php b/src/Node/Expression/Binary/HasSomeBinary.php index 86665a348a6..6aedd82404c 100644 --- a/src/Node/Expression/Binary/HasSomeBinary.php +++ b/src/Node/Expression/Binary/HasSomeBinary.php @@ -23,7 +23,7 @@ public function compile(Compiler $compiler): void ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) - ->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source))') + ->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->getChecker()->isSandboxed($this->source))') ; } diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index ad6d3881ae1..94036c1d3a0 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -104,7 +104,7 @@ protected function compileArguments(Compiler $compiler, $isArray = false): void if (!$first) { $compiler->raw(', '); } - $compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source)'); + $compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->getChecker()->isSandboxed($this->source)'); $first = false; } diff --git a/src/Node/SandboxNode.php b/src/Node/SandboxNode.php index d51cea44b48..47feeb09513 100644 --- a/src/Node/SandboxNode.php +++ b/src/Node/SandboxNode.php @@ -33,7 +33,7 @@ public function compile(Compiler $compiler): void ->addDebugInfo($this) ->write("if (!\$alreadySandboxed = \$this->sandbox->isSandboxed()) {\n") ->indent() - ->write("\$this->sandbox->enableSandbox();\n") + ->write("\$this->sandbox->setSandboxed(true);\n") ->outdent() ->write("}\n") ->write("try {\n") @@ -44,7 +44,7 @@ public function compile(Compiler $compiler): void ->indent() ->write("if (!\$alreadySandboxed) {\n") ->indent() - ->write("\$this->sandbox->disableSandbox();\n") + ->write("\$this->sandbox->setSandboxed(false);\n") ->outdent() ->write("}\n") ->outdent() diff --git a/src/Sandbox/Sandbox.php b/src/Sandbox/Sandbox.php new file mode 100644 index 00000000000..6cfaa51dcb4 --- /dev/null +++ b/src/Sandbox/Sandbox.php @@ -0,0 +1,92 @@ + + */ +final class Sandbox +{ + public function __construct( + private Environment $env, + SecurityPolicyInterface $policy, + ) { + if ($policy instanceof SecurityPolicy && !$policy->isStrict()) { + throw new \LogicException('The sandbox requires a strict security policy, call "setStrict(true)" on a dedicated policy for this sandbox.'); + } + + try { + $env->addExtension(new SandboxExtension($policy, true)); + } catch (\LogicException $e) { + throw new \LogicException(\sprintf('The environment passed to "%s" must be dedicated to it: pass a freshly built environment that has no "%s" registered and has not been used yet.', self::class, SandboxExtension::class), 0, $e); + } + } + + public function render(string $name, array $context = []): string + { + return $this->env->render($name, $context); + } + + public function display(string $name, array $context = []): void + { + $this->env->display($name, $context); + } + + /** + * @return iterable + */ + public function stream(string $name, array $context = []): iterable + { + yield from $this->env->load($name)->stream($context); + } + + public function renderBlock(string $name, string $block, array $context = []): string + { + return $this->env->load($name)->renderBlock($block, $context); + } + + public function displayBlock(string $name, string $block, array $context = []): void + { + $this->env->load($name)->displayBlock($block, $context); + } + + /** + * @return iterable + */ + public function streamBlock(string $name, string $block, array $context = []): iterable + { + yield from $this->env->load($name)->streamBlock($block, $context); + } + + /** + * Creates a sandboxed template from source. + * + * The template can only reference templates available in the environment loader. + */ + public function createTemplate(string $template, ?string $name = null): TemplateWrapper + { + return $this->env->createTemplate($template, $name); + } +} diff --git a/src/Sandbox/SecurityChecker.php b/src/Sandbox/SecurityChecker.php new file mode 100644 index 00000000000..09aceb44297 --- /dev/null +++ b/src/Sandbox/SecurityChecker.php @@ -0,0 +1,234 @@ + + * + * @internal + */ +final class SecurityChecker +{ + private bool $sandboxed = false; + + public function __construct( + private SecurityPolicyInterface $policy, + private bool $sandboxedGlobally = false, + private ?SourcePolicyInterface $sourcePolicy = null, + ) { + } + + /** + * Toggles the sandbox mode for the legacy stateful mechanisms. + * + * To be removed in 4.0 together with the $sandboxed state: a sandbox + * environment is permanently sandboxed. + */ + public function setSandboxed(bool $sandboxed): void + { + $this->sandboxed = $sandboxed; + } + + public function isSandboxed(?Source $source = null): bool + { + return $this->sandboxedGlobally || $this->sandboxed || $this->isSourceSandboxed($source); + } + + /** + * To be removed in 4.0: only the deprecated SandboxExtension::isSandboxedGlobally() uses it. + */ + public function isSandboxedGlobally(): bool + { + return $this->sandboxedGlobally; + } + + /** + * To be removed in 4.0 together with the deprecated SourcePolicyInterface. + */ + private function isSourceSandboxed(?Source $source): bool + { + if (null === $source || null === $this->sourcePolicy) { + return false; + } + + return $this->sourcePolicy->enableSandbox($source); + } + + /** + * To be removed in 4.0: a sandbox policy is fixed at construction. + */ + public function setSecurityPolicy(SecurityPolicyInterface $policy): void + { + $this->policy = $policy; + } + + public function getSecurityPolicy(): SecurityPolicyInterface + { + return $this->policy; + } + + public function checkSecurity(array $tags, array $filters, array $functions, array $tests, ?Source $source = null): void + { + if (!$this->isSandboxed($source)) { + return; + } + + if ((new \ReflectionMethod($this->policy, 'checkSecurity'))->getNumberOfParameters() >= 4) { + $this->policy->checkSecurity($tags, $filters, $functions, $tests); + + return; + } + + trigger_deprecation('twig/twig', '3.28', 'The "%s::checkSecurity()" method will take a 4th "array $tests" argument in 4.0; not declaring it is deprecated.', $this->policy::class); + + $this->policy->checkSecurity($tags, $filters, $functions); + } + + public function checkMethodAllowed(mixed $obj, mixed $method, int $lineno = -1, ?Source $source = null): void + { + if ($this->isSandboxed($source)) { + try { + $this->policy->checkMethodAllowed($obj, $method); + } catch (SecurityNotAllowedMethodError $e) { + $e->setSourceContext($source); + $e->setTemplateLine($lineno); + + throw $e; + } + } + } + + public function checkPropertyAllowed(mixed $obj, mixed $property, int $lineno = -1, ?Source $source = null): void + { + if ($this->isSandboxed($source)) { + try { + $this->policy->checkPropertyAllowed($obj, $property); + } catch (SecurityNotAllowedPropertyError $e) { + $e->setSourceContext($source); + $e->setTemplateLine($lineno); + + throw $e; + } + } + } + + /** + * @throws SecurityNotAllowedMethodError + */ + public function ensureToStringAllowed(mixed $obj, int $lineno = -1, ?Source $source = null): mixed + { + return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage()); + } + + /** + * Materializes a spread operand and runs the policy on every element. + * + * @throws SecurityNotAllowedMethodError + */ + 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, $seen); + + return $obj; + } + + private function doEnsureToStringAllowed(mixed $obj, int $lineno, ?Source $source, \SplObjectStorage $seen): mixed + { + 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: materialize 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) { + continue; + } + + if (!\is_array($v)) { + $this->doEnsureToStringAllowed($v, $lineno, $source, $seen); + continue; + } + + if ($r = \ReflectionReference::fromArrayElement($obj, $k)) { + if (isset($stack[$r->getId()])) { + continue; + } + + $stack[$r->getId()] = true; + } + + $this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack); + } + } +} diff --git a/src/Sandbox/SecurityPolicy.php b/src/Sandbox/SecurityPolicy.php index 7d1172bf13c..a10082747b6 100644 --- a/src/Sandbox/SecurityPolicy.php +++ b/src/Sandbox/SecurityPolicy.php @@ -87,6 +87,11 @@ public function setStrict(bool $strict): void $this->strict = $strict; } + public function isStrict(): bool + { + return $this->strict; + } + public function checkSecurity($tags, $filters, $functions, array $tests = []): void { if (\func_num_args() < 4) { diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php index faada348a55..af90fb3631d 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php @@ -384,6 +384,10 @@ public static function provideCompareCases() ]; } + /** + * @group legacy + */ + #[Group('legacy')] public function testSandboxedInclude() { $twig = new Environment(new ArrayLoader([ @@ -399,6 +403,10 @@ public function testSandboxedInclude() $twig->render('index'); } + /** + * @group legacy + */ + #[Group('legacy')] public function testSandboxedIncludeWithPreloadedTemplate() { $twig = new Environment(new ArrayLoader([ @@ -418,6 +426,10 @@ public function testSandboxedIncludeWithPreloadedTemplate() $twig->render('index'); } + /** + * @group legacy + */ + #[Group('legacy')] public function testSandboxedIncludeResultStaysEscapedWhenAssigned() { $twig = new Environment(new ArrayLoader([ diff --git a/tests/Extension/SandboxDeprecationsTest.php b/tests/Extension/SandboxDeprecationsTest.php new file mode 100644 index 00000000000..2417dac156d --- /dev/null +++ b/tests/Extension/SandboxDeprecationsTest.php @@ -0,0 +1,72 @@ +expectDeprecation('Since twig/twig 3.29: The "Twig\Extension\SandboxExtension::enableSandbox()" method is deprecated, use "Twig\Sandbox\Sandbox" to render untrusted templates instead.'); + $extension->enableSandbox(); + + $this->assertTrue($extension->isSandboxed()); + } + + public function testDisableSandboxIsDeprecated() + { + $extension = new SandboxExtension(new SecurityPolicy()); + $extension->getChecker()->setSandboxed(true); + + $this->expectDeprecation('Since twig/twig 3.29: The "Twig\Extension\SandboxExtension::disableSandbox()" method is deprecated, use "Twig\Sandbox\Sandbox" to render untrusted templates instead.'); + $extension->disableSandbox(); + + $this->assertFalse($extension->isSandboxed()); + } + + public function testIsSandboxedGloballyIsDeprecated() + { + $extension = new SandboxExtension(new SecurityPolicy(), true); + + $this->expectDeprecation('Since twig/twig 3.29: The "Twig\Extension\SandboxExtension::isSandboxedGlobally()" method is deprecated, use "Twig\Sandbox\Sandbox" to render untrusted templates instead.'); + + $this->assertTrue($extension->isSandboxedGlobally()); + } + + public function testIncludeFunctionSandboxedArgumentIsDeprecated() + { + $twig = new Environment(new ArrayLoader([ + 'index' => '{{ include("partial", sandboxed: true) }}', + 'partial' => 'partial content', + ]), ['autoescape' => false]); + $twig->addExtension(new SandboxExtension(new SecurityPolicy(allowedFunctions: ['include']))); + + $this->expectDeprecation('Since twig/twig 3.29: The "sandboxed" argument of the "include" function is deprecated, use "Twig\Sandbox\Sandbox" to render untrusted templates instead.'); + + $this->assertSame('partial content', $twig->render('index')); + } +} diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php index f6b1fcc6e23..226a62f745d 100644 --- a/tests/Extension/SandboxStateChangeTest.php +++ b/tests/Extension/SandboxStateChangeTest.php @@ -33,7 +33,10 @@ * Without the fix, the compiled checkSecurity() method only ran once at * construction time, locking in the verdict computed against whatever sandbox * state was active when the template was first loaded. + * + * @group legacy */ +#[Group('legacy')] class SandboxStateChangeTest extends TestCase { use ExpectDeprecationTrait; diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index 0a134cb9d28..e3b2dfa0468 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -55,6 +55,13 @@ use Twig\TwigFunction; use Twig\TwigTest; +/** + * Covers the deprecated stateful sandbox mechanisms; new code should use + * \Twig\Sandbox\Sandbox, covered by \Twig\Tests\Sandbox\SandboxTest. + * + * @group legacy + */ +#[Group('legacy')] class SandboxTest extends TestCase { use ExpectDeprecationTrait; diff --git a/tests/Fixtures/functions/include/sandbox.test b/tests/Fixtures/functions/include/sandbox.legacy.test similarity index 100% rename from tests/Fixtures/functions/include/sandbox.test rename to tests/Fixtures/functions/include/sandbox.legacy.test diff --git a/tests/Fixtures/functions/include/sandbox_disabling.test b/tests/Fixtures/functions/include/sandbox_disabling.legacy.test similarity index 100% rename from tests/Fixtures/functions/include/sandbox_disabling.test rename to tests/Fixtures/functions/include/sandbox_disabling.legacy.test diff --git a/tests/Fixtures/functions/include/sandbox_disabling_ignore_missing.test b/tests/Fixtures/functions/include/sandbox_disabling_ignore_missing.legacy.test similarity index 100% rename from tests/Fixtures/functions/include/sandbox_disabling_ignore_missing.test rename to tests/Fixtures/functions/include/sandbox_disabling_ignore_missing.legacy.test diff --git a/tests/Node/Expression/FilterTest.php b/tests/Node/Expression/FilterTest.php index e77dd5bb82a..241ca5528e2 100644 --- a/tests/Node/Expression/FilterTest.php +++ b/tests/Node/Expression/FilterTest.php @@ -89,11 +89,11 @@ public static function provideTests(): iterable // 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]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox($this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->getChecker()->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]; + $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)->getChecker()->isSandboxed($this->source), "abc")', $environment]; // needs environment $node = self::createFilter($environment, $string, 'bar'); diff --git a/tests/Node/SandboxTest.php b/tests/Node/SandboxTest.php index c8df0ad8ddd..671ab66a18a 100644 --- a/tests/Node/SandboxTest.php +++ b/tests/Node/SandboxTest.php @@ -44,13 +44,13 @@ public static function provideTests(): iterable $tests[] = [$node, <<sandbox->isSandboxed()) { - \$this->sandbox->enableSandbox(); + \$this->sandbox->setSandboxed(true); } try { yield "foo"; } finally { if (!\$alreadySandboxed) { - \$this->sandbox->disableSandbox(); + \$this->sandbox->setSandboxed(false); } } EOF diff --git a/tests/Sandbox/SandboxTest.php b/tests/Sandbox/SandboxTest.php new file mode 100644 index 00000000000..d40c0fd62fa --- /dev/null +++ b/tests/Sandbox/SandboxTest.php @@ -0,0 +1,378 @@ + '{% if greet %}Hello {{ name|upper }}{% endif %}', + ]), self::strictPolicy(tags: ['if'], filters: ['upper'])); + + $this->assertSame('Hello FABIEN', $sandbox->render('index', ['greet' => true, 'name' => 'fabien'])); + } + + public function testNonStrictSecurityPolicyIsRejected() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The sandbox requires a strict security policy, call "setStrict(true)" on a dedicated policy for this sandbox.'); + + new Sandbox(self::env(), new SecurityPolicy()); + } + + public function testAnAlreadyUsedEnvironmentIsRejected() + { + $env = self::env(['index' => 'plain content']); + $env->render('index'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('must be dedicated to it'); + new Sandbox($env, self::strictPolicy()); + } + + public function testRenderingDirectlyOnTheSandboxEnvironmentIsSandboxed() + { + $env = self::env(['index' => '{{ "a"|upper }}']); + new Sandbox($env, self::strictPolicy()); + + $this->expectException(SecurityNotAllowedFilterError::class); + $env->render('index'); + } + + public function testTheEnvironmentCanBeConfiguredAfterTheSandboxIsCreated() + { + $env = self::env(['index' => '{{ shout("hi") }}']); + $sandbox = new Sandbox($env, self::strictPolicy(functions: ['shout'])); + + $env->addFunction(new TwigFunction('shout', 'strtoupper')); + + $this->assertSame('HI', $sandbox->render('index')); + } + + public function testCustomSecurityPolicyImplementationsAreAccepted() + { + $policy = new class implements SecurityPolicyInterface { + public function checkSecurity($tags, $filters, $functions, array $tests = []): void + { + if ($filters) { + throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filters[0]), $filters[0]); + } + } + + public function checkMethodAllowed($obj, $method): void + { + } + + public function checkPropertyAllowed($obj, $property): void + { + } + }; + + $sandbox = new Sandbox(self::env([ + 'static' => 'static text', + 'filtered' => '{{ "a"|upper }}', + ]), $policy); + + $this->assertSame('static text', $sandbox->render('static')); + + $this->expectException(SecurityNotAllowedFilterError::class); + $sandbox->render('filtered'); + } + + public function testDisallowedTagIsRejected() + { + $sandbox = new Sandbox(self::env(['index' => '{% if 1 %}x{% endif %}']), self::strictPolicy()); + + $this->expectException(SecurityNotAllowedTagError::class); + $this->expectExceptionMessage('Tag "if" is not allowed'); + $sandbox->render('index'); + } + + public function testDisallowedFilterIsRejected() + { + $sandbox = new Sandbox(self::env(['index' => '{{ "a"|upper }}']), self::strictPolicy()); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $sandbox->render('index'); + } + + public function testDisallowedFunctionIsRejected() + { + $sandbox = new Sandbox(self::env(['index' => '{{ max(1, 2) }}']), self::strictPolicy()); + + $this->expectException(SecurityNotAllowedFunctionError::class); + $this->expectExceptionMessage('Function "max" is not allowed'); + $sandbox->render('index'); + } + + public function testDisallowedTestIsRejected() + { + $env = self::env(['index' => '{{ 1 is funky ? "y" : "n" }}']); + $env->addTest(new TwigTest('funky', static fn ($value) => true)); + + $sandbox = new Sandbox($env, self::strictPolicy()); + + $this->expectException(SecurityNotAllowedTestError::class); + $this->expectExceptionMessage('Test "funky" is not allowed'); + $sandbox->render('index'); + } + + public function testAllowedTestIsAccepted() + { + $env = self::env(['index' => '{{ 1 is funky ? "y" : "n" }}']); + $env->addTest(new TwigTest('funky', static fn ($value) => true)); + + $sandbox = new Sandbox($env, self::strictPolicy(tests: ['funky'])); + + $this->assertSame('y', $sandbox->render('index')); + } + + public function testMethodCallsAreGovernedByThePolicy() + { + $templates = ['index' => '{{ obj.getName() }}']; + $context = ['obj' => new SandboxTestObject()]; + + $allowing = new Sandbox(self::env($templates), self::strictPolicy(methods: [SandboxTestObject::class => ['getName']])); + $this->assertSame('fabien', $allowing->render('index', $context)); + + $denying = new Sandbox(self::env($templates), self::strictPolicy()); + $this->expectException(SecurityNotAllowedMethodError::class); + $denying->render('index', $context); + } + + public function testPropertyAccessesAreGovernedByThePolicy() + { + $templates = ['index' => '{{ obj.name }}']; + $context = ['obj' => new SandboxTestObject()]; + + $allowing = new Sandbox(self::env($templates), self::strictPolicy(properties: [SandboxTestObject::class => ['name']])); + $this->assertSame('fabien', $allowing->render('index', $context)); + + $denying = new Sandbox(self::env($templates), self::strictPolicy()); + $this->expectException(SecurityNotAllowedPropertyError::class); + $denying->render('index', $context); + } + + public function testToStringCoercionIsGovernedByThePolicy() + { + $templates = ['index' => '{{ obj }}']; + $context = ['obj' => new SandboxTestObject()]; + + $allowing = new Sandbox(self::env($templates), self::strictPolicy(methods: [SandboxTestObject::class => ['__toString']])); + $this->assertSame('object', $allowing->render('index', $context)); + + $denying = new Sandbox(self::env($templates), self::strictPolicy()); + $this->expectException(SecurityNotAllowedMethodError::class); + $denying->render('index', $context); + } + + public function testMarkupValuesCanBePrinted() + { + $sandbox = new Sandbox(self::env(['index' => '{{ m }}']), self::strictPolicy()); + + $this->assertSame('b', $sandbox->render('index', ['m' => new Markup('b', 'UTF-8')])); + } + + public function testIncludedTemplatesAreSandboxed() + { + $sandbox = new Sandbox(self::env([ + 'index' => '{{ include("partial") }}', + 'partial' => '{{ "a"|upper }}', + ]), self::strictPolicy(functions: ['include'])); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "upper" is not allowed'); + $sandbox->render('index'); + } + + public function testTheExtendsTagMustBeAllowed() + { + $templates = [ + 'index' => '{% extends "layout" %}', + 'layout' => 'layout content', + ]; + + $allowing = new Sandbox(self::env($templates), self::strictPolicy(tags: ['extends'])); + $this->assertSame('layout content', $allowing->render('index')); + + $denying = new Sandbox(self::env($templates), self::strictPolicy()); + $this->expectException(SecurityNotAllowedTagError::class); + $this->expectExceptionMessage('Tag "extends" is not allowed'); + $denying->render('index'); + } + + public function testARenderOnAnotherEnvironmentDuringASandboxedRenderIsNotSandboxed() + { + $app = new Environment(new ArrayLoader(['trusted' => '{{ value|upper }}']), ['autoescape' => false]); + + $env = self::env(['index' => '{{ render_trusted() }}']); + $env->addFunction(new TwigFunction('render_trusted', static fn () => $app->render('trusted', ['value' => 'ok']))); + + $sandbox = new Sandbox($env, self::strictPolicy(functions: ['render_trusted'])); + + $this->assertSame('OK', $sandbox->render('index')); + } + + public function testAnEnvironmentWithASandboxExtensionIsRejected() + { + $env = self::env(); + $env->addExtension(new SandboxExtension(self::strictPolicy())); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('must be dedicated to it'); + new Sandbox($env, self::strictPolicy()); + } + + public function testAutoEscapingRequiresTheEscapeFilterToBeAllowed() + { + $denying = new Sandbox(new Environment(new ArrayLoader(['index' => '{{ v }}'])), self::strictPolicy()); + try { + $denying->render('index', ['v' => 'a&b']); + $this->fail('Auto-escaping must be subject to the filter allow-list.'); + } catch (SecurityNotAllowedFilterError $e) { + $this->assertStringContainsString('Filter "escape" is not allowed', $e->getMessage()); + } + + $allowing = new Sandbox(new Environment(new ArrayLoader(['index' => '{{ v }}'])), self::strictPolicy(filters: ['escape'])); + $this->assertSame('a&b', $allowing->render('index', ['v' => 'a&b'])); + } + + public function testDisplay() + { + $sandbox = new Sandbox(self::env(['index' => 'Hello {{ name|upper }}']), self::strictPolicy(filters: ['upper'])); + + ob_start(); + try { + $sandbox->display('index', ['name' => 'fabien']); + } finally { + $output = ob_get_clean(); + } + + $this->assertSame('Hello FABIEN', $output); + } + + public function testStream() + { + $sandbox = new Sandbox(self::env(['index' => 'Hello {{ name|upper }}']), self::strictPolicy(filters: ['upper'])); + + $output = ''; + foreach ($sandbox->stream('index', ['name' => 'fabien']) as $chunk) { + $output .= $chunk; + } + + $this->assertSame('Hello FABIEN', $output); + } + + public function testRenderBlock() + { + $sandbox = new Sandbox(self::env([ + 'blocks' => '{% block greeting %}Hello {{ name|upper }}{% endblock %} not part of the block', + ]), self::strictPolicy(tags: ['block'], filters: ['upper'])); + + $this->assertSame('Hello FABIEN', $sandbox->renderBlock('blocks', 'greeting', ['name' => 'fabien'])); + } + + public function testDisplayBlock() + { + $sandbox = new Sandbox(self::env([ + 'blocks' => '{% block greeting %}Hello {{ name|upper }}{% endblock %} not part of the block', + ]), self::strictPolicy(tags: ['block'], filters: ['upper'])); + + ob_start(); + try { + $sandbox->displayBlock('blocks', 'greeting', ['name' => 'fabien']); + } finally { + $output = ob_get_clean(); + } + + $this->assertSame('Hello FABIEN', $output); + } + + public function testStreamBlock() + { + $sandbox = new Sandbox(self::env([ + 'blocks' => '{% block greeting %}Hello {{ name|upper }}{% endblock %} not part of the block', + ]), self::strictPolicy(tags: ['block'], filters: ['upper'])); + + $output = ''; + foreach ($sandbox->streamBlock('blocks', 'greeting', ['name' => 'fabien']) as $chunk) { + $output .= $chunk; + } + + $this->assertSame('Hello FABIEN', $output); + } + + public function testCreateTemplateIsSandboxed() + { + $sandbox = new Sandbox(self::env(), self::strictPolicy(filters: ['upper'])); + + $this->assertSame('FABIEN', $sandbox->createTemplate('{{ name|upper }}')->render(['name' => 'fabien'])); + + $this->expectException(SecurityNotAllowedFilterError::class); + $this->expectExceptionMessage('Filter "lower" is not allowed'); + $sandbox->createTemplate('{{ name|lower }}')->render(['name' => 'fabien']); + } + + public function testCreateTemplateCanReferenceLoaderTemplates() + { + $sandbox = new Sandbox(self::env(['partial' => 'from the loader']), self::strictPolicy(functions: ['include'])); + + $this->assertSame('from the loader', $sandbox->createTemplate('{{ include("partial") }}')->render()); + } + + private static function env(array $templates = []): Environment + { + return new Environment(new ArrayLoader($templates), ['autoescape' => false]); + } + + private static function strictPolicy(array $tags = [], array $filters = [], array $methods = [], array $properties = [], array $functions = [], array $tests = []): SecurityPolicy + { + $policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions, $tests); + $policy->setStrict(true); + + return $policy; + } +} + +final class SandboxTestObject implements \Stringable +{ + public $name = 'fabien'; + + public function getName(): string + { + return 'fabien'; + } + + public function __toString(): string + { + return 'object'; + } +}