'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' => <<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('safe', $twig->load('index')->render(['markup' => new Markup('safe', '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('safe', $twig->load('index')->render(['markup' => new Markup('safe', '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('safe', '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' => <<{{ text }}
{% endmacro %}
{{- macros.test('username') }}
EOF
], ['macro', 'import'], ['escape']);
$this->assertEquals('username
', $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' => <<assertSame('Hi World', $twig->load('index')->render([]));
}
public function testSandboxWithClosureFilter(): void
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => << 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);
}
}