From 987a94e15e2582026107ed7aaf8714e113307cea Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 12:18:56 -0700 Subject: [PATCH] fix(hub): bypass CSRF for bearer-only requests (legacy-JWT path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: csrfGuard rejected every mutating /api/* request that didn't carry a csrf_token cookie + matching X-CSRF-Token header. Users on the legacy-JWT soak path (authMethod=legacy_jwt) never get the csrf_token cookie issued, so POST /api/supervisors/:id/scan (and every other mutating REST call) returned 403 csrf_failed. Fix: skip CSRF enforcement when the request authenticates via Authorization: Bearer and has no session cookie. Bearer-only requests are not CSRF-vulnerable because browsers never auto-attach Authorization headers cross-origin (unlike cookies) — JS must opt-in, which a malicious cross-origin page cannot do without the token. When BOTH a session cookie AND a bearer token are present, CSRF is still enforced (cookie wins — matches authMiddleware precedence). Added 2 csrf.test.ts cases covering both branches; full suite 20/20. --- hub/src/csrf.ts | 16 ++++++++++++++++ hub/test/csrf.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) 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);