From d1c9e14bf08c60a7109f5bb3ef3c09a09f9c617d Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:38:35 +0800 Subject: [PATCH] feat(auth): add csrfOriginCheck middleware Co-Authored-By: Claude Sonnet 4.6 --- src/web/middleware/csrf.test.ts | 58 +++++++++++++++++++++++++++++++++ src/web/middleware/csrf.ts | 35 ++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/web/middleware/csrf.test.ts create mode 100644 src/web/middleware/csrf.ts 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; + } +}