Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
36 / 36 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| CsrfMiddleware | |
100.00% |
35 / 35 |
|
100.00% |
4 / 4 |
14 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| process | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| extractToken | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| createForbiddenResponse | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Security\Middleware; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Audit\Application\Service\SecurityAuditLogger; |
| 12 | use App\Core\Security\Csrf\CsrfTokenManagerInterface; |
| 13 | use Psr\Http\Message\ResponseFactoryInterface; |
| 14 | use Psr\Http\Message\ResponseInterface; |
| 15 | use Psr\Http\Message\ServerRequestInterface; |
| 16 | use Psr\Http\Server\MiddlewareInterface; |
| 17 | use Psr\Http\Server\RequestHandlerInterface; |
| 18 | |
| 19 | /** |
| 20 | * Universal CSRF Protection Middleware. |
| 21 | * |
| 22 | * Enforces CSRF token validation on unsafe HTTP methods (POST, PUT, DELETE, PATCH). |
| 23 | * Extracts tokens from X-CSRF-Token header, X-XSRF-Token header, or _csrf body parameter. |
| 24 | * Automatically injects active X-CSRF-Token response header for HTMX synchronization. |
| 25 | * |
| 26 | * @package App\Core\Security\Middleware |
| 27 | */ |
| 28 | final readonly class CsrfMiddleware implements MiddlewareInterface |
| 29 | { |
| 30 | private const array SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']; |
| 31 | private const string CSRF_HEADER = 'X-CSRF-Token'; |
| 32 | |
| 33 | /** |
| 34 | * CsrfMiddleware constructor. |
| 35 | * |
| 36 | * @param CsrfTokenManagerInterface $csrfTokenManager CSRF token manager contract. |
| 37 | * @param ResponseFactoryInterface $responseFactory PSR-17 response factory. |
| 38 | * @param SecurityAuditLogger|null $securityLogger Optional security audit logger. |
| 39 | */ |
| 40 | public function __construct( |
| 41 | private CsrfTokenManagerInterface $csrfTokenManager, |
| 42 | private ResponseFactoryInterface $responseFactory, |
| 43 | private ?SecurityAuditLogger $securityLogger = null, |
| 44 | ) { |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * {@inheritdoc} |
| 49 | */ |
| 50 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface |
| 51 | { |
| 52 | $method = strtoupper($request->getMethod()); |
| 53 | |
| 54 | if (in_array($method, self::SAFE_METHODS, true)) { |
| 55 | $response = $handler->handle($request); |
| 56 | return $response->withHeader(self::CSRF_HEADER, $this->csrfTokenManager->getToken()); |
| 57 | } |
| 58 | |
| 59 | $token = $this->extractToken($request); |
| 60 | |
| 61 | if (!$this->csrfTokenManager->validateToken($token)) { |
| 62 | $serverParams = $request->getServerParams(); |
| 63 | $ip = (string) ($serverParams['REMOTE_ADDR'] ?? '127.0.0.1'); |
| 64 | $reqId = $request->getHeaderLine('X-Request-ID') ?: null; |
| 65 | $this->securityLogger?->logCsrfViolation($ip, (string) $request->getUri(), $reqId); |
| 66 | |
| 67 | return $this->createForbiddenResponse($request); |
| 68 | } |
| 69 | |
| 70 | $response = $handler->handle($request); |
| 71 | return $response->withHeader(self::CSRF_HEADER, $this->csrfTokenManager->getToken()); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Extracts submitted CSRF token from HTTP headers or request body. |
| 76 | * |
| 77 | * @param ServerRequestInterface $request PSR-7 server request. |
| 78 | * @return string Extracted token string or empty string. |
| 79 | */ |
| 80 | private function extractToken(ServerRequestInterface $request): string |
| 81 | { |
| 82 | $token = $request->getHeaderLine(self::CSRF_HEADER); |
| 83 | if ($token === '') { |
| 84 | $token = $request->getHeaderLine('X-XSRF-Token'); |
| 85 | } |
| 86 | |
| 87 | if ($token === '') { |
| 88 | $body = $request->getParsedBody(); |
| 89 | if (is_array($body)) { |
| 90 | $candidate = $body['_csrf'] ?? ($body['csrf_token'] ?? null); |
| 91 | $token = is_string($candidate) ? $candidate : ''; |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | return trim($token); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Builds standard 403 Forbidden response. |
| 100 | * |
| 101 | * @param ServerRequestInterface $request PSR-7 server request. |
| 102 | * @return ResponseInterface Formatted 403 response. |
| 103 | */ |
| 104 | private function createForbiddenResponse(ServerRequestInterface $request): ResponseInterface |
| 105 | { |
| 106 | $response = $this->responseFactory->createResponse(403); |
| 107 | $isHtmx = $request->hasHeader('HX-Request'); |
| 108 | $accept = $request->getHeaderLine('Accept'); |
| 109 | |
| 110 | if ($isHtmx || str_contains($accept, 'application/json')) { |
| 111 | $payload = json_encode([ |
| 112 | 'status' => false, |
| 113 | 'message' => 'Forbidden: Invalid or missing CSRF token.', |
| 114 | ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
| 115 | |
| 116 | $response->getBody()->write($payload !== false ? $payload : '{}'); |
| 117 | return $response->withHeader('Content-Type', 'application/json'); |
| 118 | } |
| 119 | |
| 120 | $response->getBody()->write('Forbidden: Invalid or missing CSRF token.'); |
| 121 | return $response->withHeader('Content-Type', 'text/plain; charset=utf-8'); |
| 122 | } |
| 123 | } |