Skip to content

Commit ec39a9d

Browse files
nicolas-grekasfabpot
authored andcommittedNov 6, 2024
Sandbox ArrayAccess and do sandbox checks before isset() checks
1 parent cafc608 commit ec39a9d

File tree

4 files changed

+155
-19
lines changed

4 files changed

+155
-19
lines changed
 

‎doc/api.rst

+9
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,15 @@ able to call the ``getTitle()`` and ``getBody()`` methods on ``Article``
486486
objects, and the ``title`` and ``body`` public properties. Everything else
487487
won't be allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception.
488488

489+
.. note::
490+
491+
As of Twig 1.14.1 (and on Twig 3.11.2), if the ``Article`` class implements
492+
the ``ArrayAccess`` interface, the templates will only be able to access
493+
the ``title`` and ``body`` attributes.
494+
495+
Note that native array-like classes (like ``ArrayObject``) are always
496+
allowed, you don't need to configure them.
497+
489498
The policy object is the first argument of the sandbox constructor::
490499

491500
$sandbox = new \Twig\Extension\SandboxExtension($policy);

‎src/Extension/CoreExtension.php

+56-8
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
use Twig\Node\Expression\Unary\NotUnary;
5858
use Twig\Node\Expression\Unary\PosUnary;
5959
use Twig\NodeVisitor\MacroAutoImportNodeVisitor;
60+
use Twig\Sandbox\SecurityNotAllowedMethodError;
61+
use Twig\Sandbox\SecurityNotAllowedPropertyError;
6062
use Twig\Source;
6163
use Twig\Template;
6264
use Twig\TemplateWrapper;
@@ -82,6 +84,20 @@
8284

8385
final class CoreExtension extends AbstractExtension
8486
{
87+
public const ARRAY_LIKE_CLASSES = [
88+
'ArrayIterator',
89+
'ArrayObject',
90+
'CachingIterator',
91+
'RecursiveArrayIterator',
92+
'RecursiveCachingIterator',
93+
'SplDoublyLinkedList',
94+
'SplFixedArray',
95+
'SplObjectStorage',
96+
'SplQueue',
97+
'SplStack',
98+
'WeakMap',
99+
];
100+
85101
private $dateFormats = ['F j, Y H:i', '%d days'];
86102
private $numberFormat = [0, '.', ','];
87103
private $timezone = null;
@@ -1549,10 +1565,20 @@ public static function batch($items, $size, $fill = null, $preserveKeys = true):
15491565
*/
15501566
public static function getAttribute(Environment $env, Source $source, $object, $item, array $arguments = [], $type = /* Template::ANY_CALL */ 'any', $isDefinedTest = false, $ignoreStrictCheck = false, $sandboxed = false, int $lineno = -1)
15511567
{
1568+
$propertyNotAllowedError = null;
1569+
15521570
// array
15531571
if (/* Template::METHOD_CALL */ 'method' !== $type) {
15541572
$arrayItem = \is_bool($item) || \is_float($item) ? (int) $item : $item;
15551573

1574+
if ($sandboxed && $object instanceof \ArrayAccess && !\in_array($object::class, self::ARRAY_LIKE_CLASSES, true)) {
1575+
try {
1576+
$env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $arrayItem, $lineno, $source);
1577+
} catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) {
1578+
goto methodCheck;
1579+
}
1580+
}
1581+
15561582
if (((\is_array($object) || $object instanceof \ArrayObject) && (isset($object[$arrayItem]) || \array_key_exists($arrayItem, (array) $object)))
15571583
|| ($object instanceof \ArrayAccess && isset($object[$arrayItem]))
15581584
) {
@@ -1624,19 +1650,25 @@ public static function getAttribute(Environment $env, Source $source, $object, $
16241650

16251651
// object property
16261652
if (/* Template::METHOD_CALL */ 'method' !== $type) {
1653+
if ($sandboxed) {
1654+
try {
1655+
$env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source);
1656+
} catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) {
1657+
goto methodCheck;
1658+
}
1659+
}
1660+
16271661
if (isset($object->$item) || \array_key_exists((string) $item, (array) $object)) {
16281662
if ($isDefinedTest) {
16291663
return true;
16301664
}
16311665

1632-
if ($sandboxed) {
1633-
$env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source);
1634-
}
1635-
16361666
return $object->$item;
16371667
}
16381668
}
16391669

