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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

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

View 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"
);
}
}

View 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;
}
}

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

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

View 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;
}
}
}
}
}

View 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));
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}
}
}
}