fix(security): authenticate searchContacts and getCurrentUserProfile - #73
Merged
Conversation
Two 'use server' actions were reachable as unauthenticated POST endpoints. Server actions compile to callable endpoints and src/proxy.ts:8 lets all /api paths through without a session, so neither had anything standing between an anonymous caller and Ministry Platform PII. searchContacts — no session check Searched Contacts across name, email, and mobile phone and returned up to 20 records including email address and mobile phone, to any caller. A one-character term was enough. Now calls auth.api.getSession and throws 'Authentication required' first. The check sits before the try/catch on purpose: inside it, the existing catch would have masked the auth failure as 'Failed to search contacts'. Placing it first also stops the empty-search-term early return from being an unauthenticated success path. getCurrentUserProfile — no session check and no ownership check Took an arbitrary User_GUID and returned that user's profile plus their roles and user groups, disclosing the authorization model for any user whose GUID was known. GUIDs are not usefully secret — they are in the client session and in /contactlookup/[guid] URLs. Rather than validate the parameter, the parameter is gone. The GUID now comes from the session, which makes the ownership question unaskable instead of merely answered: there is no argument left to tamper with. Also throws when an authenticated session carries no userGuid. The sole production caller (UserProvider) already passed the session's own GUID, so this is not a capability regression. It keeps userGuid as an effect dependency so switching users still re-fetches. Cross-user profile reads, if ever needed, belong in a separate role-gated function rather than a widening of this one. Tests: 427 passing (up from 421). New cases cover null session, session with no user.id, missing userGuid, an unauthenticated empty search term, and that a caller-supplied GUID cannot displace the session's. Audited the two remaining no-getSession action files and left them as-is: getMpTimezone returns a non-sensitive config string, and handleSignOut must work without a valid session. Closes the two TODOs; TestCoverage.md 5.2/5.3 updated to match. 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
Two
'use server'actions were reachable as unauthenticated POST endpoints. Server actions compile to callable endpoints andsrc/proxy.ts:8lets all/apipaths through without a session, so neither had anything standing between an anonymous caller and Ministry Platform PII.Closes the two TODOs under
.claude/TODO/.searchContacts— no session checkSearched
ContactsacrossFirst_Name,Last_Name,Nickname,Email_Address, andMobile_Phone, returning up to 20 records including email address and mobile phone, to any caller. A one-character search term was enough.Now calls
auth.api.getSessionand throwsAuthentication requiredbefore any other work.Two placement details that are deliberate rather than stylistic:
try/catch. Inside it, the existing catch would have rewritten the auth failure asFailed to search contacts— a misleading error, and one that hides a security condition behind a generic one. Sibling files put the check inside their try, but they also rethrow the original error, so the same masking does not occur there.searchContacts('')previously returned[]— a cheap oracle confirming the endpoint is live.getCurrentUserProfile— no session check and no ownership checkTook an arbitrary
User_GUIDand returned that user's profile plus their roles and user groups (userService.ts:72-89) — i.e. it disclosed the authorization model for any user whose GUID was known. GUIDs are not usefully secret:session.user.userGuidis in the client-side session, and MP GUIDs appear in/contactlookup/[guid]URLs.Rather than validate the parameter, the parameter is gone. The GUID now comes from the session:
This makes the ownership question unaskable rather than merely answered — there is no argument left for a caller to tamper with, so no ownership comparison to get wrong later. The TODO recommended this over the reject-or-role-gate alternative, and it was available: the sole production caller (
UserProvider) already passed the session's own GUID, so this is not a capability regression.UserProviderkeepsuserGuidas an effect dependency so switching users still re-fetches.Cross-user profile reads, if ever needed, belong in a separate role-gated function rather than a widening of this one — noted in the JSDoc.
Tests
427 passing, up from 421. New cases: null session, session with no
user.id, authenticated session missinguserGuid, an unauthenticated empty search term, and — the one the old tests structurally could not ask — that a caller-supplied GUID cannot displace the session's:The type signature stops a compiled caller from passing a GUID; that test covers a hand-rolled POST at the endpoint, which the type system does not reach.
Both files were previously at 100% statement and branch coverage. Coverage could not flag a check that was never written — recorded in
TestCoverage.md§5.2/§5.3, now updated from 🔴 to ✅ with the reasoning.Also audited, deliberately unchanged
The other two
'use server'files with nogetSessioncall are both fine:getMpTimezone— returns a non-sensitive server config string (an IANA zone), no PII.handleSignOut— sign-out is idempotent and must work without a valid session.Verification
npm run test:run— 30 files, 427 tests passingnpm run lint— cleannpm run build— compiles, TypeScript clean, 7/7 static pagesOut of scope
Two unrelated in-flight working-tree changes (
investigate-emnapi-lockfile-drift.md, a deleted Better Auth playbook) were deliberately left unstaged and are not in this branch.🤖 Generated with Claude Code