csrf.ts148 lines · main
| 1 | import type { MiddlewareHandler } from 'hono'; |
| 2 | |
| 3 | import { env } from '../env.js'; |
| 4 | import { log } from '../lib/logger.js'; |
| 5 | import { isRegisteredOrigin } from '../services/auth-origin-allowlist.js'; |
| 6 | import type { Session } from './session.js'; |
| 7 | |
| 8 | const 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 | */ |
| 25 | const 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 | |
| 65 | function 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 | */ |
| 87 | export 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 | |
| 101 | function 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 | |
| 109 | export 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 | }; |