Files
michaelschiemer/src/Framework/ErrorReporting/ErrorReportingMiddleware.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

261 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use Throwable;
/**
* Middleware for automatic error reporting with request context enrichment
*/
final readonly class ErrorReportingMiddleware implements HttpMiddleware
{
private const int MAX_RECURSION_DEPTH = 5;
private const int MAX_ARRAY_SIZE = 50;
private const int MAX_STRING_LENGTH = 1000;
private const array PROXY_HEADERS = [
'X-Forwarded-For',
'X-Real-IP',
'Client-IP',
'X-Forwarded',
'Forwarded-For',
'Forwarded',
];
private const array SENSITIVE_KEYS = [
'password', 'passwd', 'pwd', 'secret', 'token', 'key', 'api_key',
'authorization', 'auth', 'csrf', 'csrf_token', 'access_token',
'refresh_token', 'private_key', 'credit_card', 'card_number',
'cvv', 'cvc', 'ssn', 'social_security',
];
public function __construct(
private ErrorReporter $reporter,
private bool $enabled = true
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
if (! $this->enabled) {
return $next($context);
}
try {
return $next($context);
} catch (Throwable $e) {
$this->reportError($e, $context);
throw $e; // Re-throw for proper error handling chain
}
}
/**
* Report error with enriched request context
*/
private function reportError(Throwable $error, MiddlewareContext $context): void
{
try {
$request = $context->request;
$requestContext = $this->buildRequestContext($request);
$contextualReporter = $this->reporter->withRequestContext(
method: $requestContext['method'],
route: $requestContext['route'],
requestId: $requestContext['requestId'],
userAgent: $requestContext['userAgent'],
ipAddress: $requestContext['ipAddress'],
requestData: $requestContext['requestData']
);
$contextualReporter->reportThrowable($error);
} catch (Throwable) {
// Silently fail to prevent error reporting from breaking the application
}
}
/**
* Build comprehensive request context for error reporting
*/
private function buildRequestContext(Request $request): array
{
return [
'method' => $request->method->value,
'route' => $request->path,
'requestId' => $request->id->toString(),
'userAgent' => $request->server->getUserAgent()->value,
'ipAddress' => $this->extractClientIpAddress($request),
'requestData' => $this->extractRequestData($request),
];
}
/**
* Extract client IP address from request headers and server environment
*/
private function extractClientIpAddress(Request $request): ?string
{
// Check proxy headers for real client IP
foreach (self::PROXY_HEADERS as $header) {
$ip = $this->extractIpFromHeader($request, $header);
if ($ip !== null) {
return $ip;
}
}
// Fallback to direct connection IP
return $request->server->get('REMOTE_ADDR');
}
/**
* Extract and validate IP address from specific header
*/
private function extractIpFromHeader(Request $request, string $header): ?string
{
$headerValue = $request->headers->get($header);
if (empty($headerValue)) {
return null;
}
// Handle comma-separated list (X-Forwarded-For)
if (str_contains($headerValue, ',')) {
$headerValue = trim(explode(',', $headerValue)[0]);
}
// Validate as public IP address
return filter_var($headerValue, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
? $headerValue
: null;
}
/**
* Extract and sanitize request data from multiple sources
*/
private function extractRequestData(Request $request): ?array
{
$data = [];
// Query parameters
if (! empty($request->queryParams)) {
$data['query'] = $this->sanitizeData($request->queryParams);
}
// Request body (could be form data or JSON)
if (! empty($request->body)) {
$data['body'] = $this->sanitizeRequestBody($request);
}
return ! empty($data) ? $data : null;
}
/**
* Sanitize request body based on content type
*/
private function sanitizeRequestBody(Request $request): mixed
{
$contentType = $request->headers->get('Content-Type') ?? '';
if (str_contains($contentType, 'application/json')) {
return $this->sanitizeJsonBody($request->body);
}
// For other content types, just sanitize as string
return $this->sanitizeData($request->body);
}
/**
* Parse and sanitize JSON body
*/
private function sanitizeJsonBody(string $body): mixed
{
try {
$jsonData = json_decode($body, true);
return json_last_error() === JSON_ERROR_NONE
? $this->sanitizeData($jsonData)
: '[invalid_json]';
} catch (Throwable) {
return '[json_parse_error]';
}
}
/**
* Recursively sanitize data to remove sensitive information
*/
private function sanitizeData(mixed $data, int $depth = 0): mixed
{
if ($depth > self::MAX_RECURSION_DEPTH) {
return '[max_depth_reached]';
}
return match (true) {
is_array($data) => $this->sanitizeArray($data, $depth),
is_string($data) => $this->sanitizeString($data),
default => $data
};
}
/**
* Sanitize array data with size limits and sensitive key filtering
*/
private function sanitizeArray(array $data, int $depth): array
{
// Limit array size to prevent memory issues
if (count($data) > self::MAX_ARRAY_SIZE) {
$data = array_slice($data, 0, self::MAX_ARRAY_SIZE, true);
$data['[truncated]'] = 'Array truncated - too many items';
}
$sanitized = [];
foreach ($data as $key => $value) {
$sanitized[$key] = $this->isSensitiveKey((string)$key)
? '[redacted]'
: $this->sanitizeData($value, $depth + 1);
}
return $sanitized;
}
/**
* Sanitize string data with length limits and pattern removal
*/
private function sanitizeString(string $data): string
{
// Limit string length
if (strlen($data) > self::MAX_STRING_LENGTH) {
$data = substr($data, 0, self::MAX_STRING_LENGTH) . '[truncated]';
}
// Remove sensitive patterns
$patterns = [
'/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/' => '[card_number]',
'/\b\d{3}-?\d{2}-?\d{4}\b/' => '[ssn]',
'/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/' => 'Bearer [redacted]',
];
return preg_replace(array_keys($patterns), array_values($patterns), $data);
}
/**
* Check if a key contains sensitive information that should be redacted
*/
private function isSensitiveKey(string $key): bool
{
$lowerKey = strtolower($key);
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
if (str_contains($lowerKey, $sensitiveKey)) {
return true;
}
}
return false;
}
}