import type { Context, Next } from "hono"; import { getCookie, setCookie } from "hono/cookie"; const CSRF_COOKIE_NAME = "csrf_token"; const CSRF_HEADER_NAME = "x-csrf-token"; const CSRF_FORM_FIELD = "_csrf"; /** * Generate a cryptographically secure random token */ function generateToken(): string { const buffer = new Uint8Array(32); crypto.getRandomValues(buffer); return Array.from(buffer, (b) => b.toString(16).padStart(2, "0")).join(""); } /** * Get or create a CSRF token for the current session */ export function getCSRFToken(c: Context): string { let token = getCookie(c, CSRF_COOKIE_NAME); if (!token) { token = generateToken(); setCookie(c, CSRF_COOKIE_NAME, token, { httpOnly: true, secure: process.env.PUBLIC_URL?.startsWith("https") || false, sameSite: "Strict", path: "/", maxAge: 60 * 60 * 24, // 24 hours }); } return token; } /** * Middleware to validate CSRF token on POST/PUT/DELETE requests */ export async function csrfProtection(c: Context, next: Next) { const method = c.req.method.toUpperCase(); // Only check CSRF for state-changing methods if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) { const cookieToken = getCookie(c, CSRF_COOKIE_NAME); if (!cookieToken) { return c.text("CSRF token missing", 403); } // Check header first (for AJAX requests) let requestToken = c.req.header(CSRF_HEADER_NAME); // Fall back to form field if (!requestToken) { const contentType = c.req.header("content-type") || ""; if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { try { const body = await c.req.parseBody(); requestToken = body[CSRF_FORM_FIELD] as string; } catch { // Body might have already been parsed } } } if (!requestToken || requestToken !== cookieToken) { return c.text("CSRF token invalid", 403); } } await next(); } import { raw } from "hono/html"; /** * HTML helper to generate a hidden CSRF input field * Returns a raw HTML string that won't be escaped by Hono's html template */ export function csrfField(token: string) { return raw( ``, ); }