File
Blob: src/worker/middleware/origin.ts
| 1 | import type { MiddlewareHandler } from "hono"; |
| 2 | |
| 3 | import type { AppBindings } from "@/worker/hono"; |
| 4 | import { HttpError } from "@/worker/http"; |
| 5 | |
| 6 | // CSRF boundary for tessera-owned cookie-authenticated mutating routes. |
| 7 | // Better Auth runs its own originCheck inside `auth.handler` for /api/auth/* |
| 8 | // requests; tessera's custom routes sit outside that and need their own |
| 9 | // gate. SameSite=Lax on the session cookie blocks cross-site form POSTs, |
| 10 | // but does NOT stop a sibling subdomain on the same site (e.g. |
| 11 | // evil.limic.dev posting to auth.limic.dev) — Origin is the correct |
| 12 | // primary signal for that case. |
| 13 | // |
| 14 | // Safe methods (GET/HEAD/OPTIONS) skip the check: browsers do not reliably |
| 15 | // send Origin on those requests, and read-side access is gated by |
| 16 | // SameSite=Lax plus the session cookie. Origin matching on safe methods |
| 17 | // would block legitimate cross-tab GETs without changing the threat model. |
| 18 | // |
| 19 | // Order: this runs before requireUser/requireAdmin so an unauthenticated |
| 20 | // foreign-origin POST short-circuits at 403 without burning a session |
| 21 | // lookup. |
| 22 | export const requireSameOriginForMutations: MiddlewareHandler<AppBindings> = async (c, next) => { |
| 23 | const method = c.req.method; |
| 24 | if (method === "GET" || method === "HEAD" || method === "OPTIONS") { |
| 25 | await next(); |
| 26 | return; |
| 27 | } |
| 28 | const expected = new URL(c.var.baseURL).origin; |
| 29 | const actual = c.req.header("origin"); |
| 30 | if (!actual || actual !== expected) { |
| 31 | throw new HttpError(403, "forbidden_origin", "Request origin not allowed."); |
| 32 | } |
| 33 | await next(); |
| 34 | }; |