From 19b1c40651ba0b2bbd80c17c406dc650e6b6a486 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 11 Jun 2026 11:16:12 +0100 Subject: [PATCH] Fix arbitrary code execution in the compiled runtime --- CHANGELOG.md | 5 +++++ src/CompilerRuntime.php | 2 +- src/Parser.php | 2 +- src/TreeCompiler.php | 4 ++-- tests/CompilerRuntimeTest.php | 32 +++++++++++++++++++++++++++++++- tests/ParserTest.php | 18 ++++++++++++++++++ tests/TreeCompilerTest.php | 23 +++++++++++++++++++++++ 7 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8122d2..c336000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 2.9.1 - Upcoming + +* Fixed the compiled runtime to emit function names as string literals, preventing arbitrary code execution. +* Fixed the parser to reject non-identifier function callees, such as literal and raw string callees. + ## 2.9.0 - 2026-06-10 * Added PHP 8.5 support. diff --git a/src/CompilerRuntime.php b/src/CompilerRuntime.php index a03ee25..3edab38 100644 --- a/src/CompilerRuntime.php +++ b/src/CompilerRuntime.php @@ -14,7 +14,7 @@ */ class CompilerRuntime { - const CACHE_VERSION = 4; + const CACHE_VERSION = 5; private $parser; private $compiler; diff --git a/src/Parser.php b/src/Parser.php index a47d3cd..5a1d3de 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -274,7 +274,7 @@ private function led_pipe(array $left) private function led_lparen(array $left) { - if (!isset($left['value'])) { + if (!isset($left['type'], $left['value']) || $left['type'] !== 'field') { throw $this->syntax('Invalid function name'); } diff --git a/src/TreeCompiler.php b/src/TreeCompiler.php index 9dc6f03..3c8d6b8 100644 --- a/src/TreeCompiler.php +++ b/src/TreeCompiler.php @@ -257,8 +257,8 @@ private function visit_function(array $node) } return $this->write( - '$value = Fd::getInstance()->__invoke("%s", %s);', - $node['value'], $args + '$value = Fd::getInstance()->__invoke(%s, %s);', + var_export($node['value'], true), $args ); } diff --git a/tests/CompilerRuntimeTest.php b/tests/CompilerRuntimeTest.php index b14f57f..504756f 100644 --- a/tests/CompilerRuntimeTest.php +++ b/tests/CompilerRuntimeTest.php @@ -3,6 +3,7 @@ use JmesPath\AstRuntime; use JmesPath\CompilerRuntime; +use JmesPath\SyntaxErrorException; use PHPUnit\Framework\TestCase; class CompilerRuntimeTest extends TestCase @@ -29,7 +30,7 @@ public function testFunctionNameUsesVersionedSalt(): void { $this->assertSame( 'jmespath_' . md5( - 'jmespath:' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . ':4:foo.bar' + 'jmespath:' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . ':5:foo.bar' ), CompilerRuntime::functionName('foo.bar') ); @@ -162,6 +163,35 @@ public function testCompiledRuntimeSortsNumerically(): void } } + public function testRejectsLiteralFunctionName(): void + { + $dir = $this->createTempDir(); + + try { + $runtime = new CompilerRuntime($dir); + $runtime('`"not_a_function"`(@)', []); + $this->fail('Expected SyntaxErrorException'); + } catch (SyntaxErrorException $e) { + $this->assertSame([], glob($dir . '/*') ?: []); + } finally { + $this->removeTempDir($dir); + } + } + + public function testStillCompilesValidFunctions(): void + { + $dir = $this->createTempDir(); + + try { + $runtime = new CompilerRuntime($dir); + + $this->assertSame(3, $runtime('length(@)', [1, 2, 3])); + $this->assertTrue($runtime("contains(@, 'b')", ['a', 'b', 'c'])); + } finally { + $this->removeTempDir($dir); + } + } + private function createTempDir() { $dir = sys_get_temp_dir() . '/jmespath-compiler-' . bin2hex(random_bytes(12)); diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 94d330d..f2b7bd1 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -36,6 +36,8 @@ public static function invalidExpressionProvider(): array ['>', 'Syntax error at character 0'], ['|', 'Syntax error at character 0'], ['@(foo)', 'Invalid function name'], + ['`"not_a_function"`(@)', 'Invalid function name'], + ["'not_a_function'(@)", 'Invalid function name'], ['@=', 'Did not reach the end of the token stream'], ['`1` `2`', 'Did not reach the end of the token stream'], ['{a: @', 'Syntax error at character 5'], @@ -69,6 +71,22 @@ public function testInvalidExpressionsThrowCleanSyntaxErrors(string $expr): void $this->assertSame([], $diags, "PHP warnings/notices emitted while parsing: $expr"); } + public function testParsesIdentifierFunctionName(): void + { + $parser = new Parser(); + + $this->assertSame( + [ + 'type' => 'function', + 'value' => 'length', + 'children' => [ + ['type' => 'current'], + ], + ], + $parser->parse('length(@)') + ); + } + public static function invalidLedTokenProvider(): array { return [ diff --git a/tests/TreeCompilerTest.php b/tests/TreeCompilerTest.php index b08c688..a8f72ce 100644 --- a/tests/TreeCompilerTest.php +++ b/tests/TreeCompilerTest.php @@ -42,4 +42,27 @@ public function testFromlessProjectionUsesCorrectGuard(): void $source ); } + + public function testEscapesFunctionName(): void + { + $compiler = new TreeCompiler(); + $source = $compiler->visit( + [ + 'type' => 'function', + 'value' => '" . $shouldNotBeCode . "', + 'children' => [], + ], + 'testing', + 'example' + ); + + $this->assertStringContainsString( + '$value = Fd::getInstance()->__invoke(\'" . $shouldNotBeCode . "\', $args);', + $source + ); + $this->assertStringNotContainsString( + '__invoke("" . $shouldNotBeCode . "", $args);', + $source + ); + } }