security #cve-2026-46640 Fix sandbox bypass: PHP code injection via _self / import macro reference (alexandre-daubois, fabpot)

This PR was merged into the twig-3.x branch.
This commit is contained in:
Fabien Potencier
2026-05-19 23:43:36 +02:00
4 changed files with 121 additions and 2 deletions
@@ -67,12 +67,15 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi
if (
$expr instanceof NameExpression
&& $attribute instanceof ConstantExpression
&& \is_string($name = $attribute->getAttribute('value'))
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
&& (
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
|| '_self' === $expr->getAttribute('name') && $attribute instanceof ConstantExpression
|| '_self' === $expr->getAttribute('name')
)
) {
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine());
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$name, $arguments, $expr->getTemplateLine());
}
return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe);
@@ -26,6 +26,14 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
public function __construct(TemplateVariable $template, string $name, AbstractExpression $arguments, int $lineno)
{
// The name is emitted as raw PHP in compile() via "->{$name}(...)",
// so it must be a valid PHP method identifier. Reject anything else
// as a defense-in-depth against accidental PHP code injection from
// a caller that forgot to validate user-controlled input.
if (!preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)) {
throw new \LogicException(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
}
parent::__construct(['template' => $template, 'arguments' => $arguments], ['name' => $name], $lineno);
}
+67
View File
@@ -519,6 +519,73 @@ EOF
$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);
@@ -0,0 +1,41 @@
<?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\Node\Expression;
use PHPUnit\Framework\TestCase;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\MacroReferenceExpression;
use Twig\Node\Expression\Variable\TemplateVariable;
class MacroReferenceTest extends TestCase
{
/**
* @dataProvider provideInvalidMacroNames
*/
public function testConstructorRejectsNonIdentifierName(string $name)
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
new MacroReferenceExpression(new TemplateVariable('foo', 1), $name, new ArrayExpression([], 1), 1);
}
public static function provideInvalidMacroNames(): iterable
{
yield 'empty' => [''];
yield 'starts with digit' => ['1foo'];
yield 'contains space' => ['foo bar'];
yield 'contains semicolon' => ['foo;bar'];
yield 'PHP injection payload' => ['macro_foo + 1; trigger_error("BAD") //'];
yield 'contains NUL byte' => ["foo\x00bar"];
}
}