Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

101 lines · 4.5 KBRaw History
1import { getCookie, setCookie } from "hono/cookie";
2import { credentialFrom, type AppContext } from "../auth/principal.js";
3import { 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 */
10export const CSRF_COOKIE = "dfy_csrf";
11export const CSRF_FIELD = "_csrf";
12
13const memo = new WeakMap<Request, string>();
14const VALID = /^[A-Za-z0-9_-]{20,64}$/;
15
16/** The CSRF token for this request; sets the cookie when the browser has none. */
17export 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
29export 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). */
43export 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 */
49export 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). */
59export 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). */
84export 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}