Skip to content

fix(hub): CSRF guard bypasses Bearer-auth requests (legacy JWT)#65

Merged
finedesignz merged 1 commit into
mainfrom
fix/csrf-bypass-bearer
May 26, 2026
Merged

fix(hub): CSRF guard bypasses Bearer-auth requests (legacy JWT)#65
finedesignz merged 1 commit into
mainfrom
fix/csrf-bypass-bearer

Conversation

@finedesignz
Copy link
Copy Markdown
Owner

Summary

Fixes a 403 csrf_failed regression for production users on the legacy JWT auth path. Symptom: clicking Scan on SupervisorPage (POST /api/supervisors/:id/scan) returns 403 in the browser.

Root cause

  1. Legacy JWT login (hub/src/api/auth.ts) does NOT issue the csrf_token cookie — only the new session-cookie auth path does.
  2. Frontend apiFetch reads csrf_token from cookies, finds nothing, sends the POST without X-CSRF-Token.
  3. csrfGuard() rejects with { error: 'csrf_failed' }, 403.

Fix

csrfGuard() now bypasses CSRF enforcement when the request carries an Authorization: Bearer <token> header. Strict regex /^Bearer\s+\S+/i — empty Authorization:, non-Bearer schemes (Basic, etc.), and Bearer with no token all still go through normal CSRF check.

Threat model (also embedded as a comment in csrf.ts)

CSRF attacks exploit the browser's ambient-credential behavior:

  • Cookies are attached cross-origin automatically — the attacker just rides them.
  • Authorization headers are NOT in the ambient-credential set. Browsers never auto-attach them; JS must explicitly set them per request.
  • 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.

→ A request carrying a Bearer token is, by construction, not CSRF-eligible. The double-submit cookie defense is for cookie-auth requests, where the attacker has ambient credentials but cannot read the CSRF nonce. Bearer auth has the inverse property: the attacker has no ambient access at all.

Scope guardrails

  • ONLY the Bearer scheme bypasses. Custom headers (X-Auth, etc.) do not qualify — a CSRF attacker can set arbitrary custom headers via fetch() if CORS allows.
  • Empty Authorization: header does NOT bypass.
  • Cookie-auth users continue to use double-submit (unchanged).

Test plan

  • 10 new cases in hub/test/csrf.test.ts covering the bypass + every guardrail (empty header, Basic scheme, Bearer with no token, lowercase header name, GET passthrough, cookie-auth still works without Bearer).
  • All 27 csrf tests pass.
  • Full hub test suite: 293 pass, 5 pre-existing failures in insert-run-started-at.test.ts (unrelated, on main since fix(scheduler.registry): ensure started_at always populated on cron fires #60).
  • Post-deploy: legacy-JWT user can POST /api/supervisors/:id/scan without 403.

🤖 Generated with Claude Code

Production users on the legacy JWT auth path were hitting 403 csrf_failed
on every mutating REST call (e.g. POST /api/supervisors/:id/scan from
SupervisorPage). Root cause: legacy JWT login never issues the csrf_token
cookie that the new session-cookie auth path issues, so apiFetch can't
echo it back in X-CSRF-Token, so csrfGuard rejects.

Fix: csrfGuard now bypasses enforcement when the request carries an
Authorization: Bearer <non-empty> header. The bypass is gated by a strict
/^Bearer\s+\S+/i regex — empty Authorization, non-Bearer schemes (Basic,
etc.), and 'Bearer' without a token all still fall through to normal CSRF
enforcement.

Threat model (also documented in csrf.ts):
- CSRF attacks rely on the browser's ambient-credential behavior. Cookies
  are attached cross-origin automatically; Authorization headers are NOT.
- JS on evil.com cannot read app.remo-code.com's bearer token from
  localStorage (same-origin policy), so it cannot forge a Bearer-authed
  request.
- Therefore a Bearer-authed request is, by construction, not CSRF-eligible
  and does not need the double-submit token.
- Scope guardrails: ONLY the Bearer scheme bypasses. Custom headers do not
  qualify — a CSRF attacker can set arbitrary custom headers via fetch()
  if CORS allows, so 'presence of custom header' is not a safe proxy.
- Cookie-auth users continue to use the double-submit pattern unchanged.

Tests: 10 new cases in hub/test/csrf.test.ts cover the bypass + every
guardrail (empty header, Basic scheme, 'Bearer' with no token, lowercase
header name, GET passthrough, cookie-auth still works without Bearer).
All 27 csrf tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@finedesignz finedesignz merged commit e629ba0 into main May 26, 2026
1 check passed
finedesignz added a commit that referenced this pull request May 26, 2026
…ays send Bearer (#67)

PR #65 fixed csrf_failed for legacy-JWT users by adding a server-side
Bearer-bypass in csrfGuard. Two follow-up edges still produced
csrf_failed on POST /api/account/coolify-webhook-secret/rotate (and
every other re-auth-gated mutation) for users in the field:

1. Cookie-auth users in the drift state:
   `__Host-remo_sid` cookie valid, `csrf_token` cookie missing (cleared
   by a privacy extension, expired separately, or never set on a session
   that pre-dated the double-submit pattern and survived across deploys
   via sliding-idle touch). csrfGuard had no header to compare against
   and 403'd with no client-side recovery short of logout+login.

   Fix: when csrf cookie is missing but the session cookie validates
   against the DAL, csrfGuard re-issues a fresh csrf_token cookie and
   allows the current request through. Safe because the session
   cookie's SameSite=Lax already blocks cross-site mutating requests,
   and the session token is verified before self-heal triggers. DAL
   errors fall through to the normal 403 so self-heal never masks real
   CSRF rejections or crashes on infra issues.

2. Legacy-JWT users with a stale csrf_token cookie still hanging
   around (e.g. they previously magic-link-logged-in, then logged out
   and re-logged in via bcrypt — the cookie didn't always get cleared).
   hubFetch's old logic only attached `Authorization: Bearer` when the
   csrf cookie was ABSENT, so the stale cookie caused us to send an
   X-CSRF-Token instead of the Bearer. PR #65's server-side Bearer
   bypass therefore never fired → double-submit enforcement → 403.

   Fix: hubFetch now always attaches `Authorization: Bearer <token>`
   whenever a real legacy token is in localStorage (sentinel 'cookie'
   excluded), independent of csrf cookie state. That guarantees PR
   #65's Bearer bypass actually triggers for legacy users.

Tests: 2 new csrf middleware regression tests cover (a) bogus session
token + no csrf cookie → still 403 (DAL miss falls through), and (b)
session cookie present + DB unavailable → 403, never 500. All 29 csrf
tests pass. The positive self-heal path (valid session token → re-issue
+ allow) is covered by hub/test/coolify-webhook-secret.test.ts when
REMO_E2E_DB_URL is set.

Docs: docs/auth.md "CSRF model" section updated to document both the
client-side always-Bearer rule and the server-side self-heal branch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant