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).
49 lines
1.4 KiB
JavaScript
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'
|
|
})
|
|
}
|
|
})
|