1<?php
2
3/*
4 * This file is part of Twig.
5 *
6 * (c) Fabien Potencier
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12use Twig\Environment;
13use Twig\Extension\SandboxExtension;
14use Twig\Loader\ArrayLoader;
15use Twig\Sandbox\SecurityError;
16use Twig\Sandbox\SecurityPolicy;
17
18class Twig_Tests_Extension_SandboxTest extends \PHPUnit\Framework\TestCase
19{
20    protected static $params;
21    protected static $templates;
22
23    protected function setUp()
24    {
25        self::$params = [
26            'name' => 'Fabien',
27            'obj' => new FooObject(),
28            'arr' => ['obj' => new FooObject()],
29        ];
30
31        self::$templates = [
32            '1_basic1' => '{{ obj.foo }}',
33            '1_basic2' => '{{ name|upper }}',
34            '1_basic3' => '{% if name %}foo{% endif %}',
35            '1_basic4' => '{{ obj.bar }}',
36            '1_basic5' => '{{ obj }}',
37            '1_basic7' => '{{ cycle(["foo","bar"], 1) }}',
38            '1_basic8' => '{{ obj.getfoobar }}{{ obj.getFooBar }}',
39            '1_basic9' => '{{ obj.foobar }}{{ obj.fooBar }}',
40            '1_basic' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
41            '1_layout' => '{% block content %}{% endblock %}',
42            '1_child' => "{% extends \"1_layout\" %}\n{% block content %}\n{{ \"a\"|json_encode }}\n{% endblock %}",
43            '1_include' => '{{ include("1_basic1", sandboxed=true) }}',
44            '1_range_operator' => '{{ (1..2)[0] }}',
45        ];
46    }
47
48    /**
49     * @expectedException        \Twig\Sandbox\SecurityError
50     * @expectedExceptionMessage Filter "json_encode" is not allowed in "1_child" at line 3.
51     */
52    public function testSandboxWithInheritance()
53    {
54        $twig = $this->getEnvironment(true, [], self::$templates, ['block']);
55        $twig->load('1_child')->render([]);
56    }
57
58    public function testSandboxGloballySet()
59    {
60        $twig = $this->getEnvironment(false, [], self::$templates);
61        $this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params), 'Sandbox does nothing if it is disabled globally');
62    }
63
64    public function testSandboxUnallowedMethodAccessor()
65    {
66        $twig = $this->getEnvironment(true, [], self::$templates);
67        try {
68            $twig->load('1_basic1')->render(self::$params);
69            $this->fail('Sandbox throws a SecurityError exception if an unallowed method is called');
70        } catch (SecurityError $e) {
71            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedMethodError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedMethodError');
72            $this->assertEquals('FooObject', $e->getClassName(), 'Exception should be raised on the "FooObject" class');
73            $this->assertEquals('foo', $e->getMethodName(), 'Exception should be raised on the "foo" method');
74        }
75    }
76
77    public function testSandboxUnallowedFilter()
78    {
79        $twig = $this->getEnvironment(true, [], self::$templates);
80        try {
81            $twig->load('1_basic2')->render(self::$params);
82            $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called');
83        } catch (SecurityError $e) {
84            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFilterError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFilterError');
85            $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter');
86        }
87    }
88
89    public function testSandboxUnallowedTag()
90    {
91        $twig = $this->getEnvironment(true, [], self::$templates);
92        try {
93            $twig->load('1_basic3')->render(self::$params);
94            $this->fail('Sandbox throws a SecurityError exception if an unallowed tag is used in the template');
95        } catch (SecurityError $e) {
96            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedTagError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedTagError');
97            $this->assertEquals('if', $e->getTagName(), 'Exception should be raised on the "if" tag');
98        }
99    }
100
101    public function testSandboxUnallowedProperty()
102    {
103        $twig = $this->getEnvironment(true, [], self::$templates);
104        try {
105            $twig->load('1_basic4')->render(self::$params);
106            $this->fail('Sandbox throws a SecurityError exception if an unallowed property is called in the template');
107        } catch (SecurityError $e) {
108            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedPropertyError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedPropertyError');
109            $this->assertEquals('FooObject', $e->getClassName(), 'Exception should be raised on the "FooObject" class');
110            $this->assertEquals('bar', $e->getPropertyName(), 'Exception should be raised on the "bar" property');
111        }
112    }
113
114    /**
115     * @dataProvider getSandboxUnallowedToStringTests
116     */
117    public function testSandboxUnallowedToString($template)
118    {
119        $twig = $this->getEnvironment(true, [], ['index' => $template], [], ['upper'], ['FooObject' => 'getAnotherFooObject'], [], ['random']);
120        try {
121            $twig->load('index')->render(self::$params);
122            $this->fail('Sandbox throws a SecurityError exception if an unallowed method (__toString()) is called in the template');
123        } catch (SecurityError $e) {
124            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedMethodError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedMethodError');
125            $this->assertEquals('FooObject', $e->getClassName(), 'Exception should be raised on the "FooObject" class');
126            $this->assertEquals('__tostring', $e->getMethodName(), 'Exception should be raised on the "__toString" method');
127        }
128    }
129
130    public function getSandboxUnallowedToStringTests()
131    {
132        return [
133            'simple' => ['{{ obj }}'],
134            'object_from_array' => ['{{ arr.obj }}'],
135            'object_chain' => ['{{ obj.anotherFooObject }}'],
136            'filter' => ['{{ obj|upper }}'],
137            'filter_from_array' => ['{{ arr.obj|upper }}'],
138            'function' => ['{{ random(obj) }}'],
139            'function_from_array' => ['{{ random(arr.obj) }}'],
140            'function_and_filter' => ['{{ random(obj|upper) }}'],
141            'function_and_filter_from_array' => ['{{ random(arr.obj|upper) }}'],
142            'object_chain_and_filter' => ['{{ obj.anotherFooObject|upper }}'],
143            'object_chain_and_function' => ['{{ random(obj.anotherFooObject) }}'],
144            'concat' => ['{{ obj ~ "" }}'],
145            'concat_again' => ['{{ "" ~ obj }}'],
146        ];
147    }
148
149    /**
150     * @dataProvider getSandboxAllowedToStringTests
151     */
152    public function testSandboxAllowedToString($template, $output)
153    {
154        $twig = $this->getEnvironment(true, [], ['index' => $template], ['set'], [], ['FooObject' => ['foo', 'getAnotherFooObject']]);
155        $this->assertEquals($output, $twig->load('index')->render(self::$params));
156    }
157
158    public function getSandboxAllowedToStringTests()
159    {
160        return [
161            'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''],
162            'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'],
163            'is_defined' => ['{{ obj.anotherFooObject is defined }}', '1'],
164            'is_null' => ['{{ obj is null }}', ''],
165            'is_sameas' => ['{{ obj is same as(obj) }}', '1'],
166            'is_sameas_from_array' => ['{{ arr.obj is same as(arr.obj) }}', '1'],
167            'is_sameas_from_another_method' => ['{{ obj.anotherFooObject is same as(obj.anotherFooObject) }}', ''],
168        ];
169    }
170
171    public function testSandboxAllowMethodToString()
172    {
173        $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['FooObject' => '__toString']);
174        FooObject::reset();
175        $this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allow some methods');
176        $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once');
177    }
178
179    public function testSandboxAllowMethodToStringDisabled()
180    {
181        $twig = $this->getEnvironment(false, [], self::$templates);
182        FooObject::reset();
183        $this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allows __toString when sandbox disabled');
184        $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once');
185    }
186
187    public function testSandboxUnallowedFunction()
188    {
189        $twig = $this->getEnvironment(true, [], self::$templates);
190        try {
191            $twig->load('1_basic7')->render(self::$params);
192            $this->fail('Sandbox throws a SecurityError exception if an unallowed function is called in the template');
193        } catch (SecurityError $e) {
194            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFunctionError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFunctionError');
195            $this->assertEquals('cycle', $e->getFunctionName(), 'Exception should be raised on the "cycle" function');
196        }
197    }
198
199    public function testSandboxUnallowedRangeOperator()
200    {
201        $twig = $this->getEnvironment(true, [], self::$templates);
202        try {
203            $twig->load('1_range_operator')->render(self::$params);
204            $this->fail('Sandbox throws a SecurityError exception if the unallowed range operator is called');
205        } catch (SecurityError $e) {
206            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFunctionError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFunctionError');
207            $this->assertEquals('range', $e->getFunctionName(), 'Exception should be raised on the "range" function');
208        }
209    }
210
211    public function testSandboxAllowMethodFoo()
212    {
213        $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['FooObject' => 'foo']);
214        FooObject::reset();
215        $this->assertEquals('foo', $twig->load('1_basic1')->render(self::$params), 'Sandbox allow some methods');
216        $this->assertEquals(1, FooObject::$called['foo'], 'Sandbox only calls method once');
217    }
218
219    public function testSandboxAllowFilter()
220    {
221        $twig = $this->getEnvironment(true, [], self::$templates, [], ['upper']);
222        $this->assertEquals('FABIEN', $twig->load('1_basic2')->render(self::$params), 'Sandbox allow some filters');
223    }
224
225    public function testSandboxAllowTag()
226    {
227        $twig = $this->getEnvironment(true, [], self::$templates, ['if']);
228        $this->assertEquals('foo', $twig->load('1_basic3')->render(self::$params), 'Sandbox allow some tags');
229    }
230
231    public function testSandboxAllowProperty()
232    {
233        $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], ['FooObject' => 'bar']);
234        $this->assertEquals('bar', $twig->load('1_basic4')->render(self::$params), 'Sandbox allow some properties');
235    }
236
237    public function testSandboxAllowFunction()
238    {
239        $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['cycle']);
240        $this->assertEquals('bar', $twig->load('1_basic7')->render(self::$params), 'Sandbox allow some functions');
241    }
242
243    public function testSandboxAllowRangeOperator()
244    {
245        $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['range']);
246        $this->assertEquals('1', $twig->load('1_range_operator')->render(self::$params), 'Sandbox allow the range operator');
247    }
248
249    public function testSandboxAllowFunctionsCaseInsensitive()
250    {
251        foreach (['getfoobar', 'getFoobar', 'getFooBar'] as $name) {
252            $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['FooObject' => $name]);
253            FooObject::reset();
254            $this->assertEquals('foobarfoobar', $twig->load('1_basic8')->render(self::$params), 'Sandbox allow methods in a case-insensitive way');
255            $this->assertEquals(2, FooObject::$called['getFooBar'], 'Sandbox only calls method once');
256
257            $this->assertEquals('foobarfoobar', $twig->load('1_basic9')->render(self::$params), 'Sandbox allow methods via shortcut names (ie. without get/set)');
258        }
259    }
260
261    public function testSandboxLocallySetForAnInclude()
262    {
263        self::$templates = [
264            '2_basic' => '{{ obj.foo }}{% include "2_included" %}{{ obj.foo }}',
265            '2_included' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
266        ];
267
268        $twig = $this->getEnvironment(false, [], self::$templates);
269        $this->assertEquals('fooFOOfoo', $twig->load('2_basic')->render(self::$params), 'Sandbox does nothing if disabled globally and sandboxed not used for the include');
270
271        self::$templates = [
272            '3_basic' => '{{ obj.foo }}{% sandbox %}{% include "3_included" %}{% endsandbox %}{{ obj.foo }}',
273            '3_included' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}',
274        ];
275
276        $twig = $this->getEnvironment(true, [], self::$templates);
277        try {
278            $twig->load('3_basic')->render(self::$params);
279            $this->fail('Sandbox throws a SecurityError exception when the included file is sandboxed');
280        } catch (SecurityError $e) {
281            $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedTagError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedTagError');
282            $this->assertEquals('sandbox', $e->getTagName());
283        }
284    }
285
286    public function testMacrosInASandbox()
287    {
288        $twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<<EOF
289{%- import _self as macros %}
290
291{%- macro test(text) %}<p>{{ text }}</p>{% endmacro %}
292
293{{- macros.test('username') }}
294EOF
295        ], ['macro', 'import'], ['escape']);
296
297        $this->assertEquals('<p>username</p>', $twig->load('index')->render([]));
298    }
299
300    public function testSandboxDisabledAfterIncludeFunctionError()
301    {
302        $twig = $this->getEnvironment(false, [], self::$templates);
303
304        $e = null;
305        try {
306            $twig->load('1_include')->render(self::$params);
307        } catch (\Throwable $e) {
308        } catch (\Exception $e) {
309        }
310        if (null === $e) {
311            $this->fail('An exception should be thrown for this test to be valid.');
312        }
313
314        $this->assertFalse($twig->getExtension('\Twig\Extension\SandboxExtension')->isSandboxed(), 'Sandboxed include() function call should not leave Sandbox enabled when an error occurs.');
315    }
316
317    protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [])
318    {
319        $loader = new ArrayLoader($templates);
320        $twig = new Environment($loader, array_merge(['debug' => true, 'cache' => false, 'autoescape' => false], $options));
321        $policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions);
322        $twig->addExtension(new SandboxExtension($policy, $sandboxed));
323
324        return $twig;
325    }
326}
327
328class FooObject
329{
330    public static $called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0];
331
332    public $bar = 'bar';
333
334    public static function reset()
335    {
336        self::$called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0];
337    }
338
339    public function __toString()
340    {
341        ++self::$called['__toString'];
342
343        return 'foo';
344    }
345
346    public function foo()
347    {
348        ++self::$called['foo'];
349
350        return 'foo';
351    }
352
353    public function getFooBar()
354    {
355        ++self::$called['getFooBar'];
356
357        return 'foobar';
358    }
359
360    public function getAnotherFooObject()
361    {
362        return new self();
363    }
364}
365