Add optional signed CSRF token mode and Origin fallback - #356
Open
adrianbj wants to merge 2 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-Sitepresent, 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 sessionno-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
SessionCSRFisn'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 thesession-goneandstale-tokenclasses (30 of my 39) by construction.Properties preserved: same API (
renderInput(),getToken*(),hasValidToken(),validate()— no form or validation code changes when switching modes); sameTOKEN<digits>X<timestamp>name shape; rotation at login for free (Session::___login()already callsCSRF->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 tohash_equals()in both modes.Commit 2:
$config->csrfOriginFallback(default off)The
no-cookieclass 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 onSec-Fetch-Site. When enabled and the submitted token fails validation, the POST is still accepted if the browser proves it is same-origin: anOriginheader whose scheme matches the current request and whose host is in$config->httpHosts, or — when noOriginis 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.phpsince 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
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 onresetAll()/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.sessionmodesignedmodeWith
csrfOriginFallback = true(session mode, no cookies sent at all):Originmatching the siteOriginOrigin/Sec-Fetch-Site(curl/scanner)Defaults unchanged: with neither option set, behavior is identical to current dev.