Fabien Potencier
Contributed by Fabien Potencier

The sandbox has been part of Twig since 2009. If your users write newsletters, CMS blocks or email themes, the sandbox is the piece that keeps their creativity away from your application internals. For seventeen years, it has done that job as an extension bolted onto your application environment. Twig 4.0 promotes it to a first-class citizen: a dedicated Sandbox class that owns an environment built for untrusted templates, and for nothing else. Let me show you how it works.

The Problem: One Environment, Two Trust Levels

The historical sandbox shares everything with your application. To render one untrusted template, you enable sandbox mode on your main environment and remember to disable it afterwards:

1
2
3
4
5
6
7
8
9
10
use Twig\Extension\SandboxExtension;

$sandboxExtension = $twig->getExtension(SandboxExtension::class);
$sandboxExtension->enableSandbox();

try {
    echo $twig->render('newsletter.twig', $context);
} finally {
    $sandboxExtension->disableSandbox();
}

The documentation recommended a dedicated environment, and careful developers created one by hand. But the API never asked for one. I wrote that design, and for a long time I found it good enough: one environment, one switch.

Sharing, though, is precisely what you do not want at a security boundary. An untrusted template could load any template known to the application loader when loading templates was allowed by the policy. It saw every registered global, including your app variable, and inherited every extension, filter, function and test used by your application. The security policy had to compensate for an environment that was never designed for untrusted code.

Twig 4.0 gives sandboxed templates a dedicated environment, with no shared state to toggle and no application configuration inherited by accident.

An Environment of Their Own

The new model fits in one sentence: you craft an environment for untrusted templates, and a Sandbox takes ownership of it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\Sandbox;
use Twig\Sandbox\SecurityPolicy;

$sandboxEnvironment = new Environment(
    new ArrayLoader($newsletterTemplates),
    ['cache' => '/var/cache/newsletters'],
);

$policy = new SecurityPolicy(
    allowedTags: ['if', 'for'],
    allowedFilters: ['escape', 'upper', 'date'],
);
$policy->setStrict(true);

$sandbox = new Sandbox($sandboxEnvironment, $policy);

echo $sandbox->render('newsletter.twig', ['name' => 'Fabien']);

// or render a template held as a string, straight from your database
echo $sandbox->createTemplate($newsletter->getBody())->render([
    'name' => 'Fabien',
]);

The environment must be fresh and dedicated to the sandbox. Passing one that has already rendered a template or already has a SandboxExtension throws a LogicException instead of creating a half-isolated setup. Never pass your application environment: the sandbox takes ownership of it and renders all of its templates in sandbox mode.

Sandbox implements SandboxInterface. Type-hint the interface when you inject a sandbox into an application service.

Each argument answers one question. The environment loader defines which templates exist. Its built-in and registered extensions, filters, functions, tests and globals define the available capabilities. The policy decides which of those capabilities may execute, and which methods and properties may be accessed on context objects.

There is no mode to toggle and no state to restore. Everything rendered through a Sandbox is sandboxed: render(), display() and stream(), their block counterparts renderBlock(), displayBlock() and streamBlock(), and the templates returned by createTemplate(). And because your application environment is not involved, a trusted render happening in the middle of a sandboxed one is not sandboxed. Isolation works in both directions.

Strict by Default

Twig's 4.0 SecurityPolicy no longer has the historical exceptions that implicitly allowed some tags, functions and tests. Anything that is not in an allow-list is denied, except for built-ins that Twig marks as always safe in a sandbox. The setStrict(true) call opts into those rules on Twig 3.29; it is a harmless no-op on Twig 4.0, so the same setup works on both versions.

What happens when a template author reaches beyond the policy or the dedicated loader? Twig reports the exact capability or template involved:

  • Filter "json_encode" is not allowed in "newsletter.twig" at line 1.
  • Calling "delete" method on a "Customer" object is not allowed in "profile.twig" at line 1.
  • Template "admin/config.html.twig" is not defined in "page.twig" at line 1.

The last error comes from the loader, not the policy. Templates are not another allow-list: include, extends and their friends can only resolve names through the sandbox environment's loader.

One boundary remains worth spelling out. The sandbox restricts what template source can express; PHP code invoked by an allowed filter, function or extension still runs with full PHP capabilities. Only register callables that are safe with attacker-chosen arguments, and enforce CPU and memory limits outside Twig when resource exhaustion is a concern.

Untrusted Fragments in Trusted Pages

The classic CMS scenario is a trusted page embedding user-authored blocks. For that, register the SandboxBridgeExtension and inject the sandbox lazily into its runtime:

1
2
3
4
5
6
7
8
9
10
use Twig\Extension\SandboxBridgeExtension;
use Twig\Runtime\SandboxBridgeRuntime;
use Twig\RuntimeLoader\FactoryRuntimeLoader;

$twig->addExtension(new SandboxBridgeExtension());
$twig->addRuntimeLoader(new FactoryRuntimeLoader([
    SandboxBridgeRuntime::class => fn () => new SandboxBridgeRuntime(
        $sandbox,
    ),
]));

Your trusted template can then call render_sandboxed():

1
2
3
4
5
6
7
8
{# page.html.twig, a trusted template rendered by your application #}
<article>
    {{ render_sandboxed(
        'block-' ~ block.id,
        {title: page.title},
        'html',
    ) }}
</article>

The context mapping is the complete context passed to the sandboxed template; variables from the trusted template are not copied implicitly. The third argument declares the escaping strategy for which the result is safe. Unlike |raw, this keeps the result safe only in HTML and lets Twig escape it when used in another context, such as JavaScript.

The strategy must be a non-empty literal string other than all. It does not sanitize the fragment: declaring html means that HTML written by the untrusted template author may reach the response, which should be an explicit application decision.

The Upgrade Path

Twig 3.29 deprecates the sandboxed argument of include() and the legacy SandboxExtension methods, completing the path started by earlier 3.x deprecations. Their replacements, the Sandbox class and render_sandboxed() function, work on 3.29, so a setup that no longer uses legacy APIs is ready for Twig 4.0.

Published in #Living on the edge #Twig