Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/origin-csrf-guard.md
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 39 additions & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -68,6 +69,7 @@ type ResolvedSeamlessAuthServerOptions = {
cookieDomain: string;
cookieSecure?: boolean;
cookieSameSite?: CookieSameSite;
allowedOrigins?: string[];
accessCookieName: string;
registrationCookieName: string;
refreshCookieName: string;
Expand All @@ -85,6 +87,7 @@ export type SeamlessAuthServerOptions = {
cookieDomain?: string;
cookieSecure?: boolean;
cookieSameSite?: CookieSameSite;
allowedOrigins?: string[];
accessCookieName?: string;
registrationCookieName?: string;
refreshCookieName?: string;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions packages/express/src/internal/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CookieSecurityOptions, "cookieSecure" | "cookieSameSite">,
): 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,
Expand All @@ -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),
};
}

Expand Down
103 changes: 103 additions & 0 deletions packages/express/src/middleware/originGuard.ts
Original file line number Diff line number Diff line change
@@ -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<string> | null {
if (!origins) {
return null;
}

const normalized = new Set<string>();
for (const origin of origins) {
const value = normalizeOrigin(origin);
if (value) {
normalized.add(value);
}
}

return normalized;
}
Loading
Loading