Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
195 changes: 195 additions & 0 deletions src/CsrfTokenCookieMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Csrf;

use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\Http\Header;

use function gettype;
use function implode;
use function in_array;
use function is_bool;
use function is_string;
use function preg_match;
use function rawurlencode;
use function sprintf;

/**
* PSR-15 middleware that publishes the current CSRF token in a JavaScript-readable response cookie.
*
* It is intended for AJAX/SPA clients that read the token from a cookie and send it back explicitly in a request
* header (the "cookie-to-header" pattern), for example Inertia and Axios which use the `XSRF-TOKEN` cookie and the
* `X-XSRF-TOKEN` header.
*
* The middleware only writes the cookie. Token validation stays the responsibility of {@see CsrfTokenMiddleware},
* which reads the submitted token from a header or a body parameter. Place this middleware before
* {@see CsrfTokenMiddleware} in the stack, so the cookie is published even on a rejected request.
*
* A `Set-Cookie` header does not by itself prevent a response from being cached (RFC 9111 section 7.3). A cached
* response may replay a stale token, or reach a shared (proxy or CDN) cache and hand one user's token to another. By
* default the middleware guards against this by setting `Cache-Control: no-store` on every response it touches; see
* the `$cacheControl` constructor parameter to change or disable this.
*
* @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#alternative-using-a-double-submit-cookie-pattern
* @link https://www.rfc-editor.org/rfc/rfc9111#section-7.3
*/
final class CsrfTokenCookieMiddleware implements MiddlewareInterface
{
public const COOKIE_NAME = 'XSRF-TOKEN';

public const SAME_SITE_LAX = 'Lax';
public const SAME_SITE_STRICT = 'Strict';
public const SAME_SITE_NONE = 'None';

/**
* Control characters (including CR and LF) and the `;` attribute separator. These would let a configured
* cookie name, path or domain inject extra attributes or split the response header. Whether the values are
* otherwise well-formed is left to the caller.
*/
private const PATTERN_HEADER_INJECTION = '/[\x00-\x1F\x7F\x3B]/';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it include = as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

= only matters for cookieName, but this pattern also validates path and domain, where = is. This class doesn't aim to fully validate cookie attributes against the RFC — only to keep the response header safe from
injection. So = doesn't belong here.


private CsrfTokenInterface $token;
private string $cookieName;
private string $path;
private ?string $domain;
private ?bool $secure;
private ?string $sameSite;

/**
* @var bool|string
*/
private $cacheControl;

/**
* The cookie name, path and domain are checked only for control characters and `;` to prevent response header
* injection; making sure they are otherwise valid is up to the caller.
*
* @param CsrfTokenInterface $token The CSRF token to publish.
* @param string $cookieName The name of the cookie holding the token.
* @param string $path The `Path` attribute of the cookie.
* @param string|null $domain The `Domain` attribute of the cookie, or `null` to omit it.
* @param bool|null $secure Whether the cookie should only be sent over HTTPS. A non-null value is used as given.
* When `null`, it is resolved automatically: `true` for `self::SAME_SITE_NONE` (browsers require `Secure` for such
* cookies), otherwise from the request URI scheme (`true` when the scheme is `https`).
* @param string|null $sameSite The `SameSite` attribute of the cookie: one of `self::SAME_SITE_LAX`,
* `self::SAME_SITE_STRICT`, `self::SAME_SITE_NONE`, or `null` to omit it. When `self::SAME_SITE_NONE` is used,
* `$secure` must not be `false`.
* @param bool|string $cacheControl How to handle the `Cache-Control` response header: `true` to set it to
* `no-store` (keeping the published token out of any cache), `false` to leave the header untouched, or a string
* to set it to that exact value. `true` and a string value replace an existing `Cache-Control`.
*
* @throws InvalidArgumentException When a cookie attribute or the `$cacheControl` argument is not valid.
*/
public function __construct(
CsrfTokenInterface $token,
string $cookieName = self::COOKIE_NAME,
string $path = '/',
?string $domain = null,
?bool $secure = null,
?string $sameSite = self::SAME_SITE_LAX,
$cacheControl = true
) {
if (preg_match(self::PATTERN_HEADER_INJECTION, $cookieName)) {
throw new InvalidArgumentException(
sprintf('The cookie name "%s" contains invalid characters.', $cookieName),
);
}

if (preg_match(self::PATTERN_HEADER_INJECTION, $path)) {
throw new InvalidArgumentException(
sprintf('The cookie path "%s" contains invalid characters.', $path),
);
}

if ($domain !== null && preg_match(self::PATTERN_HEADER_INJECTION, $domain)) {
throw new InvalidArgumentException(
sprintf('The cookie domain "%s" contains invalid characters.', $domain),
);
}

$allowedSameSite = [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE];
if ($sameSite !== null && !in_array($sameSite, $allowedSameSite, true)) {
throw new InvalidArgumentException(
sprintf('The "SameSite" attribute value "%s" is not valid.', $sameSite),
);
}

if ($sameSite === self::SAME_SITE_NONE && $secure === false) {
throw new InvalidArgumentException(
'The "secure" flag is required for cookies with "SameSite" attribute set to "None".',
);
}

/**
* The parameter has no native type because a `bool|string` union is not available on PHP 7.4, so a value of
* any other type can still be passed at runtime. Once the minimum PHP version is 8.0+, type the parameter as
* `bool|string` and remove this check together with the suppression below.
*
* @psalm-suppress DocblockTypeContradiction
*/
if (!is_bool($cacheControl) && !is_string($cacheControl)) {
throw new InvalidArgumentException(
sprintf('The "cacheControl" argument must be a bool or a string, "%s" given.', gettype($cacheControl)),
);
}

$this->token = $token;
$this->cookieName = $cookieName;
$this->path = $path;
$this->domain = $domain;
Comment thread
vjik marked this conversation as resolved.
$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,
);
}
}
4 changes: 1 addition & 3 deletions src/StubCsrfToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 1 addition & 3 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
Loading