mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-30 12:06:56 +00:00
security #552 Fix sandbox __toString policy bypass via dynamic mapping keys (fabpot)
This PR was squashed before being merged into the twig-3.x branch. Discussion ---------- Fix sandbox __toString policy bypass via dynamic mapping keys Fixes #548 Fixing this one introduces a new feature that I've decided to "keep" and document :) Commits -------635cea4789Document new support for any expression as a dynamic mapping key9ff4101463Fix sandbox __toString policy bypass via dynamic mapping keys
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
* Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`)
|
||||
* Fix sandbox `__toString` bypass via the `in` and `not in` operators
|
||||
* Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template
|
||||
* Add support for any expression as a dynamic mapping key (attribute access, filters, ...)
|
||||
* Fix sandbox `__toString` policy bypass via dynamic mapping keys
|
||||
|
||||
# 3.26.0 (2026-05-20)
|
||||
|
||||
|
||||
@@ -639,6 +639,42 @@ exist:
|
||||
{% set key = 'name' %}
|
||||
{(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'}
|
||||
|
||||
Any expression is supported as a dynamic key. The result is coerced to
|
||||
string, so objects implementing ``__toString()`` (PHP ``Stringable``) are
|
||||
accepted:
|
||||
|
||||
.. code-block:: twig
|
||||
|
||||
{# attribute access #}
|
||||
{(user.role): 'allowed'}
|
||||
|
||||
{# method call #}
|
||||
{(user.getRole()): 'allowed'}
|
||||
|
||||
{# filter result #}
|
||||
{(name|upper): 'Fabien'}
|
||||
|
||||
{# function call #}
|
||||
{(slug(title)): post}
|
||||
|
||||
{# chained expression #}
|
||||
{(user.email|lower): 'subscribed'}
|
||||
|
||||
{# Stringable object (cast via __toString) #}
|
||||
{(uuid): 'token'}
|
||||
|
||||
.. versionadded:: 3.26.1
|
||||
|
||||
Support for arbitrary expressions as dynamic mapping keys
|
||||
(attribute access, method calls, filter results, function calls,
|
||||
and any ``Stringable`` object) was added in Twig 3.26.1.
|
||||
|
||||
.. note::
|
||||
|
||||
Inside a sandbox, the ``__toString()`` coercion goes through the
|
||||
``SecurityPolicy`` method allowlist, the same way as ``{{ obj }}``
|
||||
or ``{{ obj|upper }}``.
|
||||
|
||||
* ``true`` / ``false``: ``true`` represents the true value, ``false``
|
||||
represents the false value.
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ namespace Twig\Node\Expression;
|
||||
|
||||
use Twig\Compiler;
|
||||
use Twig\Error\SyntaxError;
|
||||
use Twig\Node\CoercesChildrenToStringInterface;
|
||||
use Twig\Node\Expression\Unary\SpreadUnary;
|
||||
use Twig\Node\Expression\Unary\StringCastUnary;
|
||||
use Twig\Node\Expression\Variable\ContextVariable;
|
||||
|
||||
class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface
|
||||
class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface, CoercesChildrenToStringInterface
|
||||
{
|
||||
use SupportDefinedTestTrait;
|
||||
|
||||
@@ -95,6 +95,24 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn
|
||||
array_push($this->nodes, $key, $value);
|
||||
}
|
||||
|
||||
public function getStringCoercedChildNames(): array
|
||||
{
|
||||
// dynamic mapping keys (computed at runtime) are coerced to string;
|
||||
// static keys (constants or sequence indexes) are emitted as PHP
|
||||
// literals by compile() and never trigger a __toString() call
|
||||
$names = [];
|
||||
foreach (array_chunk($this->nodes, 2) as $i => $pair) {
|
||||
$key = $pair[0];
|
||||
if ($key instanceof ConstantExpression || $key instanceof TempNameExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names[] = (string) ($i * 2);
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
public function compile(Compiler $compiler): void
|
||||
{
|
||||
if ($this->definedTest) {
|
||||
@@ -118,13 +136,15 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn
|
||||
}
|
||||
|
||||
$key = null;
|
||||
if ($pair['key'] instanceof ContextVariable) {
|
||||
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
|
||||
} elseif ($pair['key'] instanceof TempNameExpression) {
|
||||
if ($pair['key'] instanceof TempNameExpression) {
|
||||
$key = $pair['key']->getAttribute('name');
|
||||
$pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine());
|
||||
} elseif ($pair['key'] instanceof ConstantExpression) {
|
||||
$key = $pair['key']->getAttribute('value');
|
||||
} else {
|
||||
// dynamic key: cast to string so PHP accepts it as an array offset
|
||||
// (the sandbox visitor has already wrapped it with a __toString policy check)
|
||||
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
|
||||
}
|
||||
|
||||
if ($key !== $i) {
|
||||
|
||||
@@ -627,6 +627,9 @@ class SandboxTest extends TestCase
|
||||
'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" }}'],
|
||||
@@ -796,6 +799,15 @@ class SandboxTest extends TestCase
|
||||
$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);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
--TEST--
|
||||
Mapping keys can be any expression that evaluates to a scalar or Stringable
|
||||
--TEMPLATE--
|
||||
{# context variable holding a Stringable object #}
|
||||
{{ {(obj): 'a'}|keys|join(',') }}
|
||||
|
||||
{# attribute access on an object yielding a Stringable #}
|
||||
{{ {(holder.stringable): 'a'}|keys|join(',') }}
|
||||
|
||||
{# attribute access yielding a string #}
|
||||
{{ {(holder.name): 'a'}|keys|join(',') }}
|
||||
|
||||
{# filter result yielding a Stringable #}
|
||||
{{ {(obj|raw): 'a'}|keys|join(',') }}
|
||||
|
||||
{# filter result yielding a string #}
|
||||
{{ {('hello'|upper): 'a'}|keys|join(',') }}
|
||||
|
||||
{# filter result yielding an integer (keys stay int) #}
|
||||
{{ {(holder.name|length): 'a'}|keys|first == 4 ? 'ok' : 'ko' }}
|
||||
|
||||
{# method call yielding a Stringable #}
|
||||
{{ {(holder.getStringable()): 'a'}|keys|join(',') }}
|
||||
|
||||
{# mixed static and dynamic keys #}
|
||||
{{ {'static': 's', (obj): 'd', (holder.name): 'n'}|keys|join(',') }}
|
||||
|
||||
{# nested mapping with a dynamic Stringable key #}
|
||||
{{ {'outer': {(obj): 'inner'}} | json_encode | raw }}
|
||||
--DATA--
|
||||
class TwigTestStringy implements \Stringable {
|
||||
public function __construct(private string $v) {}
|
||||
public function __toString(): string { return $this->v; }
|
||||
}
|
||||
class TwigTestHolder {
|
||||
public string $name = 'attr';
|
||||
public TwigTestStringy $stringable;
|
||||
public function __construct() { $this->stringable = new TwigTestStringy('attr_obj'); }
|
||||
public function getStringable(): TwigTestStringy { return new TwigTestStringy('method_obj'); }
|
||||
}
|
||||
return ['obj' => new TwigTestStringy('ctx_obj'), 'holder' => new TwigTestHolder()]
|
||||
--EXPECT--
|
||||
ctx_obj
|
||||
|
||||
attr_obj
|
||||
|
||||
attr
|
||||
|
||||
ctx_obj
|
||||
|
||||
HELLO
|
||||
|
||||
ok
|
||||
|
||||
method_obj
|
||||
|
||||
static,ctx_obj,attr
|
||||
|
||||
{"outer":{"ctx_obj":"inner"}}
|
||||
Reference in New Issue
Block a user