From 750544fa3d50f77aa30e4973cf172498a6d0bb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Sun, 12 Apr 2026 11:57:46 +0200 Subject: [PATCH] Fix: Return empty content for multipart requests to match PHP-FPM behavior For multipart/form-data requests, getContent() now returns empty string to match PHP-FPM behavior where php://input is not available. This prevents: - Duplicate memory usage (file data stored twice) - Data exposure via getContent() logging - Behavioral inconsistency with PHP-FPM Also improves content-type detection using stripos() instead of preg_match(). --- CHANGELOG.md | 8 ++++++ src/DTO/RequestConverter.php | 12 ++++++-- tests/RequestConverterTest.php | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db4bc33..4def3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `RequestConverter::toSymfonyRequest()` now returns empty content for multipart/form-data requests + - Matches PHP-FPM behavior where `php://input` is not available for multipart + - Previously `getContent()` returned full raw body including file contents + - Files remain accessible via `$request->files` as before + - **Migration**: If your code relies on reading raw multipart body via `getContent()`, you'll need to adapt it + ## [0.13.0] - 2026-04-05 ### Added diff --git a/src/DTO/RequestConverter.php b/src/DTO/RequestConverter.php index b08f515..684ad22 100644 --- a/src/DTO/RequestConverter.php +++ b/src/DTO/RequestConverter.php @@ -22,8 +22,10 @@ public static function toSymfonyRequest(\Workerman\Protocols\Http\Request $rawRe // Only populate POST bag for form-encoded content types // JSON and other content types should leave POST bag empty (like PHP-FPM) - $contentType = $rawRequest->header('content-type', ''); - $isFormData = preg_match('/^(application\/x-www-form-urlencoded|multipart\/form-data)\b/i', (string) $contentType); + $contentType = strtolower((string) $rawRequest->header('content-type', '')); + $isFormUrlEncoded = str_starts_with($contentType, 'application/x-www-form-urlencoded'); + $isMultipart = str_starts_with($contentType, 'multipart/form-data'); + $isFormData = $isFormUrlEncoded || $isMultipart; // Build server bag with HTTP_* headers (CGI convention) $headers = $rawRequest->header() ?? []; @@ -69,6 +71,10 @@ public static function toSymfonyRequest(\Workerman\Protocols\Http\Request $rawRe } } + // For multipart requests, pass empty content to match PHP-FPM behavior + // (php://input is not available for multipart - body is consumed during $_POST/$_FILES parsing) + $content = $isMultipart ? '' : $rawRequest->rawBody(); + return new Request( is_array($query) ? $query : [], $isFormData && is_array($post) ? $post : [], @@ -76,7 +82,7 @@ public static function toSymfonyRequest(\Workerman\Protocols\Http\Request $rawRe is_array($cookies) ? $cookies : [], $files, $server, - $rawRequest->rawBody(), + $content, ); } } diff --git a/tests/RequestConverterTest.php b/tests/RequestConverterTest.php index 1d1b0e0..1a50f44 100644 --- a/tests/RequestConverterTest.php +++ b/tests/RequestConverterTest.php @@ -374,6 +374,56 @@ public function testRequestTimeAndRequestTimeFloatAreSet(): void $this->assertSame($requestTime, (int) $requestTimeFloat); } + public function testMultipartRequestReturnsEmptyContent(): void + { + $buffer = $this->createMultipartRequest( + boundary: 'TestBoundary', + fields: [ + [ + 'name' => 'test_file', + 'filename' => 'test.txt', + 'content' => 'test content', + ], + ], + ); + + $rawRequest = new Request($buffer); + $symfonyRequest = RequestConverter::toSymfonyRequest($rawRequest); + + $this->assertSame('', $symfonyRequest->getContent()); + $this->assertTrue($symfonyRequest->files->has('test_file')); + } + + public function testJsonRequestPreservesContent(): void + { + $buffer = "POST /test HTTP/1.1\r\n"; + $buffer .= "Host: localhost\r\n"; + $buffer .= "Content-Type: application/json\r\n"; + $buffer .= "Content-Length: 15\r\n"; + $buffer .= "\r\n"; + $buffer .= '{"key":"value"}'; + + $rawRequest = new Request($buffer); + $symfonyRequest = RequestConverter::toSymfonyRequest($rawRequest); + + $this->assertSame('{"key":"value"}', $symfonyRequest->getContent()); + } + + public function testFormUrlEncodedPreservesContent(): void + { + $buffer = "POST /test HTTP/1.1\r\n"; + $buffer .= "Host: localhost\r\n"; + $buffer .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $buffer .= "Content-Length: 9\r\n"; + $buffer .= "\r\n"; + $buffer .= 'key=value'; + + $rawRequest = new Request($buffer); + $symfonyRequest = RequestConverter::toSymfonyRequest($rawRequest); + + $this->assertSame('key=value', $symfonyRequest->getContent()); + } + private function createMockConnection(int $localPort): \Workerman\Connection\TcpConnection { return new class ($localPort) extends \Workerman\Connection\TcpConnection {