fix(security): stop GET /api/users/:id leaking any user's apiToken and email - #765
Merged
Conversation
…d email
CRITICAL. Confirmed by live execution against production: a plain, non-admin,
verified account fetched another user's profile and received the full Mongo
document — including `apiToken`, a working `cm_` bearer that authenticates AS
that user via middleware/auth.ts, plus their email address. Any account that
could sign up could take over any other account, including admins. 68 verified
humans were exposed.
Cause: `toSocialProfile` spread `userDoc.toObject()` wholesale, and
`.select('-password')` was the only projection standing between the schema and
the response body.
Two layers, because either alone is incomplete:
1. The serializer now drops credentials unconditionally (apiToken,
agentRuntimeTokens, digestUnsubscribeToken, password) and drops
account-private fields (email, entitlements, token scopes, ban state,
preferences, contacts) whenever the viewer is not the profile's owner.
Public social fields are untouched, so profiles still render.
2. `apiToken` becomes `select: false` on the schema, so a live credential can
no longer ride along on any incidental query anywhere else in the codebase.
Filtering by the field is unaffected (projection and filter are separate),
so token authentication keeps working.
Layer 2 has one dangerous failure mode: code that READS the value back now
gets `undefined` unless it asks for `+apiToken`, and
`issueUserTokenForInstallation` treats an absent token as "none exists" and
rotates — which would have silently invalidated the credentials of all 42 agent
accounts that currently hold one. Every reader is therefore updated explicitly:
`agentIdentityService.getOrCreateAgentUser` (which backs the token-issuance and
bootstrap paths), the agent-user lookup at agentIdentityService:526, and the
`agent-tokens` hasToken probe.
Two existing test mocks returned bare objects where production now chains
`.select()`; both are updated to thenable query builders. Their assertions are
unchanged.
Not yet fixed, filed separately: the /api/pg/* shadow router (unfiltered
instance-wide pod listing and a join with no policy check, both confirmed
live) and instance-wide pod enumeration via getPodsByType/getPodById.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES
CodeQL flagged js/missing-rate-limiting on routes/registry/agent-tokens.ts once this PR touched the file. The gap was pre-existing and these are exactly the routes that read and mint agent credentials, so an unbounded loop against them is worth blunting: 60 requests/min, keyed on the hashed bearer token. Imported as ESM and placed first on each route because the query cannot trace a limiter through a require() return or a router.use wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES
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.
Severity: critical, confirmed by live execution
As a plain, non-admin, verified account I fetched another user's profile on production and received their full Mongo document:
apiTokenis a workingcm_bearer that authenticates as that user (middleware/auth.ts:41). So any account that could sign up could take over any other account, including admins. 68 verified humans are exposed today.Cause:
toSocialProfile(userController.ts:8) spreaduserDoc.toObject()wholesale, and.select('-password')was the only thing between the schema and the response.Fix — two layers
1. Serializer. Credentials (
apiToken,agentRuntimeTokens,digestUnsubscribeToken,password) are dropped unconditionally — including from your own profile, since the UI gets a token fromPOST /api/auth/api-token/generateand never from here. Account-private fields (email, entitlements, token scopes, ban state, preferences, contacts) drop whenever the viewer isn't the owner. Public social fields are untouched.2.
select: falseonapiToken. So a credential can't ride along on any incidental query anywhere else, now or in future. Filtering by the field still works (projection ≠ filter), so token auth is unaffected.The trap in layer 2, and how it's handled
Code that reads the value back now gets
undefinedunless it asks for+apiToken— andissueUserTokenForInstallationtreats an absent token as "none exists" and rotates. That would have silently invalidated the credentials of all 42 agent accounts that hold one.Every reader is updated explicitly:
getOrCreateAgentUser(which backs both the token-issuance and bootstrap paths), the lookup atagentIdentityService:526, and theagent-tokenshasToken probe. I've fingerprinted all 42 tokens pre-merge to verify post-deploy that none rotated.Test
6 new tests covering the exact exploit (another user can't get the token or email), that your own profile keeps private fields but still never the token, and that the schema field is genuinely
select: false.Two existing mocks returned bare objects where production now chains
.select(); both updated to thenable query builders, assertions unchanged. Net test failures vsorigin/main: zero (the 3 remaining suite-level failures are the pre-existing Node-26jsonwebtokendrift, identical on main).Not in this PR
Also confirmed live and still open:
GET /api/pg/podsreturns all 229 pods instance-wide including private ones, andPOST /api/pg/pods/:id/joinlet a non-member join a private pod with a 200. Being fixed separately.🤖 Generated with Claude Code