From a371aa4dad5f928fe7fe1cf97a543a4fe3e09190 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 20 Jul 2026 09:39:35 -0400 Subject: [PATCH 1/2] Prevent tokenizer loops on invalid bytes --- src/Connection/ImapTokenizer.php | 18 +++++- tests/Unit/Connection/ImapTokenizerTest.php | 67 +++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/Connection/ImapTokenizer.php b/src/Connection/ImapTokenizer.php index 17e83f0..2755ba0 100644 --- a/src/Connection/ImapTokenizer.php +++ b/src/Connection/ImapTokenizer.php @@ -387,6 +387,20 @@ protected function readAtom(): Atom $this->advance(); } + // If no value was read, we will throw an exception since + // an atom must contain at least one valid character. + if ($value === '') { + if ($char === null) { + throw new ImapStreamException('Unexpected end of stream while reading atom'); + } + + throw new ImapParserException(sprintf( + 'Unexpected byte 0x%02X in response at buffer offset %d', + ord($char), + $this->position + )); + } + if (strcasecmp($value, 'NIL') === 0) { return new Nil($value); } @@ -437,7 +451,9 @@ protected function ensureBuffer(int $length): void while ((strlen($this->buffer) - $this->position) < $length) { $data = $this->stream->fgets(); - if ($data === false) { + // If the stream did not return any data, we will stop + // filling the buffer until another read is attempted. + if ($data === false || $data === '') { return; } diff --git a/tests/Unit/Connection/ImapTokenizerTest.php b/tests/Unit/Connection/ImapTokenizerTest.php index b32d42c..00d7eae 100644 --- a/tests/Unit/Connection/ImapTokenizerTest.php +++ b/tests/Unit/Connection/ImapTokenizerTest.php @@ -207,6 +207,73 @@ $tokenizer->nextToken(); })->throws(ImapParserException::class); +test('tokenizer throws exception for an unexpected byte instead of returning an empty atom', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw("TAG1 OK L\x80GIN completed\r\n"); + + $tokenizer = new ImapTokenizer($stream); + + expect($tokenizer->nextToken()->value)->toBe('TAG1'); + expect($tokenizer->nextToken()->value)->toBe('OK'); + expect($tokenizer->nextToken()->value)->toBe('L'); + + expect(fn () => $tokenizer->nextToken()) + ->toThrow(ImapParserException::class, 'Unexpected byte 0x80 in response at buffer offset 9'); +}); + +test('tokenizer throws exception for an unexpected atom delimiter', function (string $delimiter, string $hex) { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw($delimiter); + + $tokenizer = new ImapTokenizer($stream); + + expect(fn () => $tokenizer->nextToken()) + ->toThrow(ImapParserException::class, "Unexpected byte 0x{$hex} in response at buffer offset 0"); +})->with([ + 'closing curly brace' => ['}', '7D'], + 'closing angle bracket' => ['>', '3E'], +]); + +test('tokenizer stops filling the buffer when the stream returns an empty string', function () { + $stream = new class extends FakeStream + { + public int $reads = 0; + + public function fgets(): string|false + { + if ($this->reads++ > 1) { + throw new RuntimeException('The tokenizer continued reading after an empty stream read'); + } + + return ''; + } + }; + + $stream->open(); + + $tokenizer = new ImapTokenizer($stream); + + expect($tokenizer->nextToken())->toBeNull(); + expect($stream->reads)->toBe(2); +}); + +test('tokenizer does not treat zero stream data as empty', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw(['0', "\r\n"]); + + $tokenizer = new ImapTokenizer($stream); + + $token = $tokenizer->nextToken(); + + expect($token->value)->toBe('0'); +}); + test('tokenizer throws exception for unterminated quoted string', function () { $stream = new FakeStream; $stream->open(); From 3967894bb636188fb70c597a831ca1a79263121b Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 20 Jul 2026 09:51:53 -0400 Subject: [PATCH 2/2] Adjust test formatting --- tests/Integration/MessagesTest.php | 24 +++++++++++++++------ tests/Unit/Connection/ImapTokenizerTest.php | 11 +++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 1aee80f..54f251c 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -59,15 +59,21 @@ function folder(): Folder test('first or fail', function () { $folder = folder(); - expect(fn () => $folder->messages()->firstOrFail())->toThrow(ItemNotFoundException::class); - $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), ); - expect($folder->messages()->firstOrFail()->uid())->toBe($uid); + $message = $folder->messages()->firstOrFail(); + + expect($message->uid())->toBe($uid); }); +test('first or fail throws exception', function () { + $folder = folder(); + + $folder->messages()->firstOrFail(); +})->throws(ItemNotFoundException::class); + test('find', function () { $folder = folder(); @@ -93,13 +99,17 @@ function folder(): Folder ), ); - expect($folder->messages()->findOrFail($uid))->toBeInstanceOf(Message::class); + $message = $folder->messages()->findOrFail($uid); - expect(function () use ($folder) { - $folder->messages()->findOrFail(999); - })->toThrow(ItemNotFoundException::class); + expect($message)->toBeInstanceOf(Message::class); }); +test('find or fail throws exception', function () { + $folder = folder(); + + $folder->messages()->findOrFail(999); +})->throws(ItemNotFoundException::class); + test('get without fetches', function () { $folder = folder(); diff --git a/tests/Unit/Connection/ImapTokenizerTest.php b/tests/Unit/Connection/ImapTokenizerTest.php index 00d7eae..bd84043 100644 --- a/tests/Unit/Connection/ImapTokenizerTest.php +++ b/tests/Unit/Connection/ImapTokenizerTest.php @@ -219,9 +219,8 @@ expect($tokenizer->nextToken()->value)->toBe('OK'); expect($tokenizer->nextToken()->value)->toBe('L'); - expect(fn () => $tokenizer->nextToken()) - ->toThrow(ImapParserException::class, 'Unexpected byte 0x80 in response at buffer offset 9'); -}); + $tokenizer->nextToken(); +})->throws(ImapParserException::class, 'Unexpected byte 0x80 in response at buffer offset 9'); test('tokenizer throws exception for an unexpected atom delimiter', function (string $delimiter, string $hex) { $stream = new FakeStream; @@ -231,8 +230,10 @@ $tokenizer = new ImapTokenizer($stream); - expect(fn () => $tokenizer->nextToken()) - ->toThrow(ImapParserException::class, "Unexpected byte 0x{$hex} in response at buffer offset 0"); + expect(fn () => $tokenizer->nextToken())->toThrow( + ImapParserException::class, + "Unexpected byte 0x{$hex} in response at buffer offset 0" + ); })->with([ 'closing curly brace' => ['}', '7D'], 'closing angle bracket' => ['>', '3E'],