scope private origin CSRF exception to redeem route
223 unmodified linesExpand all224225226227228228229230231232273 unmodified linesExpand all223 unmodified linesExpand all statusCode, } }) // Private-site subdomains (<siteId>.priv.<host>) legitimately POST /private/redeem // to redeem audience-scoped shares, so allow that origin through CSRF. // to redeem audience-scoped shares. The middleware scopes this exception to that path. .onBeforeHandle(csrfProtection([`.${process.env.PRIVATE_HOST || `priv.${BASE_HOST}`}`])) .get('/', async ({ request, set }) => { // Build dynamic login URL for AT Protocol OAuth entryway const loginUrl = isLocalDev ? `${new URL(request.url).origin}/api/auth/login` : `${config.domain}/api/auth/login`273 unmodified linesExpand all122345680 unmodified lines87888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124import { describe, expect, test } from 'bun:test'import { verifyRequestOrigin } from './csrf'import { csrfProtection, verifyRequestOrigin } from './csrf'
describe('verifyRequestOrigin', () => { test('should accept matching origin and host', () => { expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)80 unmodified lines expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true) expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true) })})
describe('csrfProtection', () => { const privateOrigin = 'https://amber-anchor-fox-1234.priv.wisp.place' const protect = csrfProtection(['.priv.wisp.place'])
const run = (path: string) => { const set: { status?: number | string } = {} const result = protect({ request: new Request(`https://wisp.place${path}`, { method: 'POST', headers: { Host: 'wisp.place', Origin: privateOrigin }, }), set, }) return { result, set } }
test('allows private-site origins for scoped share redemption', () => { const { result, set } = run('/private/redeem') expect(result).toBeUndefined() expect(set.status).toBeUndefined() })
test('rejects private-site origins for other unsafe routes', () => { for (const path of ['/wisp/upload-files', '/api/auth/logout', '/api/domain/claim']) { const { result, set } = run(path) expect(set.status).toBe(403) expect(result).toEqual({ error: 'CSRF validation failed', message: 'Request origin does not match host', }) } })})37 unmodified lines383940414243444243444546474816 unmodified lines6566676869707170717273747576777678798081825 unmodified lines37 unmodified lines * 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 exact hosts (e.g. the private-site * origin that legitimately POSTs /private/redeem). Entries beginning with `.` * match any subdomain of that suffix, so `<siteId>.priv.<host>` is covered. * `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. * * Usage: * ```ts * import { csrfProtection } from './lib/csrf'16 unmodified lines // 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 const extraHosts = path === '/private/redeem' ? allowedExtraOrigins : []
// Validate origin matches host or an explicitly allowlisted origin if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, ...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: new URL(request.url).pathname, path, })
set.status = 403 return {5 unmodified lines