Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions hub/src/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)) {
Expand Down
19 changes: 19 additions & 0 deletions hub/test/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down