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
48 changes: 48 additions & 0 deletions apps/api/src/middleware/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
67 changes: 56 additions & 11 deletions apps/api/src/middleware/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
Loading