diff --git a/apps/api/src/middleware/csrf.test.ts b/apps/api/src/middleware/csrf.test.ts index 9517d7a..9685e64 100644 --- a/apps/api/src/middleware/csrf.test.ts +++ b/apps/api/src/middleware/csrf.test.ts @@ -130,4 +130,52 @@ describe("csrfMiddleware", () => { }); expect(res.status).toBe(403); }); + + it("allows POST when Origin matches the proxied X-Forwarded-Host", 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 () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://evil.example.com", + "x-forwarded-host": "archmax.example.com", + "x-forwarded-proto": "https", + }, + body: "{}", + }); + expect(res.status).toBe(403); + }); }); diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 97b195b..8a7df37 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -13,18 +13,54 @@ 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; + + const forwardedProto = + c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() || "https"; + + return `${forwardedProto}://${forwardedHost}`; +} + /** * 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 that resolves to one of - * `corsOrigins`. Real browsers always attach `Origin` on credentialed - * non-GET requests, so this stops browser-driven CSRF without needing a - * separate token round-trip. Missing both headers is *also* rejected: the - * routes below this middleware are session-cookie authenticated, so a - * caller that suppresses Origin/Referer would otherwise drive cookie - * mutations unprotected. Non-browser API clients should authenticate via - * the dedicated bearer-token MCP surface (`/mcp/:slug/mcp`) instead. + * 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. + * + * Missing both `Origin` and `Referer` is also rejected: the routes below + * this middleware are session-cookie authenticated, so a caller that + * suppresses both headers would otherwise drive cookie mutations + * unprotected. Non-browser API clients should authenticate via the + * dedicated bearer-token MCP surface (`/mcp/:slug/mcp`) instead. * * `/api/auth/*` is exempted because Better Auth runs before this middleware * in app.ts and applies its own CSRF protection. @@ -43,13 +79,22 @@ export async function csrfMiddleware(c: Context, next: Next) { ); } - const trusted = new Set(getEnv().corsOrigins); const candidate = originHeader ?? refererHeader!; const origin = originFromHeader(candidate); - if (!origin || !trusted.has(origin)) { + if (!origin) { return c.json({ error: "Forbidden: invalid request origin" }, 403); } - await next(); + const trusted = new Set(getEnv().corsOrigins); + 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); }