diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 2d668e22..d907e643 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -16,12 +16,14 @@ use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Uri; use Pest\Browser\Contracts\HttpServer; use Pest\Browser\Exceptions\ServerNotFoundException; use Pest\Browser\Execution; use Pest\Browser\GlobalState; +use Pest\Browser\Http\RequestBodyParser; use Psr\Log\NullLogger; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -48,6 +50,11 @@ final class LaravelHttpServer implements HttpServer */ private ?Throwable $lastThrowable = null; + /** + * A body parser for url-encoded and multipart requests + */ + private RequestBodyParser $requestBodyParser; + /** * Creates a new laravel http server instance. */ @@ -55,7 +62,7 @@ public function __construct( public readonly string $host, public readonly int $port, ) { - // + $this->requestBodyParser = new RequestBodyParser(); } /** @@ -235,20 +242,16 @@ private function handleRequest(AmpRequest $request): Response $kernel = app()->make(HttpKernel::class); - $contentType = $request->getHeader('content-type') ?? ''; $method = mb_strtoupper($request->getMethod()); $rawBody = (string) $request->getBody(); - $parameters = []; - if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) { - parse_str($rawBody, $parameters); - } + [$post, $files] = $this->requestBodyParser->parseForm($request, $rawBody); $symfonyRequest = Request::create( $absoluteUrl, $method, - $parameters, + $post, $request->getCookies(), - [], // @TODO files... + $this->convertUploadedFiles($files), [], // @TODO server variables... $rawBody ); @@ -296,6 +299,28 @@ private function handleRequest(AmpRequest $request): Response ); } + /** + * Convert the array to a Laravel file, to keep the test flag. + * If the file is empty, we return the original array, so Laravel + * would convert it to a null. + */ + // @phpstan-ignore-next-line + private function convertUploadedFiles(array $files): array + { + // @phpstan-ignore-next-line + return array_map(function (array $file) { + if (isset($file['error'])) { + if ($file['error'] === UPLOAD_ERR_NO_FILE) { + return $file; + } + + return new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['error'], true); + } + + return $this->convertUploadedFiles($file); + }, $files); + } + /** * Return an asset response. */ diff --git a/src/Http/MultipartParser.php b/src/Http/MultipartParser.php new file mode 100644 index 00000000..206525fe --- /dev/null +++ b/src/Http/MultipartParser.php @@ -0,0 +1,397 @@ +, "$_FILES": array} + */ + private array $superglobals = ['$_POST' => [], '$_FILES' => []]; + + private ?int $maxFileSize = null; + + /** + * Based on $maxInputVars and $maxFileUploads + */ + private int $maxMultipartBodyParts; + + /** + * ini setting "max_input_vars" + * + * Assume PHP default of 1000 here. + * + * @link http://php.net/manual/en/info.configuration.php#ini.max-input-vars + */ + private int $maxInputVars; + + /** + * ini setting "max_input_nesting_level" + * + * Assume PHP's default of 64 here. + * + * @link http://php.net/manual/en/info.configuration.php#ini.max-input-nesting-level + */ + private int $maxInputNestingLevel; + + /** + * ini setting "upload_max_filesize" + */ + private int|float $uploadMaxFilesize; + + /** + * ini setting "max_file_uploads" + * + * Additionally, setting "file_uploads = off" effectively sets this to zero. + */ + private int $maxFileUploads; + + private int $multipartBodyPartCount = 0; + + private int $postCount = 0; + + private int $filesCount = 0; + + private int $emptyCount = 0; + + private int|false $cursor = 0; + + public function __construct( + ?string $uploadMaxFilesize = null, + ?int $maxFileUploads = null, + ) { + $this->maxInputVars = (int) ini_get('max_input_vars'); + $this->maxInputNestingLevel = (int) ini_get('max_input_nesting_level'); + + if ($uploadMaxFilesize === null) { + $uploadMaxFilesize = (string) ini_get('upload_max_filesize'); + } + + $this->uploadMaxFilesize = self::iniSizeToBytes($uploadMaxFilesize); + $this->maxFileUploads = $maxFileUploads ?? (ini_get('file_uploads') === '' ? 0 : (int) ini_get('max_file_uploads')); + + $this->maxMultipartBodyParts = $this->maxInputVars + $this->maxFileUploads; + } + + /** + * @return array{array, array} + */ + public function parse(AmpRequest $request, string $body): array + { + $contentType = $request->getHeader('content-type') ?? ''; + if (preg_match('/boundary="?(.*?)"?$/', $contentType, $matches) === false) { + return [[], []]; + } + + $this->parseBody('--'.$matches[1], $body); + + $superglobals = $this->superglobals; + $this->superglobals = ['$_POST' => [], '$_FILES' => []]; + $this->multipartBodyPartCount = 0; + $this->cursor = 0; + $this->postCount = 0; + $this->filesCount = 0; + $this->emptyCount = 0; + $this->maxFileSize = null; + + return array_values($superglobals); + } + + /** + * @see https://github.com/reactphp/http/blob/3.x/src/Io/IniUtil.php + */ + private static function iniSizeToBytes(string $size): int|float + { + if (is_numeric($size)) { + return (int) $size; + } + + $suffix = strtoupper(substr($size, -1)); + $strippedSize = substr($size, 0, -1); + + if (! is_numeric($strippedSize)) { + throw new InvalidArgumentException("$size is not a valid ini size"); + } + + if ($strippedSize <= 0) { + throw new InvalidArgumentException("Expect $size to be higher isn't zero or lower"); + } + + if ($suffix === 'K') { + return $strippedSize * 1024; + } + if ($suffix === 'M') { + return $strippedSize * 1024 * 1024; + } + if ($suffix === 'G') { + return $strippedSize * 1024 * 1024 * 1024; + } + if ($suffix === 'T') { + return $strippedSize * 1024 * 1024 * 1024 * 1024; + } + + return (int) $size; + } + + private function parseBody(string $boundary, string $buffer): void + { + $len = strlen($boundary); + + // ignore everything before initial boundary (SHOULD be empty) + $this->cursor = strpos($buffer, $boundary."\r\n"); + + while ($this->cursor !== false) { + // search following boundary (preceded by newline) + // ignore last if not followed by boundary (SHOULD end with "--") + $this->cursor += $len + 2; + $end = strpos($buffer, "\r\n".$boundary, $this->cursor); + if ($end === false) { + break; + } + + // parse one part and continue searching for next + $this->parsePart(substr($buffer, $this->cursor, $end - $this->cursor)); + $this->cursor = $end; + + if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { + break; + } + } + } + + private function parsePart(string $chunk): void + { + $pos = strpos($chunk, "\r\n\r\n"); + if ($pos === false) { + return; + } + + $headers = $this->parseHeaders(substr($chunk, 0, $pos)); + $body = substr($chunk, $pos + 4); + + if (! isset($headers['content-disposition'])) { + return; + } + + $name = $this->getParameterFromHeader($headers['content-disposition'], 'name'); + if ($name === null) { + return; + } + + $filename = $this->getParameterFromHeader($headers['content-disposition'], 'filename'); + if ($filename !== null) { + $this->parseFile( + $name, + $filename, + $headers['content-type'][0] ?? null, + $body + ); + } else { + $this->parsePost($name, $body); + } + } + + private function parseFile(string $name, string $filename, ?string $contentType, string $contents): void + { + $file = $this->parseUploadedFile($filename, $contentType, $contents); + if ($file === null) { + return; + } + + $this->superglobals['$_FILES'] = $this->extractPost( + $this->superglobals['$_FILES'], + $name, + $file, + ); + } + + /** + * @return array{tmp_file: string, error: int, name: string, type: string, size: int}|null + */ + private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?array + { + $contentType ??= 'application/octet-stream'; + $size = strlen($contents); + + // no file selected (zero size and empty filename) + if ($size === 0 && $filename === '') { + // ignore excessive number of empty file uploads + if (++$this->emptyCount + $this->filesCount > $this->maxInputVars) { + return null; + } + + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; + } + + // ignore excessive number of file uploads + if (++$this->filesCount > $this->maxFileUploads) { + return null; + } + + // file exceeds "upload_max_filesize" ini setting + if ($size > $this->uploadMaxFilesize) { + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_INI_SIZE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; + } + + // file exceeds MAX_FILE_SIZE value + if ($this->maxFileSize !== null && $size > $this->maxFileSize) { + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_FORM_SIZE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; + } + + $tempFileName = tempnam(sys_get_temp_dir(), 'php'); + file_put_contents($tempFileName, $contents); + + return [ + 'tmp_name' => $tempFileName, + 'error' => UPLOAD_ERR_OK, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; + } + + private function parsePost(string $name, string $value): void + { + // ignore excessive number of post fields + if (++$this->postCount > $this->maxInputVars) { + return; + } + + $this->superglobals['$_POST'] = $this->extractPost( + $this->superglobals['$_POST'], + $name, + $value + ); + + if (strtoupper($name) === 'MAX_FILE_SIZE') { + $this->maxFileSize = (int) $value; + + if ($this->maxFileSize === 0) { + $this->maxFileSize = null; + } + } + } + + /** + * @return array + */ + private function parseHeaders(string $header): array + { + $headers = []; + + foreach (explode("\r\n", trim($header)) as $line) { + $parts = explode(':', $line, 2); + if (! isset($parts[1])) { + continue; + } + + $key = strtolower(trim($parts[0])); + $values = explode(';', $parts[1]); + $values = array_map('trim', $values); + $headers[$key] = $values; + } + + return $headers; + } + + /** + * @param string[] $header + */ + private function getParameterFromHeader(array $header, string $parameter): ?string + { + foreach ($header as $part) { + if (preg_match('/'.$parameter.'="?(.*?)"?$/', $part, $matches) === 1) { + return $matches[1]; + } + } + + return null; + } + + /** + * @template TArray of array + * + * @param TArray $postFields + * @return TArray + */ + private function extractPost(array $postFields, string $key, mixed $value): array + { + $chunks = explode('[', $key); + if (count($chunks) === 1) { + $postFields[$key] = $value; + + /** @phpstan-ignore-next-line I don't know why it changes the value */ + return $postFields; + } + + // ignore this key if maximum nesting level is exceeded + if (isset($chunks[$this->maxInputNestingLevel])) { + return $postFields; + } + + $chunkKey = rtrim($chunks[0], ']'); + $parent = &$postFields; + for ($i = 1; isset($chunks[$i]); $i++) { + $previousChunkKey = $chunkKey; + + if ($previousChunkKey === '') { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent[] = []; + /** @phpstan-ignore-next-line Array manipulation over mixed */ + end($parent); + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent = &$parent[key($parent)]; + } else { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + if (! isset($parent[$previousChunkKey]) || ! is_array($parent[$previousChunkKey])) { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent[$previousChunkKey] = []; + } + $parent = &$parent[$previousChunkKey]; + } + + $chunkKey = rtrim($chunks[$i], ']'); + } + + if ($chunkKey === '') { + /** @phpstan-ignore-next-line More array manipulation over mixed */ + $parent[] = $value; + } else { + /** @phpstan-ignore-next-line More array manipulation over mixed */ + $parent[$chunkKey] = $value; + } + + return $postFields; + } +} diff --git a/src/Http/RequestBodyParser.php b/src/Http/RequestBodyParser.php new file mode 100644 index 00000000..a5d41d00 --- /dev/null +++ b/src/Http/RequestBodyParser.php @@ -0,0 +1,55 @@ +multipart = new MultipartParser($uploadMaxFilesize, $maxFileUploads); + } + + /** + * @return array{array, array} + */ + public function parseForm(AmpRequest $request, string $body): array + { + $type = strtolower($request->getHeader('Content-Type') ?? ''); + [$type] = explode(';', $type); + + if ($type === 'application/x-www-form-urlencoded') { + return $this->parseFormUrlencoded($body); + } + + if ($type === 'multipart/form-data') { + return $this->multipart->parse($request, $body); + } + + return [[], []]; + } + + /** + * @return array{array, array} + */ + private function parseFormUrlencoded(string $body): array + { + // parse string into array structure + // ignore warnings due to excessive data structures (max_input_vars and max_input_nesting_level) + $ret = []; + @parse_str($body, $ret); + + return [$ret, []]; + } +} diff --git a/tests/Fixtures/example.pdf b/tests/Fixtures/example.pdf new file mode 100644 index 00000000..afa36062 Binary files /dev/null and b/tests/Fixtures/example.pdf differ diff --git a/tests/Fixtures/lorem-ipsum.txt b/tests/Fixtures/lorem-ipsum.txt new file mode 100644 index 00000000..f1727858 --- /dev/null +++ b/tests/Fixtures/lorem-ipsum.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sit amet bibendum dui. Phasellus viverra justo quam, eu sollicitudin felis commodo eu. Integer at elit commodo, egestas ipsum eu, vestibulum libero. Donec ut metus sed sapien lobortis ultricies sit amet ac quam. Proin vitae dui non tortor laoreet laoreet quis rutrum leo. Ut pharetra nec arcu gravida egestas. Duis eget nisl pharetra, fermentum sapien vel, tempus ligula. Sed fringilla feugiat gravida. Mauris efficitur eu metus non consectetur. Vestibulum faucibus lacus tellus, ut vulputate nisl dapibus nec. Nunc eleifend nisl urna, ut imperdiet velit tristique nec. Nunc vel lacus libero. Praesent et pretium metus. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +In urna tellus, interdum in molestie nec, venenatis ut turpis. Aliquam erat volutpat. Nulla dictum turpis et tellus iaculis egestas. Proin velit neque, posuere sit amet sapien eget, dapibus pharetra velit. Morbi ultricies arcu et orci tempus accumsan. Maecenas ullamcorper, tortor nec finibus iaculis, sapien ipsum varius turpis, sit amet rutrum turpis libero cursus lorem. Nam bibendum lectus odio, ut sollicitudin justo gravida in. Integer aliquam, dolor eu tempor rutrum, enim nisl fringilla eros, a eleifend lectus est in turpis. + +Nulla interdum mi erat, cursus ultricies nisi vulputate non. Cras at neque ex. Sed sodales vehicula dolor, ut dignissim eros convallis et. Vestibulum ornare sagittis eleifend. Mauris fermentum orci est, sed venenatis ex tincidunt id. Morbi ut nisi sagittis, egestas quam in, ornare tellus. Mauris molestie tincidunt lorem ac volutpat. + +Duis fermentum, tellus vitae mollis finibus, diam augue pretium sapien, sed mattis urna lorem a leo. Sed quis enim finibus, finibus dui sit amet, luctus elit. Nullam velit enim, fermentum sit amet sem vitae, elementum volutpat justo. Integer porttitor tellus non dictum vulputate. Donec vulputate eu mi sit amet maximus. Donec egestas metus sit amet ante egestas, in suscipit elit dapibus. Proin magna massa, fringilla placerat facilisis vel, eleifend quis erat. Praesent ultrices lectus ac condimentum consectetur. + +Ut vel venenatis augue, nec luctus neque. Nullam commodo urna a felis tempor posuere. Duis nec nisl eget leo efficitur feugiat. Nulla elementum massa a sapien finibus fermentum. Quisque pretium ligula in sapien auctor, in rhoncus ex eleifend. Sed id bibendum orci, sed ultrices sem. Curabitur sollicitudin quam odio, dignissim lacinia dolor ultrices vel. diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 1b9aa3de..336f9eff 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; + it('rewrites the URLs on JS files', function (): void { @file_put_contents( public_path('app.js'), @@ -15,3 +18,138 @@ $page->assertSee('http://127.0.0.1') ->assertDontSee('http://localhost'); }); + +it('parse a URL-encoded body', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+ + + "); + + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('Hello World'); +}); + +it('parse a multipart body with files', function (): void { + Route::get('favicon.ico', static fn (): string => ''); + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+

Text file: {$request->file('file1')?->getClientOriginalName()}

+

Binary file: {$request->file('file2')?->getClientOriginalName()}

+

Empty file: {$request->file('file3')?->getClientOriginalName()}

+ + + "); + + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->attach('Your text file', fixture('lorem-ipsum.txt')); + $page->attach('Your binary file', fixture('example.pdf')); + $page->submit(); + + $page->assertSee('Hello World'); + $page->assertSee('Text file: lorem-ipsum.txt'); + $page->assertSee('Binary file: example.pdf'); + $page->assertSee('Empty file: '); +}); + +it('parse a multipart body with nested fields', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + + + + +
+ + + "); + Route::post('/form', static function (Request $request): string { + $childNames = implode(', ', (array) $request->input('children')); + + return " + + + +

Hello {$request->input('person.first_name')} {$request->input('person.last_name')}

+

and $childNames

+ + + "; + }); + + $page = visit('/'); + + $page->fill('Your first name', 'Jane'); + $page->fill('Your last name', 'Doe'); + $page->fill('Child 2', 'Johnathan'); + $page->fill('Child 3', 'Jamie'); + $page->fill('Child 1', 'John'); + $page->submit(); + + $page->assertSee('Hello Jane Doe') + ->assertSee('and John, Johnathan, Jamie'); +});