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:
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}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user