Files
Twig/tests/Extension/SandboxTest.php
Fabien Potencier f640320202 Merge branch '3.x' into 4.x
* 3.x: (26 commits)
  Remove the documentation comments compilation overhead
  Clarify source function trust requirements
  Throw on PCRE errors in the matches operator
  Document that reusing a non-rewindable iterator after destructuring is unsupported
  Release destructuring temporaries after assignment
  Deprecate prefixed macro definedness checks
  Fix duplicate macro deprecation wording
  Throw when list formatting fails
  Document that sequence destructuring consumes one value per pattern slot
  Fix the html_attr documentation about iterables in data attributes
  Warn about untrusted input with the default Tempest markdown converter
  Document that overriding MacroNode::compile() is not supported anymore
  Merge overlapping CHANGELOG entries for the destructuring fatal error fix
  Document that include_only keeps global variables available
  Remove lazy macro import resolution
  Honor date formatter prototype calendars
  Fix Stringable keys for ArrayAccess implementations
  Fix repeated object destructuring evaluation
  Restore void return type compatibility for extension points
  Reject destructuring patterns containing no variables
  ...

# Conflicts:
#	CHANGELOG
#	doc/deprecated.rst
#	doc/filters/format_datetime.rst
#	extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php
#	extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php
#	extra/twig-extra-bundle/TwigExtraBundle.php
#	src/MacroNamespace.php
#	src/Node/MacrosNode.php
#	src/Parser.php
#	src/Test/IntegrationTestCase.php
#	src/Test/NodeTestCase.php
#	tests/CallMacroTest.php
#	tests/ExpressionParserTest.php
#	tests/Fixtures/macros/duplicate_definition.legacy.test
#	tests/Node/MacrosTest.php
#	tests/ParserTest.php
2026-08-29 00:25:20 +02:00

