| 1 | import { getCookie, setCookie } from "hono/cookie"; |
| 2 | import { credentialFrom, type AppContext } from "../auth/principal.js"; |
| 3 | import { HttpError, randomToken, safeEqual } from "../lib/util.js"; |
| 4 | |
| 5 | /** |
| 6 | * Double-submit cookie CSRF protection for the web UI. Every rendered page |
| 7 | * ensures a random `dfy_csrf` cookie (HttpOnly, SameSite=Strict, Secure) and |
| 8 | * every form carries the same value in a hidden `_csrf` field. |
| 9 | */ |
| 10 | export const CSRF_COOKIE = "dfy_csrf"; |
| 11 | export const CSRF_FIELD = "_csrf"; |
| 12 | |
| 13 | const memo = new WeakMap<Request, string>(); |
| 14 | const VALID = /^[A-Za-z0-9_-]{20,64}$/; |
| 15 | |
| 16 | /** The CSRF token for this request; sets the cookie when the browser has none. */ |
| 17 | export function csrfToken(c: AppContext): string { |
| 18 | const cached = memo.get(c.req.raw); |
| 19 | if (cached) return cached; |
| 20 | let token = getCookie(c, CSRF_COOKIE); |
| 21 | if (!token || !VALID.test(token)) { |
| 22 | token = randomToken(24); |
| 23 | setCookie(c, CSRF_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 * 60 * 24 * 30 }); |
| 24 | } |
| 25 | memo.set(c.req.raw, token); |
| 26 | return token; |
| 27 | } |
| 28 | |
| 29 | export interface FormData { |
| 30 | /** First string value of a field ("" when absent). */ |
| 31 | s(name: string): string; |
| 32 | /** All string values of a (repeated) field. */ |
| 33 | all(name: string): string[]; |
| 34 | /** Checkbox semantics: present and not "false"/"0". */ |
| 35 | b(name: string): boolean; |
| 36 | /** Field names present in the form. */ |
| 37 | keys(): string[]; |
| 38 | /** An uploaded file (multipart forms); null when the field is missing or empty. */ |
| 39 | file(name: string): File | null; |
| 40 | } |
| 41 | |
| 42 | /** Form bodies bigger than this are refused before they are read (routes taking files pass a larger cap). */ |
| 43 | export const MAX_FORM_BYTES = 10 * 1024 * 1024; |
| 44 | |
| 45 | /** |
| 46 | * Refuse a body over `maxBytes` before anything buffers it. Browsers always |
| 47 | * send Content-Length with a form, so a body without one is refused too. |
| 48 | */ |
| 49 | export function checkFormSize(c: AppContext, maxBytes = MAX_FORM_BYTES): void { |
| 50 | const len = c.req.header("content-length"); |
| 51 | if (len === undefined || !/^\d+$/.test(len)) { |
| 52 | if (c.req.raw.body === null) return; |
| 53 | throw new HttpError(411, "send the form with a Content-Length header"); |
| 54 | } |
| 55 | if (Number(len) > maxBytes) throw new HttpError(413, `the form is too large (the limit is ${Math.floor(maxBytes / 1024 / 1024)} MB)`); |
| 56 | } |
| 57 | |
| 58 | /** Parse an urlencoded/multipart form once per request (Hono caches the body). */ |
| 59 | export async function formData(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> { |
| 60 | checkFormSize(c, maxBytes); |
| 61 | const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>; |
| 62 | const all = (name: string): string[] => { |
| 63 | const v = raw[name]; |
| 64 | const list = Array.isArray(v) ? v : v === undefined ? [] : [v]; |
| 65 | return list.filter((x): x is string => typeof x === "string"); |
| 66 | }; |
| 67 | return { |
| 68 | s: (name) => all(name)[0] ?? "", |
| 69 | all, |
| 70 | b: (name) => { |
| 71 | const v = all(name)[0]; |
| 72 | return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off"; |
| 73 | }, |
| 74 | keys: () => Object.keys(raw), |
| 75 | file: (name) => { |
| 76 | const v = raw[name]; |
| 77 | const f = (Array.isArray(v) ? v : [v]).find((x): x is File => typeof x === "object" && x !== null && typeof (x as File).arrayBuffer === "function"); |
| 78 | return f && f.size > 0 ? f : null; |
| 79 | }, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | /** Reject state-changing requests without a matching token (and cross-origin requests). */ |
| 84 | export async function verifyCsrf(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> { |
| 85 | // Web forms are for people in a browser. A token (personal, agent, job or sandbox) uses the API, |
| 86 | // where its scope and repository limits apply; here it could forge the double-submit cookie. |
| 87 | if (credentialFrom(c.req.raw)) throw new HttpError(403, "web forms need a browser session; use the API with a token"); |
| 88 | const origin = c.req.header("origin"); |
| 89 | if (origin && origin !== "null") { |
| 90 | const self = new URL(c.req.url).origin; |
| 91 | const pub = c.env.PUBLIC_URL ? new URL(c.env.PUBLIC_URL).origin : null; |
| 92 | if (origin !== self && origin !== pub) throw new HttpError(403, "cross-origin form submission rejected"); |
| 93 | } |
| 94 | const type = c.req.header("content-type") ?? ""; |
| 95 | if (!/^(application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type)) throw new HttpError(415, "expected a form submission"); |
| 96 | const form = await formData(c, maxBytes); |
| 97 | const cookie = getCookie(c, CSRF_COOKIE); |
| 98 | const sent = form.s(CSRF_FIELD); |
| 99 | if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again"); |
| 100 | return form; |
| 101 | } |