feat(express): add cross-site request guard for SameSite=None cookies - #111
Merged
Conversation
Reject cross-site state-changing requests when the effective sameSite is none, so a SameSite=None session cookie cannot drive a forged call from another origin. Enforce Sec-Fetch-Site by default: a non-safe method is blocked with 403 when the header is cross-site, which page JavaScript cannot forge and same-origin SPA calls pass untouched. When Sec-Fetch-Site is absent, match the request Origin against the new opt-in allowedOrigins list, and pass when it is unset so nothing regresses. Safe methods (GET, HEAD, OPTIONS) are never gated, server-to-server callers that send neither header pass, and a literal null origin is treated as cross-site. The guard reuses the shared effective-sameSite resolution so it cannot drift from the cookie attributes. Closes #104
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.
Summary
Closes #104.
Adds a router-level CSRF guard that rejects cross-site state-changing requests when the adapter issues
SameSite=Nonecookies. ASameSite=Nonesession cookie is attached to cross-site requests, so without this a page on any origin could drive a state-changing auth call (mailbox flooding viaPOST /magic-link, or thePOST /totp/disable/DELETE /users/credentialsvectors that a permissivecors({ origin: true, credentials: true })exposes) using the signed-in user's session.Enforcement model
Implemented exactly as decided in the issue. This is intentionally non-breaking: no warn-only phase, no deny-all-by-default.
Sec-Fetch-Siteby default, zero config. A non-safe method (anything other thanGET,HEAD,OPTIONS) is rejected whenSec-Fetch-Siteiscross-site. The header ships on current Chrome, Firefox, and Safari, page JavaScript cannot forge it, and same-origin SPA calls sendsame-origin/same-site, so legitimate traffic passes with no allowlist.allowedOrigins?: string[]is opt-in and consulted only as the fallback whenSec-Fetch-Siteis absent (older browsers). Absent header +allowedOriginsunset -> pass (pre-security(express): add an Origin/Sec-Fetch-Site check when sameSite is none #104 status quo, nobody regresses). Absent header +allowedOriginsset -> the requestOriginmust be in the list, else reject.sameSiteisnone. The guard reuses the same effective-sameSite resolution asbuildCookieSigner(factored into a sharedresolveCookieSameSitehelper ininternal/cookie.ts) so the guard and the cookie attributes cannot drift.GET,HEAD, andOPTIONSpass unconditionally, so the CORS preflight andGET /magic-link/verify/:tokenare exempt for free.OriginnorSec-Fetch-Site(same-origin or server-to-server) passes. A literalOrigin: nullis treated as cross-site and rejected when gating applies.Rejections return
403 { "error": "cross_site_request_blocked" }with no Origin echoed back and no error-level logging (client-caused).Placement
The middleware lives in its own module (
packages/express/src/middleware/originGuard.ts) and is mounted increateServer.tsaftercookieParser()and beforeensureCookiesand the routes, so a blocked request never triggers a token refresh or reaches a handler.Tests
New
tests/originGuard.test.jscovers: cross-site rejected;same-originandsame-siteaccepted; no-header server-to-server accepted;Origin: nullrejected; GET never gated;GET /magic-link/verify/:tokennot gated; OPTIONS passes;cookieSameSite: laxmakes the guard inert even cross-site; and theSec-Fetch-Site-absent fallback in all three shapes (allowlisted origin passes, non-allowlisted rejected, allowlist unset passes).Verification
pnpm buildandpnpm testat the workspace root both pass (24 suites, 127 tests).A
minorchangeset for@seamless-auth/expressis included.allowedOriginsis documented in the README options block and a new "Cross-site request protection" subsection under Cookie security.No deviations from the decided enforcement model.