Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
CsrfMiddleware
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
4 / 4
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 extractToken
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 createForbiddenResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Middleware;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\SecurityAuditLogger;
12use App\Core\Security\Csrf\CsrfTokenManagerInterface;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Psr\Http\Server\MiddlewareInterface;
17use 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 */
28final 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}