Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.97% covered (success)
96.97%
32 / 33
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
DoubleSubmitCookieCsrfMiddleware
96.97% covered (success)
96.97%
32 / 33
75.00% covered (warning)
75.00%
3 / 4
12
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 isValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php declare(strict_types=1);
2
3namespace Taproot\IndieAuth\Middleware;
4
5use GuzzleHttp\Psr7\Response;
6use Psr\Http\Message\ServerRequestInterface;
7use Psr\Http\Message\ResponseInterface;
8use Psr\Http\Server\MiddlewareInterface;
9use Psr\Http\Server\RequestHandlerInterface;
10use Dflydev\FigCookies;
11use Psr\Log\LoggerAwareInterface;
12use Psr\Log\LoggerInterface;
13use Psr\Log\NullLogger;
14
15use 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 */
37class 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}