diff --git a/composer.json b/composer.json index 96d45db..2e6ca74 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.6", "rregeer/phpunit-coverage-check": "^0.3.1", - "spatie/phpunit-watcher": "^1.23 || ^1.24", "squizlabs/php_codesniffer": "^3.11" }, "config": { @@ -62,7 +61,7 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/phpunit-watcher watch", + "test": "phpunit", "test-ci": "phpunit", "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", diff --git a/flight/net/Request.php b/flight/net/Request.php index c56f1f4..4aa51f4 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -147,6 +147,13 @@ class Request */ public string $body = ''; + /** + * Hold tmp file handles created via tmpfile() so they persist for request lifetime + * + * @var array + */ + private array $tmpFileHandles = []; + /** * Constructor. * @@ -233,14 +240,9 @@ class Request $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data - } elseif (in_array($this->method, ['PUT', 'DELETE', 'PATCH'], true) === true) { - $body = $this->getBody(); - if ($body !== '') { - $data = []; - parse_str($body, $data); - $this->data->setData($data); - } + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data + } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + $this->parseRequestBodyForHttpMethods(); } return $this; @@ -473,13 +475,17 @@ class Request /** * Retrieves the array of uploaded files. * - * @return array|array>> The array of uploaded files. + * @return array> Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded. */ public function getUploadedFiles(): array { - $files = []; + $uploadedFiles = []; $correctedFilesArray = $this->reArrayFiles($this->files); foreach ($correctedFilesArray as $keyName => $files) { + // Check if original data was array format (files_name[] style) + $originalFile = $this->files->getData()[$keyName] ?? null; + $isArrayFormat = $originalFile && is_array($originalFile['name']); + foreach ($files as $file) { $UploadedFile = new UploadedFile( $file['name'], @@ -488,15 +494,17 @@ class Request $file['tmp_name'], $file['error'] ); - if (count($files) > 1) { - $files[$keyName][] = $UploadedFile; + + // Always use array format if original data was array, regardless of count + if ($isArrayFormat === true) { + $uploadedFiles[$keyName][] = $UploadedFile; } else { - $files[$keyName] = $UploadedFile; + $uploadedFiles[$keyName] = $UploadedFile; } } } - return $files; + return $uploadedFiles; } /** @@ -508,10 +516,9 @@ class Request */ protected function reArrayFiles(Collection $filesCollection): array { - $fileArray = []; foreach ($filesCollection as $fileKeyName => $file) { - $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $isMulti = is_array($file['name']) === true; $fileCount = $isMulti === true ? count($file['name']) : 1; $fileKeys = array_keys($file); @@ -528,4 +535,302 @@ class Request return $fileArray; } + + /** + * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) + * + * @return void + */ + protected function parseRequestBodyForHttpMethods(): void + { + $body = $this->getBody(); + + // Empty body + if ($body === '') { + return; + } + + // Check Content-Type for multipart/form-data + $contentType = strtolower(trim($this->type)); + $isMultipart = strpos($contentType, 'multipart/form-data') === 0; + $boundary = null; + + if ($isMultipart === true) { + // Extract boundary more safely + if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { + $boundary = $matches[2]; + } + + // If no boundary found, it's not valid multipart + if (empty($boundary)) { + $isMultipart = false; + } + + $firstLine = strtok($body, "\r\n"); + if ($firstLine === false || strpos($firstLine, '--' . $boundary) !== 0) { + // Does not start with the boundary marker; fall back + $isMultipart = false; + } + } + + // Parse application/x-www-form-urlencoded + if ($isMultipart === false) { + parse_str($body, $data); + $this->data->setData($data); + return; + } + + $this->setParsedRequestBodyMultipartFormData($body, $boundary); + } + + /** + * Sets the parsed request body for multipart form data requests + * + * This method processes and stores multipart form data from the request body, + * parsing it according to the specified boundary delimiter. It handles the + * complex parsing of multipart data including file uploads and form fields. + * + * @param string $body The raw multipart request body content + * @param string $boundary The boundary string used to separate multipart sections + * + * @return void + */ + protected function setParsedRequestBodyMultipartFormData(string $body, string $boundary): void + { + + $data = []; + $file = []; + + // Parse multipart/form-data + $bodyParts = preg_split('/\R?-+' . preg_quote($boundary, '/') . '/s', $body); + array_pop($bodyParts); // Remove last element (closing boundary or empty) + + $partsProcessed = 0; + $filesTotalBytes = 0; + // Use ini values directly + $maxParts = (int) ini_get('max_file_uploads'); + if ($maxParts <= 0) { + // unlimited parts if not specified + $maxParts = PHP_INT_MAX; // @codeCoverageIgnore + } + $maxTotalBytes = self::derivePostMaxSizeBytes(); + + foreach ($bodyParts as $bodyPart) { + if ($partsProcessed >= $maxParts) { + // reached part limit from ini + break; // @codeCoverageIgnore + } + if ($bodyPart === '' || $bodyPart === null) { + continue; // skip empty segments + } + $partsProcessed++; + + // Split headers and value; if format invalid, skip early + $split = preg_split('/\R\R/', $bodyPart, 2); + if ($split === false || count($split) < 2) { + continue; + } + [$header, $value] = $split; + + // Fast header sanity checks + if (stripos($header, 'content-disposition') === false) { + continue; + } + if (strlen($header) > 16384) { // 16KB header block guard + continue; + } + + $value = ltrim($value, "\r\n"); + + // Parse headers (simple approach, fail-fast on anomalies) + $headers = $this->parseRequestBodyHeadersFromMultipartFormData($header); + + // Required disposition/name + if (isset($headers['content-disposition']['name']) === false) { + continue; + } + $keyName = str_replace('[]', '', (string) $headers['content-disposition']['name']); + if ($keyName === '') { + continue; // avoid empty keys + } + + // Non-file field + if (isset($headers['content-disposition']['filename']) === false) { + if (isset($data[$keyName]) === false) { + $data[$keyName] = $value; + } else { + if (is_array($data[$keyName]) === false) { + $data[$keyName] = [$data[$keyName]]; + } + $data[$keyName][] = $value; + } + continue; // done with this part + } + + // Sanitize filename early + $rawFilename = (string) $headers['content-disposition']['filename']; + $rawFilename = str_replace(["\0", "\r", "\n"], '', $rawFilename); + $sanitizedFilename = basename($rawFilename); + $matchCriteria = preg_match('/^[A-Za-z0-9._-]{1,255}$/', $sanitizedFilename); + if ($sanitizedFilename === '' || $matchCriteria !== 1) { + $sanitizedFilename = 'upload_' . uniqid('', true); + } + + $size = mb_strlen($value, '8bit'); + $filesTotalBytes += $size; + $tmpFile = [ + 'name' => $sanitizedFilename, + 'type' => $headers['content-type'] ?? 'application/octet-stream', + 'size' => $size, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_OK, + ]; + + // Fail fast on size constraints + if ($size > $this->getUploadMaxFileSize() || $filesTotalBytes > $maxTotalBytes) { + // individual file or total size exceeded + $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; // @codeCoverageIgnore + } else { + $tempResult = $this->createTempFile($value); + $tmpFile['tmp_name'] = $tempResult['tmp_name']; + $tmpFile['error'] = $tempResult['error']; + } + + // Aggregate into synthetic files array + foreach ($tmpFile as $metaKey => $metaVal) { + if (!isset($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = $metaVal; + continue; + } + if (!is_array($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = [$file[$keyName][$metaKey]]; + } + $file[$keyName][$metaKey][] = $metaVal; + } + } + + $this->data->setData($data); + $this->files->setData($file); + } + + /** + * Parses request body headers from multipart form data + * + * This method extracts and processes headers from a multipart form data section, + * typically used for file uploads or complex form submissions. It parses the + * header string and returns an associative array of header name-value pairs. + * + * @param string $header The raw header string from a multipart form data section + * + * @return array An associative array containing parsed header name-value pairs + */ + protected function parseRequestBodyHeadersFromMultipartFormData(string $header): array + { + $headers = []; + foreach (preg_split('/\R/', $header) as $headerLine) { + if (strpos($headerLine, ':') === false) { + continue; + } + [$headerKey, $headerValue] = explode(':', $headerLine, 2); + $headerKey = strtolower(trim($headerKey)); + $headerValue = trim($headerValue); + if (strpos($headerValue, ';') !== false) { + $headers[$headerKey] = []; + foreach (explode(';', $headerValue) as $hvPart) { + preg_match_all('/(\w+)="?([^";]+)"?/', $hvPart, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $subKey = strtolower($m[1]); + $headers[$headerKey][$subKey] = $m[2]; + } + } + } else { + $headers[$headerKey] = $headerValue; + } + } + return $headers; + } + + /** + * Get the maximum file size that can be uploaded. + * + * @return int The maximum file size in bytes. + */ + public function getUploadMaxFileSize(): int + { + $value = ini_get('upload_max_filesize'); + return self::parsePhpSize($value); + } + + /** + * Parse a PHP shorthand size string (like "1K", "1.5M") into bytes. + * Returns 0 on unknown or unsupported unit (keeps existing behavior). + * + * @param string $size + * + * @return int + */ + public static function parsePhpSize(string $size): int + { + $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $size)); + $value = (int) preg_replace('/[^\d.]/', '', $size); + + // No unit => follow existing behavior and return value directly if > 1024 (1K) + if ($unit === '' && $value >= 1024) { + return $value; + } + + switch ($unit) { + case 't': + case 'tb': + $value *= 1024; // Fall through + case 'g': + case 'gb': + $value *= 1024; // Fall through + case 'm': + case 'mb': + $value *= 1024; // Fall through + case 'k': + $value *= 1024; + break; + default: + return 0; + } + + return $value; + } + + /** + * Derive post_max_size in bytes. Returns 0 when unlimited or unparsable. + */ + private static function derivePostMaxSizeBytes(): int + { + $postMax = (string) ini_get('post_max_size'); + $bytes = self::parsePhpSize($postMax); + return $bytes; // 0 means unlimited + } + + /** + * Create a temporary file for uploaded content using tmpfile(). + * Returns array with tmp_name and error code. + * + * @param string $content + * + * @return array + */ + private function createTempFile(string $content): array + { + $fp = tmpfile(); + if ($fp === false) { + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $bytes = fwrite($fp, $content); + if ($bytes === false) { + fclose($fp); // @codeCoverageIgnore + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $meta = stream_get_meta_data($fp); + $tmpName = isset($meta['uri']) ? $meta['uri'] : ''; + $this->tmpFileHandles[] = $fp; // retain handle for lifecycle + return ['tmp_name' => $tmpName, 'error' => UPLOAD_ERR_OK]; + } } diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php index 2b3947b..c2f3ad3 100644 --- a/flight/net/UploadedFile.php +++ b/flight/net/UploadedFile.php @@ -33,6 +33,11 @@ class UploadedFile */ private int $error; + /** + * @var bool $isPostUploadedFile Indicates if the file was uploaded via POST method. + */ + private bool $isPostUploadedFile = false; + /** * Constructs a new UploadedFile object. * @@ -41,14 +46,20 @@ class UploadedFile * @param int $size The size of the uploaded file in bytes. * @param string $tmpName The temporary name of the uploaded file. * @param int $error The error code associated with the uploaded file. + * @param bool|null $isPostUploadedFile Indicates if the file was uploaded via POST method. */ - public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error) + public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error, ?bool $isPostUploadedFile = null) { $this->name = $name; $this->mimeType = $mimeType; $this->size = $size; $this->tmpName = $tmpName; $this->error = $error; + if (is_uploaded_file($tmpName) === true) { + $this->isPostUploadedFile = true; // @codeCoverageIgnore + } else { + $this->isPostUploadedFile = $isPostUploadedFile ?? false; + } } /** @@ -114,15 +125,42 @@ class UploadedFile throw new Exception($this->getUploadErrorMessage($this->error)); } - $isUploadedFile = is_uploaded_file($this->tmpName) === true; - if ( - $isUploadedFile === true - && - move_uploaded_file($this->tmpName, $targetPath) === false - ) { - throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore - } elseif ($isUploadedFile === false && getenv('PHPUNIT_TEST')) { - rename($this->tmpName, $targetPath); + if (is_writeable(dirname($targetPath)) === false) { + throw new Exception('Target directory is not writable'); + } + + // Prevent path traversal attacks + if (strpos($targetPath, '..') !== false) { + throw new Exception('Invalid target path: contains directory traversal'); + } + // Prevent absolute paths (basic check for Unix/Windows) + if ($targetPath[0] === '/' || (strlen($targetPath) > 1 && $targetPath[1] === ':')) { + throw new Exception('Invalid target path: absolute paths not allowed'); + } + + // Prevent overwriting existing files + if (file_exists($targetPath)) { + throw new Exception('Target file already exists'); + } + + // Check if this is a legitimate uploaded file (POST method uploads) + $isUploadedFile = $this->isPostUploadedFile; + + // Prevent symlink attacks for non-POST uploads + if (!$isUploadedFile && is_link($this->tmpName)) { + throw new Exception('Invalid temp file: symlink detected'); + } + + $uploadFunctionToCall = $isUploadedFile === true ? + // Standard POST upload - use move_uploaded_file for security + 'move_uploaded_file' : + // Handle non-POST uploads (PATCH, PUT, DELETE) or other valid temp files + 'rename'; + + $result = $uploadFunctionToCall($this->tmpName, $targetPath); + + if ($result === false) { + throw new Exception('Cannot move uploaded file'); } } diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php new file mode 100644 index 0000000..1cc5bdf --- /dev/null +++ b/tests/RequestBodyParserTest.php @@ -0,0 +1,449 @@ + '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => $contentType, + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + } + + private function assertUrlEncodedParsing(string $method): void + { + $body = 'foo=bar&baz=qux&key=value'; + $tmpfile = null; + $config = $this->createRequestConfig($method, 'application/x-www-form-urlencoded', $body, $tmpfile); + + $request = new Request($config); + + $expectedData = [ + 'foo' => 'bar', + 'baz' => 'qux', + 'key' => 'value' + ]; + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + private function createMultipartBody(string $boundary, array $fields, array $files = []): string + { + $body = ''; + + // Add form fields + foreach ($fields as $name => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$item}\r\n"; + } + } else { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$value}\r\n"; + } + } + + // Add files + foreach ($files as $name => $file) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$file['filename']}\"\r\n"; + $body .= "Content-Type: {$file['type']}\r\n"; + $body .= "\r\n"; + $body .= "{$file['content']}\r\n"; + } + + $body .= "--{$boundary}--\r\n"; + + return $body; + } + + public function testParseUrlEncodedBodyForPutMethod(): void + { + $this->assertUrlEncodedParsing('PUT'); + } + + public function testParseUrlEncodedBodyForPatchMethod(): void + { + $this->assertUrlEncodedParsing('PATCH'); + } + + public function testParseUrlEncodedBodyForDeleteMethod(): void + { + $this->assertUrlEncodedParsing('DELETE'); + } + + public function testParseMultipartFormDataWithFiles(): void + { + $boundary = 'boundary123456789'; + $fields = ['title' => 'Test Document']; + $files = [ + 'file' => [ + 'filename' => 'file.txt', + 'type' => 'text/plain', + 'content' => 'This is test file content' + ] + ]; + + $body = $this->createMultipartBody($boundary, $fields, $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals(['title' => 'Test Document'], $request->data->getData()); + + $file = $request->getUploadedFiles()['file']; + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(strlen('This is test file content'), $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithQuotedBoundary(): void + { + $boundary = 'boundary123456789'; + $fields = ['foo' => 'bar']; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PATCH', "multipart/form-data; boundary=\"{$boundary}\"", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($fields, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithArrayFields(): void + { + $boundary = 'boundary123456789'; + $fields = ['name[]' => ['foo', 'bar']]; + $expectedData = ['name' => ['foo', 'bar']]; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseEmptyBody(): void + { + $config = $this->createRequestConfig('PUT', 'application/x-www-form-urlencoded', '', $tmpfile); + $request = new Request($config); + + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseInvalidMultipartWithoutBoundary(): void + { + $originalData = ['foo foo' => 'bar bar', 'baz baz' => 'qux']; + $body = http_build_query($originalData); + $expectedData = ['foo_foo' => 'bar bar', 'baz_baz' => 'qux']; + + $config = $this->createRequestConfig('PUT', 'multipart/form-data', $body, $tmpfile); // no boundary + $request = new Request($config); + + // should fall back to URL encoding and parse correctly + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartWithLargeFile(): void + { + $boundary = 'boundary123456789'; + $largeContent = str_repeat('A', 10000); // 10KB content + $files = [ + 'file' => [ + 'filename' => 'large.txt', + 'type' => 'text/plain', + 'content' => $largeContent + ] + ]; + + $body = $this->createMultipartBody($boundary, [], $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $file = $request->getUploadedFiles()['file']; + $this->assertArrayHasKey('file', $request->getUploadedFiles()); + $this->assertEquals('large.txt', $file->getClientFilename()); + $this->assertEquals(10000, $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testGetMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('GET', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // GET method should not trigger parsing + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testPostMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('POST', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // POST method should not trigger this parsing (uses $_POST instead) + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + /** + * Tests getUploadMaxFileSize parsing for various php.ini unit suffixes. + * We'll call the method in-process after setting ini values via ini_set + * and also simulate a value with unknown unit to hit the default branch. + */ + public function testGetUploadMaxFileSizeUnits(): void + { + // Use PHP CLI with -d to set upload_max_filesize (ini_set can't change this setting in many SAPIs) + $cases = [ + // No unit yields default branch which returns 0 in current implementation + ['1' , 0], // no unit and number too small + ['1K' , 1024], + ['2M' , 2 * 1024 * 1024], + ['1G' , 1024 * 1024 * 1024], + ['1T' , 1024 * 1024 * 1024 * 1024], + ['1Z' , 0 ], // Unknown unit and number too small + [ '1024', 1024 ] + ]; + + foreach ($cases as [$iniVal, $expected]) { + $actual = Request::parsePhpSize($iniVal); + $this->assertEquals($expected, $actual, "upload_max_filesize={$iniVal}"); + } + } + + /** + * Helper: run PHP CLI with -d upload_max_filesize and return the Request::getUploadMaxFileSize() result. + */ + // removed CLI helper; parsePhpSize covers unit parsing and is pure + + public function testMultipartBoundaryInvalidFallsBackToUrlEncoded(): void + { + // Body doesn't start with boundary marker => fallback to urlencoded branch + $body = 'field1=value1&field2=value2'; + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=BOUNDARYXYZ', // claims multipart + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['field1' => 'value1', 'field2' => 'value2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } + + public function testMultipartParsingEdgeCases(): void + { + $boundary = 'MBOUND123'; + $parts = []; + + // A: invalid split (no blank line) => skipped + $parts[] = "Content-Disposition: form-data; name=\"skipnosplit\""; // no value portion + + // B: missing content-disposition entirely => skipped + $parts[] = "Content-Type: text/plain\r\n\r\nignoredvalue"; + + // C: header too long (>16384) => skipped + $longHeader = 'Content-Disposition: form-data; name="toolong"; filename="toolong.txt"; ' . str_repeat('x', 16500); + $parts[] = $longHeader . "\r\n\r\nlongvalue"; + + // D: header line without colon gets skipped but rest processed; becomes non-file field + $parts[] = "BadHeaderLine\r\nContent-Disposition: form-data; name=\"fieldX\"\r\n\r\nvalueX"; + + // E: disposition without name => skipped + $parts[] = "Content-Disposition: form-data; filename=\"nofname.txt\"\r\n\r\nnoNameValue"; + + // F: empty name => skipped + $parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue"; + + // G: invalid filename triggers sanitized fallback + $parts[] = "Content-Disposition: form-data; name=\"filebad\"; filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD"; + + // H1 & H2: two files same key for aggregation logic (arrays) + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE"; + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO"; + + // I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE + $parts[] = "Content-Disposition: form-data; name=\"filebig\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n" . str_repeat('A', 10); + + // Build full body + $body = ''; + foreach ($parts as $p) { + $body .= '--' . $boundary . "\r\n" . $p . "\r\n"; + } + $body .= '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $data = $request->data->getData(); + $this->assertArrayHasKey('fieldX', $data); // only processed non-file field + $this->assertEquals('valueX', $data['fieldX']); + $files = $request->files->getData(); + + // filebad fallback name + $this->assertArrayHasKey('filebad', $files); + $this->assertMatchesRegularExpression('/^upload_/', $files['filebad']['name']); + + // filemulti aggregated arrays + $this->assertArrayHasKey('filemulti', $files); + $this->assertEquals(['one.txt', 'two.txt'], $files['filemulti']['name']); + $this->assertEquals(['text/plain', 'text/plain'], $files['filemulti']['type']); + + // filebig error path + $this->assertArrayHasKey('filebig', $files); + $uploadMax = Request::parsePhpSize(ini_get('upload_max_filesize')); + $postMax = Request::parsePhpSize(ini_get('post_max_size')); + $shouldError = ($uploadMax > 0 && $uploadMax < 10) || ($postMax > 0 && $postMax < 10); + if ($shouldError) { + $this->assertEquals(UPLOAD_ERR_INI_SIZE, $files['filebig']['error']); + } else { + $this->assertEquals(UPLOAD_ERR_OK, $files['filebig']['error']); + } + } + + + public function testMultipartEmptyArrayNameStripped(): void + { + // Covers line where keyName becomes empty after removing [] (name="[]") and header param extraction (preg_match_all) + $boundary = 'BOUNDARYEMPTY'; + $validFilePart = "Content-Disposition: form-data; name=\"fileok\"; filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK"; + $emptyNameFilePart = "Content-Disposition: form-data; name=\"[]\"; filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP"; + $body = '--' . $boundary . "\r\n" . $validFilePart . "\r\n" . '--' . $boundary . "\r\n" . $emptyNameFilePart . "\r\n" . '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $files = $request->files->getData(); + // fileok processed + $this->assertArrayHasKey('fileok', $files); + // name="[]" stripped => keyName becomes empty -> skipped + $this->assertArrayNotHasKey('empty', $files); // just to show not mistakenly created + $this->assertCount(5, $files['fileok']); // meta keys name,type,size,tmp_name,error + } + + public function testMultipartMalformedBoundaryFallsBackToUrlEncoded(): void + { + // boundary has invalid characters (spaces) so regex validation fails -> line 589 path + $invalidBoundary = 'BAD BOUNDARY WITH SPACE'; + $body = 'alpha=1&beta=2'; // should parse as urlencoded after fallback + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $invalidBoundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['alpha' => '1', 'beta' => '2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 214b26e..38321ea 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -330,31 +330,78 @@ class RequestTest extends TestCase public function testGetMultiFileUpload(): void { - $_FILES['files'] = [ - 'name' => ['file1.txt', 'file2.txt'], - 'type' => ['text/plain', 'text/plain'], - 'size' => [123, 456], - 'tmp_name' => ['/tmp/php123', '/tmp/php456'], + // Arrange: Setup multiple file upload arrays + $_FILES['files_1'] = [ + 'name' => 'file1.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 + ]; + $_FILES['files_2'] = [ + 'name' => ['file2.txt'], + 'type' => ['text/plain'], + 'size' => [456], + 'tmp_name' => ['/tmp/php456'], + 'error' => [0] + ]; + $_FILES['files_3'] = [ + 'name' => ['file3.txt', 'file4.txt'], + 'type' => ['text/html', 'application/json'], + 'size' => [789, 321], + 'tmp_name' => ['/tmp/php789', '/tmp/php321'], 'error' => [0, 0] ]; + // Act $request = new Request(); + $uploadedFiles = $request->getUploadedFiles(); + + // Assert: Verify first file group (single file) + /* + + */ + $firstFile = $uploadedFiles['files_1'] ?? null; + $this->assertNotNull($firstFile, 'First file should exist'); + $this->assertUploadedFile($firstFile, 'file1.txt', 'text/plain', 123, '/tmp/php123', 0); + + // Assert: Verify second file group (array format with single file) + /* + + */ + $secondGroup = $uploadedFiles['files_2'] ?? []; + $this->assertCount(1, $secondGroup, 'Second file group should contain 1 file in array format'); + + $this->assertUploadedFile($secondGroup[0], 'file2.txt', 'text/plain', 456, '/tmp/php456', 0); + + // Assert: Verify third file group (multiple files) + /* + + + */ + $thirdGroup = $uploadedFiles['files_3'] ?? []; + $this->assertCount(2, $thirdGroup, 'Third file group should contain 2 files'); + + $this->assertUploadedFile($thirdGroup[0], 'file3.txt', 'text/html', 789, '/tmp/php789', 0); + $this->assertUploadedFile($thirdGroup[1], 'file4.txt', 'application/json', 321, '/tmp/php321', 0); + } - $files = $request->getUploadedFiles()['files']; - - $this->assertCount(2, $files); - - $this->assertEquals('file1.txt', $files[0]->getClientFilename()); - $this->assertEquals('text/plain', $files[0]->getClientMediaType()); - $this->assertEquals(123, $files[0]->getSize()); - $this->assertEquals('/tmp/php123', $files[0]->getTempName()); - $this->assertEquals(0, $files[0]->getError()); - - $this->assertEquals('file2.txt', $files[1]->getClientFilename()); - $this->assertEquals('text/plain', $files[1]->getClientMediaType()); - $this->assertEquals(456, $files[1]->getSize()); - $this->assertEquals('/tmp/php456', $files[1]->getTempName()); - $this->assertEquals(0, $files[1]->getError()); + /** + * Helper method to assert uploaded file properties + */ + private function assertUploadedFile( + $file, + string $expectedName, + string $expectedType, + int $expectedSize, + string $expectedTmpName, + int $expectedError + ): void { + $this->assertEquals($expectedName, $file->getClientFilename()); + $this->assertEquals($expectedType, $file->getClientMediaType()); + $this->assertEquals($expectedSize, $file->getSize()); + $this->assertEquals($expectedTmpName, $file->getTempName()); + $this->assertEquals($expectedError, $file->getError()); } public function testUrlWithAtSymbol(): void diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 05cdc95..f64e155 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -18,14 +18,24 @@ class UploadedFileTest extends TestCase if (file_exists('tmp_name')) { unlink('tmp_name'); } + if (file_exists('existing.txt')) { + unlink('existing.txt'); + } + if (file_exists('real_file')) { + unlink('real_file'); + } + + // not found with file_exists...just delete it brute force + @unlink('tmp_symlink'); } - public function testMoveToSuccess(): void + public function testMoveToFalseSuccess(): void { + // This test would have passed in the real world but we can't actually force a post request in unit tests file_put_contents('tmp_name', 'test'); - $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Cannot move uploaded file'); $uploadedFile->moveTo('file.txt'); - $this->assertFileExists('file.txt'); } public function getFileErrorMessageTests(): array @@ -53,4 +63,62 @@ class UploadedFileTest extends TestCase $this->expectExceptionMessage($message); $uploadedFile->moveTo('file.txt'); } + + public function testMoveToBadLocation(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Target directory is not writable'); + $uploadedFile->moveTo('/root/file.txt'); + } + + public function testMoveToSuccessNonPost(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $uploadedFile->moveTo('file.txt'); + $this->assertFileExists('file.txt'); + $this->assertEquals('test', file_get_contents('file.txt')); + } + + public function testMoveToPathTraversal(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: contains directory traversal'); + $uploadedFile->moveTo('../file.txt'); + } + + public function testMoveToAbsolutePath(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: absolute paths not allowed'); + $uploadedFile->moveTo('/tmp/file.txt'); + } + + public function testMoveToOverwrite(): void + { + file_put_contents('tmp_name', 'test'); + file_put_contents('existing.txt', 'existing'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Target file already exists'); + $uploadedFile->moveTo('existing.txt'); + } + + public function testMoveToSymlinkNonPost(): void + { + file_put_contents('real_file', 'test'); + if (file_exists('tmp_symlink')) { + unlink('tmp_symlink'); + } + symlink('real_file', 'tmp_symlink'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_symlink', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid temp file: symlink detected'); + $uploadedFile->moveTo('file.txt'); + } }