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