Files
Twig/tests/Extension/SandboxTest.php
T
2026-06-02 14:10:55 +02:00

1557 lines
68 KiB
PHP

<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests\Extension;
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Extension\SandboxExtension;
use Twig\Extension\StringLoaderExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityError;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Source;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
class SandboxTest extends TestCase
{
protected static $params;
protected static $templates;
protected function setUp(): void
{
self::$params = [
'name' => 'Fabien',
'obj' => new FooObject(),
'arr' => ['obj' => new FooObject()],
'child_obj' => new ChildClass(),
'some_array' => [5, 6, 7, new FooObject()],
'array_like' => new ArrayLikeObject(),
'magic' => new MagicObject(),
'recursion' => [4],
'iterator' => new \ArrayIterator(['a', new FooObject()]),
'iterator_map' => new \ArrayIterator(['__toString' => new FooObject()]),
'iterator_nested' => new \ArrayIterator(['a', new \ArrayIterator(['b', new FooObject()])]),
'stringable_iterator' => new StringableTraversableObject(['a', new FooObject()]),
'stringable_iterator_map' => new StringableTraversableObject(['__toString' => new FooObject()]),
];
self::$params['recursion'][] = &self::$params['recursion'];
self::$params['recursion'][] = new FooObject();
self::$templates = [
'1_basic1' => '{{ obj.foo }}',
'1_basic2' => '{{ name|upper }}',
'1_basic3' => '{% if name %}foo{% endif %}',
'1_basic4' => '{{ obj.bar }}',
'1_basic5' => '{{ obj }}',
'1_basic7' => '{{ cycle(["foo","bar"], 1) }}',
'1_basic8' => '{{ obj.getfoobar }}{{ obj.getFooBar }}',
'1_basic9' => '{{ obj.foobar }}{{ obj.fooBar }}',
'1_basic' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
'1_layout' => '{% block content %}{% endblock %}',
'1_child' => "{% extends \"1_layout\" %}\n{% block content %}\n{{ \"a\"|json_encode }}\n{% endblock %}",
'1_include' => '{{ include("1_basic1", sandboxed=true) }}',
'1_basic2_include_template_from_string_sandboxed' => '{{ include(template_from_string("{{ name|upper }}"), sandboxed=true) }}',
'1_basic2_include_template_from_string' => '{{ include(template_from_string("{{ name|upper }}")) }}',
'1_range_operator' => '{{ (1..2)[0] }}',
'1_syntax_error_wrapper' => '{{ include("1_syntax_error", sandboxed: true) }}',
'1_syntax_error' => '{% syntax error }}',
'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)
{
$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)
{
$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)
{
$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',
];
}
public function testSandboxWithInheritance()
{
$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()
{
$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()
{
$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()
{
$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)
{
$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 testIfSandBoxIsDisabledAfterSyntaxError()
{
$twig = $this->getEnvironment(false, [], self::$templates);
try {
$twig->load('1_syntax_error_wrapper')->render(self::$params);
} catch (SyntaxError $e) {
/** @var SandboxExtension $sandbox */
$sandbox = $twig->getExtension(SandboxExtension::class);
$this->assertFalse($sandbox->isSandboxed());
}
}
public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromStringSandboxed()
{
$twig = $this->getEnvironment(false, [], self::$templates);
$twig->addExtension(new StringLoaderExtension());
try {
$twig->load('1_basic2_include_template_from_string_sandboxed')->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 testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringSandboxed()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']);
$twig->addExtension(new StringLoaderExtension());
try {
$twig->load('1_basic2_include_template_from_string_sandboxed')->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 testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromStringNotSandboxed()
{
$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()
{
$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()
{
$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()
{
$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()
{
$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');
}
}
#[DataProvider('getSandboxUnallowedToStringTests')]
public function testSandboxUnallowedToString($template)
{
$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()
{
$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()
{
$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()
{
$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 testSandboxBlocksToStringOnIncludeTemplateName()
{
$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()
{
$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()
{
$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()
{
$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()
{
$twig = $this->getEnvironment(true, [], ['index' => '{% if "x" is constant(obj) %}LEAK{% endif %}'], ['if']);
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()
{
$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()
{
$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)
{
$twig = $this->getEnvironment(true, [], ['index' => $template], ['set', 'do'], [], [FooObject::class => ['foo', 'getAnotherFooObject']]);
$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()
{
$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()
{
$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()
{
$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 testSandboxUnallowedFunction()
{
$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()
{
$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()
{
$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()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], ['upper']);
$this->assertEquals('FABIEN', $twig->load('1_basic2')->render(self::$params), 'Sandbox allow some filters');
}
public function testSandboxAllowTag()
{
$twig = $this->getEnvironment(true, [], self::$templates, ['if']);
$this->assertEquals('foo', $twig->load('1_basic3')->render(self::$params), 'Sandbox allow some tags');
}
public function testSandboxAllowProperty()
{
$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()
{
$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()
{
$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()
{
$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()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['cycle']);
$this->assertEquals('bar', $twig->load('1_basic7')->render(self::$params), 'Sandbox allow some functions');
}
public function testSandboxAllowRangeOperator()
{
$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()
{
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 testSandboxLocallySetForAnInclude()
{
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');
self::$templates = [
'3_basic' => '{{ include("3_included", sandboxed: true) }}',
'3_included' => '{% if true %}{{ "foo"|upper }}{% endif %}',
];
$twig = $this->getEnvironment(true, [], self::$templates, functions: ['include']);
try {
$twig->load('3_basic')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception when the included file is sandboxed');
} catch (SecurityNotAllowedTagError $e) {
$this->assertEquals('if', $e->getTagName());
}
}
public function testMacrosInASandbox()
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{%- import _self as macros %}
{%- macro test(text) %}<p>{{ text }}</p>{% endmacro %}
{{- macros.test('username') }}
EOF
], ['macro', 'import'], ['escape']);
$this->assertEquals('<p>username</p>', $twig->load('index')->render([]));
}
public function testSelfMacroReferenceWithStringLiteralDoesNotInjectPhp()
{
$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()
{
$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()
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{%- macro greet(n) %}Hi {{ n }}{% endmacro %}
{{- _self.('greet')('World') }}
EOF
], ['macro'], ['escape']);
$this->assertSame('Hi World', $twig->load('index')->render([]));
}
public function testSandboxDisabledAfterIncludeFunctionError()
{
$twig = $this->getEnvironment(false, [], self::$templates);
$e = null;
try {
$twig->load('1_include')->render(self::$params);
} catch (\Throwable $e) {
}
if (null === $e) {
$this->fail('An exception should be thrown for this test to be valid.');
}
$this->assertFalse($twig->getExtension(SandboxExtension::class)->isSandboxed(), 'Sandboxed include() function call should not leave Sandbox enabled when an error occurs.');
}
public function testSandboxWithClosureFilter()
{
$twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
{{ ["foo", "bar", ""]|filter(v => v != "")|join(", ") }}
EOF
], [], ['escape', 'filter', 'join']);
$this->assertSame('foo, bar', $twig->load('index')->render([]));
}
public function testMultipleClassMatchesViaInheritanceInAllowedMethods()
{
$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()
{
$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()
{
$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()
{
$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()
{
$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)
{
$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()
{
// 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)
{
// 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()
{
// 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()
{
// 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));
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]));
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()
{
$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()
{
$policy = new SecurityPolicy([], [], [], [], []);
$policy->setStrict(true);
$policy->setStrict(false);
// 4.0 behavior 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 = [])
{
$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);
$twig->addExtension(new SandboxExtension($policy, $sandboxed));
return $twig;
}
public function testNeedsIsSandboxedFilterReceivesTrueWhenSandboxed()
{
$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()
{
$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()
{
$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()
{
$twig = $this->getEnvironment(true, [], ['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 testNeedsIsSandboxedTestReceivesFalseWhenNotSandboxed()
{
$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([]));
}
}
class ParentClass
{
public function ParentMethod()
{
}
}
class ChildClass extends ParentClass
{
public function ChildMethod()
{
}
}
class FooObject
{
public static $called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0];
public $bar = 'bar';
public static function reset()
{
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));
}
}
// 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;
}
}