From e9ff55f6910832428e48a35b2e0748189ad49ae3 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 28 Apr 2026 13:25:53 +0200 Subject: [PATCH 1/2] Fix sandbox bypass: PHP code injection via {% use %} template name --- src/Node/ModuleNode.php | 6 +++--- tests/Node/ModuleTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index 71c572019..a3f66827f 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -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() diff --git a/tests/Node/ModuleTest.php b/tests/Node/ModuleTest.php index df28815c5..b8df54f77 100644 --- a/tests/Node/ModuleTest.php +++ b/tests/Node/ModuleTest.php @@ -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 }}'])); From 679447fa29083043665482ccf7d64372472621b8 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 19 May 2026 22:58:27 +0200 Subject: [PATCH 2/2] Encode single quotes as \x27 in Compiler::string() This is a defense-in-depth measure: callers must always concatenate the result into a double-quoted PHP context, but if one ever (mistakenly) embeds it inside a single-quoted PHP literal, an attacker-controlled single quote in the source value could break out of that context. The previous commit fixed exactly such a bug in ModuleNode for the {% use %} template name. Encoding ' as the hex escape \x27 guarantees that the emitted PHP source never contains a literal single quote derived from user input, while the decoded runtime value is unchanged. \' is not used because it is not a recognized escape sequence in PHP double-quoted strings (the backslash would be kept literally). --- CHANGELOG | 2 +- src/Compiler.php | 8 +++++++- tests/CompilerTest.php | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 43f496cf8..4abc524c4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/src/Compiler.php b/src/Compiler.php index 6f62c0919..976030f79 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -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; } diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 58b337966..0416b6260 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -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()));