fix(security): close IDOR in getCurrentUserProfile - #24
Merged
Conversation
`getCurrentUserProfile(id)` took a caller-supplied MP `User_GUID` and passed
it to `UserService.getUserProfile` behind only a "a session exists" check.
A server action is a callable POST endpoint whose payload the caller shapes,
so the parameter was reachable regardless of what the sole legitimate caller
passed. Any authenticated MP user could therefore read any other user's
First_Name, Last_Name, Email_Address, Mobile_Phone, photo GUID, and their
full role and user-group list — the last of which is also a reconnaissance
aid for locating a high-privilege account.
This is the F1 pattern from the downstream hardening playbook ("a session
proves only that *some* MP user signed in") surviving inside the one file
CLAUDE.md rule 12 blesses as a carve-out. The carve-out is justified as "the
user's own profile"; nothing enforced "own".
The parameter is REMOVED rather than validated against the session. A value
that must equal a server-derived one has no reason to cross the wire, and
deleting it makes the carve-out's justification enforceable by the type
signature instead of by reviewer vigilance.
The guard also moves from `session?.user?.id` to a non-empty `userGuid`
check. `user.id` is Better Auth's internal ID; its presence does not prove
an MP identity exists. `userGuid` is `required: true` in `src/lib/auth.ts`,
so keying on it fails closed.
Tests drive the adversarial shape, not the type: one case calls the action
through a cast that forges an argument and asserts the session GUID is still
what reaches the service. Verified protective by negative control — restoring
the vulnerable body fails 4 of 6 cases, the IDOR case reporting
`expected [SESSION_GUID], received ["attacker-supplied-guid"]`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ons from closing the getCurrentUserProfile IDOR. **`generateSampleTemplate` now authorizes.** It was a `'use server'` action with no gate and no in-file justification — an undocumented fifth carve-out, where CLAUDE.md rule 12 allows four and requires each to be argued in writing. It reads no MP data (it builds a static .docx from constants), so it leaked nothing; it is gated rather than documented because it is a capability of the address-label tool and can simply take that tool's gate. The carve-out list stays at four. **`UserService.getUserIdByGuid` is deleted.** Deleting the dead server action `getCurrentUserIdFromSession` removed its last production caller. That action was itself worth removing on its own terms: it was an exported endpoint accepting a *session object* as a parameter, and its docstring claimed to be "the canonical way for server actions to obtain the `$userId`" — contradicting CLAUDE.md rule 13, `authorizationService.ts`, and the security reference, which all state `$userId` comes only from the gate's return value. Stale pre-AuthorizationService guidance that would have led the next reader astray. The four action test files mocking `getUserIdByGuid` turned out to have been mocking `UserService` wholesale despite their production code not importing it at all, so the entire vi.mock blocks went rather than one entry each. `src/lib/auth.ts` is a comment-only change: `extractUserGuid`'s JSDoc cited the deleted method as the downstream `validateGuid` caller; retargeted to `getUserProfile`, which is the surviving one. 805 tests pass (809 less the 4 deleted), eslint clean, build green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion
Updates the ~25 reference sites that documented
`getCurrentUserProfile(id)` and the old `session.user.id` guard, across
components/layout, contexts/user-provider, data-flow/call-graphs,
data-flow/error-catalog, auth/{user-identity,sessions,oauth-flow},
DECISIONS, testing/inventory, security/README, and both READMEs.
The security carve-out entry gains one clause noting the "own profile"
justification is now enforced by the signature rather than asserted.
Also repairs pre-existing drift found while in these files — this doc set
carries line-number citations, so stale ones are not cosmetic:
- `Error('User GUID not found in session')` no longer exists anywhere in
`src/`; the catalog row citing four action files is removed.
- `Error('Unauthorized')` had eight cited sites; seven no longer throw it.
Rewritten to the single surviving site, with a row added for the
`UnauthorizedError` the gate now raises in their place.
- `auth-wrapper.tsx` line count, test count, and verbatim implementation
block were all stale — the block omitted the `userGuid`-missing redirect
to `/session-error` entirely.
`last_verified` is bumped only on files verified in full; call-graphs.md and
error-catalog.md are left unbumped because only their auth sections were
checked, and claiming otherwise would overstate coverage.
KNOWN GAP, deliberately not fixed here: ~20 reference mentions of
`UserService.getUserIdByGuid` survive, several instructing readers to resolve
`$userId` through it. That method no longer exists, and the instruction
contradicted rule 13 even before it was deleted. Wider than this change and
scoped to a follow-up sweep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes an IDOR in
getCurrentUserProfile, plus the two cleanups that fell out of it.getCurrentUserProfile(id)accepted a caller-supplied MPUser_GUIDbehind only a "a session exists" check. A server action is a callable POST endpoint whose payload the caller shapes, so the parameter was reachable regardless of what the one legitimate call site passed. Any authenticated MP user could read any other user's name, email, mobile phone, photo GUID, and full role and user-group list.This is the F1 pattern from the downstream hardening playbook — "a session proves only that some MP user signed in" — surviving inside the one file CLAUDE.md rule 12 blesses as a carve-out. The carve-out is justified as "the user's own profile"; nothing enforced own.
Approach
The parameter is removed, not validated. A value that must equal a server-derived one has no reason to cross the wire, and deleting it makes the carve-out enforceable by the type signature rather than by reviewer vigilance.
The guard also moves from
session?.user?.idto a non-emptyuserGuidcheck —user.idis Better Auth's internal ID and doesn't prove an MP identity exists, whileuserGuidisrequired: trueinauth.ts, so keying on it fails closed.Commits
0e135f69322baegenerateSampleTemplate; delete deadgetUserIdByGuid/getCurrentUserIdFromSession64d9db7generateSampleTemplatewas an ungated'use server'action — an undocumented fifth carve-out where rule 12 allows four. It reads no MP data, so it leaked nothing; it takes the address-label tool's gate rather than a written exemption, keeping the list at four.getCurrentUserIdFromSessionwas dead code worth removing on its own terms: an exported endpoint taking a session object as a parameter, whose docstring claimed to be "the canonical way for server actions to obtain$userId" — contradicting rule 13 andauthorizationService.ts, which state it comes only from the gate's return value.Verification
expected [SESSION_GUID], received ["attacker-supplied-guid"]. The tests are protective, not merely passing.eslint .clean,npm run buildgreen.'use server'files checked — no other action accepts caller-supplied identity.Reviewer notes
src/, anError('Unauthorized')row citing eight sites when seven no longer throw it, and a staleauth-wrapper.tsximplementation block that omitted its/session-errorredirect.Known gap (follow-up)
~20 reference mentions of
UserService.getUserIdByGuidsurvive, several instructing readers to resolve$userIdthrough it. That method no longer exists after this PR, and the instruction contradicted rule 13 even before deletion. Scoped to a follow-up sweep rather than widened here.🤖 Generated with Claude Code