fix(security): require auth + ownership on profile update (IDOR) - #2861
Conversation
PUT /api/accounts/:userid had no auth middleware, so anyone could overwrite any user's profile (firstName, lastName, email, tenantId) by id -- including the email tied to a victim's login, which combined with the password-reset flow could be chained toward account takeover (security audit finding #2, Critical). Fix: - Add jwtSession.validateUser to the route so the endpoint requires a valid session (401 otherwise). - Enforce ownership in the controller: a user may only update their own profile (req.user.id === :userid), unless they hold an account- management admin role (admin / security_admin / global_admin); returns 403 otherwise. The only client caller (Profile.tsx self-edit) is unaffected: it edits the logged-in user's own id and the same-origin jwt cookie is sent automatically. Adds controller tests for owner-allowed, non-owner-blocked (IDOR), and admin-allowed cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VirginiaWu11
left a comment
There was a problem hiding this comment.
Thanks for closing the unauthenticated IDOR here — the jwtSession.validateUser + ownership check is the right shape, and the added controller tests (owner self-edit / non-owner blocked / admin override) cover the intended cases well.
One gap I think needs to be closed before merge, plus a smaller cleanup suggestion:
🔴 Cross-tenant privilege escalation (blocking)
isAccountAdmin in account-controller.ts grants override access to anyone whose JWT sub contains admin or security_admin:
const isAccountAdmin =
roles.has("admin") ||
roles.has("security_admin") ||
roles.has("global_admin");But admin/security_admin are per-tenant roles — they come from the login_tenant join table, scoped to whichever tenant the user authenticated into (see account-service.ts's authenticate(), ~L330-372). The JWT itself never encodes which tenant those roles were granted for (jwt-session.ts's JwtPayload is just {email, sub}). And accountService.updateUserProfile does update login ... where id=$<userid> with no tenant comparison against the caller.
Net effect: a user who is admin/security_admin for one tenant only (e.g. mck.foodoasis.net) can still call PUT /api/accounts/:userid for a :userid belonging to a completely different tenant (e.g. LA County) and overwrite that user's profile, including their email — which chains into the same account-takeover path this PR is meant to close, just gated behind "admin of some tenant" instead of "unauthenticated."
Closing this cleanly needs either a tenant claim on the JWT (checked against the target user's actual tenant) or an explicit lookup of the target user's tenant_id in the controller, compared against the caller's tenant, before allowing the isAccountAdmin bypass. global_admin is fine as-is since it's a login-level (non-tenant-scoped) flag.
🟡 Minor: duplicated role-parsing
const roles = new Set(
(req.user?.sub || req.user?.role || "").split(",").filter(Boolean)
);This is a verbatim copy of getUserRoles in stakeholder-access.ts — worth exporting and importing that instead so the two authorization checks (this one and the stakeholder one) can't drift apart later, especially since the PR description already calls out matching that convention.
🤖 Review generated with Claude Code
Address PR review: admin/security_admin are per-tenant roles (granted via the login_tenant join) but the JWT does not encode which tenant they apply to, so honoring them as an override let an admin of one tenant edit users in another tenant (cross-tenant privilege escalation). Restrict the override to global_admin, which is a login-level, non-tenant-scoped flag and safe to trust from the JWT. Also reuse the shared getUserRoles helper from stakeholder-access instead of duplicating the role-parsing, so the two authorization checks can't drift apart. Tests: global_admin can still edit another user; a per-tenant admin (admin/security_admin without global_admin) is now blocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the careful review, @VirginiaWu11 — both points addressed in a1b8309. 🔴 Cross-tenant escalationFixed by narrowing the override to I went with narrowing rather than adding a tenant claim + target-tenant lookup because there is currently no feature where a per-tenant admin edits another user's profile — the only client caller of Added a regression test: a per-tenant admin ( 🟡 Duplicated role-parsing
🤖 This reply was written by Claude on behalf of @hanapotski. |
VirginiaWu11
left a comment
There was a problem hiding this comment.
Re-reviewed the update — both issues from the previous round are resolved:
- Cross-tenant escalation: the override is now scoped to
global_adminonly (admin/security_adminno longer bypass ownership), with a clear comment explaining why those two are excluded (per-tenant roles, no tenant claim on the JWT). New regression testblocks a per-tenant admin from updating another user's profile (cross-tenant escalation)covers exactly this case. - Duplicated role-parsing: replaced with an import of the now-exported
getUserRolesfromstakeholder-access.ts— no more copy.
Verified independently in a clean worktree checked out from this PR's head (a1b83096):
tsc --noEmit— cleannpm run lint— cleannpx jest --ci __test__/account.test.ts— all 4 new tests pass (lets a user update their own profile,blocks a user from updating another user's profile (IDOR),lets a global_admin update another user's profile,blocks a per-tenant admin from updating another user's profile (cross-tenant escalation)); the 10 failing snapshots are the same pre-existingObject {}vs{}formatting mismatch that exists ondevelop, unrelated to this change.- No other importers of
getUserRoles/stakeholder-access.tsare affected by the export change.
Looks good — approving.
🤖 Review generated with Claude Code
Summary
Fixes security audit finding #2 (Critical) —
PUT /api/accounts/:useridhad no auth middleware, so anyone could overwrite any user's profile (firstName,lastName,email,tenantId) just by hitting the endpoint with a target user id. Overwriting theemailtied to a victim's login could be chained with the password-reset flow toward account takeover.Fix
jwtSession.validateUsertoPUT /:useridso it requires a valid session (401 otherwise).req.user.id === :userid), unless they hold an account-management admin role (admin/security_admin/global_admin). Returns 403 otherwise. Roles are read from the JWTsubclaim, matching the existingstakeholder-accesshelper convention.Compatibility
The only client caller —
Profile.tsxself-edit — is unaffected: it edits the logged-in user's own id, and thejwtcookie is sent automatically on these same-origin (/api/...) requests (same mechanism the other protected admin services rely on).Tests
Added controller tests covering:
tsc --noEmitpasses. Note:account.test.tshas a pre-existingObject {vs{Jest-serializer snapshot mismatch (10 snapshots, identical ondevelop) unrelated to this change.🤖 This PR was written by Claude on behalf of @hanapotski.
🤖 Generated with Claude Code