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.