Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
308
src/Framework/Http/Parser/CookieParser.php
Normal file
308
src/Framework/Http/Parser/CookieParser.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Cookies\Cookie;
|
||||
use App\Framework\Http\Cookies\Cookies;
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
|
||||
/**
|
||||
* Parser for HTTP Cookie headers with security validation
|
||||
* Parses both Cookie request headers and Set-Cookie response headers
|
||||
*/
|
||||
final readonly class CookieParser
|
||||
{
|
||||
public function __construct(
|
||||
private ParserCache $cache,
|
||||
private ParserConfig $config = new ParserConfig()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cookie header string into individual cookies
|
||||
* Format: "name1=value1; name2=value2; name3=value3"
|
||||
*
|
||||
* @param string $cookieHeader The raw Cookie header value
|
||||
* @return array<string, string> Associative array of cookie names to values
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseCookieHeader(string $cookieHeader): array
|
||||
{
|
||||
if ($cookieHeader === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
$cached = $this->cache->getCookies($cookieHeader);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$cookies = [];
|
||||
$pairs = explode(';', $cookieHeader);
|
||||
|
||||
// Security validation: Check cookie count
|
||||
if (count($pairs) > $this->config->maxCookieCount) {
|
||||
throw ParserSecurityException::cookieCountExceeded(
|
||||
count($pairs),
|
||||
$this->config->maxCookieCount
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
$pair = trim($pair);
|
||||
if ($pair === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('=', $pair, 2);
|
||||
if (count($parts) !== 2) {
|
||||
// Invalid cookie pair, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($parts[0]);
|
||||
$value = trim($parts[1]);
|
||||
|
||||
if ($name !== '') {
|
||||
// Security validation: Check name length
|
||||
if (strlen($name) > $this->config->maxCookieNameLength) {
|
||||
throw ParserSecurityException::cookieNameTooLong(
|
||||
$name,
|
||||
strlen($name),
|
||||
$this->config->maxCookieNameLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check value length
|
||||
if (strlen($value) > $this->config->maxCookieValueLength) {
|
||||
throw ParserSecurityException::cookieValueTooLong(
|
||||
$name,
|
||||
strlen($value),
|
||||
$this->config->maxCookieValueLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check for malicious content
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateCookieContent($name, $value);
|
||||
}
|
||||
|
||||
// Decode cookie value (cookies use URL encoding)
|
||||
$cookies[$name] = urldecode($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result before returning
|
||||
$this->cache->setCookies($cookieHeader, $cookies);
|
||||
|
||||
return $cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Cookie header into Cookies value object
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseToCookies(string $cookieHeader): Cookies
|
||||
{
|
||||
$parsedCookies = $this->parseCookieHeader($cookieHeader);
|
||||
$cookieObjects = [];
|
||||
|
||||
foreach ($parsedCookies as $name => $value) {
|
||||
$cookieObjects[] = new Cookie($name, $value);
|
||||
}
|
||||
|
||||
return new Cookies(...$cookieObjects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Set-Cookie header (for response parsing)
|
||||
* Format: "name=value; Expires=...; Path=...; Domain=...; Secure; HttpOnly; SameSite=..."
|
||||
*
|
||||
* @param string $setCookieHeader The raw Set-Cookie header value
|
||||
* @return array{
|
||||
* name: string,
|
||||
* value: string,
|
||||
* expires?: string,
|
||||
* max-age?: int,
|
||||
* domain?: string,
|
||||
* path?: string,
|
||||
* secure?: bool,
|
||||
* httponly?: bool,
|
||||
* samesite?: string
|
||||
* }
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseSetCookieHeader(string $setCookieHeader): array
|
||||
{
|
||||
$parts = explode(';', $setCookieHeader);
|
||||
$cookie = [];
|
||||
|
||||
// First part is always name=value
|
||||
$nameValue = array_shift($parts);
|
||||
if ($nameValue === null) {
|
||||
throw new \InvalidArgumentException('Invalid Set-Cookie header: missing name=value');
|
||||
}
|
||||
|
||||
$nvParts = explode('=', trim($nameValue), 2);
|
||||
if (count($nvParts) !== 2) {
|
||||
throw new \InvalidArgumentException('Invalid Set-Cookie header: invalid name=value format');
|
||||
}
|
||||
|
||||
$name = trim($nvParts[0]);
|
||||
$value = trim($nvParts[1]);
|
||||
|
||||
// Security validation: Check name length
|
||||
if (strlen($name) > $this->config->maxCookieNameLength) {
|
||||
throw ParserSecurityException::cookieNameTooLong(
|
||||
$name,
|
||||
strlen($name),
|
||||
$this->config->maxCookieNameLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check value length
|
||||
if (strlen($value) > $this->config->maxCookieValueLength) {
|
||||
throw ParserSecurityException::cookieValueTooLong(
|
||||
$name,
|
||||
strlen($value),
|
||||
$this->config->maxCookieValueLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check for malicious content
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateCookieContent($name, $value);
|
||||
}
|
||||
|
||||
$cookie['name'] = $name;
|
||||
$cookie['value'] = urldecode($value);
|
||||
|
||||
// Parse attributes
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attrParts = explode('=', $part, 2);
|
||||
$attrName = strtolower(trim($attrParts[0]));
|
||||
$attrValue = isset($attrParts[1]) ? trim($attrParts[1]) : null;
|
||||
|
||||
switch ($attrName) {
|
||||
case 'expires':
|
||||
$cookie['expires'] = $attrValue;
|
||||
|
||||
break;
|
||||
case 'max-age':
|
||||
$cookie['max-age'] = (int)$attrValue;
|
||||
|
||||
break;
|
||||
case 'domain':
|
||||
$cookie['domain'] = $attrValue;
|
||||
|
||||
break;
|
||||
case 'path':
|
||||
$cookie['path'] = $attrValue;
|
||||
|
||||
break;
|
||||
case 'secure':
|
||||
$cookie['secure'] = true;
|
||||
|
||||
break;
|
||||
case 'httponly':
|
||||
$cookie['httponly'] = true;
|
||||
|
||||
break;
|
||||
case 'samesite':
|
||||
$cookie['samesite'] = $attrValue;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multiple Set-Cookie headers
|
||||
*
|
||||
* @param string[] $setCookieHeaders Array of Set-Cookie header values
|
||||
* @return array<array> Array of parsed cookie data
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseSetCookieHeaders(array $setCookieHeaders): array
|
||||
{
|
||||
// Security validation: Check cookie count
|
||||
if (count($setCookieHeaders) > $this->config->maxCookieCount) {
|
||||
throw ParserSecurityException::cookieCountExceeded(
|
||||
count($setCookieHeaders),
|
||||
$this->config->maxCookieCount
|
||||
);
|
||||
}
|
||||
|
||||
$cookies = [];
|
||||
|
||||
foreach ($setCookieHeaders as $header) {
|
||||
try {
|
||||
$cookies[] = $this->parseSetCookieHeader($header);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Skip invalid cookies
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cookie content for malicious patterns
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function validateCookieContent(string $name, string $value): void
|
||||
{
|
||||
// Check for control characters first (more specific check)
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Control characters detected in cookie '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for CRLF injection
|
||||
if (preg_match('/\r|\n/', $name . $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"CRLF injection detected in cookie '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for script injection patterns
|
||||
$maliciousPatterns = [
|
||||
'/<script[^>]*>/i',
|
||||
'/javascript:/i',
|
||||
'/vbscript:/i',
|
||||
'/on\w+\s*=/i', // event handlers like onclick, onload
|
||||
'/expression\s*\(/i', // CSS expression
|
||||
'/url\s*\(/i', // CSS url()
|
||||
'/import\s/i', // CSS @import
|
||||
];
|
||||
|
||||
foreach ($maliciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $name) || preg_match($pattern, $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Suspicious content detected in cookie '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive URL encoding
|
||||
if (substr_count($value, '%') > 10) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Excessive URL encoding detected in cookie '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
src/Framework/Http/Parser/Exception/ParserSecurityException.php
Normal file
179
src/Framework/Http/Parser/Exception/ParserSecurityException.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser\Exception;
|
||||
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Exception thrown when parser security limits are exceeded
|
||||
*/
|
||||
final class ParserSecurityException extends FrameworkException
|
||||
{
|
||||
public static function fileSizeExceeded(string $filename, int $actualSize, int $maxSize): self
|
||||
{
|
||||
return self::simple(
|
||||
"File size exceeded for '{$filename}': {$actualSize} bytes > {$maxSize} bytes maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fileCountExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"File count exceeded: {$actualCount} files > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function totalUploadSizeExceeded(int $actualSize, int $maxSize): self
|
||||
{
|
||||
return self::simple(
|
||||
"Total upload size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fileExtensionBlocked(string $filename, string $extension): self
|
||||
{
|
||||
return self::simple(
|
||||
"File extension blocked for '{$filename}': '.{$extension}' is not allowed"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fileExtensionNotAllowed(string $filename, string $extension): self
|
||||
{
|
||||
return self::simple(
|
||||
"File extension not allowed for '{$filename}': '.{$extension}' is not in allowed list"
|
||||
);
|
||||
}
|
||||
|
||||
public static function formDataSizeExceeded(int $actualSize, int $maxSize): self
|
||||
{
|
||||
return self::simple(
|
||||
"Form data size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fieldCountExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Field count exceeded: {$actualCount} fields > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fieldNameTooLong(string $fieldName, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Field name too long '{$fieldName}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function fieldValueTooLong(string $fieldName, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Field value too long for '{$fieldName}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function maxPartsExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Maximum number of parts exceeded: {$actualCount} parts > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function queryStringSizeExceeded(int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Query string size exceeded: {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function queryParameterCountExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Query parameter count exceeded: {$actualCount} parameters > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function headerCountExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Header count exceeded: {$actualCount} headers > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function headerSizeExceeded(int $actualSize, int $maxSize): self
|
||||
{
|
||||
return self::simple(
|
||||
"Total header size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function cookieCountExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Cookie count exceeded: {$actualCount} cookies > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function cookieNameTooLong(string $name, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Cookie name too long '{$name}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function cookieValueTooLong(string $name, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Cookie value too long for '{$name}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function multipartPartsExceeded(int $actualCount, int $maxCount): self
|
||||
{
|
||||
return self::simple(
|
||||
"Multipart parts exceeded: {$actualCount} parts > {$maxCount} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function requestBodySizeExceeded(int $actualSize, int $maxSize): self
|
||||
{
|
||||
return self::simple(
|
||||
"Request body size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function maliciousContentDetected(string $reason): self
|
||||
{
|
||||
return self::simple("Malicious content detected: {$reason}");
|
||||
}
|
||||
|
||||
public static function mimeTypeMismatch(string $filename, string $expectedType, string $actualType): self
|
||||
{
|
||||
return self::simple(
|
||||
"MIME type mismatch for '{$filename}': expected '{$expectedType}', got '{$actualType}'"
|
||||
);
|
||||
}
|
||||
|
||||
public static function headerNameTooLong(string $name, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Header name too long '{$name}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function headerValueTooLong(string $name, int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Header value too long for '{$name}': {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
|
||||
public static function boundaryTooLong(int $actualLength, int $maxLength): self
|
||||
{
|
||||
return self::simple(
|
||||
"Multipart boundary too long: {$actualLength} characters > {$maxLength} maximum"
|
||||
);
|
||||
}
|
||||
}
|
||||
522
src/Framework/Http/Parser/FileUploadParser.php
Normal file
522
src/Framework/Http/Parser/FileUploadParser.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
use App\Framework\Http\UploadedFile;
|
||||
use App\Framework\Http\UploadedFiles;
|
||||
use App\Framework\Http\UploadError;
|
||||
|
||||
/**
|
||||
* Parser for file uploads in multipart/form-data requests with security validation
|
||||
*/
|
||||
final readonly class FileUploadParser
|
||||
{
|
||||
public function __construct(
|
||||
private ParserConfig $config = new ParserConfig()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart body for file uploads
|
||||
*
|
||||
* @param string $body Raw request body
|
||||
* @param string $boundary Multipart boundary
|
||||
* @return UploadedFiles
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseMultipart(string $body, string $boundary): UploadedFiles
|
||||
{
|
||||
$files = [];
|
||||
$totalUploadSize = 0;
|
||||
$fileCount = 0;
|
||||
|
||||
// Security validation: Check boundary length
|
||||
if (strlen($boundary) > $this->config->maxBoundaryLength) {
|
||||
throw ParserSecurityException::boundaryTooLong(
|
||||
strlen($boundary),
|
||||
$this->config->maxBoundaryLength
|
||||
);
|
||||
}
|
||||
|
||||
// Split by boundary
|
||||
$parts = explode("--$boundary", $body);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
// Skip empty parts and closing boundary
|
||||
if (trim($part) === '' || trim($part) === '--') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $this->parseFilePart($part);
|
||||
if ($file !== null) {
|
||||
$fileCount++;
|
||||
$fileSize = $file['upload']->size;
|
||||
$totalUploadSize += $fileSize;
|
||||
|
||||
// Security validation: Check file count
|
||||
if ($fileCount > $this->config->maxFileCount) {
|
||||
throw ParserSecurityException::fileCountExceeded(
|
||||
$fileCount,
|
||||
$this->config->maxFileCount
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check individual file size
|
||||
if ($fileSize > $this->config->maxFileSize->toBytes()) {
|
||||
throw ParserSecurityException::fileSizeExceeded(
|
||||
$file['upload']->name ?? 'unknown',
|
||||
$fileSize,
|
||||
$this->config->maxFileSize->toBytes()
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check total upload size
|
||||
if ($totalUploadSize > $this->config->maxTotalUploadSize->toBytes()) {
|
||||
throw ParserSecurityException::totalUploadSizeExceeded(
|
||||
$totalUploadSize,
|
||||
$this->config->maxTotalUploadSize->toBytes()
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check file extension
|
||||
$this->validateFileExtension($file['upload']->name ?? '');
|
||||
|
||||
// Security validation: Check file content if enabled
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateFileContent($file['upload']);
|
||||
}
|
||||
|
||||
$files = $this->addFileToArray($files, $file['name'], $file['upload']);
|
||||
}
|
||||
}
|
||||
|
||||
return new UploadedFiles($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single multipart part that might be a file
|
||||
*
|
||||
* @param string $part The multipart part
|
||||
* @return array{name: string, upload: UploadedFile}|null
|
||||
*/
|
||||
private function parseFilePart(string $part): ?array
|
||||
{
|
||||
// Split headers and content
|
||||
$sections = explode("\r\n\r\n", $part, 2);
|
||||
if (count($sections) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$headerSection, $content] = $sections;
|
||||
|
||||
// Remove trailing CRLF from content
|
||||
$content = rtrim($content, "\r\n");
|
||||
|
||||
// Parse headers
|
||||
$headers = $this->parsePartHeaders($headerSection);
|
||||
|
||||
// Check Content-Disposition for file upload
|
||||
$disposition = $headers['content-disposition'] ?? null;
|
||||
if ($disposition === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dispParams = $this->parseContentDisposition($disposition);
|
||||
|
||||
// Must have name and filename to be a file upload
|
||||
if (! isset($dispParams['name']) || ! isset($dispParams['filename'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get content type
|
||||
$contentType = $headers['content-type'] ?? 'application/octet-stream';
|
||||
|
||||
// Create temporary file
|
||||
$tmpFile = $this->createTempFile($content);
|
||||
|
||||
// Create UploadedFile object using testing factory for parser
|
||||
$uploadedFile = UploadedFile::createForTesting(
|
||||
name: $dispParams['filename'],
|
||||
type: $contentType,
|
||||
size: strlen($content),
|
||||
tmpName: $tmpFile,
|
||||
error: UploadError::OK
|
||||
);
|
||||
|
||||
return [
|
||||
'name' => $dispParams['name'],
|
||||
'upload' => $uploadedFile,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from a multipart part
|
||||
*
|
||||
* @param string $headerSection Raw headers
|
||||
* @return array<string, string> Normalized headers (lowercase keys)
|
||||
*/
|
||||
private function parsePartHeaders(string $headerSection): array
|
||||
{
|
||||
$headers = [];
|
||||
$lines = explode("\r\n", $headerSection);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '' || strpos($line, ':') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = explode(':', $line, 2);
|
||||
$headers[strtolower(trim($name))] = trim($value);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Content-Disposition header value
|
||||
*
|
||||
* @param string $disposition Content-Disposition header value
|
||||
* @return array<string, string> Parameters
|
||||
*/
|
||||
private function parseContentDisposition(string $disposition): array
|
||||
{
|
||||
$params = [];
|
||||
$parts = explode(';', $disposition);
|
||||
|
||||
// First part is disposition type (usually "form-data")
|
||||
array_shift($parts);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if (strpos($part, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = explode('=', $part, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, ' "');
|
||||
|
||||
// Decode filename if needed (RFC 2231)
|
||||
if ($key === 'filename*') {
|
||||
// Handle extended filename parameter
|
||||
$value = $this->decodeExtendedFilename($value);
|
||||
$params['filename'] = $value;
|
||||
} else {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode RFC 2231 extended filename parameter
|
||||
* Format: charset'language'encoded-filename
|
||||
*
|
||||
* @param string $value Extended parameter value
|
||||
* @return string Decoded filename
|
||||
*/
|
||||
private function decodeExtendedFilename(string $value): string
|
||||
{
|
||||
$parts = explode("'", $value, 3);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
// Invalid format, return as-is
|
||||
return $value;
|
||||
}
|
||||
|
||||
[$charset, $language, $encodedFilename] = $parts;
|
||||
|
||||
// URL decode the filename
|
||||
$filename = rawurldecode($encodedFilename);
|
||||
|
||||
// Convert charset if needed
|
||||
if ($charset !== '' && $charset !== 'UTF-8') {
|
||||
$filename = mb_convert_encoding($filename, 'UTF-8', $charset);
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create temporary file with content
|
||||
*
|
||||
* @param string $content File content
|
||||
* @return string Temporary file path
|
||||
*/
|
||||
private function createTempFile(string $content): string
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
|
||||
if ($tmpFile === false) {
|
||||
throw new \RuntimeException('Failed to create temporary file');
|
||||
}
|
||||
|
||||
if (file_put_contents($tmpFile, $content) === false) {
|
||||
unlink($tmpFile);
|
||||
|
||||
throw new \RuntimeException('Failed to write temporary file');
|
||||
}
|
||||
|
||||
// Register cleanup on script end
|
||||
register_shutdown_function(function () use ($tmpFile) {
|
||||
if (file_exists($tmpFile)) {
|
||||
unlink($tmpFile);
|
||||
}
|
||||
});
|
||||
|
||||
return $tmpFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to array, handling array notation
|
||||
*
|
||||
* @param array<string, mixed> $files Files array
|
||||
* @param string $name Field name (may include [] notation)
|
||||
* @param UploadedFile $file The uploaded file
|
||||
* @return array<string, mixed> Updated files array
|
||||
*/
|
||||
private function addFileToArray(array $files, string $name, UploadedFile $file): array
|
||||
{
|
||||
// Check for array notation
|
||||
if (preg_match('/^([^\[]+)(\[.*])$/', $name, $matches)) {
|
||||
$baseKey = $matches[1];
|
||||
$arrayPart = $matches[2];
|
||||
|
||||
if (! isset($files[$baseKey])) {
|
||||
$files[$baseKey] = [];
|
||||
}
|
||||
|
||||
// Simple array notation: field[]
|
||||
if ($arrayPart === '[]') {
|
||||
if (! is_array($files[$baseKey])) {
|
||||
$files[$baseKey] = [$files[$baseKey]];
|
||||
}
|
||||
$files[$baseKey][] = $file;
|
||||
} else {
|
||||
// Complex array notation: field[key] or field[key1][key2]
|
||||
// Parse the keys
|
||||
preg_match_all('/\[([^]]*)]/', $arrayPart, $keyMatches);
|
||||
$keys = $keyMatches[1];
|
||||
|
||||
$files[$baseKey] = $this->setNestedValue($files[$baseKey], $keys, $file);
|
||||
}
|
||||
} else {
|
||||
// Simple field
|
||||
$files[$name] = $file;
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in nested array structure (immutable)
|
||||
*
|
||||
* @param mixed $target Target structure
|
||||
* @param array<string> $keys Array of keys to traverse
|
||||
* @param UploadedFile $file File to set
|
||||
* @return mixed Updated structure
|
||||
*/
|
||||
private function setNestedValue(mixed $target, array $keys, UploadedFile $file): mixed
|
||||
{
|
||||
if (empty($keys)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$key = array_shift($keys);
|
||||
|
||||
if (! is_array($target)) {
|
||||
$target = [];
|
||||
}
|
||||
|
||||
if ($key === '') {
|
||||
// Empty key means array append
|
||||
$index = count($target);
|
||||
$target[$index] = $this->setNestedValue($target[$index] ?? null, $keys, $file);
|
||||
} else {
|
||||
$target[$key] = $this->setNestedValue($target[$key] ?? null, $keys, $file);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension against allowed/blocked lists
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function validateFileExtension(string $filename): void
|
||||
{
|
||||
if (! $this->config->validateFileExtensions || $filename === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if ($extension === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check blocked extensions first (takes precedence)
|
||||
if (! empty($this->config->blockedFileExtensions)) {
|
||||
if (in_array($extension, $this->config->blockedFileExtensions, true)) {
|
||||
throw ParserSecurityException::fileExtensionBlocked($filename, $extension);
|
||||
}
|
||||
}
|
||||
|
||||
// Check allowed extensions if list is provided
|
||||
if (! empty($this->config->allowedFileExtensions)) {
|
||||
if (! in_array($extension, $this->config->allowedFileExtensions, true)) {
|
||||
throw ParserSecurityException::fileExtensionNotAllowed($filename, $extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file content for malicious patterns
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function validateFileContent(UploadedFile $file): void
|
||||
{
|
||||
$filename = $file->name ?? 'unknown';
|
||||
$tmpFile = $file->tmpName;
|
||||
|
||||
if (! file_exists($tmpFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read first few KB of the file for content analysis
|
||||
$content = file_get_contents($tmpFile, false, null, 0, 8192);
|
||||
if ($content === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for embedded PHP code first (more specific)
|
||||
if (preg_match('/<\?(?:php|=)/i', $content)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"PHP code detected in uploaded file '{$filename}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for executable file signatures
|
||||
$executableSignatures = [
|
||||
"\x4D\x5A", // PE executable (Windows)
|
||||
"\x7F\x45\x4C\x46", // ELF executable (Linux)
|
||||
"\xFE\xED\xFA\xCE", // Mach-O executable (macOS)
|
||||
"\xFE\xED\xFA\xCF", // Mach-O executable (macOS)
|
||||
"\xCA\xFE\xBA\xBE", // Java class file
|
||||
"#!/bin/sh", // Shell script
|
||||
"#!/bin/bash", // Bash script
|
||||
];
|
||||
|
||||
foreach ($executableSignatures as $signature) {
|
||||
if (str_starts_with($content, $signature) || strpos($content, $signature) !== false) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Executable content detected in file '{$filename}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious script content
|
||||
$scriptPatterns = [
|
||||
'/\<script[^>]*\>/i', // Script tags
|
||||
'/javascript:/i', // JavaScript URLs
|
||||
'/vbscript:/i', // VBScript URLs
|
||||
'/on\w+\s*=/i', // Event handlers
|
||||
'/eval\s*\(/i', // JavaScript eval
|
||||
'/exec\s*\(/i', // Command execution
|
||||
'/system\s*\(/i', // System calls
|
||||
'/shell_exec\s*\(/i', // Shell execution
|
||||
];
|
||||
|
||||
foreach ($scriptPatterns as $pattern) {
|
||||
if (preg_match($pattern, $content)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Suspicious script content detected in file '{$filename}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for MIME type mismatch if client provided type
|
||||
$clientType = $file->type;
|
||||
if ($clientType && $clientType !== 'application/octet-stream') {
|
||||
$detectedType = $this->detectMimeType($content, $filename);
|
||||
if ($detectedType && $detectedType !== $clientType) {
|
||||
// Only throw for significant mismatches (not minor variations)
|
||||
if (! $this->isMimeTypeCompatible($clientType, $detectedType)) {
|
||||
throw ParserSecurityException::mimeTypeMismatch($filename, $clientType, $detectedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from file content
|
||||
*/
|
||||
private function detectMimeType(string $content, string $filename): ?string
|
||||
{
|
||||
// Basic MIME type detection based on file signatures
|
||||
$signatures = [
|
||||
'image/jpeg' => ["\xFF\xD8\xFF"],
|
||||
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
|
||||
'image/gif' => ["GIF87a", "GIF89a"],
|
||||
'image/webp' => ["RIFF"],
|
||||
'application/pdf' => ["%PDF-"],
|
||||
'application/zip' => ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"],
|
||||
'text/plain' => [], // Default for text-like content
|
||||
];
|
||||
|
||||
foreach ($signatures as $mimeType => $sigs) {
|
||||
foreach ($sigs as $sig) {
|
||||
if (str_starts_with($content, $sig)) {
|
||||
return $mimeType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if content appears to be text
|
||||
if (mb_check_encoding($content, 'UTF-8') && ! preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $content)) {
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME types are compatible (allowing for minor variations)
|
||||
*/
|
||||
private function isMimeTypeCompatible(string $clientType, string $detectedType): bool
|
||||
{
|
||||
if ($clientType === $detectedType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow some common compatible types
|
||||
$compatibleTypes = [
|
||||
'text/plain' => ['text/csv', 'text/tab-separated-values', 'application/csv'],
|
||||
'application/octet-stream' => ['*'], // Octet-stream can be anything
|
||||
'image/jpeg' => ['image/jpg'],
|
||||
'application/zip' => ['application/x-zip-compressed'],
|
||||
];
|
||||
|
||||
$clientMain = explode('/', $clientType)[0] ?? '';
|
||||
$detectedMain = explode('/', $detectedType)[0] ?? '';
|
||||
|
||||
// Allow same main type (e.g., image/jpeg vs image/jpg)
|
||||
if ($clientMain === $detectedMain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check specific compatibility rules
|
||||
foreach ($compatibleTypes as $baseType => $compatible) {
|
||||
if ($clientType === $baseType) {
|
||||
return in_array($detectedType, $compatible, true) || in_array('*', $compatible, true);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
358
src/Framework/Http/Parser/FormDataParser.php
Normal file
358
src/Framework/Http/Parser/FormDataParser.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
|
||||
/**
|
||||
* Parser for form data with security validation
|
||||
* (application/x-www-form-urlencoded and multipart/form-data)
|
||||
*/
|
||||
final readonly class FormDataParser
|
||||
{
|
||||
public function __construct(
|
||||
private ParserConfig $config = new ParserConfig(),
|
||||
private QueryStringParser $queryStringParser = new QueryStringParser()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse form data based on content type
|
||||
*
|
||||
* @param string $contentType The Content-Type header value
|
||||
* @param string $body The raw request body
|
||||
* @return array<string, mixed> Parsed form data
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parse(string $contentType, string $body): array
|
||||
{
|
||||
if ($body === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Security validation: Check form data size
|
||||
$bodySize = strlen($body);
|
||||
if ($bodySize > $this->config->maxFormDataSize->toBytes()) {
|
||||
throw ParserSecurityException::formDataSizeExceeded(
|
||||
$bodySize,
|
||||
$this->config->maxFormDataSize->toBytes()
|
||||
);
|
||||
}
|
||||
|
||||
// Parse content type to get media type and parameters
|
||||
$ctParts = explode(';', $contentType);
|
||||
$mediaType = strtolower(trim($ctParts[0]));
|
||||
|
||||
switch ($mediaType) {
|
||||
case 'application/x-www-form-urlencoded':
|
||||
return $this->parseUrlEncoded($body);
|
||||
|
||||
case 'multipart/form-data':
|
||||
$boundary = $this->extractBoundary($contentType);
|
||||
if ($boundary === null) {
|
||||
throw new \RuntimeException('Missing boundary in multipart/form-data');
|
||||
}
|
||||
|
||||
return $this->parseMultipart($body, $boundary);
|
||||
|
||||
default:
|
||||
// Not form data
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL-encoded form data
|
||||
* Same format as query strings
|
||||
*
|
||||
* @param string $body The raw body
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseUrlEncoded(string $body): array
|
||||
{
|
||||
return $this->queryStringParser->parse($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart/form-data
|
||||
*
|
||||
* @param string $body The raw body
|
||||
* @param string $boundary The multipart boundary
|
||||
* @return array<string, mixed> Form fields (not files)
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function parseMultipart(string $body, string $boundary): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
// Security validation: Check boundary length
|
||||
if (strlen($boundary) > $this->config->maxBoundaryLength) {
|
||||
throw ParserSecurityException::boundaryTooLong(
|
||||
strlen($boundary),
|
||||
$this->config->maxBoundaryLength
|
||||
);
|
||||
}
|
||||
|
||||
// Split by boundary
|
||||
$parts = explode("--$boundary", $body);
|
||||
|
||||
// Security validation: Check multipart parts count
|
||||
$actualParts = array_filter($parts, fn ($part) => trim($part) !== '' && trim($part) !== '--');
|
||||
if (count($actualParts) > $this->config->maxMultipartParts) {
|
||||
throw ParserSecurityException::multipartPartsExceeded(
|
||||
count($actualParts),
|
||||
$this->config->maxMultipartParts
|
||||
);
|
||||
}
|
||||
|
||||
$fieldCount = 0;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
// Skip empty parts and closing boundary
|
||||
if (trim($part) === '' || trim($part) === '--') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split headers and content
|
||||
$sections = explode("\r\n\r\n", $part, 2);
|
||||
if (count($sections) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$headers, $content] = $sections;
|
||||
|
||||
// Remove trailing CRLF from content
|
||||
$content = rtrim($content, "\r\n");
|
||||
|
||||
// Parse Content-Disposition header
|
||||
$disposition = $this->parseContentDisposition($headers);
|
||||
if ($disposition === null || ! isset($disposition['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $disposition['name'];
|
||||
|
||||
// Check if this is a file upload (has filename parameter)
|
||||
if (isset($disposition['filename'])) {
|
||||
// Skip file uploads in form data parser
|
||||
// These should be handled by FileUploadParser
|
||||
continue;
|
||||
}
|
||||
|
||||
// Security validation: Check field count
|
||||
$fieldCount++;
|
||||
if ($fieldCount > $this->config->maxFieldCount) {
|
||||
throw ParserSecurityException::fieldCountExceeded(
|
||||
$fieldCount,
|
||||
$this->config->maxFieldCount
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check field name length
|
||||
if (strlen($name) > $this->config->maxFieldNameLength) {
|
||||
throw ParserSecurityException::fieldNameTooLong(
|
||||
$name,
|
||||
strlen($name),
|
||||
$this->config->maxFieldNameLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check field value length
|
||||
if (strlen($content) > $this->config->maxFieldValueLength) {
|
||||
throw ParserSecurityException::fieldValueTooLong(
|
||||
$name,
|
||||
strlen($content),
|
||||
$this->config->maxFieldValueLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check for malicious content
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateFieldContent($name, $content);
|
||||
}
|
||||
|
||||
// Parse field name for array notation
|
||||
$data = $this->assignFieldValue($data, $name, $content);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract boundary from Content-Type header
|
||||
*
|
||||
* @param string $contentType Full Content-Type header value
|
||||
* @return string|null Boundary string or null if not found
|
||||
*/
|
||||
private function extractBoundary(string $contentType): ?string
|
||||
{
|
||||
if (preg_match('/boundary=([^;]+)/', $contentType, $matches)) {
|
||||
$boundary = trim($matches[1], ' "');
|
||||
|
||||
return $boundary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Content-Disposition header
|
||||
*
|
||||
* @param string $headers The headers section of a multipart part
|
||||
* @return array{name?: string, filename?: string}|null
|
||||
*/
|
||||
private function parseContentDisposition(string $headers): ?array
|
||||
{
|
||||
$lines = explode("\r\n", $headers);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (stripos($line, 'Content-Disposition:') === 0) {
|
||||
$disposition = [];
|
||||
$parts = explode(';', $line);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
|
||||
if (strpos($part, '=') !== false) {
|
||||
[$key, $value] = explode('=', $part, 2);
|
||||
$key = trim(strtolower($key));
|
||||
$value = trim($value, ' "');
|
||||
|
||||
if ($key === 'name' || $key === 'filename') {
|
||||
$disposition[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $disposition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign field value, handling array notation
|
||||
*
|
||||
* @param array<string, mixed> $data Data array
|
||||
* @param string $name Field name (may include [] notation)
|
||||
* @param string $value Field value
|
||||
* @return array<string, mixed> Updated data array
|
||||
*/
|
||||
private function assignFieldValue(array $data, string $name, string $value): array
|
||||
{
|
||||
// Check for array notation
|
||||
if (preg_match('/^([^\[]+)(\[.*\])$/', $name, $matches)) {
|
||||
$baseKey = $matches[1];
|
||||
$arrayPart = $matches[2];
|
||||
|
||||
// Initialize array if needed
|
||||
if (! isset($data[$baseKey])) {
|
||||
$data[$baseKey] = [];
|
||||
}
|
||||
|
||||
// Simple array notation field[]
|
||||
if ($arrayPart === '[]') {
|
||||
if (! is_array($data[$baseKey])) {
|
||||
$data[$baseKey] = [$data[$baseKey]];
|
||||
}
|
||||
$data[$baseKey][] = $value;
|
||||
} else {
|
||||
// Complex array notation field[key] or field[key1][key2]
|
||||
$data = $this->assignNestedValue($data, $name, $value);
|
||||
}
|
||||
} else {
|
||||
// Simple field
|
||||
$data[$name] = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle nested array notation
|
||||
* Delegates to QueryStringParser for consistency
|
||||
*/
|
||||
private function assignNestedValue(array $data, string $name, string $value): array
|
||||
{
|
||||
// Create a temporary query string and parse it
|
||||
$encoded = urlencode($name) . '=' . urlencode($value);
|
||||
$parsed = $this->queryStringParser->parse($encoded);
|
||||
|
||||
// Merge with existing data
|
||||
return array_merge_recursive($data, $parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field content for malicious patterns
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function validateFieldContent(string $name, string $value): void
|
||||
{
|
||||
// Check for control characters
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Control characters detected in form field '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for script injection patterns
|
||||
$maliciousPatterns = [
|
||||
'/<script[^>]*>/i', // Script tags
|
||||
'/javascript:/i', // JavaScript URLs
|
||||
'/vbscript:/i', // VBScript URLs
|
||||
'/on\w+\s*=/i', // Event handlers like onclick, onload
|
||||
'/expression\s*\(/i', // CSS expression
|
||||
'/eval\s*\(/i', // JavaScript eval
|
||||
'/alert\s*\(/i', // JavaScript alert
|
||||
'/<iframe[^>]*>/i', // Iframe injection
|
||||
'/<object[^>]*>/i', // Object injection
|
||||
'/<embed[^>]*>/i', // Embed injection
|
||||
];
|
||||
|
||||
foreach ($maliciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Suspicious content detected in form field '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SQL injection patterns
|
||||
$sqlPatterns = [
|
||||
'/union\s+select/i',
|
||||
'/select\s+.*\s+from/i',
|
||||
'/insert\s+into/i',
|
||||
'/delete\s+from/i',
|
||||
'/drop\s+table/i',
|
||||
'/truncate\s+table/i',
|
||||
'/alter\s+table/i',
|
||||
'/create\s+table/i',
|
||||
];
|
||||
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"SQL injection attempt detected in form field '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (preg_match('/\.\.\/|\.\.\\\\/', $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Path traversal attempt detected in form field '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for excessive repetition (potential DoS)
|
||||
if (preg_match('/(.)\1{100,}/', $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Excessive character repetition detected in form field '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
355
src/Framework/Http/Parser/HeaderParser.php
Normal file
355
src/Framework/Http/Parser/HeaderParser.php
Normal file
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
|
||||
/**
|
||||
* Parser for HTTP headers with security validation
|
||||
* Handles parsing from various sources (raw headers, $_SERVER array, etc.)
|
||||
*/
|
||||
final class HeaderParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ParserConfig $config = new ParserConfig()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from raw HTTP header string
|
||||
*
|
||||
* @param string $rawHeaders Raw headers separated by \r\n
|
||||
* @return Headers
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseRawHeaders(string $rawHeaders): Headers
|
||||
{
|
||||
// Security validation: Check total header size
|
||||
$headerSize = strlen($rawHeaders);
|
||||
if ($headerSize > $this->config->maxTotalHeaderSize->toBytes()) {
|
||||
throw ParserSecurityException::headerSizeExceeded(
|
||||
$headerSize,
|
||||
$this->config->maxTotalHeaderSize->toBytes()
|
||||
);
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
$lines = explode("\r\n", $rawHeaders);
|
||||
|
||||
// Security validation: Check header count
|
||||
$headerLineCount = count(array_filter($lines, fn ($line) => str_contains($line, ':')));
|
||||
if ($headerLineCount > $this->config->maxHeaderCount) {
|
||||
throw ParserSecurityException::headerCountExceeded(
|
||||
$headerLineCount,
|
||||
$this->config->maxHeaderCount
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') {
|
||||
// Empty line marks end of headers
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip the request/status line
|
||||
if (! str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = explode(':', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if ($name !== '') {
|
||||
// Security validation: Check header name length
|
||||
if (strlen($name) > $this->config->maxHeaderNameLength) {
|
||||
throw ParserSecurityException::headerNameTooLong(
|
||||
$name,
|
||||
strlen($name),
|
||||
$this->config->maxHeaderNameLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check header value length
|
||||
if (strlen($value) > $this->config->maxHeaderValueLength) {
|
||||
throw ParserSecurityException::headerValueTooLong(
|
||||
$name,
|
||||
strlen($value),
|
||||
$this->config->maxHeaderValueLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check for malicious content
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateHeaderContent($name, $value);
|
||||
}
|
||||
|
||||
if (isset($headers[$name])) {
|
||||
// Multiple headers with same name
|
||||
if (! is_array($headers[$name])) {
|
||||
$headers[$name] = [$headers[$name]];
|
||||
}
|
||||
$headers[$name][] = $value;
|
||||
} else {
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Headers($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from $_SERVER array
|
||||
*
|
||||
* @param array<string, mixed> $server Server array
|
||||
* @return Headers
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parseFromServerArray(array $server): Headers
|
||||
{
|
||||
$headers = [];
|
||||
$totalHeaderSize = 0;
|
||||
|
||||
foreach ($server as $key => $value) {
|
||||
// Convert scalar values to string
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$headerName = null;
|
||||
|
||||
// Standard headers with HTTP_ prefix
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$headerName = $this->normalizeHeaderName(substr($key, 5));
|
||||
$headerValue = $value;
|
||||
}
|
||||
// Special headers without HTTP_ prefix
|
||||
elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'])) {
|
||||
$headerName = $this->normalizeHeaderName($key);
|
||||
$headerValue = $value;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Security validation: Check header name length
|
||||
if (strlen($headerName) > $this->config->maxHeaderNameLength) {
|
||||
throw ParserSecurityException::headerNameTooLong(
|
||||
$headerName,
|
||||
strlen($headerName),
|
||||
$this->config->maxHeaderNameLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check header value length
|
||||
if (strlen($headerValue) > $this->config->maxHeaderValueLength) {
|
||||
throw ParserSecurityException::headerValueTooLong(
|
||||
$headerName,
|
||||
strlen($headerValue),
|
||||
$this->config->maxHeaderValueLength
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check for malicious content
|
||||
if ($this->config->scanForMaliciousContent) {
|
||||
$this->validateHeaderContent($headerName, $headerValue);
|
||||
}
|
||||
|
||||
$headers[$headerName] = $headerValue;
|
||||
$totalHeaderSize += strlen($headerName) + strlen($headerValue) + 4; // name: value\r\n
|
||||
}
|
||||
|
||||
// Add Authorization header if present in different formats
|
||||
if (isset($server['PHP_AUTH_USER']) && isset($server['PHP_AUTH_PW'])) {
|
||||
$authHeader = 'Basic ' . base64_encode($server['PHP_AUTH_USER'] . ':' . $server['PHP_AUTH_PW']);
|
||||
|
||||
// Security validation for generated auth header
|
||||
if (strlen($authHeader) > $this->config->maxHeaderValueLength) {
|
||||
throw ParserSecurityException::headerValueTooLong(
|
||||
'Authorization',
|
||||
strlen($authHeader),
|
||||
$this->config->maxHeaderValueLength
|
||||
);
|
||||
}
|
||||
|
||||
$headers['Authorization'] = $authHeader;
|
||||
$totalHeaderSize += strlen('Authorization') + strlen($authHeader) + 4;
|
||||
} elseif (isset($server['PHP_AUTH_DIGEST'])) {
|
||||
$authHeader = 'Digest ' . $server['PHP_AUTH_DIGEST'];
|
||||
|
||||
// Security validation for generated auth header
|
||||
if (strlen($authHeader) > $this->config->maxHeaderValueLength) {
|
||||
throw ParserSecurityException::headerValueTooLong(
|
||||
'Authorization',
|
||||
strlen($authHeader),
|
||||
$this->config->maxHeaderValueLength
|
||||
);
|
||||
}
|
||||
|
||||
$headers['Authorization'] = $authHeader;
|
||||
$totalHeaderSize += strlen('Authorization') + strlen($authHeader) + 4;
|
||||
}
|
||||
|
||||
// Security validation: Check header count
|
||||
if (count($headers) > $this->config->maxHeaderCount) {
|
||||
throw ParserSecurityException::headerCountExceeded(
|
||||
count($headers),
|
||||
$this->config->maxHeaderCount
|
||||
);
|
||||
}
|
||||
|
||||
// Security validation: Check total header size
|
||||
if ($totalHeaderSize > $this->config->maxTotalHeaderSize->toBytes()) {
|
||||
throw ParserSecurityException::headerSizeExceeded(
|
||||
$totalHeaderSize,
|
||||
$this->config->maxTotalHeaderSize->toBytes()
|
||||
);
|
||||
}
|
||||
|
||||
return new Headers($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from getallheaders() output
|
||||
*
|
||||
* @param array<string, string> $headers Headers from getallheaders()
|
||||
* @return Headers
|
||||
*/
|
||||
public function parseFromGetAllHeaders(array $headers): Headers
|
||||
{
|
||||
// getallheaders() returns headers in their original case
|
||||
// No transformation needed, just pass to Headers constructor
|
||||
return new Headers($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all headers from current request
|
||||
* Uses best available method
|
||||
*
|
||||
* @return Headers
|
||||
*/
|
||||
public function parseCurrentRequestHeaders(): Headers
|
||||
{
|
||||
// Prefer getallheaders() if available (Apache, FastCGI)
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
if ($headers !== false) {
|
||||
return $this->parseFromGetAllHeaders($headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to $_SERVER parsing
|
||||
return $this->parseFromServerArray($_SERVER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize header name from SERVER format
|
||||
* HTTP_CONTENT_TYPE -> Content-Type
|
||||
*
|
||||
* @param string $serverKey Server key without HTTP_ prefix
|
||||
* @return string Normalized header name
|
||||
*/
|
||||
private function normalizeHeaderName(string $serverKey): string
|
||||
{
|
||||
// Replace underscores with hyphens
|
||||
$headerName = str_replace('_', '-', $serverKey);
|
||||
|
||||
// Convert to proper case (Content-Type, X-Forwarded-For, etc.)
|
||||
return implode('-', array_map('ucfirst', explode('-', strtolower($headerName))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Content-Type header to extract media type and parameters
|
||||
*
|
||||
* @param string $contentType Raw Content-Type header value
|
||||
* @return array{type: string, charset?: string, boundary?: string}
|
||||
*/
|
||||
public function parseContentType(string $contentType): array
|
||||
{
|
||||
$parts = explode(';', $contentType);
|
||||
$result = ['type' => trim(array_shift($parts))];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if (str_contains($part, '=')) {
|
||||
[$key, $value] = explode('=', $part, 2);
|
||||
$key = trim(strtolower($key));
|
||||
$value = trim($value, ' "');
|
||||
|
||||
if ($key === 'charset' || $key === 'boundary') {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate header content for malicious patterns
|
||||
*
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
private function validateHeaderContent(string $name, string $value): void
|
||||
{
|
||||
// Check for control characters (HTTP headers should not contain control chars)
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Control characters detected in header '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for CRLF injection (header splitting)
|
||||
if (preg_match('/\r|\n/', $name . $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"CRLF injection detected in header '{$name}'"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for suspicious patterns in header values
|
||||
$maliciousPatterns = [
|
||||
'/<script[^>]*>/i', // Script injection
|
||||
'/javascript:/i', // JavaScript URL
|
||||
'/vbscript:/i', // VBScript URL
|
||||
'/on\w+\s*=/i', // Event handlers
|
||||
'/expression\s*\(/i', // CSS expression
|
||||
'/eval\s*\(/i', // JavaScript eval
|
||||
'/alert\s*\(/i', // JavaScript alert
|
||||
];
|
||||
|
||||
foreach ($maliciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Suspicious content detected in header '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious header names
|
||||
$suspiciousHeaderNames = [
|
||||
'X-XSS-Protection', // Might be used to disable XSS protection
|
||||
'Content-Security-Policy', // Might be used to weaken CSP
|
||||
];
|
||||
|
||||
foreach ($suspiciousHeaderNames as $suspiciousName) {
|
||||
if (stripos($name, $suspiciousName) !== false) {
|
||||
// Check for potentially dangerous values
|
||||
if (stripos($value, 'none') !== false || stripos($value, 'unsafe-inline') !== false) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Potentially dangerous security header value in '{$name}': '{$value}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessively long base64 encoded values (potential data exfiltration)
|
||||
if (preg_match('/^[A-Za-z0-9+\/]+=*$/', $value) && strlen($value) > 1000) {
|
||||
throw ParserSecurityException::maliciousContentDetected(
|
||||
"Suspicious base64 encoded value in header '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
511
src/Framework/Http/Parser/HttpRequestParser.php
Normal file
511
src/Framework/Http/Parser/HttpRequestParser.php
Normal file
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestBody;
|
||||
use App\Framework\Http\RequestIdGenerator;
|
||||
use App\Framework\Http\ServerEnvironment;
|
||||
use App\Framework\Http\UploadedFile;
|
||||
use App\Framework\Http\UploadedFiles;
|
||||
use App\Framework\Http\Uri;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Main HTTP request parser that coordinates all sub-parsers
|
||||
* Parses raw HTTP requests without relying on superglobals
|
||||
*/
|
||||
final readonly class HttpRequestParser
|
||||
{
|
||||
private ParserConfig $config;
|
||||
|
||||
public function __construct(
|
||||
ParserCache $cache,
|
||||
?ParserConfig $config = null,
|
||||
?QueryStringParser $queryParser = null,
|
||||
?CookieParser $cookieParser = null,
|
||||
?FormDataParser $formParser = null,
|
||||
?FileUploadParser $fileParser = null,
|
||||
?HeaderParser $headerParser = null,
|
||||
?StreamingParser $streamingParser = null,
|
||||
?RequestIdGenerator $requestIdGenerator = null
|
||||
) {
|
||||
// Use web-friendly configuration by default (less strict than default security config)
|
||||
$this->config = $config ?? self::createWebConfig();
|
||||
|
||||
$this->queryParser = $queryParser ?? new QueryStringParser($cache, $this->config);
|
||||
$this->cookieParser = $cookieParser ?? new CookieParser($cache, $this->config);
|
||||
$this->formParser = $formParser ?? new FormDataParser($this->config, $this->queryParser);
|
||||
$this->fileParser = $fileParser ?? new FileUploadParser($this->config);
|
||||
$this->headerParser = $headerParser ?? new HeaderParser($this->config);
|
||||
$this->streamingParser = $streamingParser ?? new StreamingParser($this->config);
|
||||
$this->requestIdGenerator = $requestIdGenerator ?? new RequestIdGenerator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a web-friendly parser configuration
|
||||
* More permissive than the default security config for normal web traffic
|
||||
*/
|
||||
private static function createWebConfig(): ParserConfig
|
||||
{
|
||||
return new ParserConfig(
|
||||
// Reasonable web limits
|
||||
maxFileSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(50),
|
||||
maxFileCount: 10,
|
||||
maxTotalUploadSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(100),
|
||||
maxFormDataSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(10),
|
||||
maxQueryStringLength: 8192,
|
||||
maxQueryParameters: 100,
|
||||
maxHeaderCount: 50,
|
||||
maxCookieCount: 50,
|
||||
|
||||
// Relaxed security for normal web use
|
||||
validateFileExtensions: false, // Don't block file extensions for normal web use
|
||||
blockedFileExtensions: ['php', 'exe', 'bat', 'sh', 'asp', 'jsp'], // Don't scan content for normal web use
|
||||
scanForMaliciousContent: false,
|
||||
|
||||
// Still block obviously dangerous extensions
|
||||
strictMimeTypeValidation: false,
|
||||
|
||||
// Performance settings
|
||||
throwOnLimitExceeded: true,
|
||||
logSecurityViolations: false, // Don't log normal violations in web context
|
||||
);
|
||||
}
|
||||
|
||||
private QueryStringParser $queryParser;
|
||||
|
||||
private CookieParser $cookieParser;
|
||||
|
||||
private FormDataParser $formParser;
|
||||
|
||||
private FileUploadParser $fileParser;
|
||||
|
||||
private HeaderParser $headerParser;
|
||||
|
||||
private StreamingParser $streamingParser;
|
||||
|
||||
private RequestIdGenerator $requestIdGenerator;
|
||||
|
||||
/**
|
||||
* Parse request from minimal PHP globals
|
||||
* Only uses $_SERVER for basic info that can't be obtained otherwise
|
||||
*
|
||||
* @param array<string, mixed> $server Server array (usually $_SERVER)
|
||||
* @param string $rawBody Raw request body (usually from php://input)
|
||||
* @return Request
|
||||
*/
|
||||
public function parseFromGlobals(array $server, string $rawBody): Request
|
||||
{
|
||||
// Get basic request info (these can't be obtained any other way in PHP)
|
||||
$method = $server['REQUEST_METHOD'] ?? 'GET';
|
||||
$uri = $server['REQUEST_URI'] ?? '/';
|
||||
#$protocol = $server['SERVER_PROTOCOL'] ?? 'HTTP/1.1';
|
||||
|
||||
// Parse with our custom parsers
|
||||
return $this->parseRequest(
|
||||
method: $method,
|
||||
uri: $uri,
|
||||
server: $server,
|
||||
rawBody: $rawBody
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse request from raw components
|
||||
* This is the main parsing method that doesn't rely on superglobals
|
||||
*
|
||||
* @param string $method HTTP method
|
||||
* @param string $uri Request URI (path + query)
|
||||
* @param array<string, mixed> $server Server environment data
|
||||
* @param string $rawBody Raw request body
|
||||
* @return Request
|
||||
*/
|
||||
public function parseRequest(
|
||||
string $method,
|
||||
string $uri,
|
||||
array $server,
|
||||
string $rawBody
|
||||
): Request {
|
||||
// Security: Validate request body size
|
||||
$this->validateRequestBodySize($rawBody);
|
||||
|
||||
// Security: Validate URI length
|
||||
$this->validateUriLength($uri);
|
||||
|
||||
// Parse URI components
|
||||
$uriComponents = parse_url($uri);
|
||||
if ($uriComponents === false) {
|
||||
throw new InvalidArgumentException("Invalid URI: $uri");
|
||||
}
|
||||
|
||||
$path = $uriComponents['path'] ?? '/';
|
||||
$queryString = $uriComponents['query'] ?? '';
|
||||
|
||||
// Normalize path
|
||||
if ($path !== '/') {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
|
||||
// Create server environment
|
||||
$serverEnvironment = new ServerEnvironment($server);
|
||||
|
||||
// Parse headers
|
||||
$headers = $this->headerParser->parseFromServerArray($server);
|
||||
|
||||
// Parse cookies from Cookie header
|
||||
$cookieHeader = $headers->getFirst('Cookie') ?? '';
|
||||
$cookies = $this->cookieParser->parseToCookies($cookieHeader);
|
||||
|
||||
// Parse query parameters
|
||||
$queryParams = $this->queryParser->parse($queryString);
|
||||
|
||||
// Determine content type
|
||||
$contentType = $headers->getFirst('Content-Type', '');
|
||||
|
||||
// Parse body based on method and content type
|
||||
$parsedBody = [];
|
||||
$uploadedFiles = new UploadedFiles([]);
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
|
||||
if (str_contains($contentType, 'multipart/form-data')) {
|
||||
// For multipart/form-data, PHP automatically populates $_POST and $_FILES
|
||||
// and makes php://input empty. Use $_POST directly in this case.
|
||||
if (strlen($rawBody) === 0 && ! empty($_POST)) {
|
||||
error_log("HttpRequestParser: Using \$_POST fallback for multipart/form-data");
|
||||
$parsedBody = $_POST;
|
||||
|
||||
// Also handle $_FILES if available
|
||||
if (! empty($_FILES)) {
|
||||
$uploadedFiles = $this->fileParser->parseFromFilesSuperglobal($_FILES);
|
||||
}
|
||||
} else {
|
||||
// Extract boundary
|
||||
$boundary = $this->extractBoundary($contentType);
|
||||
if ($boundary !== null) {
|
||||
// Use streaming parser for large requests
|
||||
if ($this->shouldUseStreamingParser($rawBody)) {
|
||||
[$parsedBody, $uploadedFiles] = $this->parseWithStreamingParser($rawBody, $boundary);
|
||||
} else {
|
||||
// Parse both form fields and files with regular parsers
|
||||
$parsedBody = $this->formParser->parse($contentType, $rawBody);
|
||||
$uploadedFiles = $this->fileParser->parseMultipart($rawBody, $boundary);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parse as regular form data
|
||||
$parsedBody = $this->formParser->parse($contentType, $rawBody);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a method enum
|
||||
$methodEnum = Method::tryFrom($method) ?? Method::GET;
|
||||
|
||||
// Check for method override in parsed body
|
||||
if ($methodEnum === Method::POST && isset($parsedBody['_method'])) {
|
||||
$overrideMethod = Method::tryFrom($parsedBody['_method']);
|
||||
if ($overrideMethod !== null) {
|
||||
$methodEnum = $overrideMethod;
|
||||
}
|
||||
}
|
||||
|
||||
// Create RequestBody object
|
||||
// For GET requests, pass query params as parsed data
|
||||
$bodyData = $methodEnum === Method::GET ? $queryParams : $parsedBody;
|
||||
|
||||
$requestBody = new RequestBody(
|
||||
method: $methodEnum,
|
||||
headers: $headers,
|
||||
body: $rawBody,
|
||||
parsedData: $bodyData
|
||||
);
|
||||
|
||||
// Generate request ID
|
||||
$requestId = $this->requestIdGenerator->generate();
|
||||
|
||||
// Create the request
|
||||
return new HttpRequest(
|
||||
method: $methodEnum,
|
||||
headers: $headers,
|
||||
body: $rawBody,
|
||||
path: $path,
|
||||
queryParams: $queryParams,
|
||||
files: $uploadedFiles,
|
||||
cookies: $cookies,
|
||||
server: $serverEnvironment,
|
||||
id: $requestId,
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw HTTP request string (for testing or special cases)
|
||||
*
|
||||
* @param string $rawRequest Complete raw HTTP request including headers and body
|
||||
* @return Request
|
||||
*/
|
||||
public function parseRawHttpRequest(string $rawRequest): Request
|
||||
{
|
||||
// Split request into headers and body
|
||||
$parts = explode("\r\n\r\n", $rawRequest, 2);
|
||||
$headerSection = $parts[0];
|
||||
$body = $parts[1] ?? '';
|
||||
|
||||
// Parse request line
|
||||
$lines = explode("\r\n", $headerSection);
|
||||
$requestLine = array_shift($lines);
|
||||
|
||||
if (! preg_match('/^(\S+)\s+(\S+)\s+(\S+)$/', $requestLine, $matches)) {
|
||||
throw new InvalidArgumentException("Invalid request line: $requestLine");
|
||||
}
|
||||
|
||||
$method = $matches[1];
|
||||
$uri = $matches[2];
|
||||
$protocol = $matches[3];
|
||||
|
||||
// Parse headers from remaining lines
|
||||
$headerString = implode("\r\n", $lines);
|
||||
$headers = $this->headerParser->parseRawHeaders($headerString);
|
||||
|
||||
// Create minimal server array
|
||||
$server = [
|
||||
'REQUEST_METHOD' => $method,
|
||||
'REQUEST_URI' => $uri,
|
||||
'SERVER_PROTOCOL' => $protocol,
|
||||
];
|
||||
|
||||
// Add some headers to server array for compatibility
|
||||
if ($headers->has('Host')) {
|
||||
$server['HTTP_HOST'] = $headers->getFirst('Host');
|
||||
}
|
||||
if ($headers->has('User-Agent')) {
|
||||
$server['HTTP_USER_AGENT'] = $headers->getFirst('User-Agent');
|
||||
}
|
||||
|
||||
return $this->parseRequest($method, $uri, $server, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract boundary from Content-Type header
|
||||
*
|
||||
* @param string $contentType Content-Type header value
|
||||
* @return string|null Boundary or null if not found
|
||||
*/
|
||||
private function extractBoundary(string $contentType): ?string
|
||||
{
|
||||
if (preg_match('/boundary=([^;]+)/', $contentType, $matches)) {
|
||||
return trim($matches[1], ' "');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request body size against security limits
|
||||
*
|
||||
* @param string $rawBody Raw request body
|
||||
* @throws ParserSecurityException If body size exceeds limits
|
||||
*/
|
||||
private function validateRequestBodySize(string $rawBody): void
|
||||
{
|
||||
$bodySize = strlen($rawBody);
|
||||
|
||||
// Check against maximum total upload size (includes form data + files)
|
||||
if ($bodySize > $this->config->maxTotalUploadSize->toBytes()) {
|
||||
throw ParserSecurityException::requestBodySizeExceeded(
|
||||
$bodySize,
|
||||
$this->config->maxTotalUploadSize->toBytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URI length against security limits
|
||||
*
|
||||
* @param string $uri Request URI
|
||||
* @throws ParserSecurityException If URI is too long
|
||||
*/
|
||||
private function validateUriLength(string $uri): void
|
||||
{
|
||||
$uriLength = strlen($uri);
|
||||
|
||||
// Most web servers have a limit around 8KB for the request line
|
||||
// We use a conservative 4KB limit for security
|
||||
$maxUriLength = 4096;
|
||||
|
||||
if ($uriLength > $maxUriLength) {
|
||||
throw ParserSecurityException::simple(
|
||||
"URI too long: {$uriLength} characters > {$maxUriLength} maximum"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if streaming parser should be used based on request size
|
||||
*
|
||||
* @param string $rawBody
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldUseStreamingParser(string $rawBody): bool
|
||||
{
|
||||
// Use streaming parser for requests larger than 10MB
|
||||
$streamingThreshold = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
return strlen($rawBody) > $streamingThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart data using the streaming parser
|
||||
*
|
||||
* @param string $rawBody
|
||||
* @param string $boundary
|
||||
* @return array{0: array<string, mixed>, 1: UploadedFiles}
|
||||
*/
|
||||
private function parseWithStreamingParser(string $rawBody, string $boundary): array
|
||||
{
|
||||
// Create stream from raw body
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, $rawBody);
|
||||
rewind($stream);
|
||||
|
||||
$formData = [];
|
||||
$files = [];
|
||||
|
||||
try {
|
||||
// Process all parts using the generator
|
||||
foreach ($this->streamingParser->streamMultipart($stream, $boundary) as $part) {
|
||||
if ($part['type'] === 'file') {
|
||||
// Handle file upload
|
||||
if (isset($part['stream'])) {
|
||||
// Create temporary file
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
if ($tempPath === false) {
|
||||
throw new \RuntimeException('Failed to create temporary file');
|
||||
}
|
||||
|
||||
$tempHandle = fopen($tempPath, 'w');
|
||||
if ($tempHandle === false) {
|
||||
throw new \RuntimeException('Failed to open temporary file for writing');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($part['stream'], $tempHandle);
|
||||
fclose($tempHandle);
|
||||
fclose($part['stream']);
|
||||
|
||||
// Create UploadedFile
|
||||
$uploadedFile = new UploadedFile(
|
||||
name: $part['filename'] ?? '',
|
||||
type: $part['headers']['Content-Type'] ?? 'application/octet-stream',
|
||||
size: filesize($tempPath) ?: 0,
|
||||
tmpName: $tempPath,
|
||||
error: \App\Framework\Http\UploadError::OK,
|
||||
skipValidation: true
|
||||
);
|
||||
|
||||
// Handle array notation
|
||||
$name = $part['name'];
|
||||
if (str_contains($name, '[')) {
|
||||
$this->addArrayFile($files, $name, $uploadedFile);
|
||||
} else {
|
||||
$files[$name] = $uploadedFile;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle form field
|
||||
$name = $part['name'];
|
||||
$value = $part['data'] ?? '';
|
||||
|
||||
// Handle array notation in form fields
|
||||
if (str_contains($name, '[')) {
|
||||
$this->addArrayValue($formData, $name, $value);
|
||||
} else {
|
||||
$formData[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return [$formData, new UploadedFiles($files)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to array structure based on field name
|
||||
*
|
||||
* @param array<string, mixed> &$files
|
||||
* @param string $name
|
||||
* @param UploadedFile $file
|
||||
*/
|
||||
private function addArrayFile(array &$files, string $name, UploadedFile $file): void
|
||||
{
|
||||
// Parse array notation like "files[0]" or "files[avatar]"
|
||||
if (preg_match('/^([^\[]+)\[([^]]*)](.*)$/', $name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
$key = $matches[2];
|
||||
$remainder = $matches[3];
|
||||
|
||||
if (! isset($files[$baseName])) {
|
||||
$files[$baseName] = [];
|
||||
}
|
||||
|
||||
if ($remainder) {
|
||||
// Nested array
|
||||
if ($key === '') {
|
||||
$key = count($files[$baseName]);
|
||||
}
|
||||
$this->addArrayFile($files[$baseName], $key . $remainder, $file);
|
||||
} else {
|
||||
// Final level
|
||||
if ($key === '') {
|
||||
$files[$baseName][] = $file;
|
||||
} else {
|
||||
$files[$baseName][$key] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add value to array structure based on field name
|
||||
*
|
||||
* @param array<string, mixed> &$data
|
||||
* @param string $name
|
||||
* @param string $value
|
||||
*/
|
||||
private function addArrayValue(array &$data, string $name, string $value): void
|
||||
{
|
||||
// Parse array notation like "fields[0]" or "fields[name]"
|
||||
if (preg_match('/^([^\[]+)\[([^]]*)](.*)$/', $name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
$key = $matches[2];
|
||||
$remainder = $matches[3];
|
||||
|
||||
if (! isset($data[$baseName])) {
|
||||
$data[$baseName] = [];
|
||||
}
|
||||
|
||||
if ($remainder) {
|
||||
// Nested array
|
||||
if ($key === '') {
|
||||
$key = count($data[$baseName]);
|
||||
}
|
||||
$this->addArrayValue($data[$baseName], $key . $remainder, $value);
|
||||
} else {
|
||||
// Final level
|
||||
if ($key === '') {
|
||||
$data[$baseName][] = $value;
|
||||
} else {
|
||||
$data[$baseName][$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
src/Framework/Http/Parser/ParserCache.php
Normal file
298
src/Framework/Http/Parser/ParserCache.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* High-performance caching system for parser results using Framework Cache
|
||||
* Reduces repeated parsing overhead for common patterns
|
||||
*/
|
||||
final readonly class ParserCache
|
||||
{
|
||||
private const string CACHE_PREFIX = 'http_parser:';
|
||||
private const int MAX_CACHE_KEY_LENGTH = 200;
|
||||
|
||||
private Duration $ttl;
|
||||
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
?Duration $ttl = null
|
||||
) {
|
||||
$this->ttl = $ttl ?? Duration::fromHours(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached query string parse result
|
||||
*
|
||||
* @param string $queryString
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getQueryString(string $queryString): ?array
|
||||
{
|
||||
if (! $this->shouldCache($queryString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('query', $queryString);
|
||||
|
||||
// Check if cached first, then get the value
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
$cacheItem = $this->cache->get($cacheKey);
|
||||
if ($cacheItem->isHit && is_array($cacheItem->value)) {
|
||||
return $cacheItem->value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache query string parse result
|
||||
*
|
||||
* @param string $queryString
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
public function setQueryString(string $queryString, array $result): void
|
||||
{
|
||||
if (! $this->shouldCache($queryString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('query', $queryString);
|
||||
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached header parse result
|
||||
*
|
||||
* @param string $headerString
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getHeaders(string $headerString): ?array
|
||||
{
|
||||
if (! $this->shouldCache($headerString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('headers', $headerString);
|
||||
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
$cacheItem = $this->cache->get($cacheKey);
|
||||
if ($cacheItem->isHit && is_array($cacheItem->value)) {
|
||||
return $cacheItem->value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache header parse result
|
||||
*
|
||||
* @param string $headerString
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
public function setHeaders(string $headerString, array $result): void
|
||||
{
|
||||
if (! $this->shouldCache($headerString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('headers', $headerString);
|
||||
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached cookie parse result
|
||||
*
|
||||
* @param string $cookieString
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getCookies(string $cookieString): ?array
|
||||
{
|
||||
if (! $this->shouldCache($cookieString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('cookies', $cookieString);
|
||||
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
$cacheItem = $this->cache->get($cacheKey);
|
||||
if ($cacheItem->isHit && is_array($cacheItem->value)) {
|
||||
return $cacheItem->value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache cookie parse result
|
||||
*
|
||||
* @param string $cookieString
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
public function setCookies(string $cookieString, array $result): void
|
||||
{
|
||||
if (! $this->shouldCache($cookieString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->generateCacheKey('cookies', $cookieString);
|
||||
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache parsed multipart boundary for reuse
|
||||
*
|
||||
* @param string $contentType
|
||||
* @return string|null
|
||||
*/
|
||||
public function getBoundary(string $contentType): ?string
|
||||
{
|
||||
$cacheKey = $this->generateCacheKey('boundary', $contentType);
|
||||
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
$cacheItem = $this->cache->get($cacheKey);
|
||||
if ($cacheItem->isHit && is_string($cacheItem->value)) {
|
||||
return $cacheItem->value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache multipart boundary
|
||||
*
|
||||
* @param string $contentType
|
||||
* @param string $boundary
|
||||
*/
|
||||
public function setBoundary(string $contentType, string $boundary): void
|
||||
{
|
||||
$cacheKey = $this->generateCacheKey('boundary', $contentType);
|
||||
$this->cache->set(CacheItem::forSet($cacheKey, $boundary, $this->ttl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all parser caches
|
||||
* Note: Framework Cache doesn't support pattern deletion, so this clears all cache
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
$this->cache->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific cache type
|
||||
* Note: Framework Cache doesn't support pattern deletion, so this clears all cache
|
||||
*
|
||||
* @param string $type One of: query, headers, cookies, boundary
|
||||
*/
|
||||
public function clearType(string $type): void
|
||||
{
|
||||
// Since we can't delete by pattern, we clear all cache
|
||||
// This is a limitation of the current Cache interface
|
||||
$this->cache->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
// Try to get stats from cache implementation
|
||||
if (method_exists($this->cache, 'getStats')) {
|
||||
$cacheStats = $this->cache->getStats();
|
||||
|
||||
// Filter for our keys if possible
|
||||
return [
|
||||
'cache_backend' => get_class($this->cache),
|
||||
'ttl' => $this->ttl->toSeconds(),
|
||||
'prefix' => self::CACHE_PREFIX,
|
||||
'backend_stats' => $cacheStats,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'cache_backend' => get_class($this->cache),
|
||||
'ttl' => $this->ttl->toSeconds(),
|
||||
'prefix' => self::CACHE_PREFIX,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for input data
|
||||
*/
|
||||
private function generateCacheKey(string $type, string $input): CacheKey
|
||||
{
|
||||
$baseKey = self::CACHE_PREFIX . $type . ':';
|
||||
|
||||
// Use hash for long inputs to prevent cache key issues
|
||||
if (strlen($input) > self::MAX_CACHE_KEY_LENGTH) {
|
||||
return CacheKey::fromString($baseKey . 'hash_' . hash('xxh64', $input));
|
||||
}
|
||||
|
||||
// Replace problematic characters for cache keys
|
||||
$cleanInput = preg_replace('/[^a-zA-Z0-9._-]/', '_', $input);
|
||||
|
||||
return CacheKey::fromString($baseKey . $cleanInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if caching should be used for given input size
|
||||
*
|
||||
* @param string $input
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldCache(string $input): bool
|
||||
{
|
||||
$length = strlen($input);
|
||||
|
||||
// Don't cache very short strings (overhead not worth it)
|
||||
if ($length < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't cache very long strings (memory usage)
|
||||
if ($length > 4096) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't cache if input contains sensitive data patterns
|
||||
if ($this->containsSensitiveData($input)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input contains potentially sensitive data that shouldn't be cached
|
||||
*
|
||||
* @param string $input
|
||||
* @return bool
|
||||
*/
|
||||
private function containsSensitiveData(string $input): bool
|
||||
{
|
||||
$sensitivePatterns = [
|
||||
'/password/i',
|
||||
'/token/i',
|
||||
'/secret/i',
|
||||
'/key/i',
|
||||
'/auth/i',
|
||||
'/session/i',
|
||||
];
|
||||
|
||||
return array_any($sensitivePatterns, fn ($pattern) => preg_match($pattern, $input));
|
||||
|
||||
}
|
||||
}
|
||||
209
src/Framework/Http/Parser/ParserConfig.php
Normal file
209
src/Framework/Http/Parser/ParserConfig.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
|
||||
/**
|
||||
* Configuration for HTTP parsers with security and performance limits
|
||||
*/
|
||||
final readonly class ParserConfig
|
||||
{
|
||||
public function __construct(
|
||||
// File upload limits
|
||||
public Byte $maxFileSize = new Byte(100 * 1024 * 1024), // 100MB
|
||||
public int $maxFileCount = 20,
|
||||
public Byte $maxTotalUploadSize = new Byte(500 * 1024 * 1024), // 500MB
|
||||
|
||||
// Form data limits
|
||||
public int $maxFieldCount = 1000,
|
||||
public int $maxFieldNameLength = 1000,
|
||||
public int $maxFieldValueLength = 1024 * 1024, // 1MB
|
||||
public Byte $maxFormDataSize = new Byte(10 * 1024 * 1024), // 10MB
|
||||
|
||||
// Query string limits
|
||||
public int $maxQueryStringLength = 8192, // 8KB
|
||||
public int $maxQueryParameters = 1000,
|
||||
|
||||
// Header limits
|
||||
public int $maxHeaderCount = 100,
|
||||
public int $maxHeaderNameLength = 256,
|
||||
public int $maxHeaderValueLength = 8192,
|
||||
public Byte $maxTotalHeaderSize = new Byte(64 * 1024), // 64KB
|
||||
|
||||
// Cookie limits
|
||||
public int $maxCookieCount = 50,
|
||||
public int $maxCookieNameLength = 256,
|
||||
public int $maxCookieValueLength = 4096,
|
||||
|
||||
// Multipart limits
|
||||
public int $maxMultipartParts = 100,
|
||||
public int $maxBoundaryLength = 256,
|
||||
|
||||
// General limits
|
||||
public Byte $maxRequestBodySize = new Byte(100 * 1024 * 1024), // 100MB
|
||||
public int $maxUriLength = 8192,
|
||||
|
||||
// Security settings
|
||||
public bool $validateFileExtensions = true,
|
||||
public array $allowedFileExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'doc', 'docx'],
|
||||
public array $blockedFileExtensions = ['php', 'js', 'html', 'exe', 'bat', 'sh'],
|
||||
public bool $scanForMaliciousContent = true,
|
||||
public bool $strictMimeTypeValidation = true,
|
||||
|
||||
// Performance settings
|
||||
public bool $enableStreamingForLargeFiles = true,
|
||||
public Byte $streamingThreshold = new Byte(10 * 1024 * 1024), // 10MB
|
||||
public string $tempDirectory = '',
|
||||
|
||||
// Error handling
|
||||
public bool $throwOnLimitExceeded = true,
|
||||
public bool $logSecurityViolations = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a development-friendly configuration with relaxed limits
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(
|
||||
maxFileSize: Byte::fromMegabytes(50),
|
||||
maxFileCount: 10,
|
||||
maxFormDataSize: Byte::fromMegabytes(5),
|
||||
validateFileExtensions: false,
|
||||
scanForMaliciousContent: false,
|
||||
strictMimeTypeValidation: false,
|
||||
throwOnLimitExceeded: false,
|
||||
logSecurityViolations: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a production-ready configuration with strict limits
|
||||
*/
|
||||
public static function production(): self
|
||||
{
|
||||
return new self(
|
||||
maxFileSize: Byte::fromMegabytes(10),
|
||||
maxFileCount: 5,
|
||||
maxTotalUploadSize: Byte::fromMegabytes(50),
|
||||
maxFieldCount: 100,
|
||||
maxFormDataSize: Byte::fromMegabytes(2),
|
||||
maxQueryParameters: 100,
|
||||
validateFileExtensions: true,
|
||||
blockedFileExtensions: ['php', 'js', 'html', 'exe', 'bat', 'sh', 'asp', 'jsp', 'py', 'rb'],
|
||||
scanForMaliciousContent: true,
|
||||
strictMimeTypeValidation: true,
|
||||
throwOnLimitExceeded: true,
|
||||
logSecurityViolations: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a high-security configuration for sensitive applications
|
||||
*/
|
||||
public static function highSecurity(): self
|
||||
{
|
||||
return new self(
|
||||
maxFileSize: Byte::fromMegabytes(5),
|
||||
maxFileCount: 3,
|
||||
maxTotalUploadSize: Byte::fromMegabytes(15),
|
||||
maxFieldCount: 50,
|
||||
maxFormDataSize: Byte::fromMegabytes(1),
|
||||
maxQueryParameters: 50,
|
||||
maxHeaderCount: 50,
|
||||
maxCookieCount: 20,
|
||||
validateFileExtensions: true,
|
||||
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'txt'],
|
||||
blockedFileExtensions: ['php', 'js', 'html', 'exe', 'bat', 'sh', 'asp', 'jsp', 'py', 'rb', 'pl', 'cgi'],
|
||||
scanForMaliciousContent: true,
|
||||
strictMimeTypeValidation: true,
|
||||
throwOnLimitExceeded: true,
|
||||
logSecurityViolations: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective temp directory
|
||||
*/
|
||||
public function getTempDirectory(): string
|
||||
{
|
||||
return $this->tempDirectory ?: sys_get_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file extension is allowed
|
||||
*/
|
||||
public function isFileExtensionAllowed(string $filename): bool
|
||||
{
|
||||
if (! $this->validateFileExtensions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
// Check blocked list first
|
||||
if (in_array($extension, $this->blockedFileExtensions, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If allowed list is empty, allow all (except blocked)
|
||||
if (empty($this->allowedFileExtensions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check allowed list
|
||||
return in_array($extension, $this->allowedFileExtensions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file size against limits
|
||||
*/
|
||||
public function validateFileSize(Byte $size): bool
|
||||
{
|
||||
return $size->isLessThanOrEqual($this->maxFileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate total upload size
|
||||
*/
|
||||
public function validateTotalUploadSize(Byte $totalSize): bool
|
||||
{
|
||||
return $totalSize->isLessThanOrEqual($this->maxTotalUploadSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form data size
|
||||
*/
|
||||
public function validateFormDataSize(Byte $size): bool
|
||||
{
|
||||
return $size->isLessThanOrEqual($this->maxFormDataSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request body size
|
||||
*/
|
||||
public function validateRequestBodySize(Byte $size): bool
|
||||
{
|
||||
return $size->isLessThanOrEqual($this->maxRequestBodySize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if streaming should be used for given size
|
||||
*/
|
||||
public function shouldUseStreaming(Byte $size): bool
|
||||
{
|
||||
return $this->enableStreamingForLargeFiles && $size->greaterThan($this->streamingThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default configuration with balanced settings
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
181
src/Framework/Http/Parser/QueryStringParser.php
Normal file
181
src/Framework/Http/Parser/QueryStringParser.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
|
||||
/**
|
||||
* Parser for URL query strings with security validation
|
||||
* Handles complex cases like arrays, nested values, and special characters
|
||||
*/
|
||||
final readonly class QueryStringParser
|
||||
{
|
||||
public function __construct(
|
||||
private ParserCache $cache,
|
||||
private ParserConfig $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a query string into an associative array
|
||||
*
|
||||
* @param string $queryString The raw query string (without leading ?)
|
||||
* @return array<string, mixed> Parsed parameters
|
||||
* @throws ParserSecurityException
|
||||
*/
|
||||
public function parse(string $queryString): array
|
||||
{
|
||||
if ($queryString === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
$cached = $this->cache->getQueryString($queryString);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Security validation
|
||||
if (strlen($queryString) > $this->config->maxQueryStringLength) {
|
||||
throw ParserSecurityException::queryStringSizeExceeded(
|
||||
strlen($queryString),
|
||||
$this->config->maxQueryStringLength
|
||||
);
|
||||
}
|
||||
|
||||
$params = [];
|
||||
$pairs = explode('&', $queryString);
|
||||
|
||||
// Check parameter count
|
||||
if (count($pairs) > $this->config->maxQueryParameters) {
|
||||
throw ParserSecurityException::queryParameterCountExceeded(
|
||||
count($pairs),
|
||||
$this->config->maxQueryParameters
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
if ($pair === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('=', $pair, 2);
|
||||
$key = $this->decodeComponent($parts[0]);
|
||||
$value = isset($parts[1]) ? $this->decodeComponent($parts[1]) : '';
|
||||
|
||||
$params = $this->assignValue($params, $key, $value);
|
||||
}
|
||||
|
||||
// Cache the result before returning
|
||||
$this->cache->setQueryString($queryString, $params);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode URL component with proper error handling
|
||||
*/
|
||||
private function decodeComponent(string $component): string
|
||||
{
|
||||
// Replace + with space before URL decoding (form encoding standard)
|
||||
$component = str_replace('+', ' ', $component);
|
||||
|
||||
// Decode percent-encoded characters
|
||||
return rawurldecode($component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign value to params array, handling array notation
|
||||
*
|
||||
* @param array<string, mixed> $params Params array
|
||||
* @param string $key The parameter key (may include [] notation)
|
||||
* @param string $value The parameter value
|
||||
* @return array<string, mixed> Updated params array
|
||||
*/
|
||||
private function assignValue(array $params, string $key, string $value): array
|
||||
{
|
||||
// Handle array notation like key[] or key[index]
|
||||
if (preg_match('/^([^\[]+)(\[.*])$/', $key, $matches)) {
|
||||
$baseKey = $matches[1];
|
||||
$arrayPart = $matches[2];
|
||||
|
||||
return $this->assignArrayValue($params, $baseKey, $arrayPart, $value);
|
||||
} else {
|
||||
// Simple key=value
|
||||
$params[$key] = $value;
|
||||
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle array notation in query parameters
|
||||
*/
|
||||
private function assignArrayValue(array $params, string $baseKey, string $arrayPart, string $value): array
|
||||
{
|
||||
// Initialize array if not exists
|
||||
if (! isset($params[$baseKey])) {
|
||||
$params[$baseKey] = [];
|
||||
}
|
||||
|
||||
// Parse array notation: [], [key], [key1][key2], etc.
|
||||
if ($arrayPart === '[]') {
|
||||
// Simple array push
|
||||
if (! is_array($params[$baseKey])) {
|
||||
$params[$baseKey] = [$params[$baseKey]];
|
||||
}
|
||||
$params[$baseKey][] = $value;
|
||||
} else {
|
||||
// Parse nested keys
|
||||
$keys = $this->parseArrayKeys($arrayPart);
|
||||
$params[$baseKey] = $this->assignNestedValue($params[$baseKey], $keys, $value);
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse array keys from notation like [key1][key2][key3]
|
||||
*
|
||||
* @return string[] Array of keys
|
||||
*/
|
||||
private function parseArrayKeys(string $arrayPart): array
|
||||
{
|
||||
preg_match_all('/\[([^]]*)]/', $arrayPart, $matches);
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign value to nested array structure (immutable)
|
||||
*
|
||||
* @param mixed $target Target array/value
|
||||
* @param string[] $keys Array of keys to traverse
|
||||
* @param string $value Value to assign
|
||||
* @return mixed Updated structure
|
||||
*/
|
||||
private function assignNestedValue(mixed $target, array $keys, string $value): mixed
|
||||
{
|
||||
if (empty($keys)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$key = array_shift($keys);
|
||||
|
||||
if (! is_array($target)) {
|
||||
$target = [];
|
||||
}
|
||||
|
||||
if ($key === '') {
|
||||
// Empty key means array push
|
||||
$index = count($target);
|
||||
$target[$index] = $this->assignNestedValue($target[$index] ?? null, $keys, $value);
|
||||
} else {
|
||||
$target[$key] = $this->assignNestedValue($target[$key] ?? null, $keys, $value);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
344
src/Framework/Http/Parser/StreamingParser.php
Normal file
344
src/Framework/Http/Parser/StreamingParser.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Parser;
|
||||
|
||||
use App\Framework\Http\Parser\Exception\ParserSecurityException;
|
||||
use App\Framework\Http\UploadedFile;
|
||||
use App\Framework\Http\UploadError;
|
||||
|
||||
/**
|
||||
* True streaming parser using PHP generators for memory-efficient processing
|
||||
* Processes multipart data without loading entire request into memory
|
||||
*/
|
||||
final class StreamingParser
|
||||
{
|
||||
private const int DEFAULT_CHUNK_SIZE = 8192; // 8KB chunks
|
||||
private const string BOUNDARY_PREFIX = '--';
|
||||
private const string CRLF = "\r\n";
|
||||
private const string DOUBLE_CRLF = "\r\n\r\n";
|
||||
|
||||
public function __construct(
|
||||
private readonly ParserConfig $config = new ParserConfig()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream multipart data using generators
|
||||
* Each part is yielded as it's completely read from the stream
|
||||
*
|
||||
* @param resource $stream Input stream (e.g., php://input)
|
||||
* @param string $boundary Multipart boundary
|
||||
* @param int $chunkSize Size of chunks to read
|
||||
* @return \Generator<int, array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}>
|
||||
*/
|
||||
public function streamMultipart($stream, string $boundary, int $chunkSize = self::DEFAULT_CHUNK_SIZE): \Generator
|
||||
{
|
||||
if (! is_resource($stream)) {
|
||||
throw new \InvalidArgumentException('First parameter must be a valid stream resource');
|
||||
}
|
||||
|
||||
$partCount = 0;
|
||||
$buffer = '';
|
||||
$inPart = false;
|
||||
$currentPart = [];
|
||||
$partHeaders = [];
|
||||
$partData = '';
|
||||
$tempFile = null;
|
||||
|
||||
// Boundary markers
|
||||
$boundaryDelimiter = self::BOUNDARY_PREFIX . $boundary;
|
||||
$finalBoundary = $boundaryDelimiter . self::BOUNDARY_PREFIX;
|
||||
|
||||
while (! feof($stream)) {
|
||||
$chunk = fread($stream, $chunkSize);
|
||||
if ($chunk === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$buffer .= $chunk;
|
||||
|
||||
// Process buffer line by line
|
||||
while (($lineEnd = strpos($buffer, self::CRLF)) !== false) {
|
||||
$line = substr($buffer, 0, $lineEnd);
|
||||
$buffer = substr($buffer, $lineEnd + 2);
|
||||
|
||||
// Check for boundary
|
||||
if ($line === $boundaryDelimiter || $line === $finalBoundary) {
|
||||
// Yield previous part if exists
|
||||
if ($inPart && ! empty($currentPart)) {
|
||||
yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);
|
||||
|
||||
$partCount++;
|
||||
if ($partCount > $this->config->maxFileCount) {
|
||||
throw ParserSecurityException::maxPartsExceeded($partCount, $this->config->maxFileCount);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is the final boundary
|
||||
if ($line === $finalBoundary) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new part
|
||||
$inPart = true;
|
||||
$currentPart = [];
|
||||
$partHeaders = [];
|
||||
$partData = '';
|
||||
$tempFile = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're in a part
|
||||
if ($inPart) {
|
||||
// Still reading headers
|
||||
if (empty($currentPart)) {
|
||||
if ($line === '') {
|
||||
// End of headers, parse them
|
||||
$currentPart = $this->parsePartHeaders($partHeaders);
|
||||
|
||||
// If it's a file, create temp stream
|
||||
if (isset($currentPart['filename'])) {
|
||||
$tempFile = tmpfile();
|
||||
if ($tempFile === false) {
|
||||
throw new \RuntimeException('Failed to create temporary file for upload');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Collect header line
|
||||
$partHeaders[] = $line;
|
||||
}
|
||||
} else {
|
||||
// Reading part data
|
||||
if (isset($currentPart['filename']) && $tempFile !== null) {
|
||||
// Write to temp file for file uploads
|
||||
fwrite($tempFile, $line . self::CRLF);
|
||||
} else {
|
||||
// Accumulate in memory for form fields
|
||||
$partData .= $line . self::CRLF;
|
||||
|
||||
// Check field size limit
|
||||
if (strlen($partData) > $this->config->maxFieldValueLength) {
|
||||
throw ParserSecurityException::fieldValueTooLong(
|
||||
$currentPart['name'] ?? 'unknown',
|
||||
strlen($partData),
|
||||
$this->config->maxFieldValueLength
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining data in buffer that might be part of file content
|
||||
if ($inPart && ! empty($currentPart) && strlen($buffer) > 0) {
|
||||
// Check if buffer might contain a boundary
|
||||
$boundaryPos = strpos($buffer, $boundaryDelimiter);
|
||||
if ($boundaryPos === false || $boundaryPos > strlen($boundaryDelimiter)) {
|
||||
// Safe to write some data
|
||||
$safeLength = $boundaryPos !== false ? $boundaryPos : strlen($buffer) - strlen($boundaryDelimiter);
|
||||
if ($safeLength > 0) {
|
||||
$safeData = substr($buffer, 0, $safeLength);
|
||||
|
||||
if (isset($currentPart['filename']) && $tempFile !== null) {
|
||||
fwrite($tempFile, $safeData);
|
||||
} else {
|
||||
$partData .= $safeData;
|
||||
}
|
||||
|
||||
$buffer = substr($buffer, $safeLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield last part if exists
|
||||
if ($inPart && ! empty($currentPart)) {
|
||||
yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from a multipart part
|
||||
*
|
||||
* @param array<string> $headerLines
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parsePartHeaders(array $headerLines): array
|
||||
{
|
||||
$headers = [];
|
||||
$result = [];
|
||||
|
||||
// Parse header lines
|
||||
foreach ($headerLines as $line) {
|
||||
if (strpos($line, ':') !== false) {
|
||||
[$name, $value] = explode(':', $line, 2);
|
||||
$headers[trim($name)] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Content-Disposition
|
||||
if (isset($headers['Content-Disposition'])) {
|
||||
$disposition = $headers['Content-Disposition'];
|
||||
|
||||
// Extract name
|
||||
if (preg_match('/name="([^"]+)"/', $disposition, $matches)) {
|
||||
$result['name'] = $matches[1];
|
||||
}
|
||||
|
||||
// Extract filename
|
||||
if (preg_match('/filename="([^"]+)"/', $disposition, $matches)) {
|
||||
$result['filename'] = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Add content type if present
|
||||
if (isset($headers['Content-Type'])) {
|
||||
$result['content_type'] = $headers['Content-Type'];
|
||||
}
|
||||
|
||||
$result['headers'] = $headers;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a part for yielding
|
||||
*
|
||||
* @param array<string, mixed> $part
|
||||
* @param array<string> $headers
|
||||
* @param string $data
|
||||
* @param resource|null $tempFile
|
||||
* @return array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}
|
||||
*/
|
||||
private function finalizePart(array $part, array $headers, string $data, $tempFile): array
|
||||
{
|
||||
$result = [
|
||||
'type' => isset($part['filename']) ? 'file' : 'field',
|
||||
'name' => $part['name'] ?? 'unknown',
|
||||
'headers' => $part['headers'] ?? [],
|
||||
];
|
||||
|
||||
if (isset($part['filename'])) {
|
||||
$result['filename'] = $part['filename'];
|
||||
|
||||
if ($tempFile !== null) {
|
||||
// Rewind temp file to beginning
|
||||
rewind($tempFile);
|
||||
$result['stream'] = $tempFile;
|
||||
|
||||
// Validate file size
|
||||
$stats = fstat($tempFile);
|
||||
if ($stats !== false && $stats['size'] > $this->config->maxFileSize->toBytes()) {
|
||||
fclose($tempFile);
|
||||
|
||||
throw ParserSecurityException::fileSizeExceeded(
|
||||
$part['filename'],
|
||||
$stats['size'],
|
||||
$this->config->maxFileSize->toBytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove trailing CRLF from field data
|
||||
$result['data'] = rtrim($data, "\r\n");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UploadedFile objects from a stream
|
||||
* This collects all file parts from the generator
|
||||
*
|
||||
* @param resource $stream
|
||||
* @param string $boundary
|
||||
* @return array<string, UploadedFile|array<UploadedFile>>
|
||||
*/
|
||||
public function parseFilesFromStream($stream, string $boundary): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ($this->streamMultipart($stream, $boundary) as $part) {
|
||||
if ($part['type'] === 'file' && isset($part['stream'])) {
|
||||
// Create temporary file path
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
if ($tempPath === false) {
|
||||
throw new \RuntimeException('Failed to create temporary file');
|
||||
}
|
||||
|
||||
// Copy stream to temp file
|
||||
$tempHandle = fopen($tempPath, 'w');
|
||||
if ($tempHandle === false) {
|
||||
throw new \RuntimeException('Failed to open temporary file for writing');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($part['stream'], $tempHandle);
|
||||
fclose($tempHandle);
|
||||
fclose($part['stream']);
|
||||
|
||||
// Get file size
|
||||
$fileSize = filesize($tempPath) ?: 0;
|
||||
|
||||
// Create UploadedFile
|
||||
$uploadedFile = new UploadedFile(
|
||||
name: $part['filename'] ?? '',
|
||||
type: $part['headers']['Content-Type'] ?? 'application/octet-stream',
|
||||
size: $fileSize,
|
||||
tmpName: $tempPath,
|
||||
error: UploadError::OK,
|
||||
skipValidation: true
|
||||
);
|
||||
|
||||
// Handle array notation in name
|
||||
$name = $part['name'];
|
||||
if (str_contains($name, '[')) {
|
||||
// Parse array notation and build nested structure
|
||||
$this->addArrayFile($files, $name, $uploadedFile);
|
||||
} else {
|
||||
$files[$name] = $uploadedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to array structure based on field name
|
||||
*
|
||||
* @param array<string, mixed> &$files
|
||||
* @param string $name
|
||||
* @param UploadedFile $file
|
||||
*/
|
||||
private function addArrayFile(array &$files, string $name, UploadedFile $file): void
|
||||
{
|
||||
// Parse array notation like "files[0]" or "files[avatar]"
|
||||
if (preg_match('/^([^\[]+)\[([^\]]*)\](.*)$/', $name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
$key = $matches[2];
|
||||
$remainder = $matches[3];
|
||||
|
||||
if (! isset($files[$baseName])) {
|
||||
$files[$baseName] = [];
|
||||
}
|
||||
|
||||
if ($remainder) {
|
||||
// Nested array
|
||||
if ($key === '') {
|
||||
$key = count($files[$baseName]);
|
||||
}
|
||||
$this->addArrayFile($files[$baseName], $key . $remainder, $file);
|
||||
} else {
|
||||
// Final level
|
||||
if ($key === '') {
|
||||
$files[$baseName][] = $file;
|
||||
} else {
|
||||
$files[$baseName][$key] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user