Skip to content
Merged
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
41 changes: 41 additions & 0 deletions hub/src/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,55 @@ export function verifyCsrfPair(cookieValue: string | null, headerValue: string |
return constantTimeEqualHex(cookieValue, headerValue);
}

// Bearer-auth bypass: matches `Authorization: Bearer <non-empty>` (case-insensitive
// scheme, any whitespace, any non-empty token). Empty/missing/non-Bearer schemes
// fall through to normal CSRF enforcement.
const BEARER_RE = /^Bearer\s+\S+/i;

// Hono middleware: enforces CSRF on mutating methods except allowlisted paths.
// Pass-through on GET/HEAD/OPTIONS and on allowlisted paths.
//
// Threat model for the Bearer bypass (legacy JWT auth path):
// CSRF attacks exploit the browser's ambient-credential behavior — a victim
// visits evil.com, evil.com triggers a cross-origin POST to app.remo-code.com,
// and the BROWSER attaches the victim's cookies automatically. The attacker
// never needs to read the cookie value; they just ride it.
//
// The double-submit cookie pattern defends against this by requiring the
// attacker to ALSO present the CSRF nonce in a header — which the browser
// does NOT attach automatically; only same-origin JS that can read the
// csrf_token cookie can echo it.
//
// `Authorization: Bearer <token>` headers are NOT in the browser's
// ambient-credential set. Browsers never attach them automatically — JS
// must explicitly set the header on each request. Cross-origin JS on
// evil.com cannot read the bearer token from app.remo-code.com's
// localStorage (same-origin policy), so it cannot forge a Bearer-authed
// request even if the user is logged in.
//
// Therefore: a request that carries a Bearer token is, by construction,
// not a CSRF-eligible request. The bypass is safe AND necessary — legacy
// JWT-auth users never receive a csrf_token cookie (only the new
// session-cookie auth issues one), so without this bypass every mutating
// call from a legacy-auth client fails closed with 403.
//
// Scope guardrails:
// - ONLY Bearer scheme. Custom headers (X-Auth, etc.) do NOT qualify —
// a CSRF attacker can set arbitrary custom headers via fetch() if CORS
// allows it, so "presence of a custom header" is not a safe proxy.
// - Empty `Authorization:` header does NOT bypass.
// - Cookie-auth users continue to use double-submit. We do not loosen
// enforcement for them.
export function csrfGuard() {
return async (c: Context, next: Next) => {
const method = c.req.method.toUpperCase();
if (!MUTATING_METHODS.has(method)) return next();
if (isCsrfAllowlisted(c.req.path)) return next();

// Bearer-auth bypass — see threat model above.
const authHeader = c.req.header('Authorization') || c.req.header('authorization');
if (authHeader && BEARER_RE.test(authHeader)) 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
73 changes: 73 additions & 0 deletions hub/test/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,77 @@ describe('csrfGuard middleware', () => {
const res = await app.request('/api/foo', { method: 'DELETE' });
expect(res.status).toBe(403);
});

// Bearer-auth bypass (legacy JWT path) — see threat model in csrf.ts.
test('POST with Authorization: Bearer <token> bypasses CSRF', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' },
});
expect(res.status).toBe(200);
});

test('POST with Bearer bypass works without any csrf cookie or header', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: 'Bearer token-xyz' },
});
expect(res.status).toBe(200);
});

test('POST with lowercase authorization header still bypasses (HTTP is case-insensitive)', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { authorization: 'bearer abc.def.ghi' },
});
expect(res.status).toBe(200);
});

test('POST with empty Authorization header does NOT bypass → 403', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: '' },
});
expect(res.status).toBe(403);
});

test('POST with non-Bearer scheme (Basic) does NOT bypass → 403', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
});
expect(res.status).toBe(403);
});

test('POST with "Bearer" but no token does NOT bypass → 403', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: 'Bearer ' },
});
expect(res.status).toBe(403);
});

test('POST with "Bearer" alone (no space, no token) does NOT bypass → 403', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { Authorization: 'Bearer' },
});
expect(res.status).toBe(403);
});

test('Bearer bypass does NOT apply to GET (irrelevant — GET is already passthrough)', async () => {
const res = await buildApp().request('/api/foo', {
method: 'GET',
headers: { Authorization: 'Bearer abc' },
});
expect(res.status).toBe(200);
});

test('cookie-auth double-submit still works when no Authorization header present', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { cookie: `${CSRF_COOKIE_NAME}=match-me-123`, [CSRF_HEADER_NAME]: 'match-me-123' },
});
expect(res.status).toBe(200);
});
});
Loading