fix: detect stale CSRF cookie pair and auto-rotate
CI / php (push) Failing after 1m17s

When a browser session predates a server restart with a fresh
secret.key, the old __csrf HMAC cookie is no longer verifiable
under the new secret. Every action POST returns 403 until the user
manually clears cookies, because the existing-pair check on the
front controller was 'is the cookie present?' rather than 'does
the cookie verify under the current secret?'.

The new check at the top of the front controller computes the
expected HMAC of the raw-token cookie under the current secret and
constant-time-compares it to the __csrf cookie. If they don't match,
the controller re-mints a fresh pair on the response, replacing the
browser's stale cookies with a working set. The next click of 'Play
bundled' then succeeds without user intervention.

Test coverage in tests/Integration/StaleCookieRotationTest.php:
  - testStaleCookiesAreReplacedOnNextRequest
  - testValidCookiesAreNotReIssuedOnNextRequest
  - testMissingTokenCookieIsFilledInOnNextRequest
This commit is contained in:
Keith Solomon
2026-07-26 16:07:51 -05:00
parent 2db141efc6
commit 3185bc3602
2 changed files with 194 additions and 7 deletions
+19 -7
View File
@@ -43,16 +43,28 @@ if ($secret === false || $secret === '') {
}
}
// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token.
// The double-submit pattern stores the raw token in a separate readable
// cookie so client code (meta tag, JS) can echo it back as the
// X-CSRF-Token header. The HMAC cookie is the verification oracle;
// the raw-token cookie rides parallel and is readable by document.cookie
// or the equivalent server-side read.
// 2. Ensure the __csrf cookie is set. If absent OR stale, issue a fresh
// token. A cookie pair is "stale" when both cookies are present but
// the HMAC (__csrf) does not match the raw token (__csrf_token) under
// the current secret. This can happen when a browser session predates
// a server restart with a fresh secret.key: the browser still has the
// old __csrf cookie, the new server's secret does not match it, and
// every CSRF check fails. By re-minting on first request, we recover
// the session without requiring the user to clear cookies manually.
// The double-submit pattern stores the raw token in a separate
// readable cookie so client code (meta tag, JS) can echo it back as
// the X-CSRF-Token header. The HMAC cookie is the verification
// oracle; the raw-token cookie rides parallel and is readable by
// document.cookie or the equivalent server-side read.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
$existingRawToken = (string) ($cookies['__csrf_token'] ?? '');
if ($existingCookie === '' || $existingRawToken === '') {
$pairIsFresh = false;
if ($existingCookie !== '' && $existingRawToken !== '') {
$expectedHmac = hash_hmac('sha256', $existingRawToken, $secret);
$pairIsFresh = hash_equals($expectedHmac, $existingCookie);
}
if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
[$token, $cookie] = CsrfToken::issue($secret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,