adjustments for security, 100% coverage and phpcs

pull/664/head
n0nag0n 2 weeks ago
parent 82daf71d0a
commit 671fea075c

@ -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.
*
@ -177,10 +184,10 @@ class Request
'user_agent' => $this->getVar('HTTP_USER_AGENT'),
'type' => $this->getVar('CONTENT_TYPE'),
'length' => intval($this->getVar('CONTENT_LENGTH', 0)),
'query' => new Collection($_GET ?? []),
'data' => new Collection($_POST ?? []),
'cookies' => new Collection($_COOKIE ?? []),
'files' => new Collection($_FILES ?? []),
'query' => new Collection($_GET),
'data' => new Collection($_POST),
'cookies' => new Collection($_COOKIE),
'files' => new Collection($_FILES),
'secure' => $scheme === 'https',
'accept' => $this->getVar('HTTP_ACCEPT'),
'proxy_ip' => $this->getProxyIpAddress(),
@ -219,7 +226,7 @@ class Request
$this->url = '/';
} else {
// Merge URL query parameters with $_GET
$_GET = array_merge($_GET ?? [], self::parseQuery($this->url));
$_GET = array_merge($_GET, self::parseQuery($this->url));
$this->query->setData($_GET);
}
@ -233,7 +240,7 @@ class Request
$this->data->setData($data);
}
}
// Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-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();
}
@ -468,7 +475,7 @@ 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
{
@ -478,7 +485,7 @@ class Request
// 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'],
@ -487,9 +494,9 @@ class Request
$file['tmp_name'],
$file['error']
);
// Always use array format if original data was array, regardless of count
if ($isArrayFormat) {
if ($isArrayFormat === true) {
$uploadedFiles[$keyName][] = $UploadedFile;
} else {
$uploadedFiles[$keyName] = $UploadedFile;
@ -531,198 +538,299 @@ class Request
/**
* 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) {
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;
}
}
$data = [];
$file = [];
$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 (empty)
$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 (empty($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;
// Get the headers and value
[$header, $value] = preg_split('/\\R\\R/', $bodyPart, 2);
// Check if the header is normal
if (strpos(strtolower($header), 'content-disposition') === false) {
// 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");
/**
* Process Header
*/
$headers = [];
// split the headers
$headerParts = preg_split('/\\R/', $header);
foreach ($headerParts as $headerPart) {
if (strpos($headerPart, ':') === false) {
continue;
}
// Parse headers (simple approach, fail-fast on anomalies)
$headers = $this->parseRequestBodyHeadersFromMultipartFormData($header);
// Process the header
[$headerKey, $headerValue] = explode(':', $headerPart, 2);
$headerKey = strtolower(trim($headerKey));
$headerValue = trim($headerValue);
if (strpos($headerValue, ';') !== false) {
$headers[$headerKey] = [];
foreach (explode(';', $headerValue) as $headerValuePart) {
preg_match_all('/(\w+)=\"?([^";]+)\"?/', $headerValuePart, $headerMatches, PREG_SET_ORDER);
foreach ($headerMatches as $headerMatch) {
$headerSubKey = strtolower($headerMatch[1]);
$headerSubValue = $headerMatch[2];
$headers[$headerKey][$headerSubKey] = $headerSubValue;
}
}
} else {
$headers[$headerKey] = $headerValue;
}
}
/**
* Process Value
*/
if (!isset($headers['content-disposition']) || !isset($headers['content-disposition']['name'])) {
// 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
}
$keyName = str_replace("[]", "", $headers['content-disposition']['name']);
// if is not file
if (!isset($headers['content-disposition']['filename'])) {
if (isset($data[$keyName])) {
if (!is_array($data[$keyName])) {
// 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;
} else {
$data[$keyName] = $value;
}
continue;
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' => $headers['content-disposition']['filename'],
'name' => $sanitizedFilename,
'type' => $headers['content-type'] ?? 'application/octet-stream',
'size' => mb_strlen($value, '8bit'),
'size' => $size,
'tmp_name' => '',
'error' => UPLOAD_ERR_OK,
];
if ($tmpFile['size'] > $this->getUploadMaxFileSize()) {
$tmpFile['error'] = UPLOAD_ERR_INI_SIZE;
// 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 {
// Create a temporary file
$tmpName = tempnam(sys_get_temp_dir(), 'flight_tmp_');
if ($tmpName === false) {
$tmpFile['error'] = UPLOAD_ERR_CANT_WRITE;
} else {
// Write the value to a temporary file
$bytes = file_put_contents($tmpName, $value);
$tempResult = $this->createTempFile($value);
$tmpFile['tmp_name'] = $tempResult['tmp_name'];
$tmpFile['error'] = $tempResult['error'];
}
if ($bytes === false) {
$tmpFile['error'] = UPLOAD_ERR_CANT_WRITE;
} else {
// delete the temporary file before ended script
register_shutdown_function(function () use ($tmpName): void {
if (file_exists($tmpName)) {
unlink($tmpName);
}
});
$tmpFile['tmp_name'] = $tmpName;
}
// 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;
}
}
foreach ($tmpFile as $key => $value) {
if (isset($file[$keyName][$key])) {
if (!is_array($file[$keyName][$key])) {
$file[$keyName][$key] = [$file[$keyName][$key]];
$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];
}
$file[$keyName][$key][] = $value;
} else {
$file[$keyName][$key] = $value;
}
} else {
$headers[$headerKey] = $headerValue;
}
}
$this->data->setData($data);
$this->files->setData($file);
return $headers;
}
/**
* Get the maximum file size that can be uploaded.
*
* @return int The maximum file size in bytes.
*/
protected function getUploadMaxFileSize(): int {
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);
$unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value));
$value = preg_replace('/[^\d.]/', '', $value);
// No unit => follow existing behavior and return value directly if > 1024 (1K)
if ($unit === '' && $value >= 1024) {
return $value;
}
switch ($unit) {
case 'p': // PentaByte
case 'pb':
$value *= 1024;
case 't': // Terabyte
$value *= 1024;
case 'g': // Gigabyte
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;
case 'm': // Megabyte
$value *= 1024;
case 'k': // Kilobyte
$value *= 1024;
case 'b': // Byte
break;
default:
return 0;
}
return (int)$value;
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];
}
}

@ -24,10 +24,6 @@ class RequestBodyParserTest extends TestCase
{
unset($_REQUEST);
unset($_SERVER);
unset($_GET);
unset($_POST);
unset($_COOKIE);
unset($_FILES);
}
private function createRequestConfig(string $method, string $contentType, string $body, &$tmpfile = null): array
@ -65,7 +61,7 @@ class RequestBodyParserTest extends TestCase
$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 = [
@ -81,7 +77,7 @@ class RequestBodyParserTest extends TestCase
private function createMultipartBody(string $boundary, array $fields, array $files = []): string
{
$body = '';
// Add form fields
foreach ($fields as $name => $value) {
if (is_array($value)) {
@ -109,7 +105,7 @@ class RequestBodyParserTest extends TestCase
}
$body .= "--{$boundary}--\r\n";
return $body;
}
@ -145,7 +141,7 @@ class RequestBodyParserTest extends TestCase
$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());
@ -259,4 +255,195 @@ class RequestBodyParserTest extends TestCase
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());
}
}

Loading…
Cancel
Save