- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
261 lines
7.7 KiB
PHP
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;
|
|
}
|
|
}
|