1997 lines
86 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;
/*
* 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.
*/
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Error\RuntimeError;
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;
use Twig\Node\TextNode;
use Twig\Parser;
use Twig\Sandbox\SecurityError;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityNotAllowedTestError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Source;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
class SandboxTest extends TestCase
{
protected static $params;
protected static $templates;
protected function setUp(): void
{
self::$params = [
'name' => 'Fabien',
'obj' => new FooObject(),
'arr' => ['obj' => new FooObject()],
'child_obj' => new ChildClass(),
'some_array' => [5, 6, 7, new FooObject()],
'array_like' => new ArrayLikeObject(),
'magic' => new MagicObject(),
'recursion' => [4],
'iterator' => new \ArrayIterator(['a', new FooObject()]),
'iterator_map' => new \ArrayIterator(['__toString' => new FooObject()]),
'iterator_nested' => new \ArrayIterator(['a', new \ArrayIterator(['b', new FooObject()])]),
'stringable_iterator' => new StringableTraversableObject(['a', new FooObject()]),
'stringable_iterator_map' => new StringableTraversableObject(['__toString' => new FooObject()]),
];
self::$params['recursion'][] = &self::$params['recursion'];
self::$params['recursion'][] = new FooObject();
self::$templates = [
'1_basic1' => '{{ obj.foo }}',
'1_basic2' => '{{ name|upper }}',
'1_basic3' => '{% if name %}foo{% endif %}',
'1_basic4' => '{{ obj.bar }}',
'1_basic5' => '{{ obj }}',
'1_basic7' => '{{ cycle(["foo","bar"], 1) }}',
'1_basic8' => '{{ obj.getfoobar }}{{ obj.getFooBar }}',
'1_basic9' => '{{ obj.foobar }}{{ obj.fooBar }}',
'1_basic' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
'1_layout' => '{% block content %}{% endblock %}',
'1_child' => "{% extends \"1_layout\" %}\n{% block content %}\n{{ \"a\"|json_encode }}\n{% endblock %}",
'1_basic2_include_template_from_string' => '{{ include(template_from_string("{{ name|upper }}")) }}',
'1_range_operator' => '{{ (1..2)[0] }}',
'1_childobj_parentmethod' => '{{ child_obj.ParentMethod() }}',
'1_childobj_childmethod' => '{{ child_obj.ChildMethod() }}',
'1_empty' => '',
'1_array_like' => '{{ array_like["foo"] }}',
];
}
#[DataProvider('getSandboxedForCoreTagsTests')]
public function testSandboxForCoreTags(string $tag, string $template): void
{
$twig = $this->getEnvironment(true, [], self::$templates, []);
$this->expectException(SecurityError::class);
$this->expectExceptionMessageMatches(\sprintf('/Tag "%s" is not allowed in "index \(string template .+?\)" at line 1/', $tag));
$twig->createTemplate($template, 'index')->render([]);
}
public static function getSandboxedForCoreTagsTests()
{
yield ['apply', '{% apply upper %}foo{% endapply %}'];
yield ['autoescape', '{% autoescape %}foo{% endautoescape %}'];
yield ['block', '{% block foo %}foo{% endblock %}'];
yield ['deprecated', '{% deprecated "message" %}'];
yield ['do', '{% do 1 + 2 %}'];
yield ['embed', '{% embed "base.twig" %}{% endembed %}'];
yield ['extends', '{% extends "base.twig" %}'];
yield ['flush', '{% flush %}'];
yield ['for', '{% for i in 1..2 %}{% endfor %}'];
yield ['from', '{% from "macros" import foo %}'];
yield ['if', '{% if false %}{% endif %}'];
yield ['import', '{% import "macros" as macros %}'];
yield ['include', '{% include "macros" %}'];
yield ['macro', '{% macro foo() %}{% endmacro %}'];
yield ['set', '{% set foo = 1 %}'];
yield ['extends', '{% extends "1_empty" %}'];
yield ['use', '{% use "1_empty" %}'];
yield ['with', '{% with foo %}{% endwith %}'];
}
#[DataProvider('getUnallowedParserCallableFunctionsTests')]
public function testSandboxUnallowedParserCallableFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $context): void
{
$twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], [], [], []);
try {
$twig->load($templateName)->render($context);
$this->fail(\sprintf('Sandbox throws a SecurityError exception when the "%s" function is not in allowedFunctions', $function));
} catch (SecurityNotAllowedFunctionError $e) {
$this->assertSame($function, $e->getFunctionName());
}
}
public static function getUnallowedParserCallableFunctionsTests()
{
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 %}',
],
['block', 'extends'],
[],
];
}
#[DataProvider('getAllowedParserCallableFunctionsTests')]
public function testSandboxWithAllowedParserCallableFunctions(string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $allowedFunctions, array $context, string $expected): void
{
$twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], $allowedMethods, $allowedProperties, $allowedFunctions);
$this->assertSame($expected, $twig->load($templateName)->render($context));
}
public static function getAllowedParserCallableFunctionsTests()
{
yield 'attribute allowed' => [
'index',
['index' => '{{ attribute(data, "x") }}'],
[], [], [], ['attribute'],
['data' => ['x' => 'OK']],
'OK',
];
yield 'block allowed' => [
'index',
['index' => '{% block content %}B{% endblock %}{{ block("content") }}'],
['block'], [], [], ['block'],
[],
'BB',
];
yield 'parent allowed' => [
'child',
[
'base' => '{% block content %}PARENT{% endblock %}',
'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD{% endblock %}',
],
['block', 'extends'], [], [], ['parent'],
[],
'PARENT CHILD',
];
}
#[DataProvider('getStrictSandboxRejectsGrandfatheredTagsTests')]
public function testStrictSandboxRejectsGrandfatheredTags(string $tag, string $template): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], []);
$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): void
{
$twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], [], [], []);
$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(): void
{
$twig = $this->getEnvironment(
true,
[],
[
'base' => '{% block content %}PARENT{% endblock %}',
'child' => '{% extends "base" %}{% block content %}{{ parent() }} CHILD - {{ attribute(data, "x") }}{% endblock %}',
],
['extends', 'block'],
[],
[],
[],
['parent', 'attribute'],
);
$this->assertSame('PARENT CHILD - OK', $twig->load('child')->render(['data' => ['x' => 'OK']]));
}
#[DataProvider('getAlwaysAllowedCoreTests')]
public function testSandboxAllowsAlwaysAllowedCoreTests(string $template): void
{
// the safe built-in tests are always allowed in a sandbox (they carry
// the `always_allowed_in_sandbox` flag), so they need no allow-list entry
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], []);
$this->assertSame('y', $twig->createTemplate($template, 'index')->render([]));
}
public static function getAlwaysAllowedCoreTests()
{
yield ['{{ "" is empty ? "y" }}'];
yield ['{{ [] is iterable ? "y" }}'];
yield ['{{ null is null ? "y" }}'];
yield ['{{ null is none ? "y" }}'];
yield ['{{ 1 is defined ? "y" }}'];
yield ['{{ 2 is even ? "y" }}'];
yield ['{{ 3 is odd ? "y" }}'];
yield ['{{ true is true ? "y" }}'];
yield ['{{ 1 is same as(1) ? "y" }}'];
yield ['{{ 4 is divisible by(2) ? "y" }}'];
yield ['{{ [] is sequence ? "y" }}'];
yield ['{{ {"a": 1} is mapping ? "y" }}'];
}
public function testSandboxAllowsAllowListedTest(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "x" is unsafe ? "y" }}'], [], [], [], [], [], ['unsafe']);
$twig->addTest(new TwigTest('unsafe', static fn ($v): bool => true));
$this->assertSame('y', $twig->load('index')->render([]));
}
public function testStrictSandboxRejectsConstantTest(): void
{
// "constant" is the only built-in test that is not always allowed
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is constant("PHP_INT_MAX") ? "y" }}'], [], [], [], [], []);
$this->expectException(SecurityNotAllowedTestError::class);
$this->expectExceptionMessage('Test "constant" is not allowed');
$twig->load('index')->render([]);
}
public function testStrictSandboxStillAllowsAllowListedTest(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is constant("PHP_INT_MAX") ? "y" : "n" }}'], [], [], [], [], [], ['constant']);
$this->assertSame('n', $twig->load('index')->render([]));
}
public function testStrictSandboxRejectsUserDefinedTest(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "x" is unsafe ? "y" }}'], [], [], [], [], []);
$twig->addTest(new TwigTest('unsafe', static fn ($v): bool => true));
$this->expectException(SecurityNotAllowedTestError::class);
$this->expectExceptionMessage('Test "unsafe" is not allowed');
$twig->load('index')->render([]);
}
public function testImplicitBooleanCoercionDoesNotRequireAllowListingTests(): void
{
// `{% if %}`, ternary, `?:`, `??`, and `|default` conditions are coerced
// to a boolean through `true`/`defined`/`null` tests that the compiler
// injects; the template author never wrote them and they are always
// allowed, so they must work with no test allow-listed
$template = "{% if x %}a{% endif %}{{ y ? 'b' : 'c' }}{{ z ?: 'd' }}{{ w ?? 'e' }}{{ v|default('f') }}";
$twig = $this->getEnvironment(true, [], ['index' => $template], ['if'], ['default'], [], [], []);
$this->assertSame('acdef', $twig->load('index')->render(['x' => true, 'y' => false]));
}
public function testStrictSandboxRejectedTestCarriesSourceAndLine(): void
{
$twig = $this->getEnvironment(true, [], ['index' => "{{ 1 }}\n{{ 1 is constant('PHP_INT_MAX') ? 'y' }}"], [], [], [], [], []);
try {
$twig->load('index')->render([]);
$this->fail('Expected SecurityNotAllowedTestError');
} catch (SecurityNotAllowedTestError $e) {
$this->assertSame('constant', $e->getTestName());
$this->assertSame(2, $e->getTemplateLine());
$this->assertSame('index', $e->getSourceContext()->getName());
}
}
public function testSandboxRejectsUnallowedTestViaSecurityPolicy(): void
{
$policy = new SecurityPolicy([], [], [], [], []);
$this->expectException(SecurityNotAllowedTestError::class);
$policy->checkSecurity([], [], [], ['empty']);
}
public function testAllowedTestsCanBeUpdatedViaSetter(): void
{
$policy = new SecurityPolicy([], [], [], [], []);
$policy->setAllowedTests(['empty']);
// does not throw
$policy->checkSecurity([], [], [], ['empty']);
$this->expectException(SecurityNotAllowedTestError::class);
$policy->checkSecurity([], [], [], ['null']);
}
public function testSandboxWithInheritance(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, ['extends', 'block']);
$this->expectException(SecurityError::class);
$this->expectExceptionMessage('Filter "json_encode" is not allowed in "1_child" at line 3.');
$twig->load('1_child')->render([]);
}
public function testSandboxGloballySet(): void
{
$twig = $this->getEnvironment(false, [], self::$templates);
$this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params), 'Sandbox does nothing if it is disabled globally');
}
public function testSandboxUnallowedPropertyAccessor(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_basic1')->render(['obj' => new MagicObject()]);
$this->fail('Sandbox throws a SecurityError exception if an unallowed method is called');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertEquals(MagicObject::class, $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\MagicObject" class');
$this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property');
}
}
public function testSandboxUnallowedArrayIndexAccessor(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
// ArrayObject and other internal array-like classes are exempted from sandbox restrictions
$this->assertSame('bar', $twig->load('1_array_like')->render(['array_like' => new \ArrayObject(['foo' => 'bar'])]));
try {
$twig->load('1_array_like')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed method is called');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertEquals(ArrayLikeObject::class, $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\ArrayLikeObject" class');
$this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property');
}
}
#[DataProvider('provideNonStringArrayAccessKeys')]
public function testSandboxNonStringKeyAccessDoesNotTriggerImplicitConversionDeprecation(string $template, string $expectedKey): void
{
$loader = new ArrayLoader(['t' => $template]);
$twig = new Environment($loader);
$twig->addExtension(new SandboxExtension(new SecurityPolicy(allowedFilters: ['escape']), true));
$obj = new class implements \ArrayAccess {
public function offsetGet($k): mixed
{
return null;
}
public function offsetExists($k): bool
{
return false;
}
public function offsetSet($k, $v): void
{
}
public function offsetUnset($k): void
{
}
};
// Promote E_DEPRECATED to an ErrorException so PHP 8.1's implicit
// float-to-int conversion notice (or any future similar notice) fails
// the test instead of slipping through error_log and leaking the
// sandboxed key value.
set_error_handler(static function (int $errno, string $msg) {
throw new \ErrorException($msg, 0, $errno);
}, \E_DEPRECATED);
try {
$twig->render('t', ['obj' => $obj]);
$this->fail('Expected SecurityNotAllowedPropertyError');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertSame($expectedKey, $e->getPropertyName());
} finally {
restore_error_handler();
}
}
public static function provideNonStringArrayAccessKeys(): iterable
{
// Float key: the one that triggers the implicit conversion deprecation
// on PHP 8.1+ before the fix.
yield 'float key' => ['{{ obj[3.14] }}', '3'];
// Bool keys: do not deprecate today but serve as regression guards
// and exercise the same coercion branch.
yield 'true key' => ['{{ obj[true] }}', '1'];
yield 'false key' => ['{{ obj[false] }}', '0'];
}
public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromStringNotSandboxed(): void
{
$twig = $this->getEnvironment(false, [], self::$templates);
$twig->addExtension(new StringLoaderExtension());
$this->assertSame('FABIEN', $twig->load('1_basic2_include_template_from_string')->render(self::$params));
}
public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringNotSandboxed(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']);
$twig->addExtension(new StringLoaderExtension());
try {
$twig->load('1_basic2_include_template_from_string')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter');
}
}
public function testSandboxUnallowedFilter(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_basic2')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called');
} catch (SecurityNotAllowedFilterError $e) {
$this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter');
}
}
public function testSandboxUnallowedTag(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_basic3')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed tag is used in the template');
} catch (SecurityNotAllowedTagError $e) {
$this->assertEquals('if', $e->getTagName(), 'Exception should be raised on the "if" tag');
}
}
public function testSandboxUnallowedProperty(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_basic4')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed property is called in the template');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertEquals(FooObject::class, $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class');
$this->assertEquals('bar', $e->getPropertyName(), 'Exception should be raised on the "bar" property');
}
}
public function testSandboxAllowedMagicCallMethod(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ o.hello }}'], [], [], [MagicCallObject::class => 'hello']);
$this->assertSame('call:hello', $twig->load('index')->render(['o' => new MagicCallObject()]), 'Sandbox allows a virtual method routed through __call() when its name is allowed');
}
public function testSandboxUnallowedMagicCallMethod(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ o.hello }}']);
try {
$twig->load('index')->render(['o' => new MagicCallObject()]);
$this->fail('Sandbox throws a SecurityError exception if a virtual method routed through __call() is not allowed');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertEquals(MagicCallObject::class, $e->getClassName());
$this->assertEquals('hello', $e->getPropertyName());
}
}
public function testSandboxUnallowedMagicCallMethodWithMethodSyntax(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ o.hello() }}']);
try {
$twig->load('index')->render(['o' => new MagicCallObject()]);
$this->fail('Sandbox throws a SecurityError exception if a virtual method routed through __call() is not allowed');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(MagicCallObject::class, $e->getClassName());
$this->assertEquals('hello', $e->getMethodName());
}
}
public function testSandboxAllowingCallLiteralDoesNotAllowMagicCallMethod(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ o.hello }}'], [], [], [MagicCallObject::class => '__call']);
try {
$twig->load('index')->render(['o' => new MagicCallObject()]);
$this->fail('Sandbox does not allow every virtual method just because "__call" is allowed');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertEquals(MagicCallObject::class, $e->getClassName());
$this->assertEquals('hello', $e->getPropertyName());
}
}
public function testSandboxFallsBackToMagicCallMethodForUnallowedProperty(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ o.secret }}'], [], [], [MagicCallObject::class => 'secret']);
$this->assertSame('call:secret', $twig->load('index')->render(['o' => new MagicCallObject()]), 'Sandbox falls back to __call() when a real property is not allowed but the method is');
}
#[DataProvider('getSandboxUnallowedToStringTests')]
public function testSandboxUnallowedToString($template): void
{
$twig = $this->getEnvironment(true, [], ['index' => $template], ['if', 'do', 'for', 'set'], ['upper', 'join', 'replace', 'format', 'split'], [FooObject::class => 'getAnotherFooObject'], [], ['random', 'range', 'my_func']);
$twig->addFunction(new TwigFunction('my_func', static fn ($a) => (string) $a));
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed method "__toString()" method is called in the template');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class');
$this->assertEquals('__tostring', $e->getMethodName(), 'Exception should be raised on the "__toString" method');
}
}
public static function getSandboxUnallowedToStringTests()
{
return [
'simple' => ['{{ obj }}'],
'object_from_array' => ['{{ arr.obj }}'],
'object_chain' => ['{{ obj.anotherFooObject }}'],
'filter' => ['{{ obj|upper }}'],
'filter_from_array' => ['{{ arr.obj|upper }}'],
'function' => ['{{ random(obj) }}'],
'function_from_array' => ['{{ random(arr.obj) }}'],
'function_and_filter' => ['{{ random(obj|upper) }}'],
'function_and_filter_from_array' => ['{{ random(arr.obj|upper) }}'],
'object_chain_and_filter' => ['{{ obj.anotherFooObject|upper }}'],
'object_chain_and_function' => ['{{ random(obj.anotherFooObject) }}'],
'concat' => ['{{ obj ~ "" }}'],
'concat_again' => ['{{ "" ~ obj }}'],
'object_in_arguments' => ['{{ "__toString"|replace({"__toString": obj}) }}'],
'object_in_array' => ['{{ [12, "foo", obj]|join(", ") }}'],
'object_in_array_var' => ['{{ some_array|join(", ") }}'],
'object_in_array_nested' => ['{{ [12, "foo", [12, "foo", obj]]|join(", ") }}'],
'object_in_array_var_nested' => ['{{ [12, "foo", some_array]|join(", ") }}'],
'object_in_array_dynamic_key' => ['{{ {(obj): "foo"}|join(", ") }}'],
'object_in_array_dynamic_key_nested' => ['{{ {"foo": { (obj): "foo" }}|join(", ") }}'],
'context' => ['{{ _context|join(", ") }}'],
'spread_array_operator' => ['{{ [1, 2, ...[5, 6, 7, obj]]|join(",") }}'],
'spread_array_operator_var' => ['{{ [1, 2, ...some_array]|join(",") }}'],
'spread_iterator_in_function_args' => ['{{ ["x", ...iterator]|join(",") }}'],
'iterator_in_join' => ['{{ iterator|join(", ") }}'],
'iterator_nested_in_join' => ['{{ iterator_nested|join(", ") }}'],
'iterator_in_replace' => ['{{ "__toString"|replace(iterator_map) }}'],
'recursion' => ['{{ recursion|join(", ") }}'],
'ternary_print' => ['{{ true ? obj : "" }}'],
'ternary_filter_input' => ['{{ (true ? obj : "")|upper }}'],
'elvis_filter_input' => ['{{ (obj ?: "")|upper }}'],
'nullcoalesce_filter_input' => ['{{ (obj ?? "")|upper }}'],
'function_arg_with_ternary' => ['{{ random(true ? obj : "") }}'],
'filter_arg_with_ternary' => ['{{ "%s"|format(true ? obj : "") }}'],
'matches_in_print' => ['{{ obj matches "/foo/" ? "1" : "0" }}'],
'equal_in_print' => ['{{ obj == "x" ? "1" : "0" }}'],
'equal_in_if' => ['{% if obj == "x" %}LEAK{% endif %}'],
'notequal_in_if' => ['{% if obj != "x" %}LEAK{% endif %}'],
'spaceship_in_if' => ['{% if (obj <=> "x") == 0 %}LEAK{% endif %}'],
'less_in_if' => ['{% if obj < "B" %}LEAK{% endif %}'],
'greater_in_if' => ['{% if obj > "A" %}LEAK{% endif %}'],
'lessequal_in_if' => ['{% if obj <= "z" %}LEAK{% endif %}'],
'greaterequal_in_if' => ['{% if obj >= "a" %}LEAK{% endif %}'],
'concat_left_in_if' => ['{% if obj ~ "" %}LEAK{% endif %}'],
'concat_right_in_if' => ['{% if "" ~ obj %}LEAK{% endif %}'],
'range_left' => ['{% for x in obj..1 %}LEAK{% endfor %}'],
'range_right' => ['{% for x in 1..obj %}LEAK{% endfor %}'],
'in_array_right' => ['{% if "needle" in [obj] %}LEAK{% endif %}'],
'in_array_left' => ['{% if obj in ["needle"] %}LEAK{% endif %}'],
'notin_array_right' => ['{% if "needle" not in [obj] %}LEAK{% endif %}'],
'notin_array_left' => ['{% if obj not in ["needle"] %}LEAK{% endif %}'],
'in_iterator_right' => ['{% if "needle" in iterator %}LEAK{% endif %}'],
'notin_iterator_right' => ['{% if "needle" not in iterator %}LEAK{% endif %}'],
'do_tag_function_arg' => ['{% do my_func(obj) %}'],
'do_tag_filter_input' => ['{% do obj|upper %}'],
'do_tag_concat' => ['{% do obj ~ "" %}'],
'set_tag_filter_input' => ['{% set _ = obj|upper %}'],
'set_tag_concat' => ['{% set _ = obj ~ "" %}'],
'set_tag_array_dynamic_key' => ['{% set _ = {(obj): "v"} %}'],
'set_tag_array_dynamic_key_nested' => ['{% set _ = {"foo": {(obj): "v"}} %}'],
'set_tag_array_dynamic_key_object_chain' => ['{% set _ = {(obj.anotherFooObject): "v"} %}'],
'set_capture_print' => ['{% set _ %}{{ obj }}{% endset %}'],
'is_empty_in_if' => ['{% if obj is empty %}LEAK{% endif %}'],
'is_empty_in_print' => ['{{ obj is empty ? "1" : "0" }}'],
'method_argument' => ['{{ obj.foo(obj.anotherFooObject) }}'],
'filter_input_in_if' => ['{% if obj|upper == "X" %}LEAK{% endif %}'],
'filter_arg_in_if' => ['{% if "x"|replace({"x": obj}) == "y" %}LEAK{% endif %}'],
'function_arg_in_if' => ['{% if not random(obj) %}LEAK{% endif %}'],
'filter_input_in_for' => ['{% for x in (obj|split(",")) %}LEAK{% endfor %}'],
'function_arg_in_for' => ['{% for x in [random(obj)] %}LEAK{% endfor %}'],
];
}
public function testSandboxBlocksToStringOnFunctionReturn(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ make_obj() }}'], [], [], [], [], ['make_obj']);
$twig->addFunction(new TwigFunction('make_obj', static fn () => new FooObject()));
try {
$twig->load('index')->render([]);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on the return of an allowed function');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnFilterReturn(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "x"|to_obj }}'], [], ['to_obj']);
$twig->addFilter(new TwigFilter('to_obj', static fn () => new FooObject()));
try {
$twig->load('index')->render([]);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on the return of an allowed filter');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnDynamicAttributeName(): void
{
$twig = $this->getEnvironment(true, ['strict_variables' => true], ['index' => '{{ arr[obj] }}'], [], [], [FooObject::class => 'getAnotherFooObject']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a dynamic attribute name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnDynamicMacroName(): void
{
$twig = $this->getEnvironment(true, [], ['index' => <<<EOF
{% import _self as macros %}
{% macro foo() %}foo{% endmacro %}
{{ macros.(obj)() }}
EOF
], ['import', 'macro']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a dynamic macro name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnIncludeTemplateName(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% include obj %}'], ['include']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on an include template name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnExtendsTemplateName(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% extends obj %}'], ['extends']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on an extends template name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnBlockFunctionTemplateName(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ block("content", obj) }}'], [], [], [], [], ['block']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a block() template argument');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnEmbedTemplateName(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% embed obj %}{% endembed %}'], ['embed', 'extends']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on an embed template name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnIsConstantTestArgument(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% if "x" is constant(obj) %}LEAK{% endif %}'], ['if'], [], [], [], [], ['constant']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a constant test argument');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnDeprecatedMessage(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% deprecated obj %}'], ['deprecated']);
$previous = set_error_handler(static fn () => true, \E_USER_DEPRECATED);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a deprecated tag message');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals(FooObject::class, $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
} finally {
restore_error_handler();
}
}
public function testSandboxKeepsSelfImportShortcut(): void
{
$tpl = "{% macro local_lower(s) %}{{ s|lower }}{% endmacro %}{% from _self import local_lower %}{{ local_lower('A') }}";
$twig = $this->getEnvironment(true, [], ['index' => $tpl], ['from', 'macro', 'import'], ['lower']);
$this->assertSame('a', $twig->load('index')->render([]));
}
#[DataProvider('getSandboxAllowedToStringTests')]
public function testSandboxAllowedToString($template, $output): void
{
$twig = $this->getEnvironment(true, [], ['index' => $template], ['set', 'do'], [], [FooObject::class => ['foo', 'getAnotherFooObject']], [], [], ['constant']);
$this->assertEquals($output, $twig->load('index')->render(self::$params));
}
public static function getSandboxAllowedToStringTests()
{
return [
'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''],
'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'],
'do_object_discarded' => ['{% do obj %}', ''],
'set_object_assigned' => ['{% set a = obj %}{{ a is defined ? "1" : "0" }}', '1'],
'is_defined1' => ['{{ obj.anotherFooObject is defined }}', '1'],
'is_defined2' => ['{{ magic.foo is defined }}', ''],
'is_null' => ['{{ obj is null }}', ''],
'is_sameas' => ['{{ obj is same as(obj) }}', '1'],
'is_sameas_no_brackets' => ['{{ obj is same as obj }}', '1'],
'is_sameas_from_array' => ['{{ arr.obj is same as(arr.obj) }}', '1'],
'is_sameas_from_array_no_brackets' => ['{{ arr.obj is same as arr.obj }}', '1'],
'is_sameas_from_another_method' => ['{{ obj.anotherFooObject is same as(obj.anotherFooObject) }}', ''],
'is_sameas_from_another_method_no_brackets' => ['{{ obj.anotherFooObject is same as obj.anotherFooObject }}', ''],
];
}
public function testSandboxAllowMethodToString(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [FooObject::class => '__toString']);
FooObject::reset();
$this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allow some methods');
$this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once');
}
public function testSandboxAllowsArrayDynamicKeyWhenToStringAllowed(): void
{
$twig = $this->getEnvironment(true, [], [
'index' => '{% set arr = {(obj): "v", (obj.anotherFooObject): "v2"} %}{{ arr|keys|join(",") }}',
], ['set'], ['join', 'keys'], ['Twig\Tests\Extension\FooObject' => ['__toString', 'getAnotherFooObject']]);
$this->assertSame('foo', $twig->load('index')->render(self::$params));
}
public function testSandboxAllowMethodToStringDisabled(): void
{
$twig = $this->getEnvironment(false, [], self::$templates);
FooObject::reset();
$this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allows __toString when sandbox disabled');
$this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once');
}
public function testSandboxAllowsPrintingMarkup(): void
{
$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(): void
{
$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(): void
{
$template = $this->getEnvironment(true, [], ['index' => 'foo'])->load('index')->unwrap();
$policy = new SecurityPolicy();
$this->expectException(SecurityNotAllowedMethodError::class);
$policy->checkMethodAllowed($template, 'getTemplateName');
}
public function testSandboxAppliesThePolicyToMarkupMethods(): void
{
$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(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_basic7')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed function is called in the template');
} catch (SecurityNotAllowedFunctionError $e) {
$this->assertEquals('cycle', $e->getFunctionName(), 'Exception should be raised on the "cycle" function');
}
}
public function testSandboxUnallowedRangeOperator(): void
{
$twig = $this->getEnvironment(true, [], self::$templates);
try {
$twig->load('1_range_operator')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if the unallowed range operator is called');
} catch (SecurityNotAllowedFunctionError $e) {
$this->assertEquals('range', $e->getFunctionName(), 'Exception should be raised on the "range" function');
}
}
public function testSandboxAllowMethodFoo(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [FooObject::class => 'foo']);
FooObject::reset();
$this->assertEquals('foo', $twig->load('1_basic1')->render(self::$params), 'Sandbox allow some methods');
$this->assertEquals(1, FooObject::$called['foo'], 'Sandbox only calls method once');
}
public function testSandboxAllowFilter(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], ['upper']);
$this->assertEquals('FABIEN', $twig->load('1_basic2')->render(self::$params), 'Sandbox allow some filters');
}
public function testSandboxAllowTag(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, ['if']);
$this->assertEquals('foo', $twig->load('1_basic3')->render(self::$params), 'Sandbox allow some tags');
}
public function testSandboxAllowProperty(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [FooObject::class => 'bar']);
$this->assertEquals('bar', $twig->load('1_basic4')->render(self::$params), 'Sandbox allow some properties');
}
public function testSandboxAllowDestructuring(): void
{
$template = '{% do {bar: x, foo: y} = obj %}{{ x }}-{{ y }}';
$twig = $this->getEnvironment(true, [], ['index' => $template], ['do'], [], [FooObject::class => 'foo'], [FooObject::class => 'bar']);
FooObject::reset();
$this->assertSame('bar-foo', $twig->load('index')->render(self::$params), 'Sandbox allows destructuring when properties and methods are allowed');
}
public function testSandboxUnallowedDestructuringProperty(): void
{
$template = '{% do {bar: x} = obj %}{{ x }}';
$twig = $this->getEnvironment(true, [], ['index' => $template], ['do']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed property is read via destructuring');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertSame(FooObject::class, $e->getClassName());
$this->assertSame('bar', $e->getPropertyName());
}
}
public function testSandboxUnallowedDestructuringMethod(): void
{
$template = '{% do {foo: y} = obj %}{{ y }}';
$twig = $this->getEnvironment(true, [], ['index' => $template], ['do'], [], [], [FooObject::class => 'foo']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if an unallowed method is called via destructuring');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame(FooObject::class, $e->getClassName());
$this->assertSame('foo', $e->getMethodName());
}
}
public function testSandboxAllowFunction(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['cycle']);
$this->assertEquals('bar', $twig->load('1_basic7')->render(self::$params), 'Sandbox allow some functions');
}
public function testSandboxAllowRangeOperator(): void
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['range']);
$this->assertEquals('1', $twig->load('1_range_operator')->render(self::$params), 'Sandbox allow the range operator');
}
public function testSandboxAllowMethodsCaseInsensitive(): void
{
foreach (['getfoobar', 'getFoobar', 'getFooBar'] as $name) {
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [FooObject::class => $name]);
FooObject::reset();
$this->assertEquals('foobarfoobar', $twig->load('1_basic8')->render(self::$params), 'Sandbox allow methods in a case-insensitive way');
$this->assertEquals(2, FooObject::$called['getFooBar'], 'Sandbox only calls method once');
$this->assertEquals('foobarfoobar', $twig->load('1_basic9')->render(self::$params), 'Sandbox allow methods via shortcut names (ie. without get/set)');
}
}
public function testSandboxNotUsedForAnInclude(): void
{
self::$templates = [
'2_basic' => '{{ obj.foo }}{% include "2_included" %}{{ obj.foo }}',
'2_included' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
];
$twig = $this->getEnvironment(false, [], self::$templates);
$this->assertEquals('fooFOOfoo', $twig->load('2_basic')->render(self::$params), 'Sandbox does nothing if disabled globally and sandboxed not used for the include');
}
public function testMacrosInASandbox(): void
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{%- import _self as macros %}
{%- macro test(text) %}<p>{{ text }}</p>{% endmacro %}
{{- macros.test('username') }}
EOF
], ['macro', 'import'], ['escape']);
$this->assertEquals('<p>username</p>', $twig->load('index')->render([]));
}
public function testSelfMacroReferenceWithStringLiteralDoesNotInjectPhp(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ _self.(\'foo + 1; trigger_error("BAD-MACRO-REF") //\')() }}']);
$compiled = $twig->compileSource($twig->getLoader()->getSourceContext('index'));
$this->assertStringNotContainsString('trigger_error("BAD-MACRO-REF")', $compiled, 'Attacker-controlled string must not appear raw in compiled PHP source.');
$this->assertStringNotContainsString('->macro_foo + 1;', $compiled, 'No raw injection should reach the generated method-call site.');
$triggered = false;
set_error_handler(static function ($severity, $message) use (&$triggered) {
if (str_contains($message, 'BAD-MACRO-REF')) {
$triggered = true;
}
return true;
}, \E_USER_NOTICE | \E_USER_WARNING);
try {
try {
$twig->load('index')->render([]);
} catch (\Throwable) {
}
} finally {
restore_error_handler();
}
$this->assertFalse($triggered, 'No PHP from the template literal must execute.');
}
public function testImportedTemplateMacroReferenceWithBadIdentifierDoesNotInjectPhp(): void
{
$payload = '{% import "m" as m %}{{ m.(\'foo + 1; trigger_error("BAD-IMPORT-REF") //\')() }}';
$twig = $this->getEnvironment(true, [], [
'index' => $payload,
'm' => '{% macro greet() %}hi{% endmacro %}',
], ['import']);
$compiled = $twig->compileSource($twig->getLoader()->getSourceContext('index'));
$this->assertStringNotContainsString('trigger_error("BAD-IMPORT-REF")', $compiled, 'Attacker-controlled string must not appear raw in compiled PHP source.');
$triggered = false;
set_error_handler(static function ($severity, $message) use (&$triggered) {
if (str_contains($message, 'BAD-IMPORT-REF')) {
$triggered = true;
}
return true;
}, \E_USER_NOTICE | \E_USER_WARNING);
try {
try {
$twig->load('index')->render([]);
} catch (\Throwable) {
}
} finally {
restore_error_handler();
}
$this->assertFalse($triggered, 'No PHP from the template literal must execute.');
}
public function testSelfMacroReferenceWithValidIdentifierStillWorks(): void
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{%- macro greet(n) %}Hi {{ n }}{% endmacro %}
{{- _self.('greet')('World') }}
EOF
], ['macro'], ['escape']);
$this->assertSame('Hi World', $twig->load('index')->render([]));
}
public function testSandboxWithClosureFilter(): void
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{{ ["foo", "bar", ""]|filter(v => v != "")|join(", ") }}
EOF
], [], ['escape', 'filter', 'join']);
$this->assertSame('foo, bar', $twig->load('index')->render([]));
}
public function testMultipleClassMatchesViaInheritanceInAllowedMethods(): void
{
$twig_child_first = $this->getEnvironment(true, [], self::$templates, [], [], [
ChildClass::class => ['ChildMethod'],
ParentClass::class => ['ParentMethod'],
]);
$twig_parent_first = $this->getEnvironment(true, [], self::$templates, [], [], [
ParentClass::class => ['ParentMethod'],
ChildClass::class => ['ChildMethod'],
]);
try {
$twig_child_first->load('1_childobj_childmethod')->render(self::$params);
} catch (SecurityError $e) {
$this->fail('This test case is malfunctioning as even the child class method which comes first is not being allowed.');
}
try {
$twig_parent_first->load('1_childobj_parentmethod')->render(self::$params);
} catch (SecurityError $e) {
$this->fail('This test case is malfunctioning as even the parent class method which comes first is not being allowed.');
}
try {
$twig_parent_first->load('1_childobj_childmethod')->render(self::$params);
} catch (SecurityError $e) {
$this->fail('checkMethodAllowed is exiting prematurely after matching a parent class and not seeing a method allowed on a child class later in the list');
}
try {
$twig_child_first->load('1_childobj_parentmethod')->render(self::$params);
} catch (SecurityError $e) {
$this->fail('checkMethodAllowed is exiting prematurely after matching a child class and not seeing a method allowed on its parent class later in the list');
}
$this->expectNotToPerformAssertions();
}
public function testSandboxAllowsColumnFilterOnAllowedProperty(): void
{
$params = ['obj' => new ColumnObject()];
$twig = $this->getEnvironment(true, [], ['index' => "{{ [obj]|column('bar')|first }}"], [], ['column', 'first'], [], [ColumnObject::class => ['bar']]);
$this->assertSame('bar', $twig->load('index')->render($params));
}
public function testSandboxBlocksColumnFilterOnDisallowedProperty(): void
{
$params = ['obj' => new ColumnObject()];
$twig = $this->getEnvironment(true, [], ['index' => "{{ [obj]|column('bar')|first }}"], [], ['column', 'first']);
try {
$twig->load('index')->render($params);
$this->fail('Sandbox should reject the "column" filter when the requested property is not in allowedProperties');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertSame(ColumnObject::class, $e->getClassName());
$this->assertSame('bar', $e->getPropertyName());
}
}
public function testSandboxBlocksColumnFilterOnDisallowedIndex(): void
{
$params = ['obj' => new ColumnObject()];
$twig = $this->getEnvironment(true, [], ['index' => "{{ [obj]|column('bar', 'foo')|keys|first }}"], [], ['column', 'first', 'keys'], [], [ColumnObject::class => ['bar']]);
try {
$twig->load('index')->render($params);
$this->fail('Sandbox should reject the "column" filter when the index argument targets a disallowed property');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertSame(ColumnObject::class, $e->getClassName());
$this->assertSame('foo', $e->getPropertyName());
}
}
public function testSandboxBlocksColumnFilterOnMagicGetter(): void
{
$params = ['magic' => new MagicObject()];
$twig = $this->getEnvironment(true, [], ['index' => "{{ [magic]|column('anything')|first }}"], [], ['column', 'first']);
try {
$twig->load('index')->render($params);
$this->fail('Sandbox should reject the "column" filter before invoking __get on a non-allowlisted property');
} catch (SecurityNotAllowedPropertyError $e) {
$this->assertSame(MagicObject::class, $e->getClassName());
$this->assertSame('anything', $e->getPropertyName());
}
}
#[DataProvider('getStringableTraversableBypassTemplates')]
public function testSandboxBlocksToStringInStringableTraversable(string $template): void
{
$twig = $this->getEnvironment(
true,
[],
['index' => $template],
[],
['join', 'replace'],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox should block __toString on objects yielded by a Stringable+Traversable container, even when the container\'s own __toString is allowed.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public static function getStringableTraversableBypassTemplates(): iterable
{
yield 'join' => ['{{ stringable_iterator|join(", ") }}'];
yield 'replace' => ['{{ "__toString"|replace(stringable_iterator_map) }}'];
}
public function testSandboxAllowsPrintingStringableTraversableWhenToStringAllowed(): void
{
// Printing the container itself yields its `__toString()` value. The
// sandbox materialises the iterable to also policy-check the elements
// (some consumers like `join`/`replace` would coerce them too), so the
// inner items must not contain anything that violates the policy.
$twig = $this->getEnvironment(
true,
['autoescape' => 'html'],
['index' => '{{ obj }}'],
[],
['escape'],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
$params = ['obj' => new StringableTraversableObject(['a', 'b'])];
$this->assertSame('stringable-traversable', $twig->load('index')->render($params));
}
#[DataProvider('getCyclicTraversableTemplates')]
public function testSandboxHandlesCyclicTraversableWithoutStackOverflow(string $template): void
{
// A self-referencing IteratorAggregate must not cause the sandbox policy
// walker to recurse infinitely when materialising the iterable. PHP itself
// throws a clean error when the cyclic object reaches `implode()` /
// string coercion; the sandbox must NOT turn that into a stack overflow.
$twig = $this->getEnvironment(
true,
[],
['index' => $template],
[],
['join', 'replace'],
);
$this->expectException(RuntimeError::class);
$twig->load('index')->render(['obj' => new CyclicTraversableObject()]);
}
public static function getCyclicTraversableTemplates(): iterable
{
yield 'join' => ['{{ obj|join(",") }}'];
yield 'replace' => ['{{ "x"|replace(obj) }}'];
yield 'spread' => ['{{ ["a", ...obj]|join(",") }}'];
}
public function testSandboxPreservesTraversableArgumentIdentity(): void
{
// Regression for https://github.com/twigphp/Twig/issues/4820:
// a typed Traversable argument (e.g. Symfony's FormView) must reach
// host code as-is, not as a plain array.
$twig = $this->getEnvironment(
true,
[],
['index' => '{{ render_traversable(obj) }}'],
[],
[],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
$twig->addFunction(new TwigFunction('render_traversable', static function ($obj) {
if (!$obj instanceof StringableTraversableObject) {
throw new \RuntimeException(\sprintf('Expected a StringableTraversableObject, got "%s".', get_debug_type($obj)));
}
return (string) $obj;
}));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['render_traversable']);
$params = ['obj' => new StringableTraversableObject(['a', 'b'])];
$this->assertSame('stringable-traversable', $twig->load('index')->render($params));
}
public function testSandboxStillBlocksDisallowedToStringInTraversableArgument(): void
{
// The container is returned as-is, but yielded elements must still
// be policy-checked since host code can string-coerce them.
$twig = $this->getEnvironment(
true,
[],
['index' => '{{ render_traversable(stringable_iterator) }}'],
[],
[],
['Twig\Tests\Extension\StringableTraversableObject' => ['__tostring']],
);
$twig->addFunction(new TwigFunction('render_traversable', static fn ($obj) => (string) $obj));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['render_traversable']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox should block __toString on objects yielded by a Traversable argument to a user function.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
#[DataProvider('getSafePhpTypesSkipToStringWrap')]
public function testSafePhpParamTypesSkipToStringWrap(string $template, callable $func, array $params): void
{
// The sandbox visitor must not wrap arguments whose target PHP
// parameter type cannot implicitly coerce to string (int, float,
// bool, non-Stringable/non-Traversable classes, ...). We observe
// the optimization by passing values whose `__toString` is NOT in
// the policy: with the wrap, the render throws; without it, it
// succeeds.
$twig = $this->getEnvironment(true, [], ['index' => $template]);
$twig->addFunction(new TwigFunction('safe_fn', $func));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['safe_fn']);
$this->assertSame('ok', $twig->load('index')->render($params));
}
public static function getSafePhpTypesSkipToStringWrap(): iterable
{
yield 'int param' => [
'{{ safe_fn(n) }}',
static fn (int $n) => 'ok',
['n' => 42],
];
yield 'float param' => [
'{{ safe_fn(n) }}',
static fn (float $n) => 'ok',
['n' => 3.14],
];
yield 'bool param' => [
'{{ safe_fn(b) }}',
static fn (bool $b) => 'ok',
['b' => true],
];
yield 'non-stringable class param' => [
'{{ safe_fn(obj) }}',
static fn (ColumnObject $o) => 'ok',
['obj' => new ColumnObject()],
];
yield 'nullable int param with null value' => [
'{{ safe_fn(n) }}',
static fn (?int $n) => 'ok',
['n' => null],
];
yield 'int|float union param' => [
'{{ safe_fn(n) }}',
static fn (int|float $n) => 'ok',
['n' => 7],
];
}
#[DataProvider('getUnsafePhpTypesStillWrap')]
public function testUnsafePhpParamTypesStillWrap(string $template, callable $func, array $params): void
{
// Conversely, an unsafe parameter type (`mixed`, untyped, `string`,
// `iterable`, `Stringable`, ...) must keep wrapping arguments so the
// sandbox can still block disallowed `__toString` calls.
$twig = $this->getEnvironment(true, [], ['index' => $template]);
$twig->addFunction(new TwigFunction('unsafe_fn', $func));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['unsafe_fn']);
try {
$twig->load('index')->render($params);
$this->fail('Sandbox should still check __toString when the PHP parameter type can implicitly coerce to string.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public static function getUnsafePhpTypesStillWrap(): iterable
{
$params = ['obj' => new FooObject()];
yield 'untyped param' => ['{{ unsafe_fn(obj) }}', static fn ($x) => (string) $x, $params];
yield 'mixed param' => ['{{ unsafe_fn(obj) }}', static fn (mixed $x) => (string) $x, $params];
yield 'string param' => ['{{ unsafe_fn(obj) }}', static fn (string $x) => $x, $params];
yield 'object param' => ['{{ unsafe_fn(obj) }}', static fn (object $x) => (string) $x, $params];
yield 'Stringable param' => ['{{ unsafe_fn(obj) }}', static fn (\Stringable $x) => (string) $x, $params];
}
#[DataProvider('getOpenPhpTypesStillWrap')]
public function testOpenPhpParamTypesStillWrap(callable $func, object $obj, string $class): void
{
// Interfaces and non-final classes are "open": a Stringable subtype
// can satisfy them, so the sandbox must keep gating __toString.
// Skipping the wrap on these would bypass the policy.
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(obj) }}']);
$twig->addFunction(new TwigFunction('unsafe_fn', $func));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['unsafe_fn']);
try {
$twig->load('index')->render(['obj' => $obj]);
$this->fail('Sandbox must still check __toString for an interface or non-final class parameter.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame($class, $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public static function getOpenPhpTypesStillWrap(): iterable
{
yield 'interface param' => [static fn (\Countable $x) => (string) $x, new CountableFooObject(), CountableFooObject::class];
yield 'non-final class param' => [static fn (PlainBaseObject $x) => (string) $x, new StringablePlainObject(), StringablePlainObject::class];
}
public function testTestArgumentsMapAfterTheTestedValueParameter(): void
{
// A test's tested value is its first PHP parameter, so its template
// arguments must be mapped to the parameters *after* it. Mapping the
// first argument to the (safe-typed) value parameter would skip its
// __toString wrap and bypass the policy.
$twig = $this->getEnvironment(true, [], ['index' => '{{ 5 is my_test(obj) }}']);
$twig->addTest(new TwigTest('my_test', static fn (int $value, $arg) => 'x' === (string) $arg));
$twig->getExtension(SandboxExtension::class)->getSecurityPolicy()->setAllowedTests(['my_test']);
try {
$twig->load('index')->render(['obj' => new FooObject()]);
$this->fail('Sandbox must check __toString on a test argument bound to an unsafe parameter.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testSafeVariadicPhpTypeSkipsToStringWrap(): void
{
// PHP-variadic with a safe type: all spilled arguments skip the wrap.
$twig = $this->getEnvironment(true, [], ['index' => '{{ safe_fn(1, 2, 3) }}']);
$twig->addFunction(new TwigFunction('safe_fn', static fn (int ...$x) => 'ok'));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['safe_fn']);
$this->assertSame('ok', $twig->load('index')->render());
}
public function testSpreadIntoUnsafeVariadicStillWraps(): void
{
// A spread fills an unsafe (untyped) variadic param, so every spilled
// element must keep its __toString wrap.
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(...args) }}']);
$twig->addFunction(new TwigFunction('unsafe_fn', static fn (...$x) => (string) $x[0]));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['unsafe_fn']);
try {
$twig->load('index')->render(['args' => [new FooObject()]]);
$this->fail('Sandbox must check __toString on spread elements bound to an unsafe variadic parameter.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testNormalizedNamedArgumentDoesNotFallThroughToSafeVariadic(): void
{
// The compiler normalizes named arguments (`foo_bar` maps to
// `$fooBar`). The sandbox visitor must use the same mapping and not
// fall back to the safe typed variadic tail, or it would skip the
// __toString check on `$fooBar`.
$twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(foo_bar: obj) }}']);
$twig->addFunction(new TwigFunction('unsafe_fn', static fn ($fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true]));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFunctions(['unsafe_fn']);
try {
$twig->load('index')->render(['obj' => new FooObject()]);
$this->fail('Sandbox must check __toString on normalized named arguments before considering the variadic tail.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testNormalizedNamedFilterArgumentDoesNotFallThroughToSafeVariadic(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1|unsafe_filter(foo_bar: obj) }}']);
$twig->addFilter(new TwigFilter('unsafe_filter', static fn ($value, $fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true]));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFilters(['unsafe_filter']);
try {
$twig->load('index')->render(['obj' => new FooObject()]);
$this->fail('Sandbox must check __toString on normalized named filter arguments before considering the variadic tail.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testNormalizedNamedTestArgumentDoesNotFallThroughToSafeVariadic(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is unsafe_test(foo_bar: obj) ? "yes" : "no" }}']);
$twig->addTest(new TwigTest('unsafe_test', static fn ($value, $fooBar, int ...$rest) => 'x' === (string) $fooBar, ['is_variadic' => true]));
$twig->getExtension(SandboxExtension::class)->getSecurityPolicy()->setAllowedTests(['unsafe_test']);
try {
$twig->load('index')->render(['obj' => new FooObject()]);
$this->fail('Sandbox must check __toString on normalized named test arguments before considering the variadic tail.');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertSame('__tostring', $e->getMethodName());
}
}
public function testFilterInputTypeSkipsToStringWrap(): void
{
// A filter whose first PHP param has a safe type also skips the
// input (`node`) wrap.
$twig = $this->getEnvironment(true, [], ['index' => '{{ n|safe_filter }}']);
$twig->addFilter(new TwigFilter('safe_filter', static fn (int $n) => 'ok'));
$policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy();
$policy->setAllowedFilters(['safe_filter']);
$this->assertSame('ok', $twig->load('index')->render(['n' => 42]));
}
public function testColumnFilterUnaffectedOutsideSandbox(): void
{
$params = ['obj' => new ColumnObject()];
$twig = $this->getEnvironment(false, [], ['index' => "{{ [obj]|column('bar')|first }}"]);
$this->assertSame('bar', $twig->load('index')->render($params));
}
/**
* Kept for forward compatibility with 3.x: code calling setStrict() must keep working.
*/
public function testSetStrictIsANoOp(): void
{
$policy = new SecurityPolicy([], [], [], [], []);
$policy->setStrict(true);
$policy->setStrict(false);
// sandbox rejection is the default and unaffected by setStrict()
$this->expectException(SecurityNotAllowedTagError::class);
$policy->checkSecurity(['extends'], [], [], []);
}
protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [], array $tests = [])
{
$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, $tests);
$twig->addExtension(new SandboxExtension($policy, $sandboxed));
return $twig;
}
public function testNeedsIsSandboxedFilterReceivesTrueWhenSandboxed(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo"|sandbox_aware }}'], [], ['sandbox_aware']);
$twig->addFilter(new TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFilterReceivesFalseWhenNotSandboxed(): void
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo"|sandbox_aware }}']);
$twig->addFilter(new TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFunctionWithoutSandboxExtension(): void
{
$loader = new ArrayLoader(['index' => '{{ sandbox_aware("foo") }}']);
$twig = new Environment($loader, ['debug' => true, 'cache' => false, 'autoescape' => false]);
$twig->addFunction(new TwigFunction('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesTrueWhenSandboxed(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}'], [], [], [], [], [], ['sandbox_aware']);
$twig->addTest(new TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesFalseWhenNotSandboxed(): void
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}']);
$twig->addTest(new TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return !$isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxFilterBypassesAllowList(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|safe_upper }}']);
$twig->addFilter(new TwigFilter('safe_upper', 'strtoupper', ['always_allowed_in_sandbox' => true]));
$this->assertSame('FABIEN', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxFilterStillEnforcedWhenFlagNotSet(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|gated_upper }}']);
$twig->addFilter(new TwigFilter('gated_upper', 'strtoupper'));
$this->expectException(SecurityNotAllowedFilterError::class);
$this->expectExceptionMessage('Filter "gated_upper" is not allowed');
$twig->load('index')->render([]);
}
public function testAlwaysAllowedInSandboxFunctionBypassesAllowList(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ safe_greet("fabien") }}']);
$twig->addFunction(new TwigFunction('safe_greet', static fn (string $name) => "hi $name", ['always_allowed_in_sandbox' => true]));
$this->assertSame('hi fabien', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxFunctionStillEnforcedWhenFlagNotSet(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ gated_greet("fabien") }}']);
$twig->addFunction(new TwigFunction('gated_greet', static fn (string $name) => "hi $name"));
$this->expectException(SecurityNotAllowedFunctionError::class);
$this->expectExceptionMessage('Function "gated_greet" is not allowed');
$twig->load('index')->render([]);
}
public function testAlwaysAllowedInSandboxTestBypassesAllowList(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ 4 is safe_even ? "yes" : "no" }}'], [], [], [], [], []);
$twig->addTest(new TwigTest('safe_even', static fn ($value) => 0 === $value % 2, ['always_allowed_in_sandbox' => true]));
$this->assertSame('yes', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxTestStillEnforcedWhenFlagNotSet(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ 4 is gated_even ? "yes" : "no" }}'], [], [], [], [], []);
$twig->addTest(new TwigTest('gated_even', static fn ($value) => 0 === $value % 2));
$this->expectException(SecurityNotAllowedTestError::class);
$this->expectExceptionMessage('Test "gated_even" is not allowed');
$twig->load('index')->render([]);
}
public function testAlwaysAllowedInSandboxFunctionAlsoCoversRangeOperator(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ (1..2)[0] }}']);
// override the built-in `range` function with one that is always allowed
$twig->addFunction(new TwigFunction('range', 'range', ['always_allowed_in_sandbox' => true]));
$this->assertSame('1', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxFilterFromUndefinedCallbackUsesParsedCallable(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "fabien"|callback_upper }}']);
$callbackCalled = false;
$twig->registerUndefinedFilterCallback(static function (string $name) use (&$callbackCalled) {
if ($callbackCalled || 'callback_upper' !== $name) {
return false;
}
$callbackCalled = true;
return new TwigFilter('callback_upper', 'strtoupper', ['always_allowed_in_sandbox' => true]);
});
$this->assertSame('FABIEN', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxFunctionFromUndefinedCallbackUsesParsedCallable(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ callback_upper("fabien") }}']);
$callbackCalled = false;
$twig->registerUndefinedFunctionCallback(static function (string $name) use (&$callbackCalled) {
if ($callbackCalled || 'callback_upper' !== $name) {
return false;
}
$callbackCalled = true;
return new TwigFunction('callback_upper', 'strtoupper', ['always_allowed_in_sandbox' => true]);
});
$this->assertSame('FABIEN', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxParserCallableFunctionFromUndefinedCallbackUsesParsedCallable(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ callback_literal() }}']);
$callbackCalled = false;
$twig->registerUndefinedFunctionCallback(static function (string $name) use (&$callbackCalled) {
if ($callbackCalled || 'callback_literal' !== $name) {
return false;
}
$callbackCalled = true;
return new TwigFunction('callback_literal', null, [
'always_allowed_in_sandbox' => true,
'parser_callable' => static fn (Parser $parser, Node $node, Nodes $arguments, int $line): ConstantExpression => new ConstantExpression('literal', $line),
]);
});
$this->assertSame('literal', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxTagBypassesAllowList(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% always_allowed_tag %}']);
$twig->addTokenParser(new AlwaysAllowedSandboxTokenParser());
$this->assertSame('always-allowed', $twig->load('index')->render([]));
}
public function testAlwaysAllowedInSandboxTagStillEnforcedWhenFlagNotSet(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{% gated_tag %}']);
$twig->addTokenParser(new GatedSandboxTokenParser());
$this->expectException(SecurityNotAllowedTagError::class);
$this->expectExceptionMessage('Tag "gated_tag" is not allowed');
$twig->load('index')->render([]);
}
public function testAlwaysAllowedInSandboxFilterStillEnforcesToStringPolicyOnArguments(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ obj|safe_upper }}']);
$twig->addFilter(new TwigFilter('safe_upper', static fn (string $s) => strtoupper($s), ['always_allowed_in_sandbox' => true]));
$this->expectException(SecurityNotAllowedMethodError::class);
$this->expectExceptionMessage('Calling "__tostring" method on a "'.FooObject::class.'" object is not allowed');
$twig->load('index')->render(['obj' => new FooObject()]);
}
public function testAlwaysAllowedInSandboxFunctionStillEnforcesToStringPolicyOnArguments(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ safe_greet(obj) }}']);
$twig->addFunction(new TwigFunction('safe_greet', static fn (string $s) => "hi $s", ['always_allowed_in_sandbox' => true]));
$this->expectException(SecurityNotAllowedMethodError::class);
$this->expectExceptionMessage('Calling "__tostring" method on a "'.FooObject::class.'" object is not allowed');
$twig->load('index')->render(['obj' => new FooObject()]);
}
}
class ParentClass
{
public function ParentMethod(): void
{
}
}
class ChildClass extends ParentClass
{
public function ChildMethod(): void
{
}
}
class FooObject
{
public static $called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0];
public $bar = 'bar';
public static function reset(): void
{
self::$called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0];
}
public function __toString()
{
++self::$called['__toString'];
return 'foo';
}
public function foo()
{
++self::$called['foo'];
return 'foo';
}
public function getFooBar()
{
++self::$called['getFooBar'];
return 'foobar';
}
public function getAnotherFooObject()
{
return new self();
}
}
class ArrayLikeObject extends \ArrayObject
{
public function offsetExists($offset): bool
{
throw new \BadMethodCallException('Should not be called.');
}
public function offsetGet($offset): mixed
{
throw new \BadMethodCallException('Should not be called.');
}
public function offsetSet($offset, $value): void
{
}
public function offsetUnset($offset): void
{
}
}
class MagicObject
{
public function __get($name): mixed
{
throw new \BadMethodCallException(\sprintf('__get(%s) should not be called inside the sandbox.', $name));
}
public function __isset($name): bool
{
throw new \BadMethodCallException(\sprintf('__isset(%s) should not be called inside the sandbox.', $name));
}
}
class MagicCallObject
{
public $secret = 'secret';
public function __call($name, $arguments)
{
return 'call:'.$name;
}
}
// Plain object without __toString: column tests exercise property access, not
// string coercion, so the array elements must not be Stringable to avoid
// triggering the generic filter-input __toString sandbox check.
class ColumnObject
{
public $bar = 'bar';
}
class CountableFooObject extends FooObject implements \Countable
{
public function count(): int
{
return 0;
}
}
class PlainBaseObject
{
}
class StringablePlainObject extends PlainBaseObject implements \Stringable
{
public function __toString(): string
{
return 'plain';
}
}
// Implements both Stringable and Traversable: a sandbox policy may legitimately
// allow the container's own `__toString`, but the elements yielded by
// `getIterator()` must still be policy-checked when consumers (`join`, `replace`,
// ...) materialise the iterable and coerce its contents to string.
class StringableTraversableObject implements \IteratorAggregate, \Stringable
{
public function __construct(private array $items)
{
}
public function __toString(): string
{
return 'stringable-traversable';
}
public function getIterator(): \Traversable
{
yield from $this->items;
}
}
// Self-referencing IteratorAggregate: getIterator() yields `$this`. Used to
// verify that the sandbox policy walker (which materialises Traversables to
// enforce the `__toString` policy on yielded elements) does not recurse
// infinitely.
class CyclicTraversableObject implements \IteratorAggregate
{
public function getIterator(): \Traversable
{
yield $this;
}
}
class AlwaysAllowedSandboxTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
return new TextNode('always-allowed', $token->getLine());
}
public function getTag(): string
{
return 'always_allowed_tag';
}
public function isAlwaysAllowedInSandbox(): bool
{
return true;
}
}
class GatedSandboxTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
return new TextNode('gated', $token->getLine());
}
public function getTag(): string
{
return 'gated_tag';
}
}
class DenyEverythingSecurityPolicy implements SecurityPolicyInterface
{
public function checkSecurity($tags, $filters, $functions, array $tests): 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);
}
}