From 54931f1f95c1d6da58766dbdf10592932a73e3df Mon Sep 17 00:00:00 2001 From: Valery Ivashchanka Date: Mon, 20 Jul 2026 02:28:33 +0300 Subject: [PATCH] fix: do not corrupt streamed response bodies Streamed responses (`StreamedResponse`, `BinaryFileResponse`) are captured via an output buffer, because `getContent()` returns `false` for them. The captured buffer was passed through `mb_trim()`, which is UTF-8 aware and therefore unsafe for arbitrary bytes. When the body starts or ends with a whitespace byte, `mb_trim()` decodes the whole buffer as UTF-8 and replaces every invalid byte with `?` (0x3F), so a binary body is silently destroyed. Trimming also changes the body length while the original `Content-Length` header is forwarded untouched, leaving the response inconsistent. The buffer is now forwarded verbatim, which also matches the non-streamed branch above, where `getContent()` is never trimmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Drivers/LaravelHttpServer.php | 5 +- tests/Browser/StreamedResponseTest.php | 66 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/Browser/StreamedResponseTest.php diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..b8cfa4fb 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -300,9 +300,10 @@ private function handleRequest(AmpRequest $request): Response ob_start(); $response->sendContent(); } finally { - // @phpstan-ignore-next-line - $content = mb_trim(ob_get_clean()); + $buffer = ob_get_clean(); } + + $content = $buffer === false ? '' : $buffer; } return new Response( diff --git a/tests/Browser/StreamedResponseTest.php b/tests/Browser/StreamedResponseTest.php new file mode 100644 index 00000000..d681ae5a --- /dev/null +++ b/tests/Browser/StreamedResponseTest.php @@ -0,0 +1,66 @@ + '
Home
'); + Route::get('/binary', fn (): StreamedResponse => response()->stream( + function () use ($bytes): void { + echo $bytes; + }, + 200, + ['Content-Type' => 'image/jpeg'], + )); + + $page = visit('/'); + + $page->assertScript( + "async () => { + const response = await fetch('/binary'); + const bytes = new Uint8Array(await response.arrayBuffer()); + + return Array.from(bytes).join(','); + }", + implode(',', unpack('C*', $bytes)), + ); +}); + +it('may serve a binary streamed image that the browser is able to decode', function (): void { + $image = file_get_contents(__DIR__.'/../Fixtures/v4.jpg'); + + Route::get('/', fn (): string => 'Streamed Image'); + Route::get('/image', fn (): StreamedResponse => response()->stream( + function () use ($image): void { + echo $image; + }, + 200, + ['Content-Type' => 'image/jpeg'], + )); + + $page = visit('/'); + + $page->assertScript("document.getElementById('image').complete && document.getElementById('image').naturalWidth > 0"); +}); + +it('may serve a textual streamed response without altering its whitespace', function (): void { + Route::get('/', fn (): string => '
Home
'); + Route::get('/text', fn (): StreamedResponse => response()->stream( + function (): void { + echo "\n Hello World \n"; + }, + 200, + ['Content-Type' => 'text/plain'], + )); + + $page = visit('/'); + + $page->assertScript( + "async () => JSON.stringify(await (await fetch('/text')).text())", + json_encode("\n Hello World \n"), + ); +});