Files
michaelschiemer/src/Framework/Http/Parser/HeaderParser.php
Michael Schiemer 55a330b223 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
2025-08-11 20:13:26 +02:00

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