Skip to content
Open
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
4 changes: 3 additions & 1 deletion apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(""),
})
Expand Down
13 changes: 6 additions & 7 deletions apps/api/src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -141,12 +145,7 @@ export function assertSameOrigin(request: Request): void {
if (origin === null) {
return;
}
const allowed = new Set<string>([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");
}
}
4 changes: 2 additions & 2 deletions apps/api/src/server/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type RedirectTargetInput = {
url: string;
baseUrl: string;
webOrigin: string;
webOrigin?: string;
};

/**
Expand All @@ -22,7 +22,7 @@ export function resolveRedirectTarget({ url, baseUrl, webOrigin }: RedirectTarge
}

const allowedOrigins = new Set<string>();
for (const candidate of [baseUrl, webOrigin]) {
for (const candidate of webOrigin === undefined ? [baseUrl] : [baseUrl, webOrigin]) {
try {
allowedOrigins.add(new URL(candidate).origin);
} catch {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/tests/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
5 changes: 5 additions & 0 deletions apps/api/tests/unit/redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>/webhooks/github` and use a stable hostname (not a quick
Expand Down
Loading