Merge branch '3.x' into 4.x

* 3.x:
  Document sandbox handling of magic __call() methods
  Add sandbox tests for methods routed through __call()

# Conflicts:
#	doc/sandbox.rst
#	tests/Extension/SandboxTest.php
This commit is contained in:
Fabien Potencier
2026-07-12 15:56:39 +02:00
2 changed files with 74 additions and 0 deletions
+16
View File
@@ -56,6 +56,22 @@ Everything else won't be allowed and will generate a
Note that native array-like classes (like ``ArrayObject``) are always
allowed, you don't need to configure them.
.. note::
When an attribute resolves through a PHP magic ``__call()`` method (the
class has no real method or property with that name), the sandbox checks
the **virtual method name written in the template**, not ``__call``. For
example, ``{{ article.slug }}`` on an object that handles ``slug`` via
``__call()`` requires ``slug`` in the method allow-list::
$methods = [
'Article' => ['slug'],
];
Allow-listing ``__call`` itself has no effect: it would only match a
template that literally writes ``{{ article.__call }}``. Allow each virtual
method by its own name so the policy stays granular.
Marking Filters, Functions, Tests, and Tags as Always Allowed
-------------------------------------------------------------
+58
View File
@@ -588,6 +588,54 @@ class SandboxTest extends TestCase
}
}
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
{
@@ -1894,6 +1942,16 @@ class MagicObject
}
}
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.