1670+
methodCheck:
1671+
16401672
static $cache = [];
16411673

16421674
$class = \get_class($object);
@@ -1695,19 +1727,35 @@ public static function getAttribute(Environment $env, Source $source, $object, $
16951727
return false;
16961728
}
16971729

1730+
if ($propertyNotAllowedError) {
1731+
throw $propertyNotAllowedError;
1732+
}
1733+
16981734
if ($ignoreStrictCheck || !$env->isStrictVariables()) {
16991735
return;
17001736
}
17011737

17021738
throw new RuntimeError(\sprintf('Neither the property "%1$s" nor one of the methods "%1$s()", "get%1$s()"/"is%1$s()"/"has%1$s()" or "__call()" exist and have public access in class "%2$s".', $item, $class), $lineno, $source);
17031739
}
17041740

1705-
if ($isDefinedTest) {
1706-
return true;
1741+
if ($sandboxed) {
1742+
try {
1743+
$env->getExtension(SandboxExtension::class)->checkMethodAllowed($object, $method, $lineno, $source);
1744+
} catch (SecurityNotAllowedMethodError $e) {
1745+
if ($isDefinedTest) {
1746+
return false;
1747+
}
1748+
1749+
if ($propertyNotAllowedError) {
1750+
throw $propertyNotAllowedError;
1751+
}
1752+
1753+
throw $e;
1754+
}
17071755
}
17081756

1709-
if ($sandboxed) {
1710-
$env->getExtension(SandboxExtension::class)->checkMethodAllowed($object, $method, $lineno, $source);
1757+
if ($isDefinedTest) {
1758+
return true;
17111759
}
17121760

17131761
// Some objects throw exceptions when they have __call, and the method we try

‎src/Node/Expression/GetAttrExpression.php

+28-5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function __construct(AbstractExpression $node, AbstractExpression $attrib
3131
public function compile(Compiler $compiler): void
3232
{
3333
$env = $compiler->getEnvironment();
34+
$arrayAccessSandbox = false;
3435

3536
// optimize array calls
3637
if (
@@ -44,17 +45,35 @@ public function compile(Compiler $compiler): void
4445
->raw('(('.$var.' = ')
4546
->subcompile($this->getNode('node'))
4647
->raw(') && is_array(')
47-
->raw($var)
48+
->raw($var);
49+
50+
if (!$env->hasExtension(SandboxExtension::class)) {
51+
$compiler
52+
->raw(') || ')
53+
->raw($var)
54+
->raw(' instanceof ArrayAccess ? (')
55+
->raw($var)
56+
->raw('[')
57+
->subcompile($this->getNode('attribute'))
58+
->raw('] ?? null) : null)')
59+
;
60+
61+
return;
62+
}
63+
64+
$arrayAccessSandbox = true;
65+
66+
$compiler
4867
->raw(') || ')
4968
->raw($var)
50-
->raw(' instanceof ArrayAccess ? (')
69+
->raw(' instanceof ArrayAccess && in_array(')
70+
->raw($var.'::class')
71+
->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (')
5172
->raw($var)
5273
->raw('[')
5374
->subcompile($this->getNode('attribute'))
54-
->raw('] ?? null) : null)')
75+
->raw('] ?? null) : ')
5576
;
56-
57-
return;
5877
}
5978

6079
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ');
@@ -83,5 +102,9 @@ public function compile(Compiler $compiler): void
83102
->raw(', ')->repr($this->getNode('node')->getTemplateLine())
84103
->raw(')')
85104
;
105+
106+
if ($arrayAccessSandbox) {
107+
$compiler->raw(')');
108+
}
86109
}
87110
}

‎tests/Extension/SandboxTest.php

+62-6
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ protected function setUp(): void
3939
'arr' => ['obj' => new FooObject()],
4040
'child_obj' => new ChildClass(),
4141
'some_array' => [5, 6, 7, new FooObject()],
42+
'array_like' => new ArrayLikeObject(),
43+
'magic' => new MagicObject(),
4244
];
4345

4446
self::$templates = [
@@ -61,6 +63,7 @@ protected function setUp(): void
6163
'1_syntax_error' => '{% syntax error }}',
6264
'1_childobj_parentmethod' => '{{ child_obj.ParentMethod() }}',
6365
'1_childobj_childmethod' => '{{ child_obj.ChildMethod() }}',
66+
'1_array_like' => '{{ array_like["foo"] }}',
6467
];
6568
}
6669

@@ -79,15 +82,31 @@ public function testSandboxGloballySet()
7982
$this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params), 'Sandbox does nothing if it is disabled globally');
8083
}
8184

