ghostguild-org/server/middleware/01.csrf.js
Jennie Robinson Faber 7a626b0a82
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m1s
Test / playwright (push) Has been cancelled
fix(csrf): exempt /api/internal/ from double-submit check
The reconcile-payments cron POSTs to /api/internal/reconcile-payments with
an X-Reconcile-Token header but no csrf-token cookie/header. The CSRF
middleware was 403ing the request before the route handler could check
the shared secret — breaking Fix #6 (daily reconciliation cron).

Found while wiring the Dokploy scheduled task. The Netlify scheduled
function would have hit the same 403; nobody noticed because the site
hasn't been deployed yet.

Removing CSRF protection from /api/internal/ is safe: every route under
that prefix is machine-to-machine and gates on its own shared-secret
header. CSRF protects against browser-driven cross-origin POSTs, which
isn't the threat model for these endpoints.

Tests: 758 passing (CSRF middleware unit tests still cover the exempt
list shape).
2026-04-26 13:16:11 +01:00

49 lines
1.4 KiB
JavaScript

import crypto from 'crypto'
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
// Routes exempt from CSRF (external webhooks, magic link verify, machine-to-
// machine internal endpoints with their own shared-secret auth)
const EXEMPT_PREFIXES = [
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify',
'/api/internal/',
'/oidc/',
]
function isExempt(path) {
return EXEMPT_PREFIXES.some(prefix => path.startsWith(prefix))
}
export default defineEventHandler((event) => {
const method = getMethod(event)
const path = getRequestURL(event).pathname
// Always set a CSRF token cookie if one doesn't exist
let csrfToken = getCookie(event, 'csrf-token')
if (!csrfToken) {
csrfToken = crypto.randomBytes(32).toString('hex')
setCookie(event, 'csrf-token', csrfToken, {
httpOnly: false, // Must be readable by JS to include in requests
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
}
// Only check state-changing methods
if (SAFE_METHODS.has(method)) return
if (!path.startsWith('/api/')) return
if (isExempt(path)) return
// Double-submit cookie check: header must match cookie
const headerToken = getHeader(event, 'x-csrf-token')
if (!headerToken || headerToken !== csrfToken) {
throw createError({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
}
})