Add a strict mode to SecurityPolicy to opt-in to the 4.0 sandbox behavior for the extends/use tags and the parent/block/attribute functions

This commit is contained in:
Fabien Potencier
2026-05-25 13:40:27 +02:00
parent 4dde307c32
commit af7bf5e181
5 changed files with 136 additions and 18 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.27.0 (2026-XX-XX)
* Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox
* Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template
* Fix PHP 8.1+ implicit float-to-int deprecation triggered by sandboxed `ArrayAccess` attribute access with a float key
* Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation
+6 -2
View File
@@ -302,11 +302,15 @@ Sandbox
* Having the ``extends`` and ``use`` tags allowed by default in a sandbox is
deprecated as of Twig 3.12. You will need to explicitly allow them if needed
in 4.0.
in 4.0. To opt-in to the 4.0 behavior now (so the tags need to be
allow-listed or get rejected), enable strict mode on the security policy by
calling ``$policy->setStrict(true)``.
* Having the ``parent``, ``block``, and ``attribute`` functions allowed by
default in a sandbox is deprecated as of Twig 3.27. You will need to
explicitly allow them if needed in 4.0.
explicitly allow them if needed in 4.0. The same ``setStrict(true)`` toggle
on ``Twig\Sandbox\SecurityPolicy`` opts-in to the 4.0 behavior for these
functions too.
* The ``Twig\Sandbox\SourcePolicyInterface`` interface is deprecated as of Twig
3.27.0 with no replacement. Passing an instance to the
+8 -3
View File
@@ -48,9 +48,14 @@ allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception.
.. caution::
The ``extends`` and ``use`` tags are always allowed in a sandboxed
template. That behavior will change in 4.0 where these tags will need to be
explicitly allowed like any other tag.
The ``extends`` and ``use`` tags, as well as the ``parent``, ``block``, and
``attribute`` functions are always allowed in a sandboxed template. That
behavior will change in 4.0 where they will need to be explicitly allowed
like any other tag or function. To opt-in to the 4.0 behavior now (so they
need to be allow-listed or get rejected), enable strict mode on the
security policy::
$policy->setStrict(true);
Enabling the Sandbox
--------------------
+25 -10
View File
@@ -26,6 +26,7 @@ final class SecurityPolicy implements SecurityPolicyInterface
private $allowedMethods;
private $allowedProperties;
private $allowedFunctions;
private bool $strict = false;
public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = [])
{
@@ -64,14 +65,28 @@ final class SecurityPolicy implements SecurityPolicyInterface
$this->allowedFunctions = $functions;
}
/**
* Toggles strict mode.
*
* In strict mode, the tags and functions that are historically always allowed in a
* sandbox (the ``extends`` and ``use`` tags, the ``parent``, ``block``, and
* ``attribute`` functions) are no longer implicitly allowed and must be added to the
* relevant allow-list to be usable. Use this flag in 3.x to opt-in to the forthcoming
* 4.0 behavior and silence the related deprecations.
*/
public function setStrict(bool $strict): void
{
$this->strict = $strict;
}
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($tags as $tag) {
if (!\in_array($tag, $this->allowedTags, true)) {
if ('extends' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "extends" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
} elseif ('use' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "use" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
if (!$this->strict && 'extends' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "extends" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'use' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "use" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} else {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
@@ -86,12 +101,12 @@ final class SecurityPolicy implements SecurityPolicyInterface
foreach ($functions as $function) {
if (!\in_array($function, $this->allowedFunctions, true)) {
if ('parent' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "parent" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
} elseif ('block' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "block" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
} elseif ('attribute' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "attribute" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
if (!$this->strict && 'parent' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "parent" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'block' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "block" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'attribute' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "attribute" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} else {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
+96 -3
View File
@@ -135,7 +135,7 @@ class SandboxTest extends TestCase
*/
public function testSandboxForExtendsAndUseTags(string $tag, string $template)
{
$this->expectDeprecation(\sprintf('Since twig/twig 3.12: The "%s" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.', $tag));
$this->expectDeprecation(\sprintf('Since twig/twig 3.12: The "%s" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $tag));
$twig = $this->getEnvironment(true, [], self::$templates, []);
$twig->createTemplate($template, 'index')->render([]);
@@ -154,7 +154,7 @@ class SandboxTest extends TestCase
*/
public function testSandboxForParserCallableFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $context, string $expected)
{
$this->expectDeprecation(\sprintf('Since twig/twig 3.27: The "%s" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.', $function));
$this->expectDeprecation(\sprintf('Since twig/twig 3.27: The "%s" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $function));
$twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], $allowedMethods, $allowedProperties, []);
$this->assertSame($expected, $twig->load($templateName)->render($context));
@@ -248,6 +248,98 @@ class SandboxTest extends TestCase
];
}
/**
* @dataProvider getStrictSandboxRejectsGrandfatheredTagsTests
*/
public function testStrictSandboxRejectsGrandfatheredTags(string $tag, string $template)
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], [], null, true);
$this->expectException(SecurityNotAllowedTagError::class);
$this->expectExceptionMessage(\sprintf('Tag "%s" is not allowed', $tag));
$twig->createTemplate($template, 'index')->render([]);
}
public static function getStrictSandboxRejectsGrandfatheredTagsTests()
{
yield ['extends', '{% extends "1_empty" %}'];
yield ['use', '{% use "1_empty" %}'];
}
/**
* @dataProvider getStrictSandboxRejectsGrandfatheredFunctionsTests
*/
public function testStrictSandboxRejectsGrandfatheredFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $context)
{
$twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], [], [], [], null, true);
$this->expectException(SecurityNotAllowedFunctionError::class);
$this->expectExceptionMessage(\sprintf('Function "%s" is not allowed', $function));
$twig->load($templateName)->render($context);
}
public static function getStrictSandboxRejectsGrandfatheredFunctionsTests()
{
yield 'attribute' => [
'attribute',
'index',
['index' => '{{ attribute(data, "secret") }}'],
[],
['data' => ['secret' => 'LEAK']],
];
yield 'block' => [
'block',
'index',
['index' => '{% block content %}B{% endblock %}{{ block("content") }}'],
['block'],
[],
];
yield 'parent' => [
'parent',
'child',
[
'base' => '{% block content %}PARENT{% endblock %}',
'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD{% endblock %}',
],
['extends', 'block'],
[],
];
}
public function testStrictSandboxStillAllowsExplicitlyAllowedGrandfatheredNames()
{
$twig = $this->getEnvironment(
true,
[],
[
'base' => '{% block content %}PARENT{% endblock %}',
'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD - {{ attribute(data, "x") }}{% endblock %}',
],
['extends', 'block'],
[],
[],
[],
['parent', 'attribute'],
null,
true,
);
$this->assertSame('PARENT CHILD - OK', $twig->load('child')->render(['data' => ['x' => 'OK']]));
}
public function testStrictModeCanBeEnabledViaSetterAfterConstruction()
{
$policy = new SecurityPolicy([], [], [], [], []);
$policy->setStrict(true);
$this->expectException(SecurityNotAllowedTagError::class);
$policy->checkSecurity(['extends'], [], []);
}
public function testSandboxWithInheritance()
{
$twig = $this->getEnvironment(true, [], self::$templates, ['extends', 'block']);
@@ -1081,11 +1173,12 @@ EOF
$this->assertSame('bar', $twig->load('index')->render($params));
}
protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [], $sourcePolicy = null)
protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [], $sourcePolicy = null, bool $strict = false)
{
$loader = new ArrayLoader($templates);
$twig = new Environment($loader, array_merge(['debug' => true, 'cache' => false, 'autoescape' => false], $options));
$policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$policy->setStrict($strict);
$twig->addExtension(new SandboxExtension($policy, $sandboxed, $sourcePolicy));
return $twig;