fix(hub): CSRF guard bypasses Bearer-auth requests (legacy JWT)#65
Merged
Conversation
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>
This was referenced May 26, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a 403
csrf_failedregression 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
hub/src/api/auth.ts) does NOT issue thecsrf_tokencookie — only the new session-cookie auth path does.apiFetchreadscsrf_tokenfrom cookies, finds nothing, sends the POST withoutX-CSRF-Token.csrfGuard()rejects with{ error: 'csrf_failed' }, 403.Fix
csrfGuard()now bypasses CSRF enforcement when the request carries anAuthorization: Bearer <token>header. Strict regex/^Bearer\s+\S+/i— emptyAuthorization:, non-Bearer schemes (Basic, etc.), andBearerwith 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:
Authorizationheaders are NOT in the ambient-credential set. Browsers never auto-attach them; JS must explicitly set them per request.evil.comcannot read the bearer token fromapp.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
Bearerscheme bypasses. Custom headers (X-Auth, etc.) do not qualify — a CSRF attacker can set arbitrary custom headers viafetch()if CORS allows.Authorization:header does NOT bypass.Test plan
hub/test/csrf.test.tscovering the bypass + every guardrail (empty header, Basic scheme,Bearerwith no token, lowercase header name, GET passthrough, cookie-auth still works without Bearer).insert-run-started-at.test.ts(unrelated, on main since fix(scheduler.registry): ensure started_at always populated on cron fires #60).POST /api/supervisors/:id/scanwithout 403.🤖 Generated with Claude Code