From fc2e51c2e5866c6e775303ee7b7060648a969e05 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 4 Oct 2026 13:05:58 +0545 Subject: [PATCH] fix(api): drop the localhost default for WEB_ORIGIN Production fell back to http://localhost:5173, which then received credentialed CORS, passed the CSRF origin guard and was an accepted post-login redirect target. Unset now means same-origin only. Signed-off-by: ashish --- apps/api/src/config.ts | 4 +++- apps/api/src/server/http.ts | 13 ++++++------- apps/api/src/server/redirect.ts | 4 ++-- apps/api/tests/unit/config.test.ts | 4 ++++ apps/api/tests/unit/redirect.test.ts | 5 +++++ docs/deployment.md | 3 ++- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index b77474a..8694c73 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -11,7 +11,9 @@ const envSchema = z // it the production server uses its own bind address (0.0.0.0:3000). AUTH_URL: z.url().optional(), ADMIN_GITHUB_IDS: z.string().default(""), - WEB_ORIGIN: z.url().default("http://localhost:5173"), + // Extra origin for external clients (CORS, CSRF, post-login redirects). + // Unset means same-origin only; no default, so production never trusts a dev origin. + WEB_ORIGIN: z.url().optional(), STORAGE_DIR: z.string().min(1).default("./storage"), GITHUB_WEBHOOK_SECRET: z.string().default(""), }) diff --git a/apps/api/src/server/http.ts b/apps/api/src/server/http.ts index 14aa24b..4d7a7c1 100644 --- a/apps/api/src/server/http.ts +++ b/apps/api/src/server/http.ts @@ -11,7 +11,11 @@ import { } from "./errors"; function applyCors(headers: Headers): void { - headers.set("access-control-allow-origin", getEnv().WEB_ORIGIN); + const webOrigin = getEnv().WEB_ORIGIN; + if (webOrigin === undefined) { + return; + } + headers.set("access-control-allow-origin", webOrigin); headers.set("access-control-allow-credentials", "true"); const vary = headers.get("vary"); if (vary === null) { @@ -141,12 +145,7 @@ export function assertSameOrigin(request: Request): void { if (origin === null) { return; } - const allowed = new Set([getEnv().WEB_ORIGIN]); - const own = requestOrigin(request); - if (own !== null) { - allowed.add(own); - } - if (!allowed.has(origin)) { + if (origin !== getEnv().WEB_ORIGIN && origin !== requestOrigin(request)) { throw new ForbiddenError("Request origin is not allowed"); } } diff --git a/apps/api/src/server/redirect.ts b/apps/api/src/server/redirect.ts index 70f1414..9205d5f 100644 --- a/apps/api/src/server/redirect.ts +++ b/apps/api/src/server/redirect.ts @@ -1,7 +1,7 @@ export type RedirectTargetInput = { url: string; baseUrl: string; - webOrigin: string; + webOrigin?: string; }; /** @@ -22,7 +22,7 @@ export function resolveRedirectTarget({ url, baseUrl, webOrigin }: RedirectTarge } const allowedOrigins = new Set(); - for (const candidate of [baseUrl, webOrigin]) { + for (const candidate of webOrigin === undefined ? [baseUrl] : [baseUrl, webOrigin]) { try { allowedOrigins.add(new URL(candidate).origin); } catch { diff --git a/apps/api/tests/unit/config.test.ts b/apps/api/tests/unit/config.test.ts index f6f724c..6dd2b3a 100644 --- a/apps/api/tests/unit/config.test.ts +++ b/apps/api/tests/unit/config.test.ts @@ -22,4 +22,8 @@ describe("parseEnv", () => { it("does not require AUTH_URL in development", () => { expect(() => parseEnv({ ...base, NODE_ENV: "development" })).not.toThrow(); }); + + it("leaves WEB_ORIGIN unset rather than defaulting to a dev origin", () => { + expect(parseEnv(base).WEB_ORIGIN).toBeUndefined(); + }); }); diff --git a/apps/api/tests/unit/redirect.test.ts b/apps/api/tests/unit/redirect.test.ts index 0353594..59dbf9f 100644 --- a/apps/api/tests/unit/redirect.test.ts +++ b/apps/api/tests/unit/redirect.test.ts @@ -22,6 +22,11 @@ describe("resolveRedirectTarget", () => { expect(resolve("http://localhost:5173/profile")).toBe("http://localhost:5173/profile"); }); + it("allows only the API origin when no frontend origin is configured", () => { + const target = "http://localhost:5173/profile"; + expect(resolveRedirectTarget({ url: target, baseUrl })).toBe(baseUrl); + }); + it("rejects third-party origins", () => { expect(resolve("http://evil.example/phish")).toBe(baseUrl); expect(resolve("https://evil.example")).toBe(baseUrl); diff --git a/docs/deployment.md b/docs/deployment.md index 31772f2..62174c8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -85,7 +85,8 @@ a registry image avoids that, as Dokploy recommends. and rate limiting prefers `cf-connecting-ip` then `x-forwarded-for`. Caddy sets these by default; Cloudflare sets `CF-Connecting-IP`. Do not strip them. 8. `WEB_ORIGIN` is only needed for **external** clients (mobile). The bundled UI - is same-origin. Set it to the public origin anyway if nothing else uses it. + is same-origin, so leave it unset otherwise: unset means no CORS headers and + only the site's own origin passes the CSRF guard and post-login redirects. 9. `GITHUB_WEBHOOK_SECRET` is optional and the endpoint is inert without it. If you enable webhooks, point the repo webhook at `https:///webhooks/github` and use a stable hostname (not a quick