Skip to content

fix(web): rotate webhook secret call uses authed fetch (no CSRF fail)#67

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

fix(web): rotate webhook secret call uses authed fetch (no CSRF fail)#67
finedesignz merged 1 commit into
mainfrom
fix/rotate-csrf

Conversation

@finedesignz
Copy link
Copy Markdown
Owner

Summary

PR #65 closed the legacy-JWT CSRF gap, but csrf_failed still showed up on Rotate URL (and every other re-auth-gated mutation) in two edge cases:

  1. Cookie-auth drift__Host-remo_sid valid, csrf_token cookie missing. No client-side recovery. csrfGuard now self-heals: if the session token verifies against the DAL, re-issue a fresh csrf_token cookie and let the current request through. Safe because the session cookie is SameSite=Lax (browser blocks cross-site mutating requests before they reach us).
  2. Legacy-JWT user with a stale csrf_token cookie (e.g. previously magic-link logged in, later switched to bcrypt; cookie didn't always get cleared). Old hubFetch only attached Authorization: Bearer when csrf cookie was absent — so the stale cookie caused us to send X-CSRF-Token instead, PR fix(hub): CSRF guard bypasses Bearer-auth requests (legacy JWT) #65's Bearer-bypass never fired, double-submit enforcement → 403. hubFetch now always attaches Authorization: Bearer <token> whenever a real legacy token is in localStorage (sentinel 'cookie' excluded).

Docs: docs/auth.md CSRF model section documents both rules.

Test plan

  • bun test hub/test/csrf.test.ts → 29 pass (added 2 regression tests covering bogus session token + DB-unavailable paths)
  • bun run build:web → clean
  • Post-deploy: hit Rotate URL in Settings → expect 200 + fresh webhook URL, not 403 csrf_failed
  • Verify legacy-JWT users hitting any mutation route now succeed (Bearer-bypass fires deterministically)

🤖 Generated with Claude Code

…ays send Bearer

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>
@finedesignz finedesignz merged commit 388d2b2 into main May 26, 2026
1 check passed
finedesignz added a commit that referenced this pull request May 26, 2026
PR #67 cleared the csrf_failed 403 on POST /api/account/coolify-webhook-secret/rotate,
but the request then hit the requireRecentAuth() gate at hub/src/index.ts:198
and 401'd. Two failure modes:

  - Legacy Bearer-JWT clients have no cookie session at all -> the gate returns
    {error: 're_auth_required', reason: 'no_cookie_session'} with no client-
    side recovery short of logout+login.
  - Cookie-auth users whose session is older than 5 min get re_auth_required
    even though their session is otherwise valid.

Threat model for rotate: an attacker who already controls the user's session
(or bearer JWT) can rotate the webhook secret -- but they already control
the account. Re-auth on rotate alone buys nothing. The per-user mutation
rate limit (10/min/user) at hub/src/index.ts:223 still applies. Sister
re-auth gates on api-keys POST/DELETE and error-projects DELETE remain
untouched -- those grant credential issuance / data destruction and the
elevated friction is warranted.

Verified: csrf + reauth test suites both green (33 pass).
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