csrf.ts148 lines · main
1import type { MiddlewareHandler } from 'hono';
2
3import { env } from '../env.js';
4import { log } from '../lib/logger.js';
5import { isRegisteredOrigin } from '../services/auth-origin-allowlist.js';
6import type { Session } from './session.js';
7
8const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
9
10/**
11 * Explicit allowlist of /v1/auth/* paths owned by Better Auth (core + the
12 * plugins we mount in apps/api/src/lib/auth.ts: magic-link + genericOAuth).
13 * Better Auth carries its own internal CSRF for these, so the origin-check
14 * carve-out is justified.
15 *
16 * NOT in this list — our custom /v1/auth/* routes (e.g. /v1/auth/cli-token):
17 * those are session-cookie POSTs that mint long-lived bearers, so they MUST
18 * fall through to the origin-check below. When you add a new Better Auth
19 * route (e.g. by enabling a new plugin), add its prefix here. When you add
20 * a new custom briven-owned route under /v1/auth/, do NOT add it here.
21 *
22 * Matched as either exact equality or `path === prefix + '/' + ...` so that
23 * `/v1/auth/sign-in/email` and `/v1/auth/callback/google` both resolve.
24 */
25const BETTER_AUTH_PATHS: readonly string[] = [
26 '/v1/auth/ok',
27 '/v1/auth/error',
28 // Sessions
29 '/v1/auth/get-session',
30 '/v1/auth/update-session',
31 '/v1/auth/list-sessions',
32 '/v1/auth/revoke-session',
33 '/v1/auth/revoke-sessions',
34 '/v1/auth/revoke-other-sessions',
35 // Sign-in / sign-up / sign-out (prefix covers /email, /social, /magic-link, /oauth2, ...)
36 '/v1/auth/sign-in',
37 '/v1/auth/sign-up',
38 '/v1/auth/sign-out',
39 // OAuth callbacks
40 '/v1/auth/callback',
41 '/v1/auth/oauth2',
42 // Magic link
43 '/v1/auth/magic-link',
44 // Email verification
45 '/v1/auth/verify-email',
46 '/v1/auth/send-verification-email',
47 // Password
48 '/v1/auth/request-password-reset',
49 '/v1/auth/reset-password',
50 '/v1/auth/change-password',
51 '/v1/auth/verify-password',
52 // User / account mutations
53 '/v1/auth/change-email',
54 '/v1/auth/update-user',
55 '/v1/auth/delete-user',
56 '/v1/auth/link-social',
57 '/v1/auth/unlink-account',
58 '/v1/auth/list-accounts',
59 '/v1/auth/account-info',
60 // Tokens
61 '/v1/auth/refresh-token',
62 '/v1/auth/get-access-token',
63];
64
65function isBetterAuthPath(path: string): boolean {
66 for (const p of BETTER_AUTH_PATHS) {
67 if (path === p || path.startsWith(p + '/')) return true;
68 }
69 return false;
70}
71
72/**
73 * Pure policy function — extracted so the middleware decision is unit-testable
74 * without spinning up a full Hono context.
75 *
76 * Defence-in-depth on top of `sameSite: 'strict'` for the session cookie.
77 * For unsafe methods on cookie-authenticated routes, require the `Origin`
78 * header to match a trusted origin. API-key authenticated requests carry
79 * no session cookie and so bypass. Webhook endpoints (Polar, etc.) are
80 * never session-authenticated, so they bypass too.
81 *
82 * Better Auth's own /v1/auth/* routes handle their internal CSRF separately;
83 * we skip those (allowlisted in BETTER_AUTH_PATHS) to avoid double-counting.
84 * Custom briven-owned routes under /v1/auth/ (e.g. /v1/auth/cli-token) are
85 * NOT exempt and must pass the origin check like every other mutating route.
86 */
87export function shouldRejectAsCsrf(input: {
88 method: string;
89 hasSession: boolean;
90 path: string;
91 origin: string | null;
92 trustedOrigins: readonly string[];
93}): boolean {
94 if (!UNSAFE_METHODS.has(input.method.toUpperCase())) return false;
95 if (!input.hasSession) return false;
96 if (isBetterAuthPath(input.path)) return false;
97 if (!input.origin || !input.trustedOrigins.includes(input.origin)) return true;
98 return false;
99}
100
101function trustedOrigins(): string[] {
102 const list = new Set<string>([env.BRIVEN_API_ORIGIN, env.BRIVEN_WEB_ORIGIN]);
103 for (const o of env.BRIVEN_TRUSTED_ORIGINS.split(',').map((s) => s.trim())) {
104 if (o) list.add(o);
105 }
106 return [...list];
107}
108
109export const csrfOriginCheck = (): MiddlewareHandler => async (c, next) => {
110 // Bearer-token carve-out: CSRF is a browser-only attack vector — the
111 // browser auto-attaches cookies, but it never auto-attaches an
112 // `Authorization: Bearer …` header from a cross-origin form/fetch.
113 // CLI requests (and any non-browser caller using a JWT) therefore
114 // can't be CSRF'd and must skip the origin check entirely. This
115 // sits above every other branch so it can't be defeated by a stray
116 // session cookie tagging along on a bearer request.
117 const authHeader = c.req.header('authorization');
118 if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
119 const token = authHeader.slice(7).trim();
120 if (token.length > 0) {
121 await next();
122 return;
123 }
124 }
125
126 const session = c.get('session') as Session | null | undefined;
127 const path = new URL(c.req.url).pathname;
128 const origin = c.req.header('origin') ?? null;
129
130 if (
131 // A project-registered app domain (or briven-own origin) is trusted — skip
132 // the CSRF rejection for it (supports wildcard subdomains).
133 !isRegisteredOrigin(origin) &&
134 shouldRejectAsCsrf({
135 method: c.req.method,
136 hasSession: Boolean(session),
137 path,
138 origin,
139 trustedOrigins: trustedOrigins(),
140 })
141 ) {
142 log.warn('csrf_origin_rejected', { path, method: c.req.method, origin });
143 return c.json({ code: 'csrf_origin_rejected', message: 'request origin is not trusted' }, 403);
144 }
145
146 await next();
147 return;
148};