'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); } }