Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
VerifyCsrf
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
5 / 5
20
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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 token
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 refused
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 expiredUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Middleware;
6
7use Celema\Core\Factory\Factory;
8use Cosray\Auth;
9use Cosray\Config;
10use Cosray\Session;
11use Cosray\Users;
12use Override;
13use Psr\Http\Message\ResponseInterface as Response;
14use Psr\Http\Message\ServerRequestInterface as Request;
15use Psr\Http\Server\MiddlewareInterface as Middleware;
16use Psr\Http\Server\RequestHandlerInterface as Handler;
17use Psr\Log\LoggerInterface as Logger;
18
19/**
20 * Refuses unsafe requests that do not carry the session's CSRF token in the
21 * `X-CSRF-Token` header or the `_token` form field. Requests with a bearer
22 * token pass, as Auth then ignores their cookies.
23 *
24 * Panel scripts send the header and learn of a refusal through the
25 * `cosray:csrf-expired` event; a plain form submit is sent to a page that
26 * explains it and links back.
27 */
28final class VerifyCsrf implements Middleware
29{
30    public const string HEADER = 'X-CSRF-Token';
31    public const string FIELD = '_token';
32    public const string EVENT = 'cosray:csrf-expired';
33
34    private const array SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
35
36    public function __construct(
37        private readonly Config $config,
38        private readonly Users $users,
39        private readonly Factory $factory,
40        private readonly Logger $logger,
41    ) {}
42
43    #[Override]
44    public function process(Request $request, Handler $handler): Response
45    {
46        $auth = new Auth($request, $this->users, $this->config);
47
48        if (in_array($request->getMethod(), self::SAFE_METHODS, true) || $auth->getAuthToken() !== '') {
49            return $handler->handle($request);
50        }
51
52        $session = $request->getAttribute('session', null);
53
54        if ($session instanceof Session && $session->csrf->verify(token: $this->token($request))) {
55            return $handler->handle($request);
56        }
57
58        $this->logger->warning('Refused {method} {path} from {ip}: missing or invalid CSRF token', [
59            'method' => $request->getMethod(),
60            'path' => $request->getUri()->getPath(),
61            'ip' => $auth->clientIp(),
62        ]);
63
64        return $this->refused($request);
65    }
66
67    private function token(Request $request): string
68    {
69        $header = $request->getHeaderLine(self::HEADER);
70
71        if ($header !== '') {
72            return $header;
73        }
74
75        $body = $request->getParsedBody();
76        $field = is_array($body) ? $body[self::FIELD] ?? null : null;
77
78        return is_string($field) ? $field : '';
79    }
80
81    private function refused(Request $request): Response
82    {
83        $scripted = $request->hasHeader('HX-Request') || $request->hasHeader(self::HEADER);
84
85        if (!$scripted && !str_contains($request->getHeaderLine('Accept'), 'application/json')) {
86            return $this->factory
87                ->response(303)
88                ->withHeader('Location', $this->expiredUrl($request));
89        }
90
91        $message = __('auth:csrf-expired');
92        $response = $this->factory
93            ->response(403)
94            ->withHeader('HX-Reswap', 'none')
95            ->withHeader('HX-Trigger', self::EVENT);
96
97        if (str_contains($request->getHeaderLine('Accept'), 'application/json')) {
98            return $response
99                ->withHeader('Content-Type', 'application/json')
100                ->withBody($this->factory->stream(json_encode(
101                    ['ok' => false, 'error' => $message],
102                    JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE,
103                )));
104        }
105
106        return $response
107            ->withHeader('Content-Type', 'text/plain; charset=utf-8')
108            ->withBody($this->factory->stream($message));
109    }
110
111    /** The page explaining the refusal, linking back to the page the form was on. */
112    private function expiredUrl(Request $request): string
113    {
114        $url = rtrim($this->config->panel->path, '/') . '/expired';
115        $referer = parse_url($request->getHeaderLine('Referer'));
116        $host = $request->getUri()->getHost();
117
118        if (!is_array($referer) || ($referer['host'] ?? null) !== $host || !isset($referer['path'])) {
119            return $url;
120        }
121
122        $back = $referer['path'] . (isset($referer['query']) ? '?' . $referer['query'] : '');
123
124        return $url . '?' . http_build_query(['next' => $back]);
125    }
126}