Merge pull request #664 from KnifeLemon/master

fixed multiple file upload errors
master
n0nag0n 5 days ago committed by GitHub
commit 854f668222
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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/",

@ -147,6 +147,13 @@ class Request
*/
public string $body = '';
/**
* Hold tmp file handles created via tmpfile() so they persist for request lifetime
*
* @var array<int, resource>
*/
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<string, array<string,UploadedFile>|array<string,array<string,UploadedFile>>> The array of uploaded files.
* @return array<string, UploadedFile|array<int, UploadedFile>> 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<string,mixed> 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<string,string|int>
*/
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];
}
}

@ -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');
}
}

@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace tests;
use flight\net\Request;
use flight\util\Collection;
use PHPUnit\Framework\TestCase;
class RequestBodyParserTest extends TestCase
{
protected function setUp(): void
{
$_SERVER = [];
$_REQUEST = [];
$_GET = [];
$_POST = [];
$_COOKIE = [];
$_FILES = [];
}
protected function tearDown(): void
{
unset($_REQUEST);
unset($_SERVER);
}
private function createRequestConfig(string $method, string $contentType, string $body, &$tmpfile = null): array
{
$tmpfile = tmpfile();
$stream_path = stream_get_meta_data($tmpfile)['uri'];
file_put_contents($stream_path, $body);
return [
'url' => '/',
'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());
}
}

@ -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)
/*
<input type="file" name="files_1">
*/
$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)
/*
<input type="file" name="files_2[]">
*/
$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)
/*
<input type="file" name="files_3[]">
<input type="file" name="files_3[]">
*/
$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

@ -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');
}
}

Loading…
Cancel
Save