- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
356 lines
12 KiB
PHP
356 lines
12 KiB
PHP
<?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}'"
|
|
);
|
|
}
|
|
}
|
|
}
|