A minimal web editor for managing standard.site records in your atproto PDS
1import type { Context, Next } from 'hono';
2import { getCookie, setCookie } from 'hono/cookie';
3
4const CSRF_COOKIE_NAME = 'csrf_token';
5const CSRF_HEADER_NAME = 'x-csrf-token';
6const CSRF_FORM_FIELD = '_csrf';
7
8/**
9 * Generate a cryptographically secure random token
10 */
11function generateToken(): string {
12 const buffer = new Uint8Array(32);
13 crypto.getRandomValues(buffer);
14 return Array.from(buffer, b => b.toString(16).padStart(2, '0')).join('');
15}
16
17/**
18 * Get or create a CSRF token for the current session
19 */
20export function getCSRFToken(c: Context): string {
21 let token = getCookie(c, CSRF_COOKIE_NAME);
22
23 if (!token) {
24 token = generateToken();
25 setCookie(c, CSRF_COOKIE_NAME, token, {
26 httpOnly: true,
27 secure: process.env.PUBLIC_URL?.startsWith('https') || false,
28 sameSite: 'Strict',
29 path: '/',
30 maxAge: 60 * 60 * 24, // 24 hours
31 });
32 }
33
34 return token;
35}
36
37/**
38 * Middleware to validate CSRF token on POST/PUT/DELETE requests
39 */
40export async function csrfProtection(c: Context, next: Next) {
41 const method = c.req.method.toUpperCase();
42
43 // Only check CSRF for state-changing methods
44 if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
45 const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
46
47 if (!cookieToken) {
48 return c.text('CSRF token missing', 403);
49 }
50
51 // Check header first (for AJAX requests)
52 let requestToken = c.req.header(CSRF_HEADER_NAME);
53
54 // Fall back to form field
55 if (!requestToken) {
56 const contentType = c.req.header('content-type') || '';
57 if (contentType.includes('application/x-www-form-urlencoded') ||
58 contentType.includes('multipart/form-data')) {
59 try {
60 const body = await c.req.parseBody();
61 requestToken = body[CSRF_FORM_FIELD] as string;
62 } catch {
63 // Body might have already been parsed
64 }
65 }
66 }
67
68 if (!requestToken || requestToken !== cookieToken) {
69 return c.text('CSRF token invalid', 403);
70 }
71 }
72
73 await next();
74}
75
76import { raw } from 'hono/html';
77
78/**
79 * HTML helper to generate a hidden CSRF input field
80 * Returns a raw HTML string that won't be escaped by Hono's html template
81 */
82export function csrfField(token: string) {
83 return raw(`<input type="hidden" name="${CSRF_FORM_FIELD}" value="${token}" />`);
84}