Skip to content

fix(security): close IDOR in getCurrentUserProfile - #24

Merged
chriskehayias merged 3 commits into
devfrom
fix/profile-idor-shared-actions
Sep 13, 2026
Merged

fix(security): close IDOR in getCurrentUserProfile#24
chriskehayias merged 3 commits into
devfrom
fix/profile-idor-shared-actions

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Summary

Closes an IDOR in getCurrentUserProfile, plus the two cleanups that fell out of it.

getCurrentUserProfile(id) accepted a caller-supplied MP User_GUID 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 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?.id to a non-empty userGuid check — user.id is Better Auth's internal ID and doesn't prove an MP identity exists, while userGuid is required: true in auth.ts, so keying on it fails closed.

Commits

Commit What
0e135f6 The IDOR fix + adversarial tests
9322bae Gate generateSampleTemplate; delete dead getUserIdByGuid / getCurrentUserIdFromSession
64d9db7 Sync ~25 reference sites; repair pre-existing citation drift

generateSampleTemplate was 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.

getCurrentUserIdFromSession was 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 and authorizationService.ts, which state it comes only from the gate's return value.

Verification

  • Negative control (the one that matters): restoring the vulnerable body fails 4 of 6 cases, the IDOR case reporting expected [SESSION_GUID], received ["attacker-supplied-guid"]. The tests are protective, not merely passing.
  • 805 tests pass (809 less the 4 deleted), eslint . clean, npm run build green.
  • Class sweep: all 14 non-test 'use server' files checked — no other action accepts caller-supplied identity.

Reviewer notes

  • The regression test calls the action through a cast that forges an argument, testing the adversarial shape rather than the type signature.
  • The docs commit repairs pre-existing drift: an error-catalog row for a throw that no longer exists anywhere in src/, an Error('Unauthorized') row citing eight sites when seven no longer throw it, and a stale auth-wrapper.tsx implementation block that omitted its /session-error redirect.

Known gap (follow-up)

~20 reference mentions of UserService.getUserIdByGuid survive, several instructing readers to resolve $userId through 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

chriskehayias and others added 3 commits September 13, 2026 06:40
`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>
@chriskehayias
chriskehayias merged commit 2bf581f into dev Sep 13, 2026
1 check passed
@chriskehayias
chriskehayias deleted the fix/profile-idor-shared-actions branch September 13, 2026 10:42
@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant