8.5 KiB
security — RBAC + token-management + user-management audit
Date: 2026-08-27 · From: engineer-20260827-180000, principal-20260827-175448
Scope: the new RBAC / API-token / user-management surface on the public domain. Read-only audit (no edits).
Out of scope (per task): DB-superuser-in-.env (tracked separately), analytics math.
Method / evidence
- Read
server/auth.ts,server/tokens.ts,server/db.ts,index.ts, clientApp.tsx/services. - Full route inventory via grep (
app.get|post|put|delete|use) — 15 API routes, all accounted for below. - Grepped for the removed
ADMIN_KEY/x-admin-keygate → no matches (fully removed). NodangerouslySetInnerHTMLinclient/src. - Live DB (role
forge_app,31.133.115.50:5440/forge):current_user=forge_app;SELECT count(*) FROM app_users WHERE role='admin'→ 1; the sole accountadminis roleadmin(bootstrap migrated correctly). No destructive SQL run.
Verified correct (no action)
- Route gating matches the spec matrix. viewer:
/api/tickets,/api/tickets/:number,/api/stats,/api/config(requireAuth). pm:/api/analytics/overview|sla,/api/insights(requireRole('pm')). lead:GET/POST /api/users,DELETE /api/users/:username. admin:GET/POST /api/tokens,POST /api/tokens/:id/revoke./api/synctoken-only. - 401 vs 403 correct (
auth.ts:46-61): unauth→401, under-privileged→403. - Role cannot be spoofed — read from the server-side PG session store (
req.session.user,auth.ts:14), never from client input. - Admin-creation guard present (
index.ts:249): non-admin creatingrole==='admin'→ 403. isRolevalidation (db.ts:191): arbitrary/invalid role string → 400invalid_role.- Self-delete guard present (
index.ts:262). - Tokens: admin-only; raw
fg_…returned once (tokens.ts:81-89), onlysha256(secret)persisted;listTokensselects id/token_id/label/dates/revoked — nevertoken_hash(tokens.ts:97-106); revoke honored (resolveTokenfiltersrevoked=false AND not expired,tokens.ts:62); constant-timetimingSafeEqualcompare. - Cookie hygiene: httpOnly, sameSite=lax, secure-in-prod,
SESSION_SECRETrequired in prod (process.exit(1)), login rate-limited (20/15min), bcrypt dummy-hash compare for unknown users (timing-uniform), bcrypt cost 12. - No secret leak:
/api/users→id/username/role/dates only (nopassword_hash);/api/me→{username,role}only. - Client gating is defense-in-depth only (
App.tsxNavigateredirects); the server is the true gate.
Ranked findings
HIGH-1 — A lead can delete admin accounts (no target-rank check)
Where: index.ts:260 (DELETE /api/users/:username gated only at requireRole('lead')) + server/db.ts:227 (deleteUser = unconditional DELETE FROM app_users WHERE username=$1).
Exploit: The create path forbids a non-admin from making an admin (index.ts:249), but the delete path has no symmetric rank check — a lead (or a compromised lead session) can delete any user, admins included. A lead can therefore remove every admin even though it can never create one.
Impact: Privilege-boundary violation. Combined with "only an admin creates an admin," a lead that deletes all admins permanently strips the org of token-management / admin-create ability until a server reboot re-seeds AUTH_USER.
Fix: Enforce actor-outranks-target in the delete path — refuse deleting a user whose role rank ≥ the actor's (a lead may not delete admins/leads), or raise DELETE /api/users/:username to admin-only. Hand to engineer (route + deleteUser).
HIGH-2 — Last-admin lockout (no guard on deleting/removing the final admin)
Where: server/db.ts:227 deleteUser; no count check anywhere. There is no demote endpoint (confirmed — only seedUser ever writes role, db.ts:179), so deletion is the only removal path.
Exploit: Live state is a single admin (admin). Deleting it (reachable today by any lead per HIGH-1, or by a second admin) leaves zero admins. /api/tokens* then 403s for everyone; no one can create a new admin.
Impact: Availability/integrity loss of the admin tier. Recovery requires shell/redeploy access — seedUser re-inserts AUTH_USER as admin only when its row is absent (db.ts:176-185), and only that one account.
Fix: Guard against removing the last remaining admin (SELECT count(*) … role='admin' before delete; refuse if target is the only admin). Same for any future demote. Hand to engineer/dba.
HIGH-3 — Deleting a user does not invalidate their active session
Where: auth.ts:46-61 (requireAuth/requireRole read identity+role from req.session.user set once at login, index.ts:47); session maxAge 7 days (auth.ts:40); session rows persist in user_sessions. deleteUser (db.ts:227) removes only the app_users row.
Exploit: An off-boarded/removed user keeps their cookie working at their cached role for up to 7 days. A deleted lead still passes requireRole('lead') and can keep deleting users (feeding HIGH-1) until session expiry. Role is never re-validated against the DB per request.
Impact: On a public domain, account removal is not effective access revocation; no mechanism to force-logout a user.
Fix: On delete (and any future role change), purge that user's sessions — e.g. DELETE FROM user_sessions WHERE (sess->'user'->>'username') = $1 — or re-check user existence/role from app_users per request (short-cached). Hand to engineer/dba.
MEDIUM-1 — No session-fixation protection on login
Where: index.ts:43-49 assigns req.session.user = ok on the pre-existing session id; no req.session.regenerate() at the privilege transition.
Exploit: Classic session fixation — an attacker who can plant a session cookie (e.g. via a sibling/adjacent context) rides the same id after the victim authenticates and it becomes privileged. Mitigated but not eliminated by httpOnly + sameSite=lax.
Fix: Regenerate the session id on successful login before writing user. Hand to engineer.
MEDIUM-2 — Weak password policy on a public login
Where: index.ts:246 — minimum 6 chars, no complexity/denylist. Throttle is per-IP only (loginLimiter 20/15min, index.ts:41).
Exploit: Created accounts can carry trivially guessable passwords; per-IP throttling doesn't stop distributed/slow guessing against a public domain.
Fix: Raise minimum (~12 chars) and/or add a zxcvbn/denylist check; consider per-account lockout in addition to per-IP. Hand to engineer.
LOW-1 — No HTTP security headers (helmet absent)
Where: app has no helmet/CSP (grep empty); serves SPA + API on a public domain.
Impact: No X-Frame-Options/frame-ancestors (clickjacking of the authenticated UI), no HSTS, no X-Content-Type-Options. Adjacent to the auth perimeter, not RBAC-specific.
Fix: Add helmet with a same-origin CSP + HSTS-in-prod. Hand to engineer/devops.
LOW-2 — Silent no-op on delete/revoke of a non-existent id
Where: db.ts:227 deleteUser, tokens.ts:108 revokeToken — no affected-row check. DELETE /api/users/:missing and revoke of an unknown token id both return {success:true}.
Impact: Cosmetic/idempotency only (no enumeration leak — always success). Consider returning 404 when 0 rows affected so the admin UI reflects reality. Hand to engineer.
LOW-3b (informational) — CSRF on state-changing POSTs rests on sameSite=lax
State-changing routes (/login, /api/users, /api/tokens*, DELETE /api/users/:username) carry no CSRF token; cross-site protection is provided by the sameSite=lax cookie (auth.ts:38). Defensible today. If any of these routes ever move to sameSite=none (e.g. cross-origin embedding), add an explicit CSRF token. No fix required now.
LOW-3 (informational) — Leads self-propagate the user-management tier
Where: index.ts:248-249 blocks only role==='admin' for non-admins; a lead may create unlimited additional leads. This is per the capability spec ("leadership tops out at lead"), so it is by design — but note it compounds HIGH-1/HIGH-3: a lead can mint a co-lead, and either can delete the sole admin. No fix required; revisit if leads should not be able to grant lead.
Next
engineer/dba — fix HIGH-1 (target-rank check on delete), HIGH-2 (last-admin guard), HIGH-3 (session purge on delete). engineer — MEDIUM-1 (session regenerate), MEDIUM-2 (password policy), LOW-1/2. Then re-audit the delete path.