From a03fa7dd7c5e92f4127592d2435de6d4dc0f0731 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 16:33:17 +0200 Subject: [PATCH] fix(api): trust APP_BASE_URL for CSRF origin checks CSRF origin checks should use APP_BASE_URL as the canonical public URL even when CORS_ORIGINS is explicitly configured. This avoids relying on proxy forwarded headers while preserving the explicit CORS origin allow-list. Made-with: Cursor --- apps/api/src/middleware/csrf.test.ts | 25 ++----------- apps/api/src/middleware/csrf.ts | 53 +++++++--------------------- 2 files changed, 16 insertions(+), 62 deletions(-) diff --git a/apps/api/src/middleware/csrf.test.ts b/apps/api/src/middleware/csrf.test.ts index 9685e64..4466d12 100644 --- a/apps/api/src/middleware/csrf.test.ts +++ b/apps/api/src/middleware/csrf.test.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { csrfMiddleware } from "./csrf"; beforeAll(() => { + process.env.APP_BASE_URL = "https://archmax.example.com"; process.env.CORS_ORIGINS = "http://localhost:5173,https://app.example.com"; process.env.MONGODB_URI = "mongodb://localhost:27017/test"; process.env.BETTER_AUTH_SECRET = "test-secret-with-at-least-32-chars-long"; @@ -131,40 +132,20 @@ describe("csrfMiddleware", () => { expect(res.status).toBe(403); }); - it("allows POST when Origin matches the proxied X-Forwarded-Host", async () => { + it("allows POST from APP_BASE_URL even when CORS_ORIGINS is explicit", async () => { const app = buildApp(); const res = await app.request("/api/projects/x/mcp-tokens", { method: "POST", headers: { "content-type": "application/json", origin: "https://archmax.example.com", - "x-forwarded-host": "archmax.example.com", - "x-forwarded-proto": "https", - host: "127.0.0.1:3000", }, body: "{}", }); expect(res.status).toBe(200); }); - it("rejects POST when only Host (no X-Forwarded-Host) matches Origin", async () => { - // The bare Host header is not trusted: direct deployments are expected - // to set APP_BASE_URL (which populates corsOrigins). Only X-Forwarded-* - // headers from a reverse proxy unlock the same-origin shortcut. - const app = buildApp(); - const res = await app.request("http://archmax.example.com/api/projects/x/mcp-tokens", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://archmax.example.com", - host: "archmax.example.com", - }, - body: "{}", - }); - expect(res.status).toBe(403); - }); - - it("rejects foreign Origin even when X-Forwarded-Host matches the foreign origin", async () => { + it("rejects foreign Origin even when proxy headers match it", async () => { const app = buildApp(); const res = await app.request("/api/projects/x/mcp-tokens", { method: "POST", diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 8a7df37..a1b4231 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -13,48 +13,26 @@ function originFromHeader(value: string): string | null { } } -/** - * Compute the public origin the browser sees when the API sits behind a - * reverse proxy (nginx, Cloudflare, etc.) — derived from the standard - * `X-Forwarded-Proto` + `X-Forwarded-Host` headers the proxy sets. - * - * Returns `null` when those headers are absent. In that case the request - * is not proxied (or the proxy is misconfigured), and the caller falls - * back to the explicit `corsOrigins` allow-list — which is what - * `APP_BASE_URL` / `CORS_ORIGINS` already configures for direct - * deployments. - */ -function proxiedSelfOrigin(c: Context): string | null { - const forwardedHost = c.req - .header("x-forwarded-host") - ?.split(",")[0] - ?.trim(); - if (!forwardedHost) return null; +function trustedOrigins(): Set { + const env = getEnv(); + const trusted = new Set(); - const forwardedProto = - c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() || "https"; + for (const value of [...env.corsOrigins, env.APP_BASE_URL]) { + if (!value) continue; + const origin = originFromHeader(value); + trusted.add(origin ?? value); + } - return `${forwardedProto}://${forwardedHost}`; + return trusted; } /** * CSRF / origin enforcement for cookie-authenticated mutation routes. * * Every state-changing `/api/*` request (POST/PUT/PATCH/DELETE) is required - * to carry an `Origin` (or `Referer`) header. The header is accepted when - * either: - * 1. it matches one of the configured `corsOrigins` (driven by - * `APP_BASE_URL` / `CORS_ORIGINS` — the canonical config for direct - * deployments and local dev), OR - * 2. it matches the public origin derived from `X-Forwarded-Proto` + - * `X-Forwarded-Host` headers set by the upstream reverse proxy. - * - * Case (2) lets deployments behind nginx / Cloudflare / a tunnel work - * out of the box without the operator having to keep `APP_BASE_URL` in - * sync with the public URL: a same-origin request from the browser to its - * own server is, by definition, not CSRF. Real browsers always attach - * `Origin` on credentialed non-GET requests, so an attacker on a foreign - * origin cannot forge a same-origin `Origin` header. + * to carry an `Origin` (or `Referer`) header that matches the configured + * public application URL (`APP_BASE_URL`) or one of the explicit + * `CORS_ORIGINS`. * * Missing both `Origin` and `Referer` is also rejected: the routes below * this middleware are session-cookie authenticated, so a caller that @@ -86,15 +64,10 @@ export async function csrfMiddleware(c: Context, next: Next) { return c.json({ error: "Forbidden: invalid request origin" }, 403); } - const trusted = new Set(getEnv().corsOrigins); + const trusted = trustedOrigins(); if (trusted.has(origin)) { return next(); } - const selfOrigin = proxiedSelfOrigin(c); - if (selfOrigin && origin === selfOrigin) { - return next(); - } - return c.json({ error: "Forbidden: invalid request origin" }, 403); }