From 32a96c48dd75af1150eaff2abf98aceab21378a2 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 3 Oct 2026 19:40:48 +0200 Subject: [PATCH 1/2] Fix reading past the end of unterminated block strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The triple-quote lookahead in readBlockString() moved one byte at a time and decoded at each step, so after a quote it could land inside a multibyte character and read past the end of the body. Comparing the next three bytes with substr() avoids decoding there. LexerTest::lexOne() now turns PHP warnings into exceptions, so out-of-bounds reads fail the tests instead of passing with a warning. 🤖 Generated with Claude Code --- src/Language/Lexer.php | 55 +++++++++++++----------------------- tests/Language/LexerTest.php | 20 ++++++++++++- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index c5b8711a3..5e395301c 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -531,6 +531,12 @@ private function readString(int $line, int $col, Token $prev): Token throw new SyntaxError($this->source, $this->position, 'Unterminated string.'); } + /** Compares bytes, since decoding one byte at a time would land inside multibyte characters. */ + private function isTripleQuoteAhead(): bool + { + return substr($this->source->body, $this->byteStreamPosition, 3) === '"""'; + } + /** * Reads a block string token from the source file. * @@ -551,52 +557,31 @@ private function readBlockString(int $line, int $col, Token $prev): Token while ($code !== null) { // Closing Triple-Quote (""") - if ($code === 34) { - // Move 2 quotes - [, $nextCode] = $this->moveStringCursor(1, 1)->readChar(); - [, $nextNextCode] = $this->moveStringCursor(1, 1)->readChar(); + if ($code === 34 && $this->isTripleQuoteAhead()) { + $value .= $chunk; - if ($nextCode === 34 && $nextNextCode === 34) { - $value .= $chunk; - - $this->moveStringCursor(1, 1); - - return new Token( - Token::BLOCK_STRING, - $start, - $this->position, - $line, - $col, - $prev, - BlockString::dedentBlockStringLines($value) - ); - } + $this->moveStringCursor(3, 3); - // move cursor back to before the first quote - $this->moveStringCursor(-2, -2); + return new Token( + Token::BLOCK_STRING, + $start, + $this->position, + $line, + $col, + $prev, + BlockString::dedentBlockStringLines($value) + ); } $this->assertValidBlockStringCharacterCode($code, $this->position); $this->moveStringCursor(1, $bytes); - [, $nextCode] = $this->readChar(); - [, $nextNextCode] = $this->moveStringCursor(1, 1)->readChar(); - [, $nextNextNextCode] = $this->moveStringCursor(1, 1)->readChar(); - // Escape Triple-Quote (\""") - if ( - $code === 92 - && $nextCode === 34 - && $nextNextCode === 34 - && $nextNextNextCode === 34 - ) { - $this->moveStringCursor(1, 1); + if ($code === 92 && $this->isTripleQuoteAhead()) { + $this->moveStringCursor(3, 3); $value .= $chunk . '"""'; $chunk = ''; } else { - // move cursor back to before the first quote - $this->moveStringCursor(-2, -2); - if ($code === 10) { // new line ++$this->line; $this->lineStart = $this->position; diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 313abdf45..a012b51ea 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -50,7 +50,14 @@ private function lexOne(string $body): Token { $lexer = new Lexer(new Source($body)); - return $lexer->advance(); + set_error_handler(static function (int $severity, string $message): bool { + throw new \ErrorException($message, 0, $severity); + }); + try { + return $lexer->advance(); + } finally { + restore_error_handler(); + } } private static function loc(int $line, int $column): SourceLocation @@ -334,6 +341,14 @@ public function testLexesStringsWithUnescapedTabCharacter(): void ); } + public function testLexesBlockStringsWithMultibyteCharactersAfterQuotes(): void + { + self::assertArraySubset( + ['kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 11, 'value' => 'a"é\\ü'], + (array) $this->lexOne('"""a"é\\ü"""') + ); + } + /** @see it('lexes block strings') */ public function testLexesBlockString(): void { @@ -479,6 +494,9 @@ public static function reportsUsefulBlockStringErrors(): iterable { yield ['"""', 'Unterminated string.', self::loc(1, 4)]; yield ['"""no end quote', 'Unterminated string.', self::loc(1, 16)]; + yield ['"""a"é', 'Unterminated string.', self::loc(1, 7)]; + yield ['""""é', 'Unterminated string.', self::loc(1, 6)]; + yield ['"""\\é', 'Unterminated string.', self::loc(1, 6)]; yield [ '"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', 'Invalid character within String: "\\u0007"', From 4b6e7f1053dcd03de1838a8154e5e14da0d43b76 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 3 Oct 2026 19:41:02 +0200 Subject: [PATCH 2/2] Add CHANGELOG entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ccea04c..5c6764d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Fixed + +- Fix reading past the end of unterminated block strings https://github.com/webonyx/graphql-php/pull/1989 + ## v15.37.3 ### Fixed