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:
130
src/Framework/Http/Middlewares/ApiVersioningMiddleware.php
Normal file
130
src/Framework/Http/Middlewares/ApiVersioningMiddleware.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Attributes\ApiVersionAttribute;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\StateKey;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Http\Versioning\ApiVersion;
|
||||
use App\Framework\Http\Versioning\VersionExtractor;
|
||||
use App\Framework\Http\Versioning\VersioningConfig;
|
||||
use App\Framework\Serialization\JsonSerializer;
|
||||
|
||||
/**
|
||||
* Middleware for API versioning support
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::HIGH)]
|
||||
final readonly class ApiVersioningMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private VersioningConfig $config,
|
||||
private VersionExtractor $versionExtractor,
|
||||
private JsonSerializer $jsonSerializer
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
// Extract requested version from request
|
||||
$requestedVersion = $this->versionExtractor->extractVersion($context->request);
|
||||
|
||||
if ($requestedVersion === null) {
|
||||
$requestedVersion = $this->config->defaultVersion;
|
||||
}
|
||||
|
||||
// Validate version support
|
||||
if ($this->config->strictVersioning && ! $this->config->isVersionSupported($requestedVersion)) {
|
||||
$response = $this->createVersionNotSupportedResponse($requestedVersion);
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
|
||||
// Store version in request state for downstream use
|
||||
$stateManager->set(StateKey::fromString('api_version'), $requestedVersion);
|
||||
|
||||
// Process request through next middleware
|
||||
$context = $next($context);
|
||||
|
||||
// Add version headers to response
|
||||
$response = $this->addVersionHeaders($context->response, $requestedVersion);
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
|
||||
public function validateRouteVersion(ApiVersionAttribute $versionAttribute, ApiVersion $requestedVersion): bool
|
||||
{
|
||||
// Check if endpoint is removed
|
||||
if ($versionAttribute->isRemoved()) {
|
||||
$removedVersion = $versionAttribute->getRemovedVersion();
|
||||
if ($removedVersion && $requestedVersion->isNewerThan($removedVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
return $requestedVersion->isCompatibleWith($versionAttribute->version);
|
||||
}
|
||||
|
||||
public function getDeprecationWarning(ApiVersionAttribute $versionAttribute, ApiVersion $requestedVersion): ?string
|
||||
{
|
||||
if (! $versionAttribute->isDeprecated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deprecatedVersion = $versionAttribute->getDeprecatedVersion();
|
||||
if ($deprecatedVersion && $requestedVersion->isNewerThan($deprecatedVersion)) {
|
||||
$removedIn = $versionAttribute->removedIn ?? 'a future version';
|
||||
|
||||
return "This endpoint is deprecated and will be removed in {$removedIn}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function addVersionHeaders(HttpResponse $response, ApiVersion $version): HttpResponse
|
||||
{
|
||||
$headers = $response->headers->toArray();
|
||||
$headers['API-Version'] = $version->toString();
|
||||
$headers['X-API-Version-Latest'] = $this->config->getLatestVersion()->toString();
|
||||
|
||||
return new HttpResponse(
|
||||
status: $response->status,
|
||||
headers: new Headers($headers),
|
||||
body: $response->body
|
||||
);
|
||||
}
|
||||
|
||||
private function createVersionNotSupportedResponse(ApiVersion $requestedVersion): HttpResponse
|
||||
{
|
||||
$supportedVersions = array_map(
|
||||
fn ($v) => $v->toString(),
|
||||
$this->config->supportedVersions
|
||||
);
|
||||
|
||||
$error = [
|
||||
'error' => 'Unsupported API version',
|
||||
'requested_version' => $requestedVersion->toString(),
|
||||
'supported_versions' => $supportedVersions,
|
||||
'latest_version' => $this->config->getLatestVersion()->toString(),
|
||||
];
|
||||
|
||||
return new HttpResponse(
|
||||
status: Status::BAD_REQUEST,
|
||||
headers: new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
'API-Version' => $this->config->getLatestVersion()->toString(),
|
||||
]),
|
||||
body: $this->jsonSerializer->serialize($error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
@@ -7,14 +9,15 @@ use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::AUTHENTICATION)]
|
||||
final readonly class AuthMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
/*if (str_starts_with($context->request->path, '/admin')) {
|
||||
return new HttpResponse(Status::FORBIDDEN, body: 'Zugriff verweigert');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -7,8 +8,8 @@ use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Response;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
|
||||
@@ -17,9 +18,10 @@ final readonly class CORSMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ResponseManipulator $manipulator
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
// Debug-Ausgabe vor der Verarbeitung
|
||||
#var_dump('CORS Middleware Start - Kontext hat Response: ' . ($context->hasResponse() ? 'ja' : 'nein'));
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Response;
|
||||
|
||||
final readonly class ControllerRequestMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
#private Container $container
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RequestStateManager $stateManager
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Session\Session;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -150)] // Push after Session Creation
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)] // Explizite Reihenfolge in MiddlewareManager
|
||||
final readonly class CsrfMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private Session $session,
|
||||
){}
|
||||
private Container $container,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
|
||||
if (!$this->session->isStarted()) {
|
||||
throw new \RuntimeException('Session must be started before CSRF validation');
|
||||
// Try to get session from container - graceful fallback if not available
|
||||
try {
|
||||
$session = $this->container->get(SessionInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
// Session not available - skip CSRF validation gracefully
|
||||
error_log("CsrfMiddleware: Session not available, skipping CSRF validation");
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
if($request->method === Method::POST) {
|
||||
// FormId ist jetzt immer vorhanden durch automatische Generierung
|
||||
$formId = $request->parsedBody->get('_form_id');
|
||||
$token = $request->parsedBody->get('_token');
|
||||
|
||||
if (!$formId || !$token) {
|
||||
throw new \Exception('CSRF-Daten fehlen');
|
||||
}
|
||||
|
||||
$valid = $this->session->csrf->validateToken($formId, $token);
|
||||
|
||||
if(!$valid) {
|
||||
throw new \Exception('CSRF-Token ungültig');
|
||||
}
|
||||
if (in_array($request->method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH])) {
|
||||
$this->validateCsrfToken($request, $session);
|
||||
}
|
||||
|
||||
return $next($context);
|
||||
$context = $next($context);
|
||||
|
||||
// After successful request processing, optionally rotate token for enhanced security
|
||||
// This can be enabled for high-security applications
|
||||
// $this->rotateTokenIfNeeded($request, $session);
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates CSRF token from the request
|
||||
*/
|
||||
private function validateCsrfToken(Request $request, SessionInterface $session): void
|
||||
{
|
||||
$formId = $request->parsedBody->get('_form_id') ??
|
||||
$request->headers->getFirst('X-CSRF-Form-ID');
|
||||
$tokenValue = $request->parsedBody->get('_token') ??
|
||||
$request->headers->getFirst('X-CSRF-Token');
|
||||
|
||||
// Debug: Log CSRF validation attempt
|
||||
error_log("CSRF Debug: Validating tokens for form_id='$formId'");
|
||||
|
||||
if (! $formId || ! $tokenValue) {
|
||||
throw new \InvalidArgumentException('CSRF protection requires both form ID and token');
|
||||
}
|
||||
|
||||
try {
|
||||
$token = CsrfToken::fromString($tokenValue);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
throw new \InvalidArgumentException('Invalid CSRF token format: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (! $session->csrf->validateToken($formId, $token)) {
|
||||
error_log("CSRF validation failed for form: " . $formId);
|
||||
|
||||
throw new \RuntimeException('CSRF token validation failed. This may indicate a security threat.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates CSRF token after successful form submission for enhanced security
|
||||
*/
|
||||
private function rotateTokenIfNeeded($request): void
|
||||
{
|
||||
$formId = $request->parsedBody->get('_form_id');
|
||||
if ($formId) {
|
||||
// Generate a fresh token for the next request
|
||||
$this->session->csrf->rotateToken($formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
210
src/Framework/Http/Middlewares/DDoSProtectionMiddleware.php
Normal file
210
src/Framework/Http/Middlewares/DDoSProtectionMiddleware.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Config\WafConfig;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\DDoS\DDoSProtectionEngine;
|
||||
use App\Framework\DDoS\Response\ValueObjects\DDoSResponse;
|
||||
use App\Framework\DDoS\Response\ValueObjects\ResponseType;
|
||||
use App\Framework\DDoS\ValueObjects\DDoSAssessment;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* DDoS Protection Middleware
|
||||
*
|
||||
* Provides distributed denial-of-service protection by analyzing traffic patterns,
|
||||
* geographic anomalies, and coordinating with existing security modules.
|
||||
* Runs with highest priority before all other security middleware.
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, 10)] // Higher than standard security
|
||||
final readonly class DDoSProtectionMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private DDoSProtectionEngine $ddosEngine,
|
||||
private Logger $logger,
|
||||
private WafConfig $config,
|
||||
private Timer $timer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
if (! $this->config->enabled || ! $this->config->enableDdosProtection) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
try {
|
||||
$request = $context->request;
|
||||
$clientIp = $request->server->getClientIp()?->value ?? '127.0.0.1';
|
||||
|
||||
// Skip trusted IPs
|
||||
if ($this->config->isTrustedIp($clientIp)) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
// Skip exempt paths
|
||||
if ($this->config->isExemptPath($request->path)) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
// Analyze request for DDoS patterns
|
||||
$assessment = $this->ddosEngine->analyzeRequest($request);
|
||||
|
||||
// Execute appropriate response based on assessment
|
||||
$ddosResponse = $this->ddosEngine->executeResponse($assessment, $request);
|
||||
|
||||
// Log significant threats
|
||||
if ($assessment->confidence > 0.5) {
|
||||
$this->logThreatDetection($request, $assessment, $ddosResponse);
|
||||
}
|
||||
|
||||
// Handle response based on type
|
||||
if ($ddosResponse->shouldBlock()) {
|
||||
return $this->handleBlockingResponse($context, $ddosResponse);
|
||||
}
|
||||
|
||||
if ($ddosResponse->requiresUserInteraction()) {
|
||||
return $this->handleChallengeResponse($context, $ddosResponse);
|
||||
}
|
||||
|
||||
if ($ddosResponse->type === ResponseType::TAR_PIT) {
|
||||
// Implement delay if tar pit response
|
||||
if (isset($ddosResponse->metadata['delay_seconds'])) {
|
||||
$this->timer->sleep(min(5, (int)$ddosResponse->metadata['delay_seconds']));
|
||||
}
|
||||
}
|
||||
|
||||
// For ALLOW responses, continue with the request but add any security headers
|
||||
$resultContext = $next($context);
|
||||
|
||||
if ($resultContext->hasResponse() && $ddosResponse->headers) {
|
||||
return $this->addSecurityHeaders($resultContext, $ddosResponse);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't block on DDoS engine failure
|
||||
$this->logger->error('DDoS protection middleware error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'request_path' => $context->request->path ?? '/',
|
||||
'client_ip' => $context->request->server->getClientIp()?->value ?? 'unknown',
|
||||
]);
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blocking response (BLOCK or RATE_LIMITED)
|
||||
*/
|
||||
private function handleBlockingResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
|
||||
$this->logger->warning('DDoS request blocked', [
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path,
|
||||
'reason' => $ddosResponse->reason,
|
||||
'response_type' => $ddosResponse->type->value,
|
||||
]);
|
||||
|
||||
// Convert DDoSResponse to HttpResponse
|
||||
$httpResponse = new HttpResponse(
|
||||
$ddosResponse->httpStatus,
|
||||
$ddosResponse->headers,
|
||||
$ddosResponse->body
|
||||
);
|
||||
|
||||
return $context->withResponse($httpResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle challenge response (CAPTCHA, etc.)
|
||||
*/
|
||||
private function handleChallengeResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
|
||||
$this->logger->info('DDoS challenge issued', [
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path,
|
||||
'challenge_type' => $ddosResponse->metadata['challenge_type'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
// Convert DDoSResponse to HttpResponse
|
||||
$httpResponse = new HttpResponse(
|
||||
$ddosResponse->httpStatus,
|
||||
$ddosResponse->headers,
|
||||
$ddosResponse->body
|
||||
);
|
||||
|
||||
return $context->withResponse($httpResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add security headers to response
|
||||
*/
|
||||
private function addSecurityHeaders(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
|
||||
{
|
||||
$response = $context->response;
|
||||
|
||||
if ($ddosResponse->headers) {
|
||||
$updatedHeaders = $response->headers->merge($ddosResponse->headers);
|
||||
|
||||
$updatedResponse = new HttpResponse(
|
||||
$response->status,
|
||||
$updatedHeaders,
|
||||
$response->body
|
||||
);
|
||||
|
||||
return $context->withResponse($updatedResponse);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log threat detection information
|
||||
*/
|
||||
private function logThreatDetection(
|
||||
$request,
|
||||
DDoSAssessment $assessment,
|
||||
DDoSResponse $ddosResponse
|
||||
): void {
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
$logLevel = $assessment->isCriticalThreat() ? 'critical' : ($assessment->isHighThreat() ? 'warning' : 'info');
|
||||
|
||||
$logData = [
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path,
|
||||
'confidence' => $assessment->confidence,
|
||||
'threat_level' => $assessment->threatLevel->name,
|
||||
'threat_indicators' => $assessment->getThreatIndicators(),
|
||||
'response_type' => $ddosResponse->type->value,
|
||||
'response_reason' => $ddosResponse->reason,
|
||||
'processing_time_ms' => $assessment->processingTime->toMilliseconds(),
|
||||
];
|
||||
|
||||
if ($assessment->isCriticalThreat() || $assessment->isHighThreat()) {
|
||||
$logData['user_agent'] = $request->headers->get('User-Agent', '');
|
||||
$logData['attack_type'] = $assessment->attackType?->value ?? 'unknown';
|
||||
$logData['geographic_data'] = $assessment->geographicData ?? [];
|
||||
}
|
||||
|
||||
$this->logger->$logLevel('DDoS threat detected', $logData);
|
||||
}
|
||||
}
|
||||
45
src/Framework/Http/Middlewares/DefaultResponseMiddleware.php
Normal file
45
src/Framework/Http/Middlewares/DefaultResponseMiddleware.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
/**
|
||||
* Terminal middleware that ensures every request gets a response.
|
||||
*
|
||||
* This middleware should be placed at the end of the middleware chain
|
||||
* to catch any requests that haven't received a response from previous
|
||||
* middlewares and provide a sensible default (404 Not Found).
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::FALLBACK)]
|
||||
final readonly class DefaultResponseMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$resultContext = $next($context);
|
||||
|
||||
if (! $resultContext->hasResponse()) {
|
||||
$response = new HttpResponse(
|
||||
status: Status::NOT_FOUND,
|
||||
headers: new Headers([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]),
|
||||
body: 'Not Found'
|
||||
);
|
||||
|
||||
return $resultContext->withResponse($response);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\ErrorHandling\ErrorHandler;
|
||||
use App\Framework\ErrorHandling\ExceptionConverter;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\RequestIdGenerator;
|
||||
use App\Framework\Logging\DefaultLogger;
|
||||
use App\Framework\Validation\Exceptions\ValidationException;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::ERROR_HANDLING)]
|
||||
final readonly class ExceptionHandlingMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private DefaultLogger $logger,
|
||||
private RequestIdGenerator $requestIdGenerator,
|
||||
private Logger $logger,
|
||||
private ErrorHandler $errorHandler,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
try {
|
||||
return $next($context);
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
$response = $this->errorHandler->createHttpResponse($e, $context);
|
||||
|
||||
return $context->withResponse($response);
|
||||
|
||||
|
||||
|
||||
|
||||
// Detaillierte Fehlerinformationen loggen
|
||||
$this->logger->error('Unbehandelte Exception in Middleware-Chain', [
|
||||
'exception' => $e,
|
||||
'path' => $context->request->path,
|
||||
'method' => $context->request->method->value,
|
||||
]);
|
||||
|
||||
// Request-ID für die Antwort holen
|
||||
$requestId = $this->requestIdGenerator->generate();
|
||||
|
||||
// Passende HTTP-Response basierend auf der Exception
|
||||
$status = ExceptionConverter::getStatusFromException($e);
|
||||
|
||||
// Debug-Modus prüfen
|
||||
$isDebug = filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
// Response-Body erstellen
|
||||
$body = ExceptionConverter::getResponseBody(
|
||||
$e,
|
||||
$isDebug,
|
||||
$requestId->getId()
|
||||
);
|
||||
|
||||
$response = new HttpResponse(
|
||||
status: $status,
|
||||
body: json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
326
src/Framework/Http/Middlewares/FormDataResponseMiddleware.php
Normal file
326
src/Framework/Http/Middlewares/FormDataResponseMiddleware.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* Middleware that processes HTML responses to replace form-related placeholders
|
||||
* with actual session data (CSRF tokens, old input values, validation errors).
|
||||
*
|
||||
* This solves the template caching issue where StringProcessors only run on first render.
|
||||
* By processing the response, we can inject dynamic content into cached templates.
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING, -2)]
|
||||
final readonly class FormDataResponseMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private FormIdGenerator $formIdGenerator,
|
||||
private ResponseManipulator $responseManipulator,
|
||||
private Container $container,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
// Continue to next middleware first
|
||||
$context = $next($context);
|
||||
|
||||
// Only process if we have a response with HTML content
|
||||
if (! $context->hasResponse()) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$response = $context->response;
|
||||
$contentType = implode(', ', $response?->headers->get('Content-Type') ?? []);
|
||||
|
||||
// Check if this is a JSON response (SPA partial)
|
||||
$isJsonResponse = str_contains($contentType, 'application/json');
|
||||
|
||||
// Only process HTML responses or JSON responses with HTML content
|
||||
if ($contentType && ! str_contains($contentType, 'text/html') && ! str_contains($contentType, 'application/xhtml') && ! $isJsonResponse) {
|
||||
// If no content type is set, assume HTML (common case)
|
||||
if ($contentType !== null) {
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
|
||||
$body = $response->body;
|
||||
|
||||
// Handle JSON responses with HTML content (SPA partials)
|
||||
if ($isJsonResponse && is_string($body)) {
|
||||
$jsonData = json_decode($body, true);
|
||||
if ($jsonData && isset($jsonData['html'])) {
|
||||
// Process the HTML content within the JSON
|
||||
$processedHtml = $this->processJsonWithHtml($jsonData, $context, $stateManager);
|
||||
if ($processedHtml !== null) {
|
||||
$updatedResponse = $this->responseManipulator->withBody($response, json_encode($processedHtml));
|
||||
|
||||
return $context->withResponse($updatedResponse);
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (! is_string($body) || empty($body)) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
// Check if we have any form-related placeholders
|
||||
$hasToken = str_contains($body, '___TOKEN___');
|
||||
$hasOldInput = str_contains($body, '___OLD_INPUT_');
|
||||
$hasError = str_contains($body, '___ERROR_');
|
||||
|
||||
if (! $hasToken && ! $hasOldInput && ! $hasError) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
// Get session from request state (set by SessionMiddleware) or container
|
||||
$session = $stateManager->get('session');
|
||||
if (! $session instanceof SessionInterface) {
|
||||
// Try to get from container as fallback
|
||||
try {
|
||||
$session = $this->container->get(SessionInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
|
||||
$processedBody = $this->processFormPlaceholders($body, $session);
|
||||
|
||||
// Update the response with the processed body using ResponseManipulator
|
||||
$updatedResponse = $this->responseManipulator->withBody($response, $processedBody);
|
||||
|
||||
return $context->withResponse($updatedResponse);
|
||||
}
|
||||
|
||||
private function processJsonWithHtml(array $jsonData, MiddlewareContext $context, RequestStateManager $stateManager): ?array
|
||||
{
|
||||
if (! isset($jsonData['html']) || ! is_string($jsonData['html'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = $jsonData['html'];
|
||||
|
||||
// Check if we have any form-related placeholders
|
||||
$hasToken = str_contains($html, '___TOKEN___');
|
||||
$hasOldInput = str_contains($html, '___OLD_INPUT_');
|
||||
$hasError = str_contains($html, '___ERROR_');
|
||||
|
||||
if (! $hasToken && ! $hasOldInput && ! $hasError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get session from request state (set by SessionMiddleware) or container
|
||||
$session = $stateManager->get('session');
|
||||
if (! $session instanceof SessionInterface) {
|
||||
// Try to get from container as fallback
|
||||
try {
|
||||
$session = $this->container->get(SessionInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the HTML content
|
||||
$processedHtml = $this->processFormPlaceholders($html, $session);
|
||||
|
||||
// Update the JSON data with processed HTML
|
||||
$jsonData['html'] = $processedHtml;
|
||||
|
||||
return $jsonData;
|
||||
}
|
||||
|
||||
private function processFormPlaceholders(string $html, SessionInterface $session): string
|
||||
{
|
||||
$formIds = $this->formIdGenerator->extractFormIdsFromHtml($html);
|
||||
|
||||
if (empty($formIds)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Process each form
|
||||
foreach ($formIds as $formId) {
|
||||
// 1. Replace CSRF tokens
|
||||
if (str_contains($html, '___TOKEN___')) {
|
||||
$token = $session->csrf->generateToken($formId);
|
||||
$html = $this->replaceTokenForFormId($html, $formId, $token->toString());
|
||||
}
|
||||
|
||||
// 2. Replace old input values
|
||||
$html = $this->replaceOldInputForForm($html, $formId, $session);
|
||||
|
||||
// 3. Replace error messages
|
||||
$html = $this->replaceErrorsForForm($html, $formId, $session);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function replaceTokenForFormId(string $html, string $formId, string $token): string
|
||||
{
|
||||
// Find the token placeholder that belongs to the corresponding form ID
|
||||
// We need to consider the form context
|
||||
$pattern = '/(<form[^>]*>.*?<input[^>]*name=["\']_form_id["\'][^>]*value=["\']' . preg_quote($formId, '/') . '["\'][^>]*>.*?)<input[^>]*name=["\']_token["\'][^>]*value=["\']___TOKEN___["\'][^>]*>/s';
|
||||
|
||||
return preg_replace_callback($pattern, function ($matches) use ($token) {
|
||||
return str_replace('___TOKEN___', $token, $matches[0]);
|
||||
}, $html);
|
||||
}
|
||||
|
||||
private function replaceOldInputForForm(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
// Use session's form storage property
|
||||
if (! $session->form->has($formId)) {
|
||||
// No old data, remove placeholders
|
||||
return $this->cleanupOldInputPlaceholders($html);
|
||||
}
|
||||
|
||||
$oldData = $session->form->getAndFlash($formId);
|
||||
|
||||
foreach ($oldData as $fieldName => $value) {
|
||||
// Text inputs and textareas
|
||||
$placeholder = "___OLD_INPUT_{$fieldName}___";
|
||||
if (str_contains($html, $placeholder)) {
|
||||
$escapedValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
$html = str_replace($placeholder, $escapedValue, $html);
|
||||
}
|
||||
|
||||
// Select options
|
||||
if (is_scalar($value)) {
|
||||
$selectPattern = "___OLD_SELECT_{$fieldName}_{$value}___";
|
||||
$html = str_replace(
|
||||
"data-selected-if=\"{$selectPattern}\"",
|
||||
'selected="selected"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// Radio buttons
|
||||
if (is_scalar($value)) {
|
||||
$radioPattern = "___OLD_RADIO_{$fieldName}_{$value}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$radioPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// Checkboxes
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $checkboxValue) {
|
||||
$checkboxPattern = "___OLD_CHECKBOX_{$fieldName}_{$checkboxValue}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$checkboxPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
} elseif ($value) {
|
||||
$checkboxValue = is_bool($value) ? '1' : (string) $value;
|
||||
$checkboxPattern = "___OLD_CHECKBOX_{$fieldName}_{$checkboxValue}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$checkboxPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cleanupOldInputPlaceholders($html);
|
||||
}
|
||||
|
||||
private function replaceErrorsForForm(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
// Use session's validation property
|
||||
if (! $session->validation->has($formId)) {
|
||||
// No errors, remove error placeholders
|
||||
return $this->cleanupErrorPlaceholders($html);
|
||||
}
|
||||
|
||||
$errors = $session->validation->getAndFlash($formId);
|
||||
|
||||
foreach ($errors as $fieldName => $fieldErrors) {
|
||||
$placeholder = "___ERROR_{$fieldName}___";
|
||||
|
||||
if (! str_contains($html, $placeholder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($fieldErrors)) {
|
||||
// Remove error display
|
||||
$html = $this->removeErrorDisplay($html, $fieldName);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build error HTML
|
||||
$errorHtml = '';
|
||||
foreach ($fieldErrors as $error) {
|
||||
$escapedError = htmlspecialchars($error, ENT_QUOTES, 'UTF-8');
|
||||
$errorHtml .= "<span class=\"error-message\">{$escapedError}</span>";
|
||||
}
|
||||
|
||||
// Replace placeholder with actual errors
|
||||
$html = str_replace($placeholder, $errorHtml, $html);
|
||||
|
||||
// Add error class to the field
|
||||
$html = $this->addErrorClassToField($html, $fieldName);
|
||||
}
|
||||
|
||||
return $this->cleanupErrorPlaceholders($html);
|
||||
}
|
||||
|
||||
private function cleanupOldInputPlaceholders(string $html): string
|
||||
{
|
||||
// Remove remaining old input placeholders
|
||||
$html = preg_replace('/___OLD_INPUT_[^_]+___/', '', $html);
|
||||
|
||||
// Remove remaining data attributes
|
||||
$html = preg_replace('/\s*data-selected-if="[^"]*"/', '', $html);
|
||||
$html = preg_replace('/\s*data-checked-if="[^"]*"/', '', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function cleanupErrorPlaceholders(string $html): string
|
||||
{
|
||||
// Remove empty error displays
|
||||
$html = preg_replace('/<div[^>]*class="[^"]*error-display[^"]*"[^>]*>___ERROR_[^_]+___<\/div>/', '', $html);
|
||||
$html = preg_replace('/___ERROR_[^_]+___/', '', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function removeErrorDisplay(string $html, string $fieldName): string
|
||||
{
|
||||
$pattern = '/<div[^>]*class="[^"]*error-display[^"]*"[^>]*data-field="' . preg_quote($fieldName, '/') . '"[^>]*>.*?<\/div>/s';
|
||||
|
||||
return preg_replace($pattern, '', $html);
|
||||
}
|
||||
|
||||
private function addErrorClassToField(string $html, string $fieldName): string
|
||||
{
|
||||
// Add 'error' class to field - handle existing class attribute
|
||||
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*class="[^"]*)(")(.*?>)/';
|
||||
$html = preg_replace($pattern, '$1 error$2$3', $html);
|
||||
|
||||
// Handle fields without existing class attribute
|
||||
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*?)(?:\s|>)/';
|
||||
$html = preg_replace($pattern, '$1 class="error" ', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -8,17 +9,20 @@ use App\Framework\Http\Method;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -140)] // Nach CSRF, vor anderen Validierungen
|
||||
final readonly class HoneypotMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ?LoggerInterface $logger = null
|
||||
) {}
|
||||
private ?Logger $logger = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
|
||||
@@ -29,20 +33,22 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
private function validateHoneypot($request): void
|
||||
private function validateHoneypot(Request $request): void
|
||||
{
|
||||
$honeypotName = $request->parsedBody->get('_honeypot_name');
|
||||
|
||||
if (!$honeypotName) {
|
||||
if (! $honeypotName) {
|
||||
$this->logSuspiciousActivity('Missing honeypot name', $request);
|
||||
|
||||
throw new \Exception('Spam-Schutz ausgelöst');
|
||||
}
|
||||
|
||||
$honeypotValue = $request->parsedBody->get($honeypotName);
|
||||
|
||||
// Honeypot wurde ausgefüllt = Bot erkannt
|
||||
if (!empty($honeypotValue)) {
|
||||
if (! empty($honeypotValue)) {
|
||||
$this->logSuspiciousActivity("Honeypot filled: {$honeypotName} = {$honeypotValue}", $request);
|
||||
|
||||
throw new \Exception('Spam-Schutz ausgelöst');
|
||||
}
|
||||
|
||||
@@ -50,26 +56,25 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
|
||||
$this->validateSubmissionTime($request);
|
||||
}
|
||||
|
||||
private function validateSubmissionTime($request): void
|
||||
private function validateSubmissionTime(Request $request): void
|
||||
{
|
||||
// Formulare, die zu schnell abgeschickt werden, sind verdächtig
|
||||
$startTime = $request->parsedBody->get('_form_start_time');
|
||||
|
||||
if ($startTime && (time() - (int)$startTime) < 2) {
|
||||
$this->logSuspiciousActivity('Form submitted too quickly', $request);
|
||||
|
||||
throw new \Exception('Spam-Schutz ausgelöst');
|
||||
}
|
||||
}
|
||||
|
||||
private function logSuspiciousActivity(string $reason, $request): void
|
||||
private function logSuspiciousActivity(string $reason, Request $request): void
|
||||
{
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('Honeypot triggered', [
|
||||
'reason' => $reason,
|
||||
'ip' => $request->getClientIp(),
|
||||
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown',
|
||||
'url' => $request->uri
|
||||
]);
|
||||
}
|
||||
$this->logger?->warning('Honeypot triggered', [
|
||||
'reason' => $reason,
|
||||
'ip' => $request->server->getClientIp()->isPrivate(),
|
||||
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown',
|
||||
'url' => $request->path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Logging\DefaultLogger;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
|
||||
final readonly class LoggingMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private DefaultLogger $logger
|
||||
) {}
|
||||
private Logger $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$start = microtime(true);
|
||||
|
||||
|
||||
144
src/Framework/Http/Middlewares/REFACTORING-SUMMARY.md
Normal file
144
src/Framework/Http/Middlewares/REFACTORING-SUMMARY.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# DDoSProtectionMiddleware Refactoring Summary
|
||||
|
||||
## Overview
|
||||
|
||||
The DDoSProtectionMiddleware has been refactored to correctly use the DDoS module's functionality, particularly the AdaptiveResponseSystem. This document summarizes the changes made, the benefits of the new implementation, and recommendations for testing and future improvements.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Leveraging the AdaptiveResponseSystem
|
||||
|
||||
The middleware now uses the `executeResponse` method from the DDoSProtectionEngine, which delegates to the AdaptiveResponseSystem to generate appropriate responses based on the threat assessment:
|
||||
|
||||
```php
|
||||
// Execute appropriate response based on assessment
|
||||
$ddosResponse = $this->ddosEngine->executeResponse($assessment, $request);
|
||||
```
|
||||
|
||||
### 2. Response Type-Based Handling
|
||||
|
||||
The middleware now handles responses based on their type, using specialized methods for different response types:
|
||||
|
||||
```php
|
||||
// Handle response based on type
|
||||
if ($ddosResponse->shouldBlock()) {
|
||||
return $this->handleBlockingResponse($context, $ddosResponse);
|
||||
}
|
||||
|
||||
if ($ddosResponse->requiresUserInteraction()) {
|
||||
return $this->handleChallengeResponse($context, $ddosResponse);
|
||||
}
|
||||
|
||||
if ($ddosResponse->type === ResponseType::TAR_PIT) {
|
||||
// Implement delay if tar pit response
|
||||
if (isset($ddosResponse->metadata['delay_seconds'])) {
|
||||
sleep(min(5, (int)$ddosResponse->metadata['delay_seconds']));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Proper Type Hints
|
||||
|
||||
Added proper type hints for all parameters, improving type safety and code readability:
|
||||
|
||||
```php
|
||||
private function handleBlockingResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
|
||||
```
|
||||
|
||||
### 4. Improved Logging
|
||||
|
||||
Enhanced logging with dynamic log levels based on the threat level and more comprehensive log data:
|
||||
|
||||
```php
|
||||
private function logThreatDetection(
|
||||
$request,
|
||||
DDoSAssessment $assessment,
|
||||
DDoSResponse $ddosResponse
|
||||
): void {
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
$logLevel = $assessment->isCriticalThreat() ? 'critical' : ($assessment->isHighThreat() ? 'warning' : 'info');
|
||||
|
||||
$logData = [
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path,
|
||||
'confidence' => $assessment->confidence,
|
||||
'threat_level' => $assessment->threatLevel->name,
|
||||
'threat_indicators' => $assessment->getThreatIndicators(),
|
||||
'response_type' => $ddosResponse->type->value,
|
||||
'response_reason' => $ddosResponse->reason,
|
||||
'processing_time_ms' => $assessment->processingTime->toMilliseconds(),
|
||||
];
|
||||
|
||||
// Additional data for high and critical threats
|
||||
if ($assessment->isCriticalThreat() || $assessment->isHighThreat()) {
|
||||
$logData['user_agent'] = $request->headers->get('User-Agent', '');
|
||||
$logData['attack_type'] = $assessment->attackType?->value ?? 'unknown';
|
||||
$logData['geographic_data'] = $assessment->geographicData ?? [];
|
||||
}
|
||||
|
||||
$this->logger->$logLevel('DDoS threat detected', $logData);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Simplified Code
|
||||
|
||||
Removed redundant code that was duplicating functionality already provided by the DDoS module, reducing the middleware from 252 lines to 210 lines.
|
||||
|
||||
## Benefits of the New Implementation
|
||||
|
||||
1. **Better Separation of Concerns**: The middleware now focuses on HTTP request/response handling, while delegating DDoS protection logic to the specialized DDoS module.
|
||||
|
||||
2. **More Comprehensive Protection**: By using the AdaptiveResponseSystem, the middleware now supports a wider range of response types, including CAPTCHA challenges and tar pit responses.
|
||||
|
||||
3. **Improved Maintainability**: The code is now more modular, with specialized methods for different response types, making it easier to maintain and extend.
|
||||
|
||||
4. **Enhanced Logging**: The logging is now more comprehensive and uses the appropriate log level based on the threat level.
|
||||
|
||||
5. **Better Type Safety**: Proper type hints for all parameters improve code readability and help catch errors at compile time.
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
To ensure the refactored middleware works correctly, the following tests should be performed:
|
||||
|
||||
### 1. Unit Tests
|
||||
|
||||
Create unit tests for the middleware that mock the DDoSProtectionEngine and verify that:
|
||||
|
||||
- The middleware correctly handles different response types (BLOCK, RATE_LIMITED, CHALLENGE, TAR_PIT, ALLOW)
|
||||
- The middleware correctly converts DDoSResponse objects to HttpResponse objects
|
||||
- The middleware correctly adds security headers to allowed responses
|
||||
- The middleware correctly logs threat detection information
|
||||
- The middleware correctly handles errors from the DDoS module
|
||||
|
||||
### 2. Integration Tests
|
||||
|
||||
Create integration tests that use the actual DDoSProtectionEngine and verify that:
|
||||
|
||||
- The middleware correctly integrates with the DDoS module
|
||||
- The middleware correctly handles different threat levels
|
||||
- The middleware correctly handles different attack patterns
|
||||
- The middleware correctly handles trusted IPs and exempt paths
|
||||
|
||||
### 3. Performance Tests
|
||||
|
||||
Create performance tests to verify that:
|
||||
|
||||
- The middleware does not add significant overhead to request processing
|
||||
- The middleware correctly implements delays for tar pit responses
|
||||
- The middleware correctly handles high load scenarios
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Asynchronous Processing**: Consider implementing asynchronous processing for non-blocking operations like logging and learning updates.
|
||||
|
||||
2. **Circuit Breaker**: Add a circuit breaker to prevent the middleware from becoming a bottleneck during high load scenarios.
|
||||
|
||||
3. **Metrics Collection**: Add metrics collection to monitor the middleware's performance and effectiveness.
|
||||
|
||||
4. **Configuration Options**: Add more configuration options to fine-tune the middleware's behavior.
|
||||
|
||||
5. **Response Caching**: Implement caching for responses to improve performance for repeated requests.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactored DDoSProtectionMiddleware now correctly uses the DDoS module's functionality, providing more comprehensive protection against DDoS attacks while maintaining good performance and maintainability. The middleware is now more modular, with specialized methods for different response types, making it easier to maintain and extend.
|
||||
161
src/Framework/Http/Middlewares/RateLimitMiddleware.php
Normal file
161
src/Framework/Http/Middlewares/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\ErrorHandling\SecurityEventLogger;
|
||||
use App\Framework\Exception\SecurityEvent\SystemExcessiveUseEvent;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Response;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\RateLimit\RateLimitConfig;
|
||||
use App\Framework\RateLimit\RateLimiter;
|
||||
use App\Framework\RateLimit\RateLimitResult;
|
||||
|
||||
/**
|
||||
* Modern Rate Limiting Middleware using the Framework's RateLimit system
|
||||
*
|
||||
* Supports both sliding window and token bucket algorithms.
|
||||
* Provides comprehensive rate limiting with security event logging.
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::RATE_LIMITING)]
|
||||
final readonly class RateLimitMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private RateLimiter $rateLimiter,
|
||||
private ResponseManipulator $responseManipulator,
|
||||
private RateLimitConfig $config = new RateLimitConfig(),
|
||||
private ?SecurityEventLogger $securityLogger = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
if (! $this->config->enabled) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
$request = $context->request;
|
||||
$clientIp = $this->getClientIp($request);
|
||||
|
||||
// Check exemptions
|
||||
if ($this->isExempt($clientIp, $request->path)) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
$result = $this->checkRateLimit($clientIp, $request->path);
|
||||
|
||||
if ($result->isExceeded()) {
|
||||
return $this->handleRateLimitExceeded($context, $clientIp, $request->path, $result);
|
||||
}
|
||||
|
||||
// Proceed with request and add rate limit headers
|
||||
$resultContext = $next($context);
|
||||
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $this->addRateLimitHeaders($resultContext->response, $result);
|
||||
|
||||
return $resultContext->withResponse($response);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
}
|
||||
|
||||
private function getClientIp($request): string
|
||||
{
|
||||
// Try to get from server environment or fallback
|
||||
$serverIp = $request->server->getClientIp();
|
||||
|
||||
return $serverIp ? $serverIp->value : '127.0.0.1';
|
||||
}
|
||||
|
||||
private function isExempt(string $clientIp, string $path): bool
|
||||
{
|
||||
return $this->config->isTrustedIp($clientIp) ||
|
||||
$this->config->isExemptPath($path);
|
||||
}
|
||||
|
||||
private function checkRateLimit(string $clientIp, string $path): RateLimitResult
|
||||
{
|
||||
$limit = $this->config->requestsPerMinute;
|
||||
$window = $this->config->windowSize;
|
||||
|
||||
// Generate rate limit key
|
||||
$key = $this->generateRateLimitKey($clientIp, $path);
|
||||
|
||||
// Use sliding window algorithm by default
|
||||
return $this->rateLimiter->checkLimit($key, $limit, $window);
|
||||
}
|
||||
|
||||
private function generateRateLimitKey(string $clientIp, string $path): string
|
||||
{
|
||||
// Simple key generation based on IP and path
|
||||
return "ratelimit:ip:{$clientIp}:path:" . md5($path);
|
||||
}
|
||||
|
||||
private function handleRateLimitExceeded(
|
||||
MiddlewareContext $context,
|
||||
string $clientIp,
|
||||
string $path,
|
||||
RateLimitResult $result
|
||||
): MiddlewareContext {
|
||||
// Log security event
|
||||
$this->logSecurityEvent($clientIp, $path, $result);
|
||||
|
||||
// Create rate limit exceeded response
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
'X-RateLimit-Limit' => (string) $result->getLimit(),
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
'X-RateLimit-Reset' => (string) (time() + ($result->getRetryAfter() ?? $this->config->windowSize)),
|
||||
'Retry-After' => (string) ($result->getRetryAfter() ?? $this->config->windowSize),
|
||||
]);
|
||||
|
||||
$body = json_encode([
|
||||
'error' => 'Rate limit exceeded',
|
||||
'message' => 'Too many requests. Please slow down.',
|
||||
'limit' => $result->getLimit(),
|
||||
'current' => $result->getCurrent(),
|
||||
'retry_after' => $result->getRetryAfter() ?? $this->config->windowSize,
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
$response = new HttpResponse(Status::TOO_MANY_REQUESTS, $headers, $body);
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
|
||||
private function addRateLimitHeaders(Response $response, RateLimitResult $result): Response
|
||||
{
|
||||
$headers = $response->headers
|
||||
->with('X-RateLimit-Limit', (string) $result->getLimit())
|
||||
->with('X-RateLimit-Remaining', (string) $result->getRemainingRequests())
|
||||
->with('X-RateLimit-Reset', (string) (time() + $this->config->windowSize));
|
||||
|
||||
return $this->responseManipulator->withHeaders($response, $headers);
|
||||
}
|
||||
|
||||
private function logSecurityEvent(string $clientIp, string $path, RateLimitResult $result): void
|
||||
{
|
||||
if (! $this->securityLogger) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = new SystemExcessiveUseEvent(
|
||||
null, // No user ID for IP-based limiting
|
||||
$clientIp,
|
||||
"Rate limit exceeded for {$path}: {$result->getCurrent()}/{$result->getLimit()} requests"
|
||||
);
|
||||
|
||||
$this->securityLogger->log($event);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Status;
|
||||
use Predis\Client as RedisClient;
|
||||
|
||||
/**
|
||||
* Middleware zum Schutz vor zu vielen Anfragen von einer IP-Adresse.
|
||||
* Verwendet Redis zum Zählen der Anfragen und implementiert ein Sliding Window Limiting.
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
|
||||
final class RateLimitingMiddleware implements XHttpMiddleware
|
||||
{
|
||||
private const string REDIS_KEY_PREFIX = 'rate_limit:';
|
||||
|
||||
/**
|
||||
* @param RedisClient $redis Redis-Client zum Speichern der Zähler
|
||||
* @param int $defaultLimit Standardmäßige Anzahl an erlaubten Anfragen im Zeitfenster
|
||||
* @param int $windowSeconds Länge des Zeitfensters in Sekunden
|
||||
* @param array<string, array{limit: int, window: int}> $pathLimits Spezifische Limits für bestimmte Pfade
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RedisClient $redis,
|
||||
private readonly int $defaultLimit = 60,
|
||||
private readonly int $windowSeconds = 60,
|
||||
private readonly array $pathLimits = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next): MiddlewareContext
|
||||
{
|
||||
$clientIp = $this->getClientIp($context->request);
|
||||
$path = $context->request->path;
|
||||
|
||||
// Limits für den aktuellen Pfad ermitteln
|
||||
[$limit, $window] = $this->getLimitsForPath($path);
|
||||
|
||||
// Redis-Schlüssel für diese IP und diesen Pfad
|
||||
$redisKey = $this->getRedisKey($clientIp, $path);
|
||||
|
||||
// Anfrage zählen und prüfen
|
||||
$requestCount = $this->countRequest($redisKey, $window);
|
||||
|
||||
// Rate-Limiting-Header zur Antwort hinzufügen
|
||||
$remaining = max(0, $limit - $requestCount);
|
||||
$context = $context->withResponseHeader('X-RateLimit-Limit', (string)$limit)
|
||||
->withResponseHeader('X-RateLimit-Remaining', (string)$remaining)
|
||||
->withResponseHeader('X-RateLimit-Reset', (string)$this->redis->ttl($redisKey));
|
||||
|
||||
// Wenn das Limit überschritten wurde, 429-Antwort zurückgeben
|
||||
if ($requestCount > $limit) {
|
||||
$response = new HttpResponse(
|
||||
Status::TOO_MANY_REQUESTS,
|
||||
new Headers(['Content-Type' => 'application/json'])
|
||||
);
|
||||
|
||||
// Retry-After Header hinzufügen
|
||||
$retryAfter = $this->redis->ttl($redisKey);
|
||||
$response = $response->withHeader('Retry-After', (string)$retryAfter);
|
||||
|
||||
// JSON-Antwort mit Fehlermeldung
|
||||
$errorResponse = [
|
||||
'error' => 'Zu viele Anfragen',
|
||||
'message' => 'Sie haben das Limit für Anfragen überschritten. Bitte versuchen Sie es später erneut.',
|
||||
'retry_after' => $retryAfter
|
||||
];
|
||||
|
||||
$response->getBody()->write(json_encode($errorResponse));
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
|
||||
// Anfrage weiterleiten
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt eine Anfrage und gibt die aktuelle Anzahl zurück.
|
||||
*
|
||||
* @param string $key Der Redis-Schlüssel für den Counter
|
||||
* @param int $window Das Zeitfenster in Sekunden
|
||||
* @return int Die aktuelle Anzahl an Anfragen im Zeitfenster
|
||||
*/
|
||||
private function countRequest(string $key, int $window): int
|
||||
{
|
||||
// Aktuelle Zeit als Score (für Sorted Set)
|
||||
$now = time();
|
||||
|
||||
// Alten Einträge entfernen (älter als das Zeitfenster)
|
||||
$this->redis->zremrangebyscore($key, 0, $now - $window);
|
||||
|
||||
// Neue Anfrage hinzufügen
|
||||
$this->redis->zadd($key, [$now => $now]);
|
||||
|
||||
// Gesamtanzahl der Anfragen im Zeitfenster ermitteln
|
||||
$count = $this->redis->zcard($key);
|
||||
|
||||
// TTL für den Key setzen (Aufräumen)
|
||||
$this->redis->expire($key, $window);
|
||||
|
||||
return (int)$count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt die Limits für einen bestimmten Pfad.
|
||||
*
|
||||
* @param string $path Der Pfad
|
||||
* @return array{int, int} [limit, window]
|
||||
*/
|
||||
private function getLimitsForPath(string $path): array
|
||||
{
|
||||
// Prüfen auf spezifische Pfad-Limits
|
||||
foreach ($this->pathLimits as $pattern => $config) {
|
||||
if (preg_match($pattern, $path)) {
|
||||
return [$config['limit'], $config['window']];
|
||||
}
|
||||
}
|
||||
|
||||
// Standardwerte zurückgeben
|
||||
return [$this->defaultLimit, $this->windowSeconds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert den Redis-Schlüssel für eine IP und einen Pfad.
|
||||
*/
|
||||
private function getRedisKey(string $ip, string $path): string
|
||||
{
|
||||
// Für allgemeines Rate-Limiting alle Pfade gleich behandeln
|
||||
if (empty($this->pathLimits)) {
|
||||
return self::REDIS_KEY_PREFIX . $ip;
|
||||
}
|
||||
|
||||
// Für pfad-spezifisches Rate-Limiting einen pfad-spezifischen Schlüssel erstellen
|
||||
$pathHash = md5($path);
|
||||
return self::REDIS_KEY_PREFIX . "{$ip}:{$pathHash}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt die IP-Adresse des Clients unter Berücksichtigung von Proxies.
|
||||
*/
|
||||
private function getClientIp(Request $request): string
|
||||
{
|
||||
$headers = [
|
||||
'X-Forwarded-For',
|
||||
'X-Real-IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_CLIENT_IP',
|
||||
'REMOTE_ADDR'
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
$value = $request->headers->get($header)[0] ?? null;
|
||||
if ($value) {
|
||||
// Bei mehreren IPs (X-Forwarded-For kann mehrere enthalten) die erste nehmen
|
||||
$ips = explode(',', $value);
|
||||
return trim($ips[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -7,6 +8,7 @@ use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
|
||||
@@ -15,9 +17,10 @@ final readonly class RemovePoweredByMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ResponseManipulator $manipulator
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
// Nächste Middleware aufrufen
|
||||
$resultContext = $next($context);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -7,8 +8,9 @@ use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestIdGenerator;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
|
||||
/**
|
||||
@@ -21,12 +23,13 @@ final readonly class RequestIdMiddleware implements HttpMiddleware
|
||||
public function __construct(
|
||||
private RequestIdGenerator $requestIdGenerator,
|
||||
private ResponseManipulator $manipulator
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
// Request-ID generieren/validieren
|
||||
$requestId = $this->requestIdGenerator->generate();
|
||||
$requestId = $context->request->id; #$this->requestIdGenerator->generate();
|
||||
|
||||
// Request-ID an den weiteren Verarbeitungskontext weitergeben
|
||||
$resultContext = $next($context);
|
||||
@@ -34,7 +37,7 @@ final readonly class RequestIdMiddleware implements HttpMiddleware
|
||||
// Request-ID in die Response-Header setzen, falls eine Response vorhanden ist
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $resultContext->response;
|
||||
$headers = $response->headers->with(
|
||||
$headers = $response?->headers->with(
|
||||
$this->requestIdGenerator::getHeaderName(),
|
||||
$requestId->toString()
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -7,9 +8,8 @@ use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Response;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
|
||||
@@ -17,8 +17,10 @@ final readonly class RequestLoggingMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private Logger $logger
|
||||
){}
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -7,7 +8,9 @@ use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\StateKey;
|
||||
use App\Framework\Router\RouteResponder;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING, -1)]
|
||||
@@ -15,50 +18,26 @@ final readonly class ResponseGeneratorMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private RouteResponder $responder,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
|
||||
if($stateManager->has('controllerResult')) {
|
||||
$originalResponse = $this->responder->respond($stateManager->get('controllerResult'));
|
||||
if ($stateManager->has(StateKey::CONTROLLER_RESULT)) {
|
||||
$controllerResult = $stateManager->get(StateKey::CONTROLLER_RESULT);
|
||||
$originalResponse = $this->responder->respond($controllerResult);
|
||||
|
||||
// Kontext mit der generierten Response aktualisieren
|
||||
$updatedContext = $context->withResponse($originalResponse);
|
||||
|
||||
|
||||
|
||||
$resultContext = $next($updatedContext);
|
||||
|
||||
if(!$resultContext->hasResponse() && $originalResponse !== null) {
|
||||
if (! $resultContext->hasResponse()) {
|
||||
return $resultContext->withResponse($originalResponse);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
}
|
||||
|
||||
|
||||
/*// Prüfen, ob es sich um einen angereicherten Request handelt
|
||||
if ($request instanceof EnrichedRequest && $request->result) {
|
||||
// Response aus dem Controller-Ergebnis generieren
|
||||
$originalResponse = $this->responder->respond($request->result);
|
||||
|
||||
// Kontext mit der generierten Response aktualisieren
|
||||
$updatedContext = $context->withResponse($originalResponse);
|
||||
|
||||
|
||||
|
||||
$resultContext = $next($updatedContext);
|
||||
|
||||
if(!$resultContext->hasResponse() && $originalResponse !== null) {
|
||||
return $resultContext->withResponse($originalResponse);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
}*/
|
||||
|
||||
// Immer die nächste Middleware aufrufen, auch wenn bereits eine Response generiert wurde
|
||||
return $next($context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Auth\Attributes\IpAuth;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Auth\ValueObjects\IpAuthPolicy;
|
||||
use App\Framework\Config\TypedConfiguration;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\IpAddress;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\StateKey;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Performance\Contracts\PerformanceServiceInterface;
|
||||
use App\Framework\Performance\PerformanceCategory;
|
||||
use App\Framework\Router\ActionResult;
|
||||
use App\Framework\Router\Exception\RouteNotFound;
|
||||
use App\Framework\Router\HttpRouter;
|
||||
use App\Framework\Router\Result\ContentNegotiationResult;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\Redirect;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\Router\RouteDispatcher;
|
||||
use App\Framework\Router\Router;
|
||||
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING)]
|
||||
final readonly class RoutingMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private HttpRouter $router,
|
||||
private Router $router,
|
||||
private RouteDispatcher $dispatcher,
|
||||
private TypedConfiguration $config,
|
||||
) {}
|
||||
private PerformanceServiceInterface $performanceService,
|
||||
private array $namespaceConfig = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the middleware logic to process a request and response through the provided context.
|
||||
@@ -35,51 +46,87 @@ final readonly class RoutingMiddleware implements HttpMiddleware
|
||||
* Then, it updates the context with the enriched request and passes it to the next middleware.
|
||||
*
|
||||
* @param MiddlewareContext $context The context containing the request and response.
|
||||
* @param callable $next The next middleware to invoke in the chain.
|
||||
* @param Next $next The next middleware to invoke in the chain.
|
||||
* @param RequestStateManager $stateManager
|
||||
* @return MiddlewareContext The updated middleware context after processing.
|
||||
* @throws RouteNotFound If the route cannot be matched in the current context.
|
||||
*/
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$routeContext = $this->router->match($request);
|
||||
error_log("DEBUG RoutingMiddleware: __invoke() called at the very beginning");
|
||||
|
||||
if (!$routeContext->isSuccess()) {
|
||||
$request = $context->request;
|
||||
|
||||
// DEBUG: Log routing attempt
|
||||
error_log("DEBUG RoutingMiddleware: Attempting to match route - Method: {$request->method->value}, Path: {$request->path}");
|
||||
|
||||
error_log("DEBUG RoutingMiddleware: About to call router->match()");
|
||||
|
||||
try {
|
||||
// Measure route matching
|
||||
$routeContext = $this->performanceService->measure(
|
||||
'route_matching',
|
||||
fn () => $this->router->match($request),
|
||||
PerformanceCategory::ROUTING,
|
||||
[
|
||||
'method' => $request->method->value,
|
||||
'path' => $request->path,
|
||||
]
|
||||
);
|
||||
error_log("DEBUG RoutingMiddleware: router->match() completed successfully");
|
||||
} catch (\Throwable $e) {
|
||||
error_log("DEBUG RoutingMiddleware: router->match() threw exception: " . $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// DEBUG: Log route matching result
|
||||
error_log("DEBUG RoutingMiddleware: Route matching result - Success: " . ($routeContext->isSuccess() ? 'true' : 'false'));
|
||||
if (! $routeContext->isSuccess()) {
|
||||
error_log("DEBUG RoutingMiddleware: Route not found for path: {$routeContext->path}");
|
||||
} else {
|
||||
error_log("DEBUG RoutingMiddleware: Route found - Controller: " . get_class($routeContext->match->route));
|
||||
}
|
||||
|
||||
if (! $routeContext->isSuccess()) {
|
||||
throw new RouteNotFound($routeContext->path);
|
||||
}
|
||||
|
||||
if (!$this->config->app->debug && in_array(Auth::class, $routeContext->match->route->attributes)) {
|
||||
// Perform IP and namespace-based authentication
|
||||
$this->performAuthenticationChecks($request, $routeContext);
|
||||
|
||||
#debug($request->server->getClientIp());
|
||||
$wireguardIp = '172.20.0.1';
|
||||
// Measure controller dispatch
|
||||
$controllerName = $routeContext->match->route->controller . '::' . $routeContext->match->route->action;
|
||||
|
||||
$ip = $request->server->getClientIp();
|
||||
if ($ip->value !== $wireguardIp) {
|
||||
throw new RouteNotFound($routeContext->path);
|
||||
}
|
||||
}
|
||||
$result = $this->performanceService->measure(
|
||||
'controller_dispatch',
|
||||
fn () => $this->dispatcher->dispatch($routeContext),
|
||||
PerformanceCategory::CONTROLLER,
|
||||
[
|
||||
'controller' => $controllerName,
|
||||
'route_name' => $routeContext->match->route->name ?? 'unnamed',
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
// Controller-Logik ausführen
|
||||
$result = $this->dispatcher->dispatch($routeContext);
|
||||
// Also track individual controller performance
|
||||
$this->performanceService->increment(
|
||||
"controller_calls_{$controllerName}",
|
||||
1,
|
||||
PerformanceCategory::CONTROLLER
|
||||
);
|
||||
|
||||
if ($result instanceof ContentNegotiationResult) {
|
||||
$result = $this->contentNegotiation($result);
|
||||
$result = $this->performanceService->measure(
|
||||
'content_negotiation',
|
||||
fn () => $this->contentNegotiation($result),
|
||||
PerformanceCategory::ROUTING
|
||||
);
|
||||
}
|
||||
|
||||
$stateManager->set('controllerResult', $result);
|
||||
|
||||
// Erstellen eines angereicherten Requests mit dem Controller-Ergebnis
|
||||
#$enrichedRequest = new EnrichedRequest($request, $result);
|
||||
|
||||
// Kontext mit dem angereicherten Request aktualisieren
|
||||
#$updatedContext = new MiddlewareContext($enrichedRequest, $context->response);
|
||||
|
||||
$updatedContext = $context;
|
||||
$stateManager->set(StateKey::CONTROLLER_RESULT, $result);
|
||||
|
||||
// Nächste Middleware aufrufen
|
||||
return $next($updatedContext);
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
private function contentNegotiation(ContentNegotiationResult $response): ActionResult
|
||||
@@ -93,9 +140,163 @@ final readonly class RoutingMiddleware implements HttpMiddleware
|
||||
return new Redirect($response->redirectTo);
|
||||
}
|
||||
if ($response->viewTemplate !== null) {
|
||||
return new ViewResult(template: $response->viewTemplate,metaData: new MetaData(''), data: $response->viewData);
|
||||
return new ViewResult(template: $response->viewTemplate, metaData: new MetaData(''), data: $response->viewData);
|
||||
}
|
||||
|
||||
// Optional: Fallback, z.B. Fehler- oder Defaultseite
|
||||
return new JsonResult(['message' => 'Not found']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform IP and namespace-based authentication checks
|
||||
*/
|
||||
private function performAuthenticationChecks($request, $routeContext): void
|
||||
{
|
||||
$clientIp = $this->getClientIp($request);
|
||||
$controllerClass = $routeContext->match->route->controller;
|
||||
|
||||
// Check legacy Auth attribute (backward compatibility)
|
||||
if (! $this->config->app->debug && in_array(Auth::class, $routeContext->match->route->attributes)) {
|
||||
$wireguardIp = '172.20.0.1';
|
||||
if ($clientIp->value !== $wireguardIp) {
|
||||
throw new RouteNotFound($routeContext->path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check namespace-based restrictions
|
||||
$namespacePolicy = $this->getNamespacePolicy($controllerClass);
|
||||
if ($namespacePolicy && ! $namespacePolicy->isAllowed($clientIp)) {
|
||||
throw new RouteNotFound($routeContext->path);
|
||||
}
|
||||
|
||||
// Check route-specific IP auth attributes
|
||||
$ipAuthAttribute = $this->getIpAuthAttribute($routeContext->match->route);
|
||||
if ($ipAuthAttribute) {
|
||||
$routePolicy = $ipAuthAttribute->createPolicy();
|
||||
if (! $routePolicy->isAllowed($clientIp)) {
|
||||
throw new RouteNotFound($routeContext->path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
private function getClientIp($request): IpAddress
|
||||
{
|
||||
return $request->server->getClientIp() ?? IpAddress::localhost();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get namespace-based IP policy
|
||||
*/
|
||||
private function getNamespacePolicy(string $controllerClass): ?IpAuthPolicy
|
||||
{
|
||||
$namespace = $this->extractNamespace($controllerClass);
|
||||
|
||||
foreach ($this->namespaceConfig as $pattern => $config) {
|
||||
if ($this->namespaceMatches($namespace, $pattern)) {
|
||||
return $this->createPolicyFromConfig($config);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract namespace from class name
|
||||
*/
|
||||
private function extractNamespace(string $className): string
|
||||
{
|
||||
$parts = explode('\\', $className);
|
||||
array_pop($parts); // Remove class name
|
||||
|
||||
return implode('\\', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if namespace matches pattern
|
||||
*/
|
||||
private function namespaceMatches(string $namespace, string $pattern): bool
|
||||
{
|
||||
// Exact match
|
||||
if ($namespace === $pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard pattern (e.g., "App\Admin\*")
|
||||
if (str_ends_with($pattern, '*')) {
|
||||
$prefix = rtrim($pattern, '*');
|
||||
|
||||
return str_starts_with($namespace, $prefix);
|
||||
}
|
||||
|
||||
// Prefix match
|
||||
return str_starts_with($namespace, $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create policy from configuration
|
||||
*/
|
||||
private function createPolicyFromConfig(array $config): IpAuthPolicy
|
||||
{
|
||||
$visibility = $config['visibility'] ?? 'public';
|
||||
|
||||
return match ($visibility) {
|
||||
'admin' => IpAuthPolicy::adminOnly(),
|
||||
'local' => IpAuthPolicy::localOnly(),
|
||||
'development' => IpAuthPolicy::development(),
|
||||
'private' => IpAuthPolicy::localOnly(),
|
||||
'custom' => IpAuthPolicy::fromConfig($config),
|
||||
default => IpAuthPolicy::fromConfig([]) // No restrictions for 'public'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IpAuth attribute from route
|
||||
*/
|
||||
private function getIpAuthAttribute($route): ?IpAuth
|
||||
{
|
||||
// Check if route has IpAuth attribute in its attributes array
|
||||
foreach ($route->attributes ?? [] as $attribute) {
|
||||
if ($attribute instanceof IpAuth) {
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check via reflection for method-level attributes
|
||||
try {
|
||||
$reflection = new \ReflectionMethod($route->controller, $route->action);
|
||||
$attributes = $reflection->getAttributes(IpAuth::class);
|
||||
|
||||
if (! empty($attributes)) {
|
||||
return $attributes[0]->newInstance();
|
||||
}
|
||||
|
||||
// Check controller class for IpAuth attribute
|
||||
$classReflection = new \ReflectionClass($route->controller);
|
||||
$classAttributes = $classReflection->getAttributes(IpAuth::class);
|
||||
|
||||
if (! empty($classAttributes)) {
|
||||
return $classAttributes[0]->newInstance();
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
// Ignore reflection errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create routing middleware with namespace configuration
|
||||
*/
|
||||
public static function withNamespaceConfig(
|
||||
Router $router,
|
||||
RouteDispatcher $dispatcher,
|
||||
TypedConfiguration $config,
|
||||
PerformanceServiceInterface $performanceService,
|
||||
array $namespaceConfig
|
||||
): self {
|
||||
return new self($router, $dispatcher, $config, $performanceService, $namespaceConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
@@ -15,7 +16,8 @@ final readonly class SecurityHeaderConfig
|
||||
public string $crossOriginOpenerPolicy = 'same-origin',
|
||||
public string $crossOriginResourcePolicy = 'same-origin',
|
||||
public bool $enableInDevelopment = false
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration für Entwicklungsumgebung mit weniger restriktiven Einstellungen
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Config\SecurityConfig;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
|
||||
@@ -15,22 +18,23 @@ final readonly class SecurityHeaderMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ResponseManipulator $manipulator,
|
||||
private SecurityHeaderConfig $config = new SecurityHeaderConfig()
|
||||
) {}
|
||||
private SecurityConfig $securityConfig
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$this->removePoweredByHeader();
|
||||
|
||||
// Nächste Middleware aufrufen
|
||||
$resultContext = $next($context);
|
||||
|
||||
// Wenn eine Response vorhanden ist, Security-Header hinzufügen
|
||||
// Security-Header IMMER hinzufügen (auch in Development)
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $resultContext->response;
|
||||
$updatedHeaders = $response->headers;
|
||||
|
||||
// Alle Security-Header hinzufügen
|
||||
// Environment-spezifische Security-Header hinzufügen
|
||||
foreach ($this->getSecurityHeaders() as $name => $value) {
|
||||
if ($this->shouldAddHeader($name, $updatedHeaders)) {
|
||||
$updatedHeaders = $updatedHeaders->with($name, $value);
|
||||
@@ -48,29 +52,34 @@ final readonly class SecurityHeaderMiddleware implements HttpMiddleware
|
||||
|
||||
private function getSecurityHeaders(): array
|
||||
{
|
||||
// Environment-spezifische Header-Konfiguration
|
||||
$config = $this->securityConfig->enableStrictMode
|
||||
? SecurityHeaderConfig::forProduction()
|
||||
: SecurityHeaderConfig::forDevelopment();
|
||||
|
||||
return [
|
||||
'Strict-Transport-Security' => $this->config->hstsHeader,
|
||||
'X-Frame-Options' => $this->config->frameOptions,
|
||||
'Strict-Transport-Security' => $config->hstsHeader,
|
||||
'X-Frame-Options' => $config->frameOptions,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => $this->config->referrerPolicy,
|
||||
'Content-Security-Policy' => $this->config->contentSecurityPolicy,
|
||||
'Permissions-Policy' => $this->config->permissionsPolicy,
|
||||
'Referrer-Policy' => $config->referrerPolicy,
|
||||
'Content-Security-Policy' => $config->contentSecurityPolicy,
|
||||
'Permissions-Policy' => $config->permissionsPolicy,
|
||||
'X-Permitted-Cross-Domain-Policies' => 'none',
|
||||
'Cross-Origin-Embedder-Policy' => $this->config->crossOriginEmbedderPolicy,
|
||||
'Cross-Origin-Opener-Policy' => $this->config->crossOriginOpenerPolicy,
|
||||
'Cross-Origin-Resource-Policy' => $this->config->crossOriginResourcePolicy,
|
||||
'Cross-Origin-Embedder-Policy' => $config->crossOriginEmbedderPolicy,
|
||||
'Cross-Origin-Opener-Policy' => $config->crossOriginOpenerPolicy,
|
||||
'Cross-Origin-Resource-Policy' => $config->crossOriginResourcePolicy,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldAddHeader(string $headerName, $currentHeaders): bool
|
||||
{
|
||||
// Header nur hinzufügen, wenn er noch nicht gesetzt ist
|
||||
return !$currentHeaders->has($headerName);
|
||||
return ! $currentHeaders->has($headerName);
|
||||
}
|
||||
|
||||
private function removePoweredByHeader():void
|
||||
private function removePoweredByHeader(): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
if (! headers_sent()) {
|
||||
header_remove('X-Powered-By');
|
||||
header_remove('Server');
|
||||
}
|
||||
|
||||
174
src/Framework/Http/Middlewares/ServeStaticFilesMiddleware.php
Normal file
174
src/Framework/Http/Middlewares/ServeStaticFilesMiddleware.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Filesystem\Storage;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
class ServeStaticFilesMiddleware implements HttpMiddleware
|
||||
{
|
||||
/** @var array<string> */
|
||||
private array $allowedExtensions = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg',
|
||||
'css', 'js', 'woff', 'woff2', 'ttf', 'eot',
|
||||
'pdf', 'ico', 'xml', 'json',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $mimeTypes = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'avif' => 'image/avif',
|
||||
'svg' => 'image/svg+xml',
|
||||
'css' => 'text/css',
|
||||
'js' => 'application/javascript',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'eot' => 'application/vnd.ms-fontobject',
|
||||
'pdf' => 'application/pdf',
|
||||
'ico' => 'image/x-icon',
|
||||
'xml' => 'application/xml',
|
||||
'json' => 'application/json',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly PathProvider $pathProvider,
|
||||
private readonly Clock $clock,
|
||||
private readonly Storage $storage,
|
||||
private readonly string $mediaPrefix = '/media'
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$path = $request->path;
|
||||
|
||||
// Prüfen ob es sich um eine Media-Anfrage handelt
|
||||
if (str_starts_with($path, $this->mediaPrefix)) {
|
||||
$filePath = substr($path, strlen($this->mediaPrefix));
|
||||
$response = $this->serveStaticFile($filePath);
|
||||
|
||||
return new MiddlewareContext($request, $response);
|
||||
}
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
private function serveStaticFile(string $filePath): HttpResponse
|
||||
{
|
||||
// Pfad bereinigen (verhindert Directory Traversal Angriffe)
|
||||
$filePath = $this->sanitizePath($filePath);
|
||||
|
||||
// Dateiendung prüfen
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
if (! in_array($extension, $this->allowedExtensions)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
// Vollständigen Dateipfad konstruieren
|
||||
$fullPath = $this->pathProvider->resolvePath('storage' . $filePath);
|
||||
|
||||
// Prüfen ob Datei existiert
|
||||
if (! $this->storage->exists($fullPath)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
// MIME-Typ ermitteln
|
||||
$mimeType = $this->mimeTypes[$extension] ?? 'application/octet-stream';
|
||||
|
||||
// Storage verwenden
|
||||
$content = $this->storage->get($fullPath);
|
||||
|
||||
// Cache-Control Header basierend auf Dateityp setzen
|
||||
$cacheControl = $this->getCacheControlHeader($extension);
|
||||
|
||||
// ETag für Caching generieren
|
||||
$etag = '"' . md5($content) . '"';
|
||||
|
||||
// Last-Modified Header mit Clock
|
||||
$lastModified = $this->clock->now()
|
||||
->format('D, d M Y H:i:s') . ' GMT';
|
||||
|
||||
// Headers zusammenstellen
|
||||
$headers = new Headers([
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => (string) strlen($content),
|
||||
'Cache-Control' => $cacheControl,
|
||||
'ETag' => $etag,
|
||||
'Last-Modified' => $lastModified,
|
||||
]);
|
||||
|
||||
return new HttpResponse(
|
||||
Status::OK,
|
||||
$headers,
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
private function sanitizePath(string $path): string
|
||||
{
|
||||
// Doppelte Slashes entfernen
|
||||
$path = preg_replace('#/+#', '/', $path) ?? $path;
|
||||
|
||||
// Führende/nachfolgende Slashes entfernen
|
||||
$path = trim($path, '/');
|
||||
|
||||
// Sicherstellen, dass kein '..' enthalten ist (verhindert Directory Traversal)
|
||||
$parts = [];
|
||||
foreach (explode('/', $path) as $part) {
|
||||
if ($part === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($part === '..') {
|
||||
array_pop($parts);
|
||||
} else {
|
||||
$parts[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return '/' . implode('/', $parts);
|
||||
}
|
||||
|
||||
private function getCacheControlHeader(string $extension): string
|
||||
{
|
||||
// Bilder und Fonts können länger gecacht werden
|
||||
$imageFontExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'woff', 'woff2', 'ttf', 'eot'];
|
||||
|
||||
if (in_array($extension, $imageFontExtensions)) {
|
||||
return 'public, max-age=31536000'; // 1 Jahr
|
||||
}
|
||||
|
||||
// CSS und JS
|
||||
if (in_array($extension, ['css', 'js'])) {
|
||||
return 'public, max-age=604800'; // 1 Woche
|
||||
}
|
||||
|
||||
// Sonstige Dateien
|
||||
return 'public, max-age=86400'; // 1 Tag
|
||||
}
|
||||
|
||||
private function notFound(): HttpResponse
|
||||
{
|
||||
return new HttpResponse(
|
||||
Status::NOT_FOUND,
|
||||
new Headers(['Content-Type' => 'text/plain']),
|
||||
'Datei nicht gefunden'
|
||||
);
|
||||
}
|
||||
}
|
||||
247
src/Framework/Http/Middlewares/WafMiddleware.php
Normal file
247
src/Framework/Http/Middlewares/WafMiddleware.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\Config\WafConfig;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\WafEngine;
|
||||
|
||||
/**
|
||||
* WAF (Web Application Firewall) Middleware
|
||||
*
|
||||
* Integrates the WAF engine into the HTTP request pipeline for security analysis
|
||||
* and threat detection. Blocks or flags requests based on WAF layer results.
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
|
||||
final readonly class WafMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private WafEngine $wafEngine,
|
||||
private Logger $logger,
|
||||
private WafConfig $config
|
||||
) {
|
||||
// Ensure security layers are registered
|
||||
$this->initializeSecurityLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize security layers if not already registered
|
||||
*/
|
||||
private function initializeSecurityLayers(): void
|
||||
{
|
||||
// Check if layers are already registered (avoid double registration)
|
||||
$healthStatus = $this->wafEngine->getHealthStatus();
|
||||
if ($healthStatus['total_layers'] > 0) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
try {
|
||||
// Register security layers directly
|
||||
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\SqlInjectionLayer());
|
||||
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\CommandInjectionLayer());
|
||||
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\PathTraversalLayer());
|
||||
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\XssLayer());
|
||||
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\SuspiciousUserAgentLayer());
|
||||
|
||||
$this->logger->info('WAF security layers initialized', [
|
||||
'layers_count' => 5,
|
||||
'health_status' => $this->wafEngine->getHealthStatus(),
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to initialize WAF security layers', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
if (! $this->config->enabled) {
|
||||
$this->logger->debug('WAF disabled, skipping analysis');
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
try {
|
||||
$request = $context->request;
|
||||
|
||||
// Debug log request details
|
||||
$this->logger->debug('WAF analyzing request', [
|
||||
'path' => $request->path ?? '/',
|
||||
'method' => $request->method->value ?? 'UNKNOWN',
|
||||
'query_params' => $request->queryParams,
|
||||
'post_data' => $request->parsedBody->data ?? null,
|
||||
'user_agent' => $request->headers->get('User-Agent', ''),
|
||||
'client_ip' => $request->server->getClientIp()?->value ?? 'unknown',
|
||||
'waf_config' => [
|
||||
'enabled' => $this->config->enabled,
|
||||
'blocking_mode' => $this->config->blockingMode,
|
||||
'enabled_layers' => $this->config->enabledLayers,
|
||||
],
|
||||
]);
|
||||
|
||||
// Analyze request with WAF engine
|
||||
$wafResult = $this->wafEngine->analyze($request);
|
||||
|
||||
// Debug log analysis result
|
||||
$this->logger->debug('WAF analysis complete', [
|
||||
'result_status' => $wafResult->getStatus()->value ?? 'unknown',
|
||||
'result_action' => $wafResult->getAction(),
|
||||
'layer_name' => $wafResult->getLayerName(),
|
||||
'message' => $wafResult->getMessage(),
|
||||
'has_detections' => $wafResult->hasDetections(),
|
||||
'detections_count' => $wafResult->hasDetections() ? count($wafResult->getDetections()->getAll()) : 0,
|
||||
]);
|
||||
|
||||
// Handle based on result action
|
||||
switch ($wafResult->getAction()) {
|
||||
case LayerResult::ACTION_BLOCK:
|
||||
$this->logger->info('WAF blocking request', ['reason' => $wafResult->getMessage()]);
|
||||
|
||||
return $this->handleBlocked($context, $wafResult);
|
||||
|
||||
case LayerResult::ACTION_SUSPICIOUS:
|
||||
$this->logger->info('WAF flagging suspicious request', ['reason' => $wafResult->getMessage()]);
|
||||
|
||||
return $this->handleSuspicious($context, $wafResult, $next);
|
||||
|
||||
case LayerResult::ACTION_PASS:
|
||||
default:
|
||||
$this->logger->debug('WAF allowing request', ['reason' => $wafResult->getMessage()]);
|
||||
|
||||
// Continue to next middleware
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Log WAF error but don't block request on WAF failure
|
||||
$this->logger->error('WAF middleware error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'request_path' => $context->request->path ?? '/',
|
||||
'client_ip' => $context->request->server->getClientIp()?->value ?? 'unknown',
|
||||
]);
|
||||
|
||||
// Continue processing on WAF error
|
||||
return $next($context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blocked requests
|
||||
*/
|
||||
private function handleBlocked(MiddlewareContext $context, LayerResult $wafResult): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
|
||||
// Log security event
|
||||
$this->logger->warning('WAF blocked request', [
|
||||
'reason' => $wafResult->getMessage(),
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path ?? '/',
|
||||
'user_agent' => $request->headers->get('User-Agent', ''),
|
||||
'detections' => $this->formatDetections($wafResult),
|
||||
'layer' => $wafResult->getLayerName(),
|
||||
]);
|
||||
|
||||
if ($this->config->blockingMode) {
|
||||
// Return 403 Forbidden response
|
||||
$response = new JsonResponse([
|
||||
'error' => 'Request blocked by security policy',
|
||||
'code' => 'WAF_BLOCKED',
|
||||
'request_id' => $request->id->value(),
|
||||
], 403);
|
||||
} else {
|
||||
// Log-only mode - continue processing but log the threat
|
||||
$this->logger->warning('WAF would block request (log-only mode)', [
|
||||
'reason' => $wafResult->getMessage(),
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path ?? '/',
|
||||
]);
|
||||
|
||||
$response = new JsonResponse([
|
||||
'warning' => 'Request flagged by security policy',
|
||||
'code' => 'WAF_FLAGGED',
|
||||
], 200);
|
||||
}
|
||||
|
||||
return $context->withResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle suspicious requests
|
||||
*/
|
||||
private function handleSuspicious(MiddlewareContext $context, LayerResult $wafResult, Next $next): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
|
||||
|
||||
// Log suspicious activity
|
||||
$this->logger->info('WAF flagged suspicious request', [
|
||||
'reason' => $wafResult->getMessage(),
|
||||
'client_ip' => $clientIp,
|
||||
'path' => $request->path ?? '/',
|
||||
'user_agent' => $request->headers->get('User-Agent', ''),
|
||||
'detections' => $this->formatDetections($wafResult),
|
||||
'layer' => $wafResult->getLayerName(),
|
||||
]);
|
||||
|
||||
// Continue to next middleware
|
||||
$resultContext = $next($context);
|
||||
|
||||
// Add WAF detection info to response headers for monitoring
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $resultContext->response;
|
||||
$updatedHeaders = $response->headers
|
||||
->with('X-Waf-Status', 'suspicious')
|
||||
->with('X-Waf-Layer', $wafResult->getLayerName());
|
||||
|
||||
$updatedResponse = new HttpResponse(
|
||||
$response->body,
|
||||
$response->statusCode,
|
||||
$updatedHeaders
|
||||
);
|
||||
|
||||
return $resultContext->withResponse($updatedResponse);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format detection information for logging
|
||||
*/
|
||||
private function formatDetections(LayerResult $wafResult): array
|
||||
{
|
||||
if (! $wafResult->hasDetections()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$detections = [];
|
||||
foreach ($wafResult->getDetections()->getAll() as $detection) {
|
||||
$detections[] = [
|
||||
'category' => $detection->category->value,
|
||||
'severity' => $detection->severity->value,
|
||||
'message' => $detection->message,
|
||||
'confidence' => $detection->confidence,
|
||||
'evidence' => $detection->evidence,
|
||||
];
|
||||
}
|
||||
|
||||
return $detections;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user