diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts new file mode 100644 index 00000000..ae28e7d2 --- /dev/null +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import express from "express"; +import request from "supertest"; +import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; + +function createApp(actorType: "board" | "agent") { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actorType === "board" ? { type: "board", userId: "board" } : { type: "agent", agentId: "agent-1" }; + next(); + }); + app.use(boardMutationGuard()); + app.post("/mutate", (_req, res) => { + res.status(204).end(); + }); + app.get("/read", (_req, res) => { + res.status(204).end(); + }); + return app; +} + +describe("boardMutationGuard", () => { + it("allows safe methods for board actor", async () => { + const app = createApp("board"); + const res = await request(app).get("/read"); + expect(res.status).toBe(204); + }); + + it("blocks board mutations without trusted origin", async () => { + const app = createApp("board"); + const res = await request(app).post("/mutate").send({ ok: true }); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" }); + }); + + it("allows board mutations from trusted origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Origin", "http://localhost:5173") + .send({ ok: true }); + expect(res.status).toBe(204); + }); + + it("allows board mutations from trusted referer origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Referer", "http://localhost:5173/issues/abc") + .send({ ok: true }); + expect(res.status).toBe(204); + }); + + it("does not block authenticated agent mutations", async () => { + const app = createApp("agent"); + const res = await request(app).post("/mutate").send({ ok: true }); + expect(res.status).toBe(204); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index f3e89ea1..8a136bc5 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,6 +6,7 @@ import type { Db } from "@paperclip/db"; import type { StorageService } from "./storage/types.js"; import { httpLogger, errorHandler } from "./middleware/index.js"; import { actorMiddleware } from "./middleware/auth.js"; +import { boardMutationGuard } from "./middleware/board-mutation-guard.js"; import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { agentRoutes } from "./routes/agents.js"; @@ -33,6 +34,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode; storageService: // Mount API routes const api = Router(); + api.use(boardMutationGuard()); api.use("/health", healthRoutes()); api.use("/companies", companyRoutes(db)); api.use(agentRoutes(db)); diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts new file mode 100644 index 00000000..5520fc86 --- /dev/null +++ b/server/src/middleware/board-mutation-guard.ts @@ -0,0 +1,61 @@ +import type { Request, RequestHandler } from "express"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const DEFAULT_DEV_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3100", + "http://127.0.0.1:3100", +]; + +function parseOrigin(value: string | undefined) { + if (!value) return null; + try { + const url = new URL(value); + return `${url.protocol}//${url.host}`.toLowerCase(); + } catch { + return null; + } +} + +function trustedOriginsForRequest(req: Request) { + const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase())); + const host = req.header("host")?.trim(); + if (host) { + origins.add(`http://${host}`.toLowerCase()); + origins.add(`https://${host}`.toLowerCase()); + } + return origins; +} + +function isTrustedBoardMutationRequest(req: Request) { + const allowedOrigins = trustedOriginsForRequest(req); + const origin = parseOrigin(req.header("origin")); + if (origin && allowedOrigins.has(origin)) return true; + + const refererOrigin = parseOrigin(req.header("referer")); + if (refererOrigin && allowedOrigins.has(refererOrigin)) return true; + + return false; +} + +export function boardMutationGuard(): RequestHandler { + return (req, res, next) => { + if (SAFE_METHODS.has(req.method.toUpperCase())) { + next(); + return; + } + + if (req.actor.type !== "board") { + next(); + return; + } + + if (!isTrustedBoardMutationRequest(req)) { + res.status(403).json({ error: "Board mutation requires trusted browser origin" }); + return; + } + + next(); + }; +}