From 821fc1d5cc767bd4a2ce87d35096ba5816cf6cb9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 22:44:43 -0400 Subject: [PATCH] feat(express): add cross-site request guard for SameSite=None cookies 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 --- .changeset/origin-csrf-guard.md | 7 + packages/express/README.md | 39 +++++ packages/express/src/createServer.ts | 14 ++ packages/express/src/internal/cookie.ts | 21 ++- .../express/src/middleware/originGuard.ts | 103 ++++++++++++ packages/express/tests/originGuard.test.js | 157 ++++++++++++++++++ 6 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 .changeset/origin-csrf-guard.md create mode 100644 packages/express/src/middleware/originGuard.ts create mode 100644 packages/express/tests/originGuard.test.js diff --git a/.changeset/origin-csrf-guard.md b/.changeset/origin-csrf-guard.md new file mode 100644 index 0000000..b8c968d --- /dev/null +++ b/.changeset/origin-csrf-guard.md @@ -0,0 +1,7 @@ +--- +"@seamless-auth/express": minor +--- + +Add a router-level CSRF guard that rejects cross-site state-changing requests when the adapter issues `SameSite=None` cookies. + +The guard enforces `Sec-Fetch-Site` by default with no configuration: a non-safe request (anything other than GET, HEAD, or OPTIONS) is rejected with 403 when `Sec-Fetch-Site` is `cross-site`. Page JavaScript cannot forge that header and same-origin SPA calls send `same-origin` or `same-site`, so legitimate traffic passes untouched. When `Sec-Fetch-Site` is absent (older browsers), the request `Origin` is matched against the new opt-in `allowedOrigins` option; if `allowedOrigins` is unset the request passes, so nobody regresses. Server-to-server callers that send neither header pass, and a literal `null` origin is treated as cross-site. The guard only activates when the effective `sameSite` is `none`. diff --git a/packages/express/README.md b/packages/express/README.md index b45e85b..275cd58 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -196,6 +196,7 @@ generate routes instead. cookieDomain?: string; // optional (defaults to host) cookieSecure?: boolean; // optional (defaults to true) cookieSameSite?: "lax" | "none" | "strict"; // optional + allowedOrigins?: string[]; // optional (older-browser Origin fallback, see Cookie security) resolveClientIp?: (req) => string | undefined; // optional (see Client IP forwarding) accessCookieName?: string; registrationCookieName?: string; @@ -288,6 +289,44 @@ app.use( ); ``` +#### Cross-site request protection + +A `SameSite=None` cookie is attached to cross-site requests, so a page on any +origin could drive a state-changing call to the adapter with the signed-in +user's session. When the effective `sameSite` is `none`, the router rejects +cross-site state-changing requests with `403 { "error": +"cross_site_request_blocked" }`. This runs by default and needs no configuration. + +- The guard reads `Sec-Fetch-Site`, which current Chrome, Firefox, and Safari + send and page JavaScript cannot forge. A non-safe request (anything other than + `GET`, `HEAD`, or `OPTIONS`) is rejected when that header is `cross-site`. + Same-origin single-page-app calls send `same-origin` or `same-site` and pass + with no allowlist. +- Safe methods are never gated, so `GET /magic-link/verify/:token` and the CORS + preflight (`OPTIONS`) are unaffected. +- A caller that sends neither `Origin` nor `Sec-Fetch-Site`, such as a + server-to-server request, passes. + +`allowedOrigins` is an opt-in fallback for older browsers that do not send +`Sec-Fetch-Site`. It is consulted only when that header is absent: the request +`Origin` must appear in the list, otherwise the request is rejected. When +`Sec-Fetch-Site` is absent and `allowedOrigins` is unset, the request passes, so +setting it is never required. Set it if you support browsers old enough to lack +`Sec-Fetch-Site` and want the `Origin` check for them: + +```ts +app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://identifier.seamlessauth.com", + cookieSecret: process.env.COOKIE_SECRET, + serviceSecret: process.env.SERVICE_SECRET, + audience: "https://identifier.seamlessauth.com", + allowedOrigins: ["https://app.example.com"], + }), +); +``` + `messaging` is the initializer-facing contract for adopter-supplied auth messaging capabilities. When `messaging` is provided, `@seamless-auth/express` requests external-delivery payloads from the upstream auth server for auth-message flows and completes delivery locally through the configured transports or handlers. These payloads contain OTPs or one-time links and are stripped before the adapter responds to the browser. diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index a70cd52..a439c48 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response, Router } from "express"; import cookieParser from "cookie-parser"; import { createEnsureCookiesMiddleware } from "./middleware/ensureCookies"; +import { createOriginGuardMiddleware } from "./middleware/originGuard"; import type { CookieSameSite } from "./internal/cookie"; import type { SeamlessAuthMessagingOptions } from "./messaging"; @@ -68,6 +69,7 @@ type ResolvedSeamlessAuthServerOptions = { cookieDomain: string; cookieSecure?: boolean; cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; accessCookieName: string; registrationCookieName: string; refreshCookieName: string; @@ -85,6 +87,7 @@ export type SeamlessAuthServerOptions = { cookieDomain?: string; cookieSecure?: boolean; cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; accessCookieName?: string; registrationCookieName?: string; refreshCookieName?: string; @@ -215,6 +218,7 @@ export function createSeamlessAuthServer( cookieDomain: opts.cookieDomain ?? "", cookieSecure: opts.cookieSecure, cookieSameSite: opts.cookieSameSite, + allowedOrigins: opts.allowedOrigins, accessCookieName: opts.accessCookieName ?? "seamless-access", registrationCookieName: opts.registrationCookieName ?? "seamless-ephemeral", refreshCookieName: opts.refreshCookieName ?? "seamless-refresh", @@ -289,6 +293,16 @@ export function createSeamlessAuthServer( res.status(upstream.status).json(data); }; + // Runs before ensureCookies and the routes so a blocked cross-site request + // never triggers a token refresh or reaches a handler. + r.use( + createOriginGuardMiddleware({ + cookieSecure: resolvedOpts.cookieSecure, + cookieSameSite: resolvedOpts.cookieSameSite, + allowedOrigins: resolvedOpts.allowedOrigins, + }), + ); + r.use( createEnsureCookiesMiddleware({ authServerUrl: resolvedOpts.authServerUrl, diff --git a/packages/express/src/internal/cookie.ts b/packages/express/src/internal/cookie.ts index c647ece..bf8c97d 100644 --- a/packages/express/src/internal/cookie.ts +++ b/packages/express/src/internal/cookie.ts @@ -15,10 +15,21 @@ export interface CookieSecurityOptions { cookieSameSite?: CookieSameSite; } +/** + * Resolves the effective `SameSite` policy from the security options. Shared so the + * cookie attributes and any policy that keys off `SameSite=None` cannot drift. + * Browsers reject `SameSite=None` without `Secure`, so the default tracks `secure`. + */ +export function resolveCookieSameSite( + opts: Pick, +): CookieSameSite { + const secure = opts.cookieSecure ?? true; + return opts.cookieSameSite ?? (secure ? "none" : "lax"); +} + /** * Single source of truth for cookie security policy. Defaults to secure so a deploy - * cannot silently ship plaintext-visible cookies. Browsers reject `SameSite=None` - * without `Secure`, so the SameSite default tracks `secure`. + * cannot silently ship plaintext-visible cookies. */ export function buildCookieSigner( opts: CookieSecurityOptions, @@ -27,12 +38,10 @@ export function buildCookieSigner( throw new Error("Missing COOKIE_SIGNING_KEY"); } - const secure = opts.cookieSecure ?? true; - return { secret: opts.cookieSecret, - secure, - sameSite: opts.cookieSameSite ?? (secure ? "none" : "lax"), + secure: opts.cookieSecure ?? true, + sameSite: resolveCookieSameSite(opts), }; } diff --git a/packages/express/src/middleware/originGuard.ts b/packages/express/src/middleware/originGuard.ts new file mode 100644 index 0000000..28aad87 --- /dev/null +++ b/packages/express/src/middleware/originGuard.ts @@ -0,0 +1,103 @@ +import { NextFunction, Request, Response } from "express"; + +import { CookieSameSite, resolveCookieSameSite } from "../internal/cookie"; + +export interface OriginGuardOptions { + cookieSecure?: boolean; + cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; +} + +// GET/HEAD are read-only and OPTIONS is the CORS preflight, so none can carry a +// state change worth gating. +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Rejects cross-site state-changing requests when the adapter issues + * `SameSite=None` cookies, which the browser would otherwise attach to a forged + * cross-site request. `Sec-Fetch-Site` is the primary signal: it ships on current + * browsers and page JavaScript cannot forge it. When it is absent (older + * browsers) the request `Origin` is matched against `allowedOrigins`, but only + * when the adopter opted in, so nothing regresses for callers that predate this. + */ +export function createOriginGuardMiddleware(opts: OriginGuardOptions) { + // A `Lax`/`Strict` cookie is not sent on a cross-site state-changing request, + // so the guard is inert and only `None` needs gating. + const active = resolveCookieSameSite(opts) === "none"; + const allowedOrigins = normalizeAllowedOrigins(opts.allowedOrigins); + + return function originGuard(req: Request, res: Response, next: NextFunction) { + if (!active || SAFE_METHODS.has(req.method)) { + next(); + return; + } + + const secFetchSite = firstHeader(req.headers["sec-fetch-site"]); + if (secFetchSite !== undefined) { + if (secFetchSite.toLowerCase() === "cross-site") { + return reject(res); + } + next(); + return; + } + + const origin = firstHeader(req.headers.origin); + if (origin === undefined) { + // No `Origin` and no `Sec-Fetch-Site` is a same-origin or non-browser + // server-to-server caller. Let it through. + next(); + return; + } + + // A literal `null` origin is an opaque or sandboxed origin, which is + // cross-site regardless of the allowlist. + if (origin === "null") { + return reject(res); + } + + if (allowedOrigins === null) { + // Older browser, but the adopter has not opted into an allowlist. Preserve + // the pre-guard behavior rather than start rejecting these. + next(); + return; + } + + if (!allowedOrigins.has(normalizeOrigin(origin))) { + return reject(res); + } + + next(); + }; +} + +function reject(res: Response): void { + res.status(403).json({ error: "cross_site_request_blocked" }); +} + +function firstHeader( + value: string | string[] | undefined, +): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function normalizeOrigin(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeAllowedOrigins( + origins: string[] | undefined, +): Set | null { + if (!origins) { + return null; + } + + const normalized = new Set(); + for (const origin of origins) { + const value = normalizeOrigin(origin); + if (value) { + normalized.add(value); + } + } + + return normalized; +} diff --git a/packages/express/tests/originGuard.test.js b/packages/express/tests/originGuard.test.js new file mode 100644 index 0000000..a04c4c3 --- /dev/null +++ b/packages/express/tests/originGuard.test.js @@ -0,0 +1,157 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +const BLOCKED = { error: "cross_site_request_blocked" }; + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createApp(overrides = {}) { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret-cookie-secret-cookie-secret", + serviceSecret: "service-secret-service-secret-service-secret", + audience: "https://auth.example.com", + jwksKid: "test-main", + ...overrides, + }), + ); + + return app; +} + +describe("origin guard (#104)", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(async () => createJsonResponse(200, {})); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("rejects a cross-site state-changing request", async () => { + const res = await request(createApp()) + .post("/auth/magic-link") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).toBe(403); + expect(res.body).toEqual(BLOCKED); + }); + + it("accepts a same-origin request", async () => { + const res = await request(createApp()) + .post("/auth/magic-link") + .set("Sec-Fetch-Site", "same-origin"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("accepts a same-site request", async () => { + const res = await request(createApp()) + .post("/auth/magic-link") + .set("Sec-Fetch-Site", "same-site"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("accepts a server-to-server request with no Origin or Sec-Fetch-Site", async () => { + const res = await request(createApp()).post("/auth/magic-link"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("rejects a literal null Origin", async () => { + const res = await request(createApp()) + .post("/auth/magic-link") + .set("Origin", "null"); + + expect(res.status).toBe(403); + expect(res.body).toEqual(BLOCKED); + }); + + it("does not gate GET requests even cross-site", async () => { + const res = await request(createApp()) + .get("/auth/oauth/providers") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("does not gate GET /magic-link/verify/:token", async () => { + const res = await request(createApp()) + .get("/auth/magic-link/verify/some-token") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("lets a CORS preflight through", async () => { + const res = await request(createApp()) + .options("/auth/magic-link") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("does nothing when cookieSameSite is lax, even cross-site", async () => { + const res = await request(createApp({ cookieSameSite: "lax" })) + .post("/auth/magic-link") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + describe("Sec-Fetch-Site absent fallback", () => { + it("accepts an allowlisted Origin when allowedOrigins is set", async () => { + const res = await request( + createApp({ allowedOrigins: ["https://app.example.com"] }), + ) + .post("/auth/magic-link") + .set("Origin", "https://app.example.com"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + + it("rejects a non-allowlisted Origin when allowedOrigins is set", async () => { + const res = await request( + createApp({ allowedOrigins: ["https://app.example.com"] }), + ) + .post("/auth/magic-link") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + expect(res.body).toEqual(BLOCKED); + }); + + it("accepts any Origin when allowedOrigins is unset", async () => { + const res = await request(createApp()) + .post("/auth/magic-link") + .set("Origin", "https://evil.example.com"); + + expect(res.status).not.toBe(403); + expect(res.body).not.toEqual(BLOCKED); + }); + }); +});