diff --git a/src/web/middleware/csrf.test.ts b/src/web/middleware/csrf.test.ts new file mode 100644 index 0000000..d056ebd --- /dev/null +++ b/src/web/middleware/csrf.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { csrfOriginCheck } from "./csrf.js"; + +describe("csrfOriginCheck middleware", () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(csrfOriginCheck); + app.get("/", (_req, res) => res.json({ ok: true })); + app.post("/", (_req, res) => res.json({ ok: true })); + }); + + it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + }); + + it("rejects POST without Origin or Referer", async () => { + const res = await request(app).post("/"); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "bad origin" }); + }); + + it("accepts POST when Origin host matches request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://example.com"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Origin host does not match request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://evil.com"); + expect(res.status).toBe(403); + }); + + it("accepts POST when Referer host matches and Origin is absent", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://example.com/some/path"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Referer host does not match", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://evil.com/some/path"); + expect(res.status).toBe(403); + }); +}); diff --git a/src/web/middleware/csrf.ts b/src/web/middleware/csrf.ts new file mode 100644 index 0000000..9907eab --- /dev/null +++ b/src/web/middleware/csrf.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from "express"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Same-origin CSRF protection. For mutating requests, the Origin or Referer + * header must indicate a host equal to the request's own host. + * + * SameSite=Lax on the session cookie blocks classic cross-site form posts; + * this header check covers the remaining attack surface. + */ +export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + const expectedHost = req.get("host"); + const originHeader = req.get("origin"); + const refererHeader = req.get("referer"); + const headerHost = hostOf(originHeader) ?? hostOf(refererHeader); + if (!headerHost || !expectedHost || headerHost !== expectedHost) { + res.status(403).json({ error: "bad origin" }); + return; + } + next(); +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).host; + } catch { + return null; + } +}