Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { Elysia } from 'elysia'
2import { logger } from './logger'
3
4/**
5 * CSRF Protection using Origin/Host header verification
6 * Based on Lucia's recommended approach for cookie-based authentication
7 *
8 * This validates that the Origin header matches the Host header for
9 * state-changing requests (POST, PUT, DELETE, PATCH).
10 */
11
12/**
13 * Verify that the request origin matches the expected host
14 * @param origin - The Origin header value
15 * @param allowedHosts - Array of allowed host values
16 * @returns true if origin is valid, false otherwise
17 */
18export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean {
19 if (!origin) {
20 return false
21 }
22
23 try {
24 const originUrl = new URL(origin)
25 const originHost = originUrl.host
26
27 return allowedHosts.some(host => originHost === host)
28 } catch {
29 // Invalid URL
30 return false
31 }
32}
33
34/**
35 * CSRF Protection Middleware for Elysia
36 *
37 * Validates Origin header against Host header for non-GET requests
38 * to prevent CSRF attacks when using cookie-based authentication.
39 *
40 * Usage:
41 * ```ts
42 * import { csrfProtection } from './lib/csrf'
43 *
44 * new Elysia()
45 * .use(csrfProtection())
46 * .post('/api/protected', handler)
47 * ```
48 */
49export const csrfProtection = () => {
50 return new Elysia({ name: 'csrf-protection' })
51 .onBeforeHandle(({ request, set }) => {
52 const method = request.method.toUpperCase()
53
54 // Only protect state-changing methods
55 if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
56 return
57 }
58
59 // Get headers
60 const originHeader = request.headers.get('Origin')
61 // Use X-Forwarded-Host if behind a proxy, otherwise use Host
62 const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host')
63
64 // Validate origin matches host
65 if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
66 logger.warn('[CSRF] Request blocked', {
67 method,
68 origin: originHeader,
69 host: hostHeader,
70 path: new URL(request.url).pathname
71 })
72
73 set.status = 403
74 return {
75 error: 'CSRF validation failed',
76 message: 'Request origin does not match host'
77 }
78 }
79 })
80}