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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
25 changes: 21 additions & 4 deletions doc/deprecated.rst
Original file line number Diff line number Diff line change
Expand Up @@ -364,18 +364,35 @@ 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' %}
{% endsandbox %}

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
-----------------
Expand Down
15 changes: 10 additions & 5 deletions doc/functions/include.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------
Expand All @@ -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)
132 changes: 112 additions & 20 deletions doc/sandbox.rst
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion doc/tags/sandbox.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 13 additions & 8 deletions src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1499,16 +1500,20 @@ 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) {
$variables = array_merge($context, $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);
}
}

Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading