security #cve-2026-46633 Fix sandbox bypass: PHP code injection via {% use %} template name (alexandre-daubois, fabpot)

This PR was merged into the twig-3.x branch.
This commit is contained in:
Fabien Potencier
2026-05-19 23:00:20 +02:00
5 changed files with 53 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# 3.26.0 (2026-XX-XX)
* n/a
* Encode single quotes as `\x27` in `Compiler::string()` as a defense-in-depth measure
# 3.25.0 (2026-05-17)
+7 -1
View File
@@ -143,7 +143,13 @@ class Compiler
*/
public function string(string $value)
{
$this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\"));
// Single quotes are encoded as \x27 (not \') as a defense-in-depth measure:
// it guarantees that the compiled output never contains a literal "'" derived
// from user input, which prevents breaking out of a surrounding single-quoted
// PHP context if a caller mistakenly concatenates the result into one.
// \' is not a recognized escape sequence in PHP double-quoted strings (the
// backslash would be kept literally), so \x27 is used instead.
$this->source .= \sprintf('"%s"', str_replace("'", '\\x27', addcslashes($value, "\0\t\"\$\\")));
return $this;
}
+3 -3
View File
@@ -248,11 +248,11 @@ final class ModuleNode extends Node
->string($key)
->raw("])) {\n")
->indent()
->write("throw new RuntimeError('Block ")
->write("throw new RuntimeError(sprintf('Block \"%s\" is not defined in trait \"%s\".', ")
->string($key)
->raw(' is not defined in trait ')
->raw(', ')
->subcompile($trait->getNode('template'))
->raw(".', ")
->raw('), ')
->repr($node->getTemplateLine())
->raw(", \$this->source);\n")
->outdent()
+18
View File
@@ -27,6 +27,24 @@ use Twig\Loader\ArrayLoader;
class CompilerTest extends TestCase
{
public function testStringEncodesSingleQuotesAsHexEscape()
{
$compiler = new Compiler(new Environment(new ArrayLoader()));
// Defense in depth: a single quote in the source value must NOT appear as a
// literal "'" in the compiled output, so that even if a caller mistakenly
// concatenates the result into a single-quoted PHP string, the value cannot
// break out of that context. It must still decode back to the original byte.
$source = $compiler->string("it's \"a\" test")->getSource();
$this->assertStringNotContainsString("'", $source);
$this->assertSame('"it\\x27s \\"a\\" test"', $source);
$decoded = null;
eval('$decoded = '.$source.';');
$this->assertSame("it's \"a\" test", $decoded);
}
public function testReprNumericValueWithLocale()
{
$compiler = new Compiler(new Environment(new ArrayLoader()));
+24
View File
@@ -21,6 +21,7 @@ namespace Twig\Tests\Node;
*/
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Loader\ArrayLoader;
use Twig\Node\BodyNode;
use Twig\Node\EmptyNode;
@@ -56,6 +57,29 @@ class ModuleTest extends NodeTestCase
$this->assertEquals($source->getName(), $node->getTemplateName());
}
public function testUseTagTemplateNameDoesNotInjectPhpInCompiledOutput()
{
$evilName = "evil' . print('BAD-EOL') . '.twig";
$loader = new ArrayLoader([
$evilName => '{% block existing %}ok{% endblock %}',
'main.twig' => "{% use \"$evilName\" with absent_block as alias %}",
]);
$twig = new Environment($loader);
ob_start();
$message = null;
try {
$twig->load('main.twig');
} catch (RuntimeError $e) {
$message = $e->getMessage();
}
$stdout = ob_get_clean();
$this->assertSame('', $stdout, 'No code from the template name must execute when the trait is loaded.');
$this->assertNotNull($message, 'A RuntimeError must be raised for the missing block.');
$this->assertStringContainsString($evilName, $message, 'The error message must contain the literal template name.');
}
public static function provideTests(): iterable
{
$twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}']));