import { logger } from './logger'
/**
* CSRF Protection using Origin/Host header verification
* Based on Lucia's recommended approach for cookie-based authentication
*
* This validates that the Origin header matches the Host header for
* state-changing requests (POST, PUT, DELETE, PATCH).
*/
/**
* Verify that the request origin matches the expected host
* @param origin - The Origin header value
* @param allowedHosts - Array of allowed host values
* @returns true if origin is valid, false otherwise
*/
export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean {
if (!origin) {
return false
}
try {
const originUrl = new URL(origin)
const originHost = originUrl.host
return allowedHosts.some((host) => originHost === host || (host.startsWith('.') && originHost.endsWith(host)))
} catch {
// Invalid URL
return false
}
}
/**
* CSRF Protection Middleware for Elysia
*
* NOTE: apply via `.onBeforeHandle(csrfProtection(...))` directly on the root
* app instance, NOT wrapped in a `new Elysia({ name })` plugin. In Elysia
* 1.4.x, lifecycle hooks from a named plugin `.use()`'d before route modules do
* not propagate to those routes, leaving CSRF silently unenforced. Applying the
* hook directly on the root instance propagates to all child `.use()` routes.
*
* `allowedExtraOrigins` permits additional hosts only for /private/redeem.
* Entries beginning with `.` match any subdomain of that suffix, so
* `<siteId>.priv.<host>` is covered without exempting any other route.
*
* `/xrpc/*` is exempt entirely: it is bearer-token authenticated, not cookie
* authenticated.
*
* Usage:
* ```ts
* import { csrfProtection } from './lib/csrf'
*
* new Elysia()
* .onBeforeHandle(csrfProtection(['.priv.example.com']))
* .post('/api/protected', handler)
* ```
*/
export const csrfProtection =
(allowedExtraOrigins: string[] = []) =>
({ request, set }: { request: Request; set: { status?: number | string } }) => {
const method = request.method.toUpperCase()
// Only protect state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return
}
// Get headers
const originHeader = request.headers.get('Origin')
// Use X-Forwarded-Host if behind a proxy, otherwise use Host
const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host')
const path = new URL(request.url).pathname
// XRPC routes authenticate only via `Authorization: Bearer <service JWT>` and never
// read the session cookie, so a cross-site POST carries no ambient authority to abuse.
// An origin check there only breaks legitimate non-browser clients: a PDS relays
// `agent.call(...)` server-to-server with no Origin header, which 403'd every CLI
// procedure (private-site deploy, domain claim, site delete).
if (path === '/xrpc' || path.startsWith('/xrpc/')) {
return
}
const extraHosts = path === '/private/redeem' ? allowedExtraOrigins : []
// Extra origins are trusted only for the scoped-share redemption endpoint.
if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, ...extraHosts])) {
logger.warn('[CSRF] Request blocked', {
method,
origin: originHeader,
host: hostHeader,
path,
})
set.status = 403
return {
error: 'CSRF validation failed',
message: 'Request origin does not match host',
}
}
}