| Code Coverage | ||||||||||
| Classes and Traits | Functions and Methods | Lines | ||||||||
| Total |  | 0.00% | 0 / 1 |  | 75.00% | 3 / 4 | CRAP |  | 96.88% | 31 / 32 | 
| DoubleSubmitCookieCsrfMiddleware |  | 0.00% | 0 / 1 |  | 75.00% | 3 / 4 | 12 |  | 96.88% | 31 / 32 | 
| __construct |  | 0.00% | 0 / 1 | 5.01 |  | 92.86% | 13 / 14 | |||
| setLogger |  | 100.00% | 1 / 1 | 1 |  | 100.00% | 2 / 2 | |||
| process |  | 100.00% | 1 / 1 | 3 |  | 100.00% | 12 / 12 | |||
| isValid |  | 100.00% | 1 / 1 | 3 |  | 100.00% | 4 / 4 | |||
| 1 | <?php declare(strict_types=1); | 
| 2 | |
| 3 | namespace Taproot\IndieAuth\Middleware; | 
| 4 | |
| 5 | use Nyholm\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 | public string $attribute; | 
| 45 | |
| 46 | public int $ttl; | 
| 47 | |
| 48 | public $errorResponse; | 
| 49 | |
| 50 | public int $tokenLength; | 
| 51 | |
| 52 | public LoggerInterface $logger; | 
| 53 | |
| 54 | /** | 
| 55 | * Constructor | 
| 56 | * | 
| 57 | * The `$errorResponse` parameter can be used to customse the error response returned when a | 
| 58 | * write request has invalid CSRF parameters. It can take the following forms: | 
| 59 | * | 
| 60 | * * A `string`, which will be returned as-is with a 400 Status Code and `Content-type: text/plain` header | 
| 61 | * * An instance of `ResponseInterface`, which will be returned as-is | 
| 62 | * * A callable with the signature `function (ServerRequestInterface $request): ResponseInterface`, | 
| 63 | * the return value of which will be returned as-is. | 
| 64 | */ | 
| 65 | public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { | 
| 66 | $this->attribute = $attribute ?? self::ATTRIBUTE; | 
| 67 | $this->ttl = $ttl ?? self::TTL; | 
| 68 | $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; | 
| 69 | |
| 70 | if (!is_callable($errorResponse)) { | 
| 71 | if (!$errorResponse instanceof ResponseInterface) { | 
| 72 | if (!is_string($errorResponse)) { | 
| 73 | $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING; | 
| 74 | } | 
| 75 | $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); | 
| 76 | } | 
| 77 | $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; | 
| 78 | } | 
| 79 | $this->errorResponse = $errorResponse; | 
| 80 | |
| 81 | if (!$logger instanceof LoggerInterface) { | 
| 82 | $logger = new NullLogger(); | 
| 83 | } | 
| 84 | $this->logger = $logger; | 
| 85 | } | 
| 86 | |
| 87 | public function setLogger(LoggerInterface $logger) { | 
| 88 | $this->logger = $logger; | 
| 89 | } | 
| 90 | |
| 91 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { | 
| 92 | // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. | 
| 93 | $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); | 
| 94 | $request = $request->withAttribute($this->attribute, $csrfToken); | 
| 95 | |
| 96 | if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { | 
| 97 | // This request is a write method with invalid CSRF parameters. | 
| 98 | $response = call_user_func($this->errorResponse, $request); | 
| 99 | } else { | 
| 100 | $response = $handler->handle($request); | 
| 101 | } | 
| 102 | |
| 103 | // Add the new CSRF cookie, restricting its scope to match the current request. | 
| 104 | $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) | 
| 105 | ->withValue($csrfToken) | 
| 106 | ->withMaxAge($this->ttl) | 
| 107 | ->withSecure($request->getUri()->getScheme() == 'https') | 
| 108 | ->withDomain($request->getUri()->getHost()) | 
| 109 | ->withPath($request->getUri()->getPath())); | 
| 110 | |
| 111 | return $response; | 
| 112 | } | 
| 113 | |
| 114 | protected function isValid(ServerRequestInterface $request) { | 
| 115 | if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { | 
| 116 | if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { | 
| 117 | // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length | 
| 118 | // as defined in $this->tokenLength. | 
| 119 | return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); | 
| 120 | } | 
| 121 | } | 
| 122 | return false; | 
| 123 | } | 
| 124 | } |