Enforce the sandbox Markup exception for every security policy

This commit is contained in:
Fabien Potencier
2026-06-07 09:46:35 +02:00
parent c2c949d5bc
commit b1cac7c3ab
5 changed files with 63 additions and 16 deletions
+1
View File
@@ -1,6 +1,7 @@
# 4.0.0 (2026-XX-XX)
* Add the `isAlwaysAllowedInSandbox()` method to `Twig\TwigCallableInterface` and `Twig\TokenParser\TokenParserInterface`
* Always allow printing a `Markup` object in a sandbox, whatever the security policy is
* Remove the `Twig\Sandbox\SourcePolicyInterface` interface and the corresponding argument of `Twig\Extension\SandboxExtension::__construct()`
* Enforce the `parent`, `block`, and `attribute` functions against the sandbox `allowedFunctions` allow-list
+4 -1
View File
@@ -11,6 +11,7 @@
namespace Twig\Extension;
use Twig\Markup;
use Twig\NodeVisitor\SandboxNodeVisitor;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
@@ -139,7 +140,9 @@ final class SandboxExtension extends AbstractExtension
return $obj;
}
if ($obj instanceof \Stringable) {
// Markup carries content that Twig already considers safe, so its
// __toString() is always allowed, whatever the security policy is.
if ($obj instanceof \Stringable && !$obj instanceof Markup) {
try {
$this->policy->checkMethodAllowed($obj, '__toString');
} catch (SecurityNotAllowedMethodError $e) {
+5 -8
View File
@@ -14,14 +14,11 @@ namespace Twig;
/**
* Marks a content as safe.
*
* Instances of this class (and any subclass) are trusted by the Twig
* sandbox: method calls and property accesses on a Markup instance bypass
* the SecurityPolicy method/property allowlists. This is by design: Markup
* represents content that has already been deemed safe to output.
*
* As a consequence, when extending this class, you are responsible for
* ensuring that every method and property exposed by your subclass is
* safe to call from a sandboxed template.
* Instances of this class are trusted by the Twig sandbox when output as
* strings: their __toString() method is always allowed. This is by design as
* Markup represents content that has already been deemed safe to output.
* Regular method calls and property accesses are still controlled by the
* SecurityPolicy method/property allowlists.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
-7
View File
@@ -11,9 +11,6 @@
namespace Twig\Sandbox;
use Twig\Markup;
use Twig\Template;
/**
* Represents a security policy which need to be enforced when sandbox mode is enabled.
*
@@ -115,10 +112,6 @@ final class SecurityPolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup) {
return;
}
$allowed = false;
$method = strtolower($method);
foreach ($this->allowedMethods as $class => $methods) {
+53
View File
@@ -28,6 +28,7 @@ use Twig\Error\SyntaxError;
use Twig\Extension\SandboxExtension;
use Twig\Extension\StringLoaderExtension;
use Twig\Loader\ArrayLoader;
use Twig\Markup;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Node;
use Twig\Node\Nodes;
@@ -40,9 +41,11 @@ use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Source;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
use Twig\TokenParser\TokenParserInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
@@ -661,6 +664,39 @@ class SandboxTest extends TestCase
$this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once');
}
public function testSandboxAllowsPrintingMarkup()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ markup }}']);
$this->assertSame('<b>safe</b>', $twig->load('index')->render(['markup' => new Markup('<b>safe</b>', 'UTF-8')]));
}
public function testSandboxAllowsPrintingMarkupWithACustomPolicyThatAllowsNothing()
{
$loader = new ArrayLoader(['index' => '{{ markup }}']);
$twig = new Environment($loader, ['cache' => false, 'autoescape' => false]);
$twig->addExtension(new SandboxExtension(new DenyEverythingSecurityPolicy(), true));
$this->assertSame('<b>safe</b>', $twig->load('index')->render(['markup' => new Markup('<b>safe</b>', 'UTF-8')]));
}
public function testSandboxAppliesThePolicyToTemplateMethods()
{
$template = $this->getEnvironment(true, [], ['index' => 'foo'])->load('index')->unwrap();
$policy = new SecurityPolicy();
$this->expectException(SecurityNotAllowedMethodError::class);
$policy->checkMethodAllowed($template, 'getTemplateName');
}
public function testSandboxAppliesThePolicyToMarkupMethods()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ markup.getCharset() }}']);
$this->expectException(SecurityNotAllowedMethodError::class);
$twig->load('index')->render(['markup' => new Markup('<b>safe</b>', 'UTF-8')]);
}
public function testSandboxUnallowedFunction()
{
$twig = $this->getEnvironment(true, [], self::$templates);
@@ -1730,3 +1766,20 @@ class GatedSandboxTokenParser extends AbstractTokenParser
return 'gated_tag';
}
}
class DenyEverythingSecurityPolicy implements SecurityPolicyInterface
{
public function checkSecurity($tags, $filters, $functions): void
{
}
public function checkMethodAllowed($obj, $method): void
{
throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $obj::class), $obj::class, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $obj::class), $obj::class, $property);
}
}