diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c32f51..3b20ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.2.4 under development +- New #100: Add `CsrfTokenCookieMiddleware` that publishes the current CSRF token in a JavaScript-readable + response cookie for the cookie-to-header pattern (@vjik) - Enh #82: Explicitly import classes and functions in "use" section (@mspirkov) - Enh #83: Remove unnecessary files from Composer package (@mspirkov) diff --git a/README.md b/README.md index ba66755..97c3028 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,89 @@ let response = fetch('https://api.example.com/whoami', { }); ``` +#### Publishing the CSRF token in a cookie + +Instead of a dedicated endpoint, `CsrfTokenCookieMiddleware` publishes the current token in a JavaScript-readable cookie +on every response. SPA HTTP clients such as [Axios](https://axios-http.com/docs/req_config) and [Inertia](https://inertiajs.com/csrf-protection) +read this cookie automatically and send it back in a request header. + +The middleware only writes the cookie. Token validation stays the responsibility of `CsrfTokenMiddleware`, and the token +from the request cookie is never trusted as proof — the submitted value is always taken from the request header or body +parameter. + +Add `CsrfTokenCookieMiddleware` to the stack before `CsrfTokenMiddleware`. It decorates the response returned by +the inner handlers, so this placement lets it publish the token even when `CsrfTokenMiddleware` rejects the request +with `422 Unprocessable Entity` and a stale SPA can recover with the fresh value. + +Every cookie attribute is set through the constructor. By default, the cookie is named `XSRF-TOKEN`, is available for +the `/` path, has no `Domain`, and is marked `SameSite=Lax`. The `Secure` attribute defaults to `null`, which means it +is resolved per request from the request URI scheme (`Secure` is added when the scheme is `https`). It is **not** +`HttpOnly`, so that the frontend can read it. + +```php +$middleware = new CsrfTokenCookieMiddleware( + $csrfToken, + 'XSRF-TOKEN', + '/', + null, + null, + CsrfTokenCookieMiddleware::SAME_SITE_LAX, + true, +); +``` + +A `Set-Cookie` header does not by itself prevent a response from being cached +([RFC 9111 section 7.3](https://www.rfc-editor.org/rfc/rfc9111#section-7.3)), so a response that publishes the token +may be cached and later replay a stale token, or end up in a shared (proxy or CDN) cache and hand one user's token to +another. The last constructor argument controls the `Cache-Control` response header: + +- `true` (default) — set `Cache-Control` to `no-store`, replacing any existing value (the middleware makes the + response user-specific, so a previously cacheable `Cache-Control` is no longer safe); +- `false` — leave the header untouched (use this when the application already manages caching for these responses); +- a string — set `Cache-Control` to that exact value, for example `'private'`. + +For example, Axios and Inertia send the token back in the `X-XSRF-TOKEN` header, so set the same name +on `CsrfTokenMiddleware`: + +```php +$csrfTokenMiddleware = $csrfTokenMiddleware->withHeaderName('X-XSRF-TOKEN'); +``` + +In a Yii application, add both middlewares to the [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher) +configuration: + +```php +use Yiisoft\Csrf\CsrfTokenCookieMiddleware; +use Yiisoft\Csrf\CsrfTokenMiddleware; + +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + SessionMiddleware::class, + CsrfTokenCookieMiddleware::class, // <-- add this (uses the default cookie settings) + [ + 'class' => CsrfTokenMiddleware::class, + 'withHeaderName()' => ['X-XSRF-TOKEN'], + ], + Router::class, +]); +``` + +To customize the cookie, replace `CsrfTokenCookieMiddleware::class` with an array definition: + +```php +[ + 'class' => CsrfTokenCookieMiddleware::class, + '__construct()' => [ + 'cookieName' => 'XSRF-TOKEN', + 'path' => '/', + 'domain' => null, + 'secure' => null, + 'sameSite' => CsrfTokenCookieMiddleware::SAME_SITE_LAX, + 'cacheControl' => true, + ], +], +``` + ## Documentation - [Internals](docs/internals.md) diff --git a/src/CsrfTokenCookieMiddleware.php b/src/CsrfTokenCookieMiddleware.php new file mode 100644 index 0000000..501e6cc --- /dev/null +++ b/src/CsrfTokenCookieMiddleware.php @@ -0,0 +1,195 @@ +token = $token; + $this->cookieName = $cookieName; + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + $this->sameSite = $sameSite; + $this->cacheControl = $cacheControl; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + $response = $response->withAddedHeader(Header::SET_COOKIE, $this->buildCookieHeaderValue($request)); + + return $this->applyCacheControl($response); + } + + private function buildCookieHeaderValue(ServerRequestInterface $request): string + { + $secure = $this->secure + ?? ($this->sameSite === self::SAME_SITE_NONE || $request->getUri()->getScheme() === 'https'); + + $parts = [$this->cookieName . '=' . rawurlencode($this->token->getValue())]; + + if ($this->domain !== null) { + $parts[] = 'Domain=' . $this->domain; + } + + $parts[] = 'Path=' . $this->path; + + if ($secure) { + $parts[] = 'Secure'; + } + + if ($this->sameSite !== null) { + $parts[] = 'SameSite=' . $this->sameSite; + } + + return implode('; ', $parts); + } + + private function applyCacheControl(ResponseInterface $response): ResponseInterface + { + if ($this->cacheControl === false) { + return $response; + } + + return $response->withHeader( + Header::CACHE_CONTROL, + $this->cacheControl === true ? 'no-store' : $this->cacheControl, + ); + } +} diff --git a/src/StubCsrfToken.php b/src/StubCsrfToken.php index 6c21f34..8067fdf 100644 --- a/src/StubCsrfToken.php +++ b/src/StubCsrfToken.php @@ -18,9 +18,7 @@ final class StubCsrfToken implements CsrfTokenInterface public function __construct(?string $token = null) { - if (null === $token) { - $token = Random::string(); - } + $token ??= Random::string(); $this->token = $token; } diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 690bb30..4b3a0a0 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -43,9 +43,7 @@ private function createContainer(?array $params = null): Container private function getDiConfig(?array $params = null): array { - if ($params === null) { - $params = $this->getParams(); - } + $params ??= $this->getParams(); return require dirname(__DIR__) . '/config/di-web.php'; } diff --git a/tests/CsrfTokenCookieMiddlewareTest.php b/tests/CsrfTokenCookieMiddlewareTest.php new file mode 100644 index 0000000..3dd4799 --- /dev/null +++ b/tests/CsrfTokenCookieMiddlewareTest.php @@ -0,0 +1,381 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('The cookie name "%s" contains invalid characters.', $cookieName), + ); + + new CsrfTokenCookieMiddleware(new StubCsrfToken(), $cookieName); + } + + /** + * @dataProvider dataHeaderInjection + */ + public function testCookiePathWithHeaderInjection(string $path): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('The cookie path "%s" contains invalid characters.', $path), + ); + + new CsrfTokenCookieMiddleware(new StubCsrfToken(), 'XSRF-TOKEN', $path); + } + + /** + * @dataProvider dataHeaderInjection + */ + public function testCookieDomainWithHeaderInjection(string $domain): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('The cookie domain "%s" contains invalid characters.', $domain), + ); + + new CsrfTokenCookieMiddleware(new StubCsrfToken(), 'XSRF-TOKEN', '/', $domain); + } + + public function dataHeaderInjection(): array + { + return [ + 'semicolon' => ['a; Secure'], + 'newline' => ["a\nSet-Cookie: b=c"], + 'carriage return' => ["a\rb"], + 'null byte' => ["a\x00b"], + ]; + } + + public function testInvalidSameSite(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "SameSite" attribute value "Weak" is not valid.'); + + new CsrfTokenCookieMiddleware(new StubCsrfToken(), 'XSRF-TOKEN', '/', null, true, 'Weak'); + } + + public function testSameSiteNoneWithoutSecure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The "secure" flag is required for cookies with "SameSite" attribute set to "None".', + ); + + new CsrfTokenCookieMiddleware( + new StubCsrfToken(), + 'XSRF-TOKEN', + '/', + null, + false, + CsrfTokenCookieMiddleware::SAME_SITE_NONE, + ); + } + + public function testDefaults(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax'], + $response->getHeader('Set-Cookie'), + ); + } + + /** + * @dataProvider dataSameSiteNoneAlwaysGetsSecure + */ + public function testSameSiteNoneAlwaysGetsSecure(string $uri): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'XSRF-TOKEN', + '/', + null, + null, + CsrfTokenCookieMiddleware::SAME_SITE_NONE, + ); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $uri), + $this->createRequestHandler(), + ); + + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure; SameSite=None'], + $response->getHeader('Set-Cookie'), + ); + } + + public function dataSameSiteNoneAlwaysGetsSecure(): array + { + return [ + 'https' => ['https://example.com/'], + 'http' => ['http://example.com/'], + ]; + } + + public function dataSecureIsResolvedFromRequestScheme(): array + { + return [ + 'https' => ['https://example.com/', 'XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax'], + 'http' => ['http://example.com/', 'XSRF-TOKEN=test-token; Path=/; SameSite=Lax'], + ]; + } + + /** + * @dataProvider dataSecureIsResolvedFromRequestScheme + */ + public function testSecureIsResolvedFromRequestScheme(string $uri, string $expectedCookie): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $uri), + $this->createRequestHandler(), + ); + + $this->assertSame([$expectedCookie], $response->getHeader('Set-Cookie')); + } + + public function testExplicitSecureTakesPrecedenceOverRequestScheme(): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'XSRF-TOKEN', + '/', + null, + true, + ); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, 'http://example.com/'), + $this->createRequestHandler(), + ); + + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testCacheControlNoStoreIsAddedByDefault(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame(['no-store'], $response->getHeader('Cache-Control')); + } + + public function testExistingCacheControlIsReplacedByDefault(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn((new Response())->withHeader('Cache-Control', 'public, max-age=3600')); + + $response = $middleware->process($this->createServerRequest(), $requestHandler); + + $this->assertSame(['no-store'], $response->getHeader('Cache-Control')); + } + + public function testCacheControlIsNotTouchedWhenDisabled(): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'XSRF-TOKEN', + '/', + null, + null, + CsrfTokenCookieMiddleware::SAME_SITE_LAX, + false, + ); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertFalse($response->hasHeader('Cache-Control')); + } + + public function testCacheControlIsSetToExplicitValue(): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'XSRF-TOKEN', + '/', + null, + null, + CsrfTokenCookieMiddleware::SAME_SITE_LAX, + 'private', + ); + + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn((new Response())->withHeader('Cache-Control', 'public')); + + $response = $middleware->process($this->createServerRequest(), $requestHandler); + + $this->assertSame(['private'], $response->getHeader('Cache-Control')); + } + + public function testInvalidCacheControl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "cacheControl" argument must be a bool or a string, "integer" given.'); + + new CsrfTokenCookieMiddleware( + new StubCsrfToken(), + 'XSRF-TOKEN', + '/', + null, + null, + CsrfTokenCookieMiddleware::SAME_SITE_LAX, + 123, + ); + } + + public function testTokenValueIsUrlEncoded(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('a b+c/d=')); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame( + ['XSRF-TOKEN=a%20b%2Bc%2Fd%3D; Path=/; Secure; SameSite=Lax'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testCustomAttributes(): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'MY-TOKEN', + '/api', + 'example.com', + false, + CsrfTokenCookieMiddleware::SAME_SITE_STRICT, + ); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame( + ['MY-TOKEN=test-token; Domain=example.com; Path=/api; SameSite=Strict'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testWithoutSameSite(): void + { + $middleware = new CsrfTokenCookieMiddleware( + new StubCsrfToken('test-token'), + 'XSRF-TOKEN', + '/', + null, + true, + null, + ); + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testCookieIsPublishedForUnsafeMethods(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $response = $middleware->process( + $this->createServerRequest(Method::POST), + $this->createRequestHandler(), + ); + + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testCookieIsPublishedOnFailureResponse(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn(new Response(422)); + + $response = $middleware->process($this->createServerRequest(Method::POST), $requestHandler); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame( + ['XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax'], + $response->getHeader('Set-Cookie'), + ); + } + + public function testExistingResponseCookiesArePreserved(): void + { + $middleware = new CsrfTokenCookieMiddleware(new StubCsrfToken('test-token')); + + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn((new Response())->withAddedHeader('Set-Cookie', 'session=abc; Path=/')); + + $response = $middleware->process($this->createServerRequest(), $requestHandler); + + $this->assertSame( + [ + 'session=abc; Path=/', + 'XSRF-TOKEN=test-token; Path=/; Secure; SameSite=Lax', + ], + $response->getHeader('Set-Cookie'), + ); + } + + private function createRequestHandler(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn(new Response(200)); + + return $requestHandler; + } + + private function createServerRequest( + string $method = Method::GET, + string $uri = 'https://example.com/' + ): ServerRequestInterface { + return new ServerRequest($method, $uri); + } +}