Maintained from the security-board review. Update when routes, endpoints,
roles, or the auth model change.
- Identity: WorkOS (SSO + password). Session = iron-session cookie
(HttpOnly, Secure, SameSite=Lax). Verified in
api/_utils/workos.ts. - Chokepoint:
requireSessionAndTenant(api/_utils/session-with-tenant.ts) validates the session, upserts the localusersrow, resolves the active tenant, and fails closed (returns 500, never data) on error. - The upload endpoint uses
enforceProductionSession(api/_utils/production-auth.ts) — now also fail-closed (see below)./api/draftand/api/agentuserejectAiGeneration(api/_utils/ai-access.ts): the same fail-closed session check, plus viewers are refused, Opus needs a paid plan, and generation needs a plan or a recent letter purchase onceDRAFTHR_ENFORCE_PAYWALL=1.
Every protected endpoint returns 401/403 to an anonymous caller. The only
200 without a session is GET /api/marketplace/listing — the intentionally
public marketplace listing, which returns published consultant cards only,
no PII. Re-run the sweep in the security-board skill's
references/runtime-verification-cookbook.md after any routing change.
| Surface | signed-out | no-role | employer | employee | consultant | admin |
|---|---|---|---|---|---|---|
/hr* |
deny | deny | allow | deny | deny | — |
/me* |
deny | deny | deny | allow | deny | — |
/c, /c/* |
deny | enroll | deny | deny | allow | — |
/matters,/employees,/bulk,/approvals,/reports* |
deny | deny | allow | deny | deny | — |
/bookings |
deny | deny | allow | deny | deny | — |
/consultants |
deny | deny | allow | deny | allow | — |
/editor/*,/settings,/calendar,/exported,/activity,/notifications |
deny | deny | own | own | — | — |
/admin/consultants |
deny | deny | deny | deny | deny | allowlist¹ |
/sign-in/*,/privacy,/terms,/join/:code,/auth/callback |
allow | allow | allow | allow | allow | allow |
¹ Server-side allowlist: requireAdmin (api/_utils/admin.ts) authorises
only a signed-in user whose email is on the server-only DRAFTHR_ADMIN_EMAILS
env list, and fails closed (empty list ⇒ nobody is admin). It gates
admin-queue + admin-review. The client VITE_ADMIN_EMAILS check only hides
nav; the real boundary is the server gate (verified 2026-06-10).
Routes are role-gated in Protected (roles prop), not just hidden in the
nav — direct URL navigation to another role's surface redirects to /.
- Booking lifecycle (
respond/message/shared): caller must be the consultant or requester on the booking (403 otherwise);from/role derived server-side, never from the body; forward-only state transitions. - Workspace
member-role+member-remove+invite-revoke: gated to owner/admin of the active tenant; target scoped to the same tenant;canManageOwners+ last-owner guard. store/draftswrites/deletes scoped byid AND tenantId. Deletes are soft (the row stays as a scrubbed tombstone); writes are revision-checked (409 on a stale revision, 410 for a deleted id), and a letter's type can't change after creation, so the per-tier paywall check at create time holds.agent,draft,export/pdf,upload/image,sign/manifest: session-gated; export/pdf + sign/manifest tenant-scope the draft byid AND tenantId.sign/manifestis disabled unlessDRAFTHR_MANIFEST_SIGNING=enabled: it signs caller-supplied bytes, so it must not run in production until the manifest is built on the server.directory-webhook: WorkOS signature verified over the raw body before the payload is trusted; resolves org→tenant (no cross-tenant write).accept-invite: session email must match the invite email.- OAuth callback:
statecookie CSRF check; provider allowlist. - Marketplace tag filter uses bound params (
sql.join), notsql.raw. Every rawexecute(advisory locks, session epoch, rate-limit counters) goes through the parameterisedsqltag; there is nosql.raw. audit_eventsis append-only in the database (migration 0021): a trigger rejects UPDATE, DELETE and TRUNCATE, except the tenant-erasure cascade and theactor_user_idSET NULL when a user is deleted. The chain is ordered by a per-tenantseqassigned under the tenant's advisory lock.
- Per-route role guards.
<Protected roles={[…]}>— HR/employee/consultant surfaces no longer render for the wrong role on direct navigation (was hidden-in-nav only). - Fail-CLOSED auth on
/api/draft,/api/agent,/api/upload/image.enforceProductionSessionpreviously bypassed auth wheneverNODE_ENV !== "production"— i.e. on every public Vercel preview URL (anon LLM spend + open uploader). Now bypasses only with an explicitDRAFTHR_DEV_NO_AUTH=1(local.env.localonly). - Workspace admin routes reachable again.
member-role,member-remove,invite-revokewere two-segment paths that 404'd under the single-segment catch-all matcher → admins couldn't change roles, remove members, or revoke invites (offboarding failure). Flattened to single-segment actions. /consultantsre-gated behind<Protected>(was briefly public).- Marketplace API 404 (single- vs multi-segment matcher) — all paths flattened so the function is actually reached.
- Tenant isolation is per-handler, not a DB invariant — BUT now
build-enforced. There's no Postgres RLS (the Neon
-poolerruns in transaction mode, where session-level RLS context doesn't hold, and the default Neon role bypasses RLS — so naïve RLS here is unsafe). Instead a static guard test (api/_utils/tenant-isolation.guard.test.ts) fails the build if any handler reads a tenant-scoped table without a tenant / user / parent-row scope signal. Verified: catches an injected unscopedfrom(drafts). This converts "every handler must remember its filter" from convention into CI enforcement. Full DB-level RLS (via a non-pooled connection +SET LOCAL app.current_tenantper request, or Neon's RLS role) remains the gold-standard next step for true defence-in-depth, but is a larger, branch-tested migration. (No missing filter found today.) DONE./admin/consultantsgate is client-siderequireAdmin(server-sideDRAFTHR_ADMIN_EMAILSallowlist, fails closed) now gatesadmin-queue/admin-review. The page also renders a neutral skeleton (not the admin header) until the server confirms admin, so the surface never paints for a non-admin (PM-04, 2026-06-10).- Vercel single-segment catch-all matcher.
[...path].ts→^/api/x/([^/]+)$. Keep ALL API paths single-segment; a multi-segment path silently 404s (unreachable ≠ secured). Documented in the dispatchers. This quirk has now bitten twice (marketplace, workspace) — grep for any/inside an API path string before shipping. - Function cap (Hobby = 12). At 11. Adding 2 raw functions drops one silently. Use dispatchers.
DONE.photoUrlstored verbatimprofile.tspersists thevalidatePhotoUrl-checked value, not the raw body (verified 2026-06-10).- In-memory rate limiter is per-instance (
api/_utils/rate-limit.ts) — effective limit ismax × warm instances. PARTLY DONE:rateLimitDurableadds a shared Postgres counter (rate_limits, migration 0021) for the auth routes and workspace invites; it fails open if the database is unreachable. AI, upload and store routes still use only the per-instance limiter. WorkOS throttles auth server-side as a backstop. No server-side session revocation.DONE (SE-03, 2026-06-10). Per-usersession_epoch(migration0010_session_epoch): the sealed cookie carries the epoch it was minted with,requireSessionAndTenantrejects any cookie behind the user's stored epoch, and the epoch is bumped on sign-out and password-reset-confirm — so a stolen/extra cookie dies immediately instead of surviving its 7-day TTL. Managed via defensive raw SQL (api/_utils/session-epoch.ts) so it's safe in either deploy order: until the migration is applied it fails open (no enforcement, no outage). Apply migration0010to activate.HARDENED. Now hard-disabled wheneverVETTING_AUTO_PUBLISH=1self-publishes consultant cardsVERCEL_ENV === "production", so a misconfigured prod env can never ship unvetted cards regardless of the flag (PM-05, 2026-06-10). Still confirm the flag is unset in prod as belt-and-suspenders.
Verdict: nothing remotely-exploitable. 23 findings → 19 confirmed after
2-skeptic adversarial verification, 0 Critical, 0 network-reachable. All
prior fix batches (auth render-gating, B1/B2 sanitiser, B3 SSRF, B10/B12 TOCTOU,
B13 deprovisioned filter, S1–S4) re-verified landed with no regressions;
tenant-isolation.guard.test.ts passes 16/16.
Fixed this pass:
- 🟠 Shared-browser PII leak (SA-01/SE-02). Persisted drafts/matters/
employees (PII) survived sign-out and workspace-switch in localStorage.
Added
clearTenantScopedLocalData()(src/lib/session/clearLocalData.ts), called before both hard-navigations. Local-only confidentiality leak; cross-tenant writes were already 403-blocked. - 🟡 AI cost cap (SE-01).
api/draft.tslet the client pickmaxOutputTokenswith no ceiling → unbounded Anthropic spend. Now clamped ≤2000 + temperature ∈ [0,1]. - 🟡 Consultant routing (PM-03). Onboarding routed off a localStorage-only
flag; made server-authoritative —
session.tsreportsisConsultant(aconsultant_profilesrow exists), threaded through the auth context. - 🟢 SSRF size cap (SA-04) enforced from
Content-Lengthbefore buffering · admin-review UUID guard (SE-04) (malformed id → 404 not 500) · admin skeleton (PM-04) · auto-publish prod gate (PM-05).
Refuted (false positives the skeptics killed): "employee self-promotes to HR"
(view toggle, not a privilege boundary — the server has no such role); "server
can't distinguish a consultant" (true, but nothing access-controls on it); CSP
unsafe-inline (non-exploitable given the sanitiser). See residual risks above
for the items that need infra (distributed rate-limiting, RLS). Follow-up,
same day: server-side session revocation (SE-03) is now implemented — see
residual risk #7; apply migration 0010_session_epoch to activate.