From a0d6d9bd2c7254f92b037c78c527016e1fd9536a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 3 Oct 2026 19:16:48 +0200 Subject: [PATCH 1/3] Reject invalid UTF-8 in documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the bulk string scan from https://github.com/webonyx/graphql-php/pull/1948, the Lexer accepts invalid UTF-8 inside strings and keeps the raw bytes. Comments and block strings swallow up to three bytes after an invalid lead byte. The spec only allows Unicode scalar values as SourceCharacter. 🤖 Generated with Claude Code --- src/Language/Lexer.php | 22 ++++++++++++++++++++++ tests/Language/LexerTest.php | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index c5b8711a3..90b0bc928 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -46,6 +46,9 @@ class Lexer /** Ignored single-byte characters that never start or end a line, so runs of them can be skipped in bulk. */ private const HORIZONTAL_WHITESPACE_BYTES = "\t ,"; + /** Matches the longest valid UTF-8 prefix, see https://www.rfc-editor.org/rfc/rfc3629#section-4. */ + private const VALID_UTF8_PREFIX = '/\A(?:[\x00-\x7F]|[\xC2-\xDF][\x80-\xBF]|\xE0[\xA0-\xBF][\x80-\xBF]|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}|\xED[\x80-\x9F][\x80-\xBF]|\xF0[\x90-\xBF][\x80-\xBF]{2}|[\xF1-\xF3][\x80-\xBF]{3}|\xF4[\x80-\x8F][\x80-\xBF]{2})*+/'; + public Source $source; /** @phpstan-var ParserOptions */ @@ -113,6 +116,10 @@ public function lookahead(): Token */ private function readToken(Token $prev): Token { + if ($prev->kind === Token::SOF) { + $this->assertValidUTF8(); + } + $bodyLength = $this->source->length; $this->positionAfterWhitespace(); @@ -259,6 +266,21 @@ private function readToken(Token $prev): Token throw new SyntaxError($this->source, $position, $this->unexpectedCharacterMessage($code)); } + /** @throws SyntaxError */ + private function assertValidUTF8(): void + { + $body = $this->source->body; + if (mb_check_encoding($body, 'UTF-8')) { + return; + } + + preg_match(self::VALID_UTF8_PREFIX, $body, $matches); + $validPrefix = $matches[0] ?? ''; + $invalidByte = strtoupper(bin2hex($body[strlen($validPrefix)])); + + throw new SyntaxError($this->source, mb_strlen($validPrefix, 'UTF-8'), "Invalid UTF-8 byte: 0x{$invalidByte}"); + } + /** @throws \JsonException */ private function unexpectedCharacterMessage(?int $code): string { diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 313abdf45..a483c253b 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -674,6 +674,26 @@ public function testReportsUsefulUnknownCharErrors(string $str, string $expected $this->expectSyntaxError($str, $expectedMessage, $location); } + /** @return iterable */ + public static function reportsInvalidUTF8(): iterable + { + yield 'in name position' => ["\xFF", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 1)]; + yield 'in string' => ["\"a\xFFbcd\"", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 3)]; + yield 'after multibyte character' => ["\"ä\xC3x\"", 'Invalid UTF-8 byte: 0xC3', self::loc(1, 3)]; + yield 'truncated at end' => ["\"ab\xE2\x82", 'Invalid UTF-8 byte: 0xE2', self::loc(1, 4)]; + yield 'encoded surrogate' => ["\"\xED\xA0\x80\"", 'Invalid UTF-8 byte: 0xED', self::loc(1, 2)]; + yield 'overlong encoding' => ["\"\xC0\xAF\"", 'Invalid UTF-8 byte: 0xC0', self::loc(1, 2)]; + yield 'in block string' => ["\"\"\"\xFF\"\"\"", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 4)]; + yield 'in comment' => ["# \xFF\nfoo", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 3)]; + yield 'after the first token' => ["foo\n \"\xFF\"", 'Invalid UTF-8 byte: 0xFF', self::loc(2, 4)]; + } + + /** @dataProvider reportsInvalidUTF8 */ + public function testReportsInvalidUTF8(string $str, string $expectedMessage, SourceLocation $location): void + { + $this->expectSyntaxError($str, $expectedMessage, $location); + } + /** @see it('lex reports useful information for dashes in names') */ public function testReportsUsefulDashesInfo(): void { From 90df90b3755044a374409bd56e8eec7447192d76 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 3 Oct 2026 19:17:00 +0200 Subject: [PATCH 2/3] 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..1973fcc35 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 + +- Reject invalid UTF-8 in documents https://github.com/webonyx/graphql-php/pull/1987 + ## v15.37.3 ### Fixed From 85a1dce1d970bddc8cfa01382f98ac843e217f94 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 3 Oct 2026 19:33:27 +0200 Subject: [PATCH 3/3] Find the invalid byte without a regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix regex hit pcre.backtrack_limit on about 1 million 3- or 4-byte characters and then reported the first byte of the document. mb_scrub() replaces only invalid bytes, so the first differing byte is the invalid one. 🤖 Generated with Claude Code --- src/Language/Lexer.php | 10 ++++------ tests/Language/LexerTest.php | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 90b0bc928..841132271 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -46,9 +46,6 @@ class Lexer /** Ignored single-byte characters that never start or end a line, so runs of them can be skipped in bulk. */ private const HORIZONTAL_WHITESPACE_BYTES = "\t ,"; - /** Matches the longest valid UTF-8 prefix, see https://www.rfc-editor.org/rfc/rfc3629#section-4. */ - private const VALID_UTF8_PREFIX = '/\A(?:[\x00-\x7F]|[\xC2-\xDF][\x80-\xBF]|\xE0[\xA0-\xBF][\x80-\xBF]|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}|\xED[\x80-\x9F][\x80-\xBF]|\xF0[\x90-\xBF][\x80-\xBF]{2}|[\xF1-\xF3][\x80-\xBF]{3}|\xF4[\x80-\x8F][\x80-\xBF]{2})*+/'; - public Source $source; /** @phpstan-var ParserOptions */ @@ -274,9 +271,10 @@ private function assertValidUTF8(): void return; } - preg_match(self::VALID_UTF8_PREFIX, $body, $matches); - $validPrefix = $matches[0] ?? ''; - $invalidByte = strtoupper(bin2hex($body[strlen($validPrefix)])); + $scrubbedBody = mb_scrub($body, 'UTF-8'); + $invalidByteOffset = strspn($body ^ $scrubbedBody, "\0"); + $invalidByte = strtoupper(bin2hex($body[$invalidByteOffset])); + $validPrefix = substr($body, 0, $invalidByteOffset); throw new SyntaxError($this->source, mb_strlen($validPrefix, 'UTF-8'), "Invalid UTF-8 byte: 0x{$invalidByte}"); } diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index a483c253b..b1fc0ddc6 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -686,6 +686,9 @@ public static function reportsInvalidUTF8(): iterable yield 'in block string' => ["\"\"\"\xFF\"\"\"", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 4)]; yield 'in comment' => ["# \xFF\nfoo", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 3)]; yield 'after the first token' => ["foo\n \"\xFF\"", 'Invalid UTF-8 byte: 0xFF', self::loc(2, 4)]; + yield 'after BOM' => ["\u{FEFF}\xFF", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 2)]; + yield 'first of two' => ["\"a\xFEb\xFF\"", 'Invalid UTF-8 byte: 0xFE', self::loc(1, 3)]; + yield 'after long multibyte prefix' => ['"' . str_repeat('€', 2000) . "\xFF\"", 'Invalid UTF-8 byte: 0xFF', self::loc(1, 2002)]; } /** @dataProvider reportsInvalidUTF8 */