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
25 changes: 3 additions & 22 deletions apps/api/src/middleware/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 13 additions & 40 deletions apps/api/src/middleware/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const env = getEnv();
const trusted = new Set<string>();

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
Expand Down Expand Up @@ -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);
}
Loading