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
3 changes: 2 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ All state-changing routes (`POST`/`PATCH`/`PUT`/`DELETE`) check **double-submit
- On any cookie-session request, the hub sets a `csrf_token` cookie (non-`HttpOnly`, `SameSite=Lax`) with a random 32-byte value.
- The web client reads `csrf_token` from `document.cookie` and forwards it as `X-CSRF-Token: <value>` on every mutation.
- The hub compares the header to the cookie using `crypto.timingSafeEqual`. Mismatch = 403.
- Routes called only with the bearer-token legacy path skip the check (legacy clients never had it).
- Routes called only with the bearer-token legacy path skip the check (legacy clients never had it). The web client always attaches `Authorization: Bearer <jwt>` whenever a legacy JWT is present in localStorage, even if a stale `csrf_token` cookie is also present — this guarantees `csrfGuard`'s Bearer-bypass fires for legacy-auth users who logged in via magic-link previously and still have the cookie around.
- **Self-heal:** if a request carries a valid `__Host-remo_sid` cookie but no `csrf_token` cookie (drift state — csrf cookie cleared, expired, or never set), `csrfGuard` re-issues a fresh `csrf_token` in the response 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 against the DAL before self-heal triggers. Without this branch, the very next mutating call (e.g. `POST /api/account/coolify-webhook-secret/rotate`) would 403 with no in-app recovery short of logout+login.

### Re-auth (step-up) gate

Expand Down
37 changes: 37 additions & 0 deletions hub/src/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Context, Next } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import { config } from './config';
import { readSessionCookie, verifyAuthSessionToken } from './session';

export const CSRF_COOKIE_NAME = 'csrf_token';
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
Expand Down Expand Up @@ -135,6 +136,42 @@ export function csrfGuard() {

const cookieValue = readCsrfCookie(c);
const headerValue = c.req.header(CSRF_HEADER_NAME) || c.req.header(CSRF_HEADER_NAME.toLowerCase());

// Self-heal: cookie users can drift into a "valid `__Host-remo_sid` but
// no `csrf_token` cookie" state when the csrf cookie expires, is cleared
// by a privacy extension, or was never set (e.g. session created before
// the double-submit pattern was added, then survived across deploys via
// sliding-idle touch). Without this branch, the very next mutating call
// (e.g. POST /api/account/coolify-webhook-secret/rotate) fails 403 and
// there is NO client-side path to recover short of logout+login.
//
// This branch is safe to allow-through because:
// 1. The session cookie itself is `SameSite=Lax` — browsers do NOT
// attach it on cross-site POST/PUT/PATCH/DELETE, so the CSRF
// attacker can't even reach this code path with a valid session.
// 2. We verify the session token against the DAL row (idle/expiry/
// user-exists checks all pass) before issuing — a forged/stale
// session cookie never gets self-healed.
// 3. We re-issue a fresh `csrf_token` cookie in the same response so
// subsequent requests use the normal double-submit path again.
if (!cookieValue) {
const sessionToken = readSessionCookie(c);
if (sessionToken) {
try {
const sessionCtx = await verifyAuthSessionToken(sessionToken);
if (sessionCtx) {
const fresh = issueCsrfToken(sessionToken);
setCsrfCookie(c, fresh);
return next();
}
} catch {
// DB unavailable or transient — fall through to normal 403.
// Self-heal is a best-effort recovery; never let it mask a real
// CSRF rejection or crash on infra issues.
}
}
}

if (!verifyCsrfPair(cookieValue, headerValue ?? null)) {
return c.json({ error: 'csrf_failed' }, 403);
}
Expand Down
28 changes: 28 additions & 0 deletions hub/test/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,32 @@ describe('csrfGuard middleware', () => {
});
expect(res.status).toBe(200);
});

// Self-heal regression: a request that has a `__Host-remo_sid` session
// cookie but NO `csrf_token` cookie (the drift state that produced
// `csrf_failed` on POST /api/account/coolify-webhook-secret/rotate) must
// not 403 outright. The DAL lookup for the bogus token fails closed → we
// fall through to the normal 403. The positive self-heal (valid session
// token → re-issue + allow) is covered by the e2e test in
// hub/test/coolify-webhook-secret.test.ts when REMO_E2E_DB_URL is set.
test('POST with session cookie but BOGUS session token + no csrf → 403 (DB miss falls through)', async () => {
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { cookie: `__Host-remo_sid=remo_bogus_token_will_not_resolve_in_DAL` },
});
expect(res.status).toBe(403);
const body = await res.json();
expect((body as any).error).toBe('csrf_failed');
});

test('POST with session cookie + DB unavailable → does not crash, returns 403', async () => {
// The csrf middleware swallows DAL errors. With no DATABASE_URL test
// setup, the call throws inside verifyAuthSessionToken — must end as 403,
// never a 500.
const res = await buildApp().request('/api/foo', {
method: 'POST',
headers: { cookie: `__Host-remo_sid=remo_anything` },
});
expect(res.status).toBe(403);
});
});
16 changes: 12 additions & 4 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,18 @@ export async function hubFetch<T = any>(
finalHeaders['X-CSRF-Token'] = csrf
}

// Soak-window fallback ONLY: if no csrf cookie present (i.e. user is still
// on a legacy bearer-token session) and a token was passed, attach it. Post
// cutover this branch is dead and `token` callers can pass null.
if (!csrf && token) {
// Soak-window: attach Authorization: Bearer whenever a legacy token is
// available AND it isn't the cookie-session sentinel. This must run
// INDEPENDENTLY of csrf-cookie presence — a legacy-JWT user can also have
// a stale `csrf_token` cookie hanging around (e.g. from a prior magic-link
// session that was later replaced by a bcrypt login). Without the Bearer
// header in that case, hub-side `csrfGuard` has no way to know this is a
// non-CSRF-eligible Bearer-authed request and falls back to double-submit
// enforcement, which fails because the stale `X-CSRF-Token` no longer
// matches the server cookie. PR #65 added the server-side Bearer bypass —
// this is the client-side half: always send the Bearer when we have a
// real legacy token so the bypass actually fires.
if (token && token !== 'cookie') {
finalHeaders['Authorization'] = `Bearer ${token}`
}

Expand Down
Loading