Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
46 / 46 |
|
100.00% |
5 / 5 |
CRAP | |
100.00% |
1 / 1 |
| VerifyCsrf | |
100.00% |
46 / 46 |
|
100.00% |
5 / 5 |
20 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| process | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| token | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| refused | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
5 | |||
| expiredUrl | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Middleware; |
| 6 | |
| 7 | use Celema\Core\Factory\Factory; |
| 8 | use Cosray\Auth; |
| 9 | use Cosray\Config; |
| 10 | use Cosray\Session; |
| 11 | use Cosray\Users; |
| 12 | use Override; |
| 13 | use Psr\Http\Message\ResponseInterface as Response; |
| 14 | use Psr\Http\Message\ServerRequestInterface as Request; |
| 15 | use Psr\Http\Server\MiddlewareInterface as Middleware; |
| 16 | use Psr\Http\Server\RequestHandlerInterface as Handler; |
| 17 | use 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 | */ |
| 28 | final 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 | } |