82-
public function testSandboxUnallowedMethodAccessor()
85+
public function testSandboxUnallowedPropertyAccessor()
8386
{
8487
$twig = $this->getEnvironment(true, [], self::$templates);
8588
try {
86-
$twig->load('1_basic1')->render(self::$params);
89+
$twig->load('1_basic1')->render(['obj' => new MagicObject()]);
8790
$this->fail('Sandbox throws a SecurityError exception if an unallowed method is called');
88-
} catch (SecurityNotAllowedMethodError $e) {
89-
$this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class');
90-
$this->assertEquals('foo', $e->getMethodName(), 'Exception should be raised on the "foo" method');
91+
} catch (SecurityNotAllowedPropertyError $e) {
92+
$this->assertEquals('Twig\Tests\Extension\MagicObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\MagicObject" class');
93+
$this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property');
94+
}
95+
}
96+
97+
public function testSandboxUnallowedArrayIndexAccessor()
98+
{
99+
$twig = $this->getEnvironment(true, [], self::$templates);
100+
101+
// ArrayObject and other internal array-like classes are exempted from sandbox restrictions
102+
$this->assertSame('bar', $twig->load('1_array_like')->render(['array_like' => new \ArrayObject(['foo' => 'bar'])]));
103+
104+
try {
105+
$twig->load('1_array_like')->render(self::$params);
106+
$this->fail('Sandbox throws a SecurityError exception if an unallowed method is called');
107+
} catch (SecurityNotAllowedPropertyError $e) {
108+
$this->assertEquals('Twig\Tests\Extension\ArrayLikeObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\ArrayLikeObject" class');
109+
$this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property');
91110
}
92111
}
93112

@@ -238,7 +257,8 @@ public function getSandboxAllowedToStringTests()
238257
return [
239258
'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''],
240259
'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'],
241-
'is_defined' => ['{{ obj.anotherFooObject is defined }}', '1'],
260+
'is_defined1' => ['{{ obj.anotherFooObject is defined }}', '1'],
261+
'is_defined2' => ['{{ magic.foo is defined }}', ''],
242262
'is_null' => ['{{ obj is null }}', ''],
243263
'is_sameas' => ['{{ obj is same as(obj) }}', '1'],
244264
'is_sameas_no_brackets' => ['{{ obj is same as obj }}', '1'],
@@ -548,3 +568,39 @@ public function getAnotherFooObject()
548568
return new self();
549569
}
550570
}
571+
572+
class ArrayLikeObject extends \ArrayObject
573+
{
574+
public function offsetExists($offset): bool
575+
{
576+
throw new \BadMethodCallException('Should not be called');
577+
}
578+
579+
#[\ReturnTypeWillChange]
580+
public function offsetGet($offset)
581+
{
582+
throw new \BadMethodCallException('Should not be called');
583+
}
584+
585+
public function offsetSet($offset, $value): void
586+
{
587+
}
588+
589+
public function offsetUnset($offset): void
590+
{
591+
}
592+
}
593+
594+
class MagicObject
595+
{
596+
#[\ReturnTypeWillChange]
597+
public function __get($name)
598+
{
599+
throw new \BadMethodCallException('Should not be called');
600+
}
601+
602+
public function __isset($name): bool
603+
{
604+
throw new \BadMethodCallException('Should not be called');
605+
}
606+
}

0 commit comments

Comments
 (0)
Please sign in to comment.