Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

csrf.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

csrf.tsBlame166 lines · 1 contributor
45e31d0Claude1/**
2 * CSRF Protection Middleware
3 *
4 * Generates and validates CSRF tokens for form submissions.
5 * Uses double-submit cookie pattern for stateless CSRF protection.
6 */
7
8import { createMiddleware } from "hono/factory";
9import { getCookie, setCookie } from "hono/cookie";
10
11const CSRF_COOKIE = "csrf_token";
12const CSRF_HEADER = "x-csrf-token";
13const CSRF_FIELD = "_csrf";
14
15function generateToken(): string {
16 const bytes = crypto.getRandomValues(new Uint8Array(32));
17 return Array.from(bytes)
18 .map((b) => b.toString(16).padStart(2, "0"))
19 .join("");
20}
21
22/**
23 * Sets a CSRF cookie on every request if not already present.
24 * Also makes the token available via c.get("csrfToken").
25 */
26export const csrfToken = createMiddleware(async (c, next) => {
27 let token = getCookie(c, CSRF_COOKIE);
28 if (!token) {
29 token = generateToken();
30 setCookie(c, CSRF_COOKIE, token, {
31 httpOnly: false, // JS needs to read it
32 sameSite: "Lax",
33 path: "/",
34 secure: process.env.NODE_ENV === "production",
35 });
36 }
37 c.set("csrfToken", token);
38 return next();
39});
40
41/**
4127ecfClaude42 * Validates CSRF on mutating requests (POST, PUT, DELETE, PATCH).
43 *
44 * Defence-in-depth, in order:
45 * 1. Same-origin check via Origin/Referer header (OWASP-recommended primary
46 * defence against CSRF — a cross-site forged request from a victim's
47 * browser always carries the attacker's Origin, never the app's host).
48 * 2. Double-submit cookie token (the `_csrf` form field or `X-CSRF-Token`
49 * header must equal the `csrf_token` cookie). Optional — used as a
50 * belt-and-braces fallback when present.
51 *
52 * The Origin check alone is sufficient for modern browsers (all major
53 * browsers send Origin on cross-origin POSTs). The token check is retained
54 * because some legacy clients strip Origin/Referer, and because it gives
55 * forms an explicit "I came from a real page" signal.
56 *
57 * A request is accepted iff EITHER the Origin/Referer matches the request
58 * host OR the token check passes.
45e31d0Claude59 */
60export const csrfProtect = createMiddleware(async (c, next) => {
61 const method = c.req.method.toUpperCase();
62 if (["GET", "HEAD", "OPTIONS"].includes(method)) {
63 return next();
64 }
65
66 // Skip CSRF for API routes with Bearer token auth (they have their own auth)
67 const authHeader = c.req.header("Authorization");
68 if (authHeader?.startsWith("Bearer ")) {
69 return next();
70 }
71
59b6fb2Claude72 // Skip CSRF for API routes (they use token auth, not cookies)
45e31d0Claude73 const path = c.req.path;
59b6fb2Claude74 if (path.startsWith("/api/")) {
75 return next();
76 }
77
78 // Skip CSRF for git protocol routes
45e31d0Claude79 if (path.endsWith(".git/git-upload-pack") || path.endsWith(".git/git-receive-pack")) {
80 return next();
81 }
82
699e5c7Claude83 // Skip CSRF for requests with no session cookie — they are unauthenticated
84 // and will be redirected to /login (or 404) by downstream auth middleware.
85 // CSRF only matters for authenticated, cookie-bearing sessions because the
86 // attack vector is a malicious site tricking a logged-in user's browser.
87 const sessionCookie = getCookie(c, "session");
88 if (!sessionCookie) {
89 return next();
90 }
91
4127ecfClaude92 // ---- 1) Same-origin check (Origin / Referer header) -----------------
93 // A genuine same-origin request from our own pages will carry an Origin
94 // (always for cross-origin POSTs, usually for same-origin too) or a
95 // Referer that matches the request host. Cross-site forged requests
96 // either carry the attacker's origin or are stripped — in both cases
97 // they fail this check.
98 const host = c.req.header("host");
99 const origin = c.req.header("origin");
100 const referer = c.req.header("referer");
101 let originOk = false;
102 if (host) {
103 if (origin) {
104 try {
105 originOk = new URL(origin).host === host;
106 } catch {
107 originOk = false;
108 }
109 } else if (referer) {
110 try {
111 originOk = new URL(referer).host === host;
112 } catch {
113 originOk = false;
114 }
115 }
116 }
117 if (originOk) {
118 return next();
119 }
120
121 // ---- 2) Double-submit cookie token (fallback) ------------------------
45e31d0Claude122 const cookieToken = getCookie(c, CSRF_COOKIE);
123 if (!cookieToken) {
4127ecfClaude124 return c.text("CSRF check failed: no Origin/Referer header and no token cookie", 403);
45e31d0Claude125 }
126
127 // Check header first, then form body
128 let submittedToken = c.req.header(CSRF_HEADER);
129 if (!submittedToken) {
130 try {
131 const contentType = c.req.header("content-type") || "";
132 if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
133 const body = await c.req.parseBody();
134 submittedToken = String(body[CSRF_FIELD] || "");
135 }
136 } catch {
137 // Can't parse body — skip
138 }
139 }
140
141 // For JSON API calls from the web UI
142 if (!submittedToken) {
143 try {
144 const contentType = c.req.header("content-type") || "";
145 if (contentType.includes("application/json")) {
146 const body = await c.req.json();
147 submittedToken = body?._csrf;
148 }
149 } catch {
150 // Can't parse JSON — skip
151 }
152 }
153
154 if (!submittedToken || submittedToken !== cookieToken) {
155 return c.text("CSRF token invalid", 403);
156 }
157
158 return next();
159});
160
161/**
162 * Helper to generate a hidden CSRF input field for forms.
163 */
164export function csrfField(token: string): string {
165 return `<input type="hidden" name="${CSRF_FIELD}" value="${token}" />`;
166}