Files
Twig/tests/Extension/SandboxStateChangeTest.php
2026-06-06 11:08:56 +02:00

432 lines
16 KiB
PHP

<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests\Extension;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Sandbox\SourcePolicyInterface;
use Twig\Source;
/**
* Regression tests for the sandbox filter/tag/function allow-list bypass that
* existed when the sandbox state changed between renders of a cached Template
* instance.
*
* Without the fix, the compiled checkSecurity() method only ran once at
* construction time, locking in the verdict computed against whatever sandbox
* state was active when the template was first loaded.
*/
class SandboxStateChangeTest extends TestCase
{
use ExpectDeprecationTrait;
public function testEnableSandboxAfterFirstRender()
{
[$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false);
$this->assertSame('FOO', $twig->render('t'));
$sandbox->enableSandbox();
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('t');
}
public function testDisableSandboxAfterFirstRender()
{
// Use enableSandbox() to toggle a non-global sandbox so disableSandbox() actually has an effect.
[$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false);
$sandbox->enableSandbox();
try {
$twig->render('t');
$this->fail('Expected SecurityNotAllowedFilterError on first render');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertSame('upper', $e->getFilterName());
}
$sandbox->disableSandbox();
$this->assertSame('FOO', $twig->render('t'));
}
public function testSetSecurityPolicyTightening()
{
$permissive = new SecurityPolicy(allowedFilters: ['upper', 'escape']);
[$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $permissive, true);
$this->assertSame('FOO', $twig->render('t'));
$sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['escape']));
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('t');
}
public function testSetSecurityPolicyLoosening()
{
$strict = new SecurityPolicy(allowedFilters: []);
[$twig, $sandbox] = $this->build(['t' => '{{ "foo"|upper }}'], $strict, true);
try {
$twig->render('t');
$this->fail('Expected SecurityNotAllowedFilterError on first render');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertSame('upper', $e->getFilterName());
}
$sandbox->setSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper']));
$this->assertSame('FOO', $twig->render('t'));
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testSourcePolicyDecisionFlip()
{
$this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".');
$sourcePolicy = new class implements SourcePolicyInterface {
public array $sandboxFor = [];
public function enableSandbox(Source $source): bool
{
return \in_array($source->getName(), $this->sandboxFor, true);
}
};
[$twig] = $this->build(['t' => '{{ "foo"|upper }}'], new SecurityPolicy(allowedFilters: []), false, $sourcePolicy);
$this->assertSame('FOO', $twig->render('t'));
$sourcePolicy->sandboxFor = ['t'];
$this->expectException(SecurityNotAllowedFilterError::class);
$twig->render('t');
}
public function testPreWarmedParentTemplateThroughExtends()
{
$templates = [
'parent.twig' => '{% block c %}default{% endblock %}{{ "hi"|upper }}',
'child.twig' => '{% extends "parent.twig" %}{% block c %}child{% endblock %}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'block'],
allowedFilters: [],
);
[$twig, $sandbox] = $this->build($templates, $policy, false);
// pre-warm with sandbox off: parent and child Template instances are now cached
$this->assertSame('childHI', $twig->render('child.twig'));
$sandbox->enableSandbox();
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('child.twig');
}
public function testPreWarmedSharedViaSandboxedInclude()
{
$templates = [
'top' => '{{ include("user", sandboxed=true) }}',
'user' => '{% extends "shared" %}{% block c %}user{% endblock %}',
'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'block'],
allowedFunctions: ['include'],
allowedFilters: ['escape'],
);
[$twig] = $this->build($templates, $policy, false);
// legitimate pre-warm of the trusted layout with sandbox off
$this->assertSame('OK', $twig->render('shared'));
// sandboxed include of user, which extends the pre-warmed shared:
// upper is not in the allow-list, so it must throw even though shared
// was loaded unsandboxed.
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('top');
}
public function testMacroFromPreWarmedTemplate()
{
$templates = [
'macros.twig' => '{% macro greet(name) %}{{ name|upper }}{% endmacro %}',
'caller.twig' => '{% import "macros.twig" as m %}{{ m.greet("world") }}',
];
$policy = new SecurityPolicy(
allowedTags: ['import', 'macro'],
allowedFilters: [],
);
[$twig, $sandbox] = $this->build($templates, $policy, false);
// pre-warm with sandbox off
$this->assertSame('WORLD', $twig->render('caller.twig'));
$sandbox->enableSandbox();
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('caller.twig');
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testSandboxTagAroundIncludeOfPreWarmedTemplate()
{
$this->expectDeprecation('Since twig/twig 3.15: The "sandbox" tag is deprecated in "wrapper" at line 1.');
$templates = [
'wrapper' => '{% sandbox %}{% include "user" %}{% endsandbox %}',
'user' => '{% extends "shared" %}{% block c %}user{% endblock %}',
'shared' => '{% block c %}{% endblock %}{{ "ok"|upper }}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'block', 'include'],
allowedFilters: ['escape'],
);
[$twig] = $this->build($templates, $policy, false);
$this->assertSame('OK', $twig->render('shared'));
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "upper" is not allowed');
$twig->render('wrapper');
}
public function testTagBypassThroughPreWarmedParent()
{
$templates = [
'parent.twig' => '{% block c %}{% for i in 1..2 %}{{ i }}{% endfor %}{% endblock %}',
'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'block'],
allowedFilters: [],
allowedFunctions: ['parent', 'range'],
);
[$twig, $sandbox] = $this->build($templates, $policy, false);
$this->assertSame('12', $twig->render('child.twig'));
$sandbox->enableSandbox();
$this->expectException(SecurityNotAllowedTagError::class);
$this->expectExceptionMessage('Tag "for" is not allowed');
$twig->render('child.twig');
}
public function testFunctionBypassThroughPreWarmedParent()
{
$templates = [
'parent.twig' => '{% block c %}{{ range(1, 2)|first }}{% endblock %}',
'child.twig' => '{% extends "parent.twig" %}{% block c %}{{ parent() }}{% endblock %}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'block'],
allowedFilters: ['first'],
allowedFunctions: ['parent'],
);
[$twig, $sandbox] = $this->build($templates, $policy, false);
$this->assertSame('1', $twig->render('child.twig'));
$sandbox->enableSandbox();
$this->expectException(SecurityNotAllowedFunctionError::class);
$this->expectExceptionMessage('Function "range" is not allowed');
$twig->render('child.twig');
}
public function testDynamicParentFilterRejectedWhenReachedViaMacroImport()
{
// Regression: getTemplateForMacro() walks getParent() to find the
// macro on a parent template. When the imported template has a
// dynamic {% extends %}, doGetParent() evaluates the user expression.
// The sandbox security check must run on the imported template
// *before* doGetParent() executes, otherwise a forbidden filter on
// the parent name escapes the allow-list.
$templates = [
'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}',
'middle.twig' => '{% extends parent_name|evil %}',
'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'import', 'macro'],
allowedFilters: [],
);
[$twig] = $this->build($templates, $policy, true);
$evilCalls = 0;
$twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) {
++$evilCalls;
return $v;
}));
try {
$twig->render('caller.twig', ['parent_name' => 'grandparent.twig']);
$this->fail('Expected SecurityNotAllowedFilterError');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertSame('evil', $e->getFilterName());
}
$this->assertSame(0, $evilCalls, 'The forbidden filter must not be invoked before the security check runs.');
}
public function testDynamicParentFunctionRejectedWhenReachedViaMacroImport()
{
// Same root cause as the filter case, but for functions called from
// within the dynamic parent expression.
$templates = [
'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}',
'middle.twig' => '{% extends evil(parent_name) %}',
'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'import', 'macro'],
allowedFunctions: [],
);
[$twig] = $this->build($templates, $policy, true);
$evilCalls = 0;
$twig->addFunction(new \Twig\TwigFunction('evil', static function ($v) use (&$evilCalls) {
++$evilCalls;
return $v;
}));
try {
$twig->render('caller.twig', ['parent_name' => 'grandparent.twig']);
$this->fail('Expected SecurityNotAllowedFunctionError');
} catch (SecurityNotAllowedFunctionError $e) {
$this->assertSame('evil', $e->getFunctionName());
}
$this->assertSame(0, $evilCalls, 'The forbidden function must not be invoked before the security check runs.');
}
public function testDynamicParentFilterRejectedOnPreWarmedTemplate()
{
// Same bypass, but reached after the template has been pre-warmed
// outside the sandbox (which would have otherwise short-circuited
// ensureSecurityChecked() at yield() time).
$templates = [
'grandparent.twig' => '{% macro foo() %}grand{% endmacro %}',
'middle.twig' => '{% extends parent_name|evil %}',
'caller.twig' => '{% import "middle.twig" as m %}{{ m.foo() }}',
];
$policy = new SecurityPolicy(
allowedTags: ['extends', 'import', 'macro'],
allowedFilters: [],
);
[$twig, $sandbox] = $this->build($templates, $policy, false);
$evilCalls = 0;
$twig->addFilter(new \Twig\TwigFilter('evil', static function ($v) use (&$evilCalls) {
++$evilCalls;
return $v;
}));
$this->assertSame('grand', $twig->render('caller.twig', ['parent_name' => 'grandparent.twig']));
$this->assertSame(1, $evilCalls);
$sandbox->enableSandbox();
try {
$twig->render('caller.twig', ['parent_name' => 'grandparent.twig']);
$this->fail('Expected SecurityNotAllowedFilterError');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertSame('evil', $e->getFilterName());
}
$this->assertSame(1, $evilCalls, 'The forbidden filter must not be invoked after the sandbox is enabled.');
}
public function testRepeatedRendersInStableSandboxedStateRunCheckEachTime()
{
$counter = new CountingSecurityPolicy(new SecurityPolicy(allowedFilters: ['upper']));
[$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, true);
for ($i = 0; $i < 5; ++$i) {
$this->assertSame('X', $twig->render('t'));
}
// The per-render check is intentionally re-run on every yield(): the cached verdict was
// the source of the original bypass. Asserting >= 5 documents the new contract.
$this->assertGreaterThanOrEqual(5, $counter->callCount);
}
public function testNoCheckWhenSandboxRemainsOff()
{
$counter = new CountingSecurityPolicy(new SecurityPolicy());
[$twig] = $this->build(['t' => '{{ "x"|upper }}'], $counter, false);
for ($i = 0; $i < 5; ++$i) {
$this->assertSame('X', $twig->render('t'));
}
$this->assertSame(0, $counter->callCount);
}
/**
* @return array{0: Environment, 1: SandboxExtension}
*/
private function build(array $templates, SecurityPolicyInterface $policy, bool $sandboxed, ?SourcePolicyInterface $sourcePolicy = null): array
{
$twig = new Environment(new ArrayLoader($templates), ['cache' => false, 'autoescape' => false]);
$sandbox = new SandboxExtension($policy, $sandboxed, $sourcePolicy);
$twig->addExtension($sandbox);
return [$twig, $sandbox];
}
}
class CountingSecurityPolicy implements SecurityPolicyInterface
{
public int $callCount = 0;
public function __construct(private SecurityPolicyInterface $inner)
{
}
public function checkSecurity($tags, $filters, $functions, array $tests = []): void
{
++$this->callCount;
$this->inner->checkSecurity($tags, $filters, $functions, $tests);
}
public function checkMethodAllowed($obj, $method): void
{
$this->inner->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->inner->checkPropertyAllowed($obj, $property);
}
}