Skip to content

feat(express): add cross-site request guard for SameSite=None cookies - #111

Merged
Bccorb merged 1 commit into
mainfrom
feat/origin-csrf-guard
Jul 21, 2026
Merged

feat(express): add cross-site request guard for SameSite=None cookies#111
Bccorb merged 1 commit into
mainfrom
feat/origin-csrf-guard

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #104.

Adds a router-level CSRF guard that rejects cross-site state-changing requests when the adapter issues SameSite=None cookies. A SameSite=None session cookie is attached to cross-site requests, so without this a page on any origin could drive a state-changing auth call (mailbox flooding via POST /magic-link, or the POST /totp/disable / DELETE /users/credentials vectors that a permissive cors({ 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-Site by default, zero config. A non-safe method (anything other than GET, HEAD, OPTIONS) is rejected when Sec-Fetch-Site is cross-site. The header ships on current Chrome, Firefox, and Safari, page JavaScript cannot forge it, and same-origin SPA calls send same-origin / same-site, so legitimate traffic passes with no allowlist.
  • allowedOrigins?: string[] is opt-in and consulted only as the fallback when Sec-Fetch-Site is absent (older browsers). Absent header + allowedOrigins unset -> pass (pre-security(express): add an Origin/Sec-Fetch-Site check when sameSite is none #104 status quo, nobody regresses). Absent header + allowedOrigins set -> the request Origin must be in the list, else reject.
  • Only activates when the effective sameSite is none. The guard reuses the same effective-sameSite resolution as buildCookieSigner (factored into a shared resolveCookieSameSite helper in internal/cookie.ts) so the guard and the cookie attributes cannot drift.
  • Only non-safe methods are gated. GET, HEAD, and OPTIONS pass unconditionally, so the CORS preflight and GET /magic-link/verify/:token are exempt for free.
  • A caller sending neither Origin nor Sec-Fetch-Site (same-origin or server-to-server) passes. A literal Origin: null is 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 in createServer.ts after cookieParser() and before ensureCookies and the routes, so a blocked request never triggers a token refresh or reaches a handler.

Tests

New tests/originGuard.test.js covers: cross-site rejected; same-origin and same-site accepted; no-header server-to-server accepted; Origin: null rejected; GET never gated; GET /magic-link/verify/:token not gated; OPTIONS passes; cookieSameSite: lax makes the guard inert even cross-site; and the Sec-Fetch-Site-absent fallback in all three shapes (allowlisted origin passes, non-allowlisted rejected, allowlist unset passes).

Verification

pnpm build and pnpm test at the workspace root both pass (24 suites, 127 tests).

A minor changeset for @seamless-auth/express is included. allowedOrigins is documented in the README options block and a new "Cross-site request protection" subsection under Cookie security.

No deviations from the decided enforcement model.

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
@Bccorb
Bccorb merged commit 9d25cca into main Jul 21, 2026
1 of 2 checks passed
@Bccorb
Bccorb deleted the feat/origin-csrf-guard branch July 21, 2026 02:48
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.

security(express): add an Origin/Sec-Fetch-Site check when sameSite is none

1 participant