diff --git a/hub/src/csrf.ts b/hub/src/csrf.ts index 8e6e1e0..942486b 100644 --- a/hub/src/csrf.ts +++ b/hub/src/csrf.ts @@ -17,6 +17,13 @@ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; import { config } from './config'; +// `__Host-remo_sid` is the only ambient credential browsers auto-attach. If +// a request authenticates via `Authorization: Bearer …` (legacy JWT path, +// MCP plugin, agent), it is NOT CSRF-vulnerable because the browser never +// auto-attaches that header cross-origin — JS must opt-in. So we skip CSRF +// enforcement when no session cookie is present AND a bearer token is. +const HOST_SESSION_COOKIE = '__Host-remo_sid'; + export const CSRF_COOKIE_NAME = 'csrf_token'; export const CSRF_HEADER_NAME = 'X-CSRF-Token'; @@ -92,6 +99,15 @@ export function csrfGuard() { if (!MUTATING_METHODS.has(method)) return next(); if (isCsrfAllowlisted(c.req.path)) return next(); + // Bearer-only requests (no session cookie present, Authorization header + // provided) are immune to CSRF: browsers never auto-attach Authorization + // headers, so a malicious cross-origin page cannot forge such a request. + // Without this bypass, every mutating call on the legacy-JWT soak path + // (and MCP plugin / agent paths that mount under /api/*) gets 403. + const hasSessionCookie = !!getCookie(c, HOST_SESSION_COOKIE); + const hasBearer = (c.req.header('Authorization') || '').startsWith('Bearer '); + if (!hasSessionCookie && hasBearer) return next(); + const cookieValue = readCsrfCookie(c); const headerValue = c.req.header(CSRF_HEADER_NAME) || c.req.header(CSRF_HEADER_NAME.toLowerCase()); if (!verifyCsrfPair(cookieValue, headerValue ?? null)) { diff --git a/hub/test/csrf.test.ts b/hub/test/csrf.test.ts index 7ae072e..8e0826f 100644 --- a/hub/test/csrf.test.ts +++ b/hub/test/csrf.test.ts @@ -117,6 +117,25 @@ describe('csrfGuard middleware', () => { expect(res.status).toBe(200); }); + test('POST with Bearer token and no session cookie bypasses CSRF (legacy-JWT/plugin/agent)', async () => { + const res = await buildApp().request('/api/foo', { + method: 'POST', + headers: { authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.x.y' }, + }); + expect(res.status).toBe(200); + }); + + test('POST with Bearer AND session cookie still enforces CSRF (cookie wins)', async () => { + const res = await buildApp().request('/api/foo', { + method: 'POST', + headers: { + authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.x.y', + cookie: '__Host-remo_sid=sid_123', + }, + }); + expect(res.status).toBe(403); + }); + test('allowlisted POST passes without CSRF', async () => { const res = await buildApp().request('/api/sentry/p/envelope/', { method: 'POST' }); expect(res.status).toBe(200);