From d446864660e06d1a2518b9876893593ee0e52cb2 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sat, 3 Oct 2026 16:23:31 +0000 Subject: [PATCH 1/2] Fix RCE: stop re-compiling captured %block output endStack() recompiled the already-rendered block buffer and getStack() ran it back through renderString(), so any {{ }} reaching a block through data was executed (e() escapes < > & " ' but not braces). The block body is already compiled and executed with the page, so return the captured output verbatim instead of compiling and rendering it a second time. Add regression tests at the StackManager unit level and end-to-end through a layout, asserting injected template syntax comes back as literal text. --- src/StackManager.php | 24 ++++++------ tests/CompileExtendsTest.php | 38 +++++++++++++++++++ ...Test__testCompileFullImportTemplate__1.txt | 4 +- tests/view/security.tintin.php | 5 +++ 4 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 tests/view/security.tintin.php diff --git a/src/StackManager.php b/src/StackManager.php index a8ce8c5..4645cb4 100644 --- a/src/StackManager.php +++ b/src/StackManager.php @@ -136,10 +136,15 @@ public function endStack() $content = $this->pushes[$block]; if (is_null($content)) { - $content = $this->tintin->getCompiler()->compile(ob_get_clean()); + // The block body was compiled and executed together with the + // page, so the output buffer already holds its rendered result. + // Compiling it a second time would execute any {{ }} that + // arrived through data (e() escapes < > & " ' but not braces), + // which is remote code execution. Keep the buffer verbatim. + $content = ob_get_clean(); } - $this->pushes[$block] = trim($content, "\n"); + $this->pushes[$block] = trim((string) $content, "\n"); } } @@ -152,18 +157,13 @@ public function endStack() */ public function getStack(string $name, ?string $default = null) { - if (array_key_exists($name, $this->pushes)) { - if (is_null($this->pushes[$name])) { - $this->pushes[$name] = $default ?? ''; - } - - return $this->tintin->renderString( - $this->pushes[$name], - array_merge($this->context, ['__tintin' => $this->tintin]) - ); + if (!array_key_exists($name, $this->pushes)) { + return $default; } - return $default; + // Return the already-rendered block output as-is. Re-rendering it + // would execute template syntax that came through data. See endStack(). + return $this->pushes[$name] ?? ($default ?? ''); } /** diff --git a/tests/CompileExtendsTest.php b/tests/CompileExtendsTest.php index c3f9f6d..97bee72 100644 --- a/tests/CompileExtendsTest.php +++ b/tests/CompileExtendsTest.php @@ -111,4 +111,42 @@ public function testShouldRendStack() $this->assertEquals('Tintin', $stack_manager->getStack('name')); $this->assertEquals('Tintin template', $stack_manager->getStack('pack')); } + + /** + * A block body is compiled and executed once, together with the page. + * Its captured output must be returned as-is and never compiled a second + * time, otherwise template syntax that reached the block through data + * (e() escapes < > & " ' but not braces) would be executed: RCE. + */ + public function testStackContentIsNotReEvaluated() + { + $tintin = new Tintin($this->loader); + + $stack_manager = $tintin->getStackManager(); + + // This is exactly what the output buffer holds after the first render + // when a value like {{ 7*7 }} arrives through the block's data. + $stack_manager->startStack('content'); + echo 'Hello {{ 7*7 }}'; + $stack_manager->endStack(); + + $output = $stack_manager->getStack('content'); + + $this->assertStringContainsString('{{ 7*7 }}', $output); + $this->assertStringNotContainsString('49', $output); + } + + /** + * End-to-end: a value carrying template syntax rendered inside a %block + * and exposed through %inject must come back as literal text. + */ + public function testBlockDataIsNotExecutedThroughLayout() + { + $tintin = new Tintin($this->loader); + + $output = $tintin->render('security', ['name' => '{{ 7*7 }}']); + + $this->assertStringContainsString('{{ 7*7 }}', $output); + $this->assertStringNotContainsString('49', $output); + } } diff --git a/tests/__snapshots__/CompileMacroTest__testCompileFullImportTemplate__1.txt b/tests/__snapshots__/CompileMacroTest__testCompileFullImportTemplate__1.txt index c379a5b..9f03c95 100644 --- a/tests/__snapshots__/CompileMacroTest__testCompileFullImportTemplate__1.txt +++ b/tests/__snapshots__/CompileMacroTest__testCompileFullImportTemplate__1.txt @@ -1,10 +1,10 @@ - Extends + Extends - Hello, Papac Sum of 1 + 2 = 3
User's Franck
+ Hello, Papac Sum of 1 + 2 = 3
User's Franck
User's Brice
User's Lucien
diff --git a/tests/view/security.tintin.php b/tests/view/security.tintin.php new file mode 100644 index 0000000..d5f09de --- /dev/null +++ b/tests/view/security.tintin.php @@ -0,0 +1,5 @@ +%extends('layout') + +%block('content') +Hello {{ $name }} +%endblock From 44452065cb190f3b8724ae296eadb32f9a80efd5 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sat, 3 Oct 2026 22:12:55 +0000 Subject: [PATCH 2/2] Harden template engine: data injection, temp files, output escaping - render()/executePlainRendering(): extract user data with EXTR_SKIP and pin the engine's own variables ($__tintin, the compiled path) before it, so a data key such as __tintin or __template can no longer overwrite the engine handle (DoS) or redirect which view file is required (view-selection hijack). - createTmpFile(): use tempnam() (atomic, unique, mode 0600) in a 0700 dir instead of a predictable md5(microtime()) name in a shared, world-readable directory, closing a local symlink/TOCTOU and disclosure window. - %json() without explicit options now defaults to JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP so values cannot break out of a