Skip to content

Add optional signed CSRF token mode and Origin fallback - #356

Open
adrianbj wants to merge 2 commits into
processwire:devfrom
adrianbj:csrf-signed-mode
Open

Add optional signed CSRF token mode and Origin fallback#356
adrianbj wants to merge 2 commits into
processwire:devfrom
adrianbj:csrf-signed-mode

Conversation

@adrianbj

Copy link
Copy Markdown
Contributor

Problem

Session-bound CSRF tokens fail for legitimate users whenever the session that rendered a form isn't the session that receives the POST. I instrumented every front-end form POST on a production site whose token would fail validation, classifying the cause from raw session state. Over the last 30 days: 39 legitimate-user failures (all with Sec-Fetch-Site present, i.e. real browsers — overwhelmingly mobile Safari and Chrome), concentrated on login, password reset, and registration:

  • session-gone (16) — session cookie arrived, but the session it points to is gone (expired/GC'd, or destroyed by an intervening logout)
  • stale-token (14) — page restored from the browser back/forward cache carries a token from an earlier session
  • no-cookie (9) — the browser sent no session cookie with the POST at all (ITP, in-app browsers, cross-site-entry SameSite behavior)

Each of these is a real person filling in a form and being told "this request appears to be forged." Sites currently have to work around it externally, and SessionCSRF isn't hookable, so workarounds have to reach into session internals.

Commit 1: $config->csrfMode = 'signed'

Following Django's CSRF design (a long-lived cookie independent of the session):

  • session (default) — exactly today's behavior, byte-for-byte.
  • signed — tokens are derived, not stored: HMAC-SHA256($config->userAuthSalt, binding cookie), where the binding cookie (<sessionName>_csrf) is a long-lived (1 year), httpOnly, Secure/SameSite-matched random cookie. A token stays valid across session expiration, re-creation, logout, and back/forward-cache restores. This fixes the session-gone and stale-token classes (30 of my 39) by construction.

Properties preserved: same API (renderInput(), getToken*(), hasValidToken(), validate() — no form or validation code changes when switching modes); same TOKEN<digits>X<timestamp> name shape; rotation at login for free (Session::___login() already calls CSRF->resetAll(), which now also rotates the binding cookie — Django rotates at login for the same reason); single-use tokens stay session-based in either mode (they require per-token server state by definition). Token comparison also switches to hash_equals() in both modes.

Commit 2: $config->csrfOriginFallback (default off)

The no-cookie class is unfixable by any token scheme — no cookie means nothing to bind a token to. Django pairs its token with an Origin check for that reason, and Laravel now gates on Sec-Fetch-Site. When enabled and the submitted token fails validation, the POST is still accepted if the browser proves it is same-origin: an Origin header whose scheme matches the current request and whose host is in $config->httpHosts, or — when no Origin is present — Sec-Fetch-Site: same-origin/none. These headers are browser-set and cannot be forged cross-site, so cross-site forgery remains blocked; requests with neither header are rejected as before.

Scope guards: applies only to the default token — named and single-use tokens never use it (rescuing a consumed single-use token would permit replay) — and only when a ProcessWire-shaped token was actually submitted, so tokenless bot/scanner POSTs are never rescued. This is a core-native version of a site-level fallback that has run in my production _init.php since June, silently rescuing all 39 of the failures above with zero user-facing errors.

The two commits are independent — either can be taken without the other — but together they cover every failure class I've measured.

Verification

  • New wire/core/Session/SessionCSRF.test.php (WireTests): 40 checks covering both token modes and the fallback — format/stability, POST and AJAX-header validation, tamper rejection, session-loss survival (signed) vs non-survival (session), rotation on resetAll()/resetToken(), single-use semantics in both modes, fallback disabled-by-default, Origin host/scheme matching, Sec-Fetch-Site handling, neither-header rejection, tokenless-POST rejection, single-use replay rejection. All pass.
  • Full existing WireTests suite: identical results with and without these commits (nothing new fails).
  • End-to-end over HTTP against the real admin login form (site-blank profile):
Scenario session mode signed mode
Valid token, session cookie dropped (lost session) rejected as forged CSRF passes, login proceeds to credential check
Tampered token rejected as forged rejected as forged
No cookies at all rejected as forged rejected as forged

With csrfOriginFallback = true (session mode, no cookies sent at all):

Scenario Result
Stale token + Origin matching the site CSRF passes, login proceeds to credential check
Stale token + cross-site Origin rejected as forged
Stale token, no Origin/Sec-Fetch-Site (curl/scanner) rejected as forged

Defaults unchanged: with neither option set, behavior is identical to current dev.

In the default 'session' mode, CSRF tokens are random values stored in the
session, so a token is only valid for the session that created it. When the
session expires, is re-created, or the browser restores a page from its
back/forward cache, a previously rendered token no longer validates and a
legitimate same-origin submission is rejected as forged.

The new opt-in 'signed' mode derives tokens (HMAC-SHA256, keyed by
$config->userAuthSalt) from a long-lived httpOnly binding cookie that is
independent of the session, so tokens stay valid across session expiration
and re-creation for as long as the binding cookie lasts. The cookie is
rotated at login (via resetAll) and whenever tokens are reset. Single-use
tokens remain session-based in either mode, and both modes share the same
API, so no form or validation code changes when switching.

Also switches token comparison to hash_equals() for constant-time checks.
…ginFallback

Some legitimate same-origin POSTs cannot pass token validation under any token
scheme: the browser withholds all cookies from the POST (ITP, in-app browser
jars, cross-site-entry SameSite behavior), so there is neither a session nor a
binding cookie to validate against. This is the residue that even the signed
token mode cannot cover.

When $config->csrfOriginFallback is enabled (off by default) and the submitted
token fails validation, the request is still accepted if the browser proves it
is same-origin: an Origin header whose scheme matches the current request and
whose host is in $config->httpHosts, or - when no Origin header is present - a
Sec-Fetch-Site header of 'same-origin' or 'none'. These headers are set by the
browser and cannot be forged cross-site, so cross-site forgery remains blocked;
requests with neither header are rejected as before.

The fallback applies only to the default token - named and single-use tokens
never use it (rescuing a consumed single-use token would permit replay) - and
only when a ProcessWire-shaped token was actually submitted, so tokenless
POSTs from bots and scanners are never rescued.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant