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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from "hono/logger";
import { getEnv } from "@archmax/core/config/env";
import { runHealthChecks } from "@archmax/core/infra/health";
import { corsMiddleware } from "./middleware/cors";
import { csrfMiddleware } from "./middleware/csrf";
import { AppError } from "./utils/errors";
import { auth } from "./lib/auth";

Expand Down Expand Up @@ -65,6 +66,7 @@ const app = new Hono()
.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
.route("/mcp/:slug/mcp", archmaxMcp)
.route("/mcp/:slug/test/mcp", archmaxMcp)
.use("/api/*", csrfMiddleware)
.use("/api/*", async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) return c.json({ error: "Unauthorized" }, 401);
Expand Down
60 changes: 46 additions & 14 deletions apps/api/src/mcp/archmax-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,23 @@ setInterval(() => {
}
}, MCP_RATE_WINDOW_MS).unref();

const UNAUTHORIZED = { error: "Invalid or missing authorization" } as const;
// JSON-RPC error codes (server-defined range -32000 to -32099).
const JSONRPC_UNAUTHORIZED = -32001;
const JSONRPC_SESSION_NOT_FOUND = -32002;

function jsonRpcError(status: number, code: number, message: string): Response {
return new Response(
JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }),
{ status, headers: { "Content-Type": "application/json" } },
);
}

const UNAUTHORIZED_MSG = "Invalid or missing authorization";

async function authenticateRequest(c: { req: { header: (name: string) => string | undefined; param: (name: string) => string | undefined } }, clientIp: string): Promise<McpAuthContext | null> {
async function authenticateRequest(
c: { req: { header: (name: string) => string | undefined; param: (name: string) => string | undefined } },
clientIp: string,
): Promise<McpAuthContext | null> {
const authHeader = c.req.header("Authorization");
const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!rawToken) return null;
Expand Down Expand Up @@ -78,6 +92,8 @@ interface McpSession {
transport: WebStandardStreamableHTTPServerTransport;
createdAt: number;
tokenId: string;
projectId: string;
slug: string;
}

const sessions = new Map<string, McpSession>();
Expand Down Expand Up @@ -109,26 +125,34 @@ app.all("/", async (c) => {
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
return new Response(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Session not found" }, id: null }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
return jsonRpcError(404, JSONRPC_SESSION_NOT_FOUND, "Session not found");
}

await connectDB();
const token = await McpToken.findById(session.tokenId).lean();
if (!token || (token.expiresAt && token.expiresAt < new Date())) {
sessions.delete(sessionId);
session.server.close().catch(() => {});
return c.json(UNAUTHORIZED, 401);
// Bind every resumed request to the originating credential and slug:
// re-authenticate the bearer token, then verify it still resolves to the
// same token + project that opened this session and that the URL slug
// hasn't been swapped for a different project.
const authCtx = await authenticateRequest(c, clientIp);
if (!authCtx) {
return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG);
}
if (
authCtx.tokenId !== session.tokenId ||
authCtx.projectId !== session.projectId ||
c.req.param("slug") !== session.slug
) {
return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG);
}

return session.transport.handleRequest(c.req.raw);
}

const authCtx = await authenticateRequest(c, clientIp);
if (!authCtx) return c.json(UNAUTHORIZED, 401);
if (!authCtx) {
return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG);
}

const slug = c.req.param("slug")!;
const isTestRoute = c.req.path.includes("/test/");
const projectsDir = getEnv().projectsDir;
let fileSvc: SemanticModelFileService;
Expand All @@ -154,10 +178,18 @@ app.all("/", async (c) => {

const capturedTempDir = tempDir;
const capturedTokenId = authCtx.tokenId ?? "";
const capturedProjectId = authCtx.projectId;
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server: mcpServer, transport, createdAt: Date.now(), tokenId: capturedTokenId });
sessions.set(sid, {
server: mcpServer,
transport,
createdAt: Date.now(),
tokenId: capturedTokenId,
projectId: capturedProjectId,
slug,
});
},
});

Expand Down
133 changes: 133 additions & 0 deletions apps/api/src/middleware/csrf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeAll } from "vitest";
import { Hono } from "hono";
import { csrfMiddleware } from "./csrf";

beforeAll(() => {
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";
process.env.UI_PASSWORD = "test-password";
});

function buildApp() {
const app = new Hono();
app.use("/api/*", csrfMiddleware);
app.post("/api/projects/x/mcp-tokens", (c) => c.json({ ok: true }));
app.delete("/api/projects/x/mcp-tokens/y", (c) => c.json({ ok: true }));
app.get("/api/projects/x/mcp-tokens", (c) => c.json({ ok: true }));
app.post("/api/auth/sign-in/email", (c) => c.json({ ok: true }));
return app;
}

describe("csrfMiddleware", () => {
it("allows GET without Origin", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens");
expect(res.status).toBe(200);
});

it("rejects POST when both Origin and Referer are absent", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/missing Origin/i);
});

it("rejects DELETE when both Origin and Referer are absent", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens/y", {
method: "DELETE",
});
expect(res.status).toBe(403);
});

it("allows POST from trusted Origin", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "http://localhost:5173",
},
body: "{}",
});
expect(res.status).toBe(200);
});

it("rejects POST from 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",
},
body: "{}",
});
expect(res.status).toBe(403);
});

it("rejects DELETE from foreign Origin", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens/y", {
method: "DELETE",
headers: { origin: "https://evil.example.com" },
});
expect(res.status).toBe(403);
});

it("falls back to Referer when Origin is absent", async () => {
const app = buildApp();

const ok = await app.request("/api/projects/x/mcp-tokens", {
method: "POST",
headers: {
"content-type": "application/json",
referer: "http://localhost:5173/some/page",
},
body: "{}",
});
expect(ok.status).toBe(200);

const bad = await app.request("/api/projects/x/mcp-tokens", {
method: "POST",
headers: {
"content-type": "application/json",
referer: "https://evil.example.com/page",
},
body: "{}",
});
expect(bad.status).toBe(403);
});

it("skips /api/auth/* (Better Auth handles its own CSRF)", async () => {
const app = buildApp();
const res = await app.request("/api/auth/sign-in/email", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "https://evil.example.com",
},
body: "{}",
});
expect(res.status).toBe(200);
});

it("rejects malformed Origin header", async () => {
const app = buildApp();
const res = await app.request("/api/projects/x/mcp-tokens", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "not a url",
},
body: "{}",
});
expect(res.status).toBe(403);
});
});
55 changes: 55 additions & 0 deletions apps/api/src/middleware/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Context, Next } from "hono";
import { getEnv } from "@archmax/core/config/env";

const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

function originFromHeader(value: string): string | null {
try {
return new URL(value).origin;
} catch {
// Origin header may itself be a bare origin like "https://example.com"
// (no path). new URL() accepts that, so failures here mean garbage input.
return null;
}
}

/**
* 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.
*
* `/api/auth/*` is exempted because Better Auth runs before this middleware
* in app.ts and applies its own CSRF protection.
*/
export async function csrfMiddleware(c: Context, next: Next) {
if (SAFE_METHODS.has(c.req.method)) return next();
if (c.req.path.startsWith("/api/auth/")) return next();

const originHeader = c.req.header("origin");
const refererHeader = c.req.header("referer");

if (!originHeader && !refererHeader) {
Comment thread
tobias-gp marked this conversation as resolved.
return c.json(
{ error: "Forbidden: missing Origin/Referer header" },
403,
);
}

const trusted = new Set(getEnv().corsOrigins);
const candidate = originHeader ?? refererHeader!;
const origin = originFromHeader(candidate);

if (!origin || !trusted.has(origin)) {
return c.json({ error: "Forbidden: invalid request origin" }, 403);
}

await next();
}
Loading
Loading