Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.97% |
32 / 33 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
DoubleSubmitCookieCsrfMiddleware | |
96.97% |
32 / 33 |
|
75.00% |
3 / 4 |
12 | |
0.00% |
0 / 1 |
__construct | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
setLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
process | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
isValid | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Taproot\IndieAuth\Middleware; |
4 | |
5 | use GuzzleHttp\Psr7\Response; |
6 | use Psr\Http\Message\ServerRequestInterface; |
7 | use Psr\Http\Message\ResponseInterface; |
8 | use Psr\Http\Server\MiddlewareInterface; |
9 | use Psr\Http\Server\RequestHandlerInterface; |
10 | use Dflydev\FigCookies; |
11 | use Psr\Log\LoggerAwareInterface; |
12 | use Psr\Log\LoggerInterface; |
13 | use Psr\Log\NullLogger; |
14 | |
15 | use function Taproot\IndieAuth\generateRandomPrintableAsciiString; |
16 | |
17 | /** |
18 | * Double-Submit Cookie CSRF Middleware |
19 | * |
20 | * A PSR-15-compatible Middleware for stateless Double-Submit-Cookie-based CSRF protection. |
21 | * |
22 | * The `$attribute` property and first constructor argument sets the key by which the CSRF token |
23 | * is referred to in all parameter sets (request attributes, request body parameters, cookies). |
24 | * |
25 | * Generates a random token of length `$tokenLength` (default 128), and stores it as an attribute |
26 | * on the `ServerRequestInterface`. It’s also added to the response as a cookie. |
27 | * |
28 | * On requests which may modify state (methods other than HEAD, GET or OPTIONS), the request body |
29 | * and request cookies are checked for matching CSRF tokens. If they match, the request is passed on |
30 | * to the handler. If they do not match, further processing is halted and an error response generated |
31 | * from the `$errorResponse` callback is returned. Refer to the constructor argument for information |
32 | * about customising the error response. |
33 | * |
34 | * @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html |
35 | * @link https://github.com/zakirullin/csrf-middleware/blob/master/src/CSRF.php |
36 | */ |
37 | class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwareInterface { |
38 | const READ_METHODS = ['HEAD', 'GET', 'OPTIONS']; |
39 | const TTL = 60 * 20; |
40 | const ATTRIBUTE = 'csrf'; |
41 | const DEFAULT_ERROR_RESPONSE_STRING = 'Invalid or missing CSRF token!'; |
42 | const CSRF_TOKEN_LENGTH = 128; |
43 | |
44 | /** @var string $attribute */ |
45 | public $attribute; |
46 | |
47 | /** @var int $ttl */ |
48 | public $ttl; |
49 | |
50 | public $errorResponse; |
51 | |
52 | /** @var int $tokenLength */ |
53 | public $tokenLength; |
54 | |
55 | /** @var LoggerInterface $logger */ |
56 | public $logger; |
57 | |
58 | /** @var ?string $cookiePath */ |
59 | public $cookiePath = null; |
60 | |
61 | /** |
62 | * Constructor |
63 | * |
64 | * The `$errorResponse` parameter can be used to customse the error response returned when a |
65 | * write request has invalid CSRF parameters. It can take the following forms: |
66 | * |
67 | * * A `string`, which will be returned as-is with a 400 Status Code and `Content-type: text/plain` header |
68 | * * An instance of `ResponseInterface`, which will be returned as-is |
69 | * * A callable with the signature `function (ServerRequestInterface $request): ResponseInterface`, |
70 | * the return value of which will be returned as-is. |
71 | */ |
72 | public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { |
73 | $this->attribute = $attribute ?? self::ATTRIBUTE; |
74 | $this->ttl = $ttl ?? self::TTL; |
75 | $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; |
76 | |
77 | if (!is_callable($errorResponse)) { |
78 | if (!$errorResponse instanceof ResponseInterface) { |
79 | if (!is_string($errorResponse)) { |
80 | $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING; |
81 | } |
82 | $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); |
83 | } |
84 | $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; |
85 | } |
86 | $this->errorResponse = $errorResponse; |
87 | |
88 | if (!$logger instanceof LoggerInterface) { |
89 | $logger = new NullLogger(); |
90 | } |
91 | $this->logger = $logger; |
92 | } |
93 | |
94 | public function setLogger(LoggerInterface $logger): void { |
95 | $this->logger = $logger; |
96 | } |
97 | |
98 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { |
99 | // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. |
100 | $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); |
101 | $request = $request->withAttribute($this->attribute, $csrfToken); |
102 | |
103 | // Add a pre-rendered CSRF form element to the request for convenience. |
104 | $csrfFormElement = '<input type="hidden" name="' . htmlentities($this->attribute) . '" value="' . htmlentities($csrfToken) . '" />'; |
105 | $request = $request->withAttribute("{$this->attribute}FormElement", $csrfFormElement); |
106 | |
107 | if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { |
108 | // This request is a write method with invalid CSRF parameters. |
109 | $response = call_user_func($this->errorResponse, $request); |
110 | } else { |
111 | $response = $handler->handle($request); |
112 | } |
113 | |
114 | // Add the new CSRF cookie, restricting its scope to match the current request. |
115 | $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) |
116 | ->withValue($csrfToken) |
117 | ->withMaxAge($this->ttl) |
118 | ->withSecure($request->getUri()->getScheme() == 'https') |
119 | ->withDomain($request->getUri()->getHost()) |
120 | ->withHttpOnly(true) |
121 | ->withPath($this->cookiePath ?? $request->getUri()->getPath())); |
122 | |
123 | return $response; |
124 | } |
125 | |
126 | protected function isValid(ServerRequestInterface $request) { |
127 | if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { |
128 | if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { |
129 | // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length |
130 | // as defined in $this->tokenLength. |
131 | return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); |
132 | } |
133 | } |
134 | return false; |
135 | } |
136 | } |