Skip to content

fix(security): stop GET /api/users/:id leaking any user's apiToken and email - #765

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/apitoken-leak
Jul 28, 2026
Merged

fix(security): stop GET /api/users/:id leaking any user's apiToken and email#765
lilyshen0722 merged 2 commits into
mainfrom
fix/apitoken-leak

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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:

LEAKS apiToken: True  | value present: True
LEAKS email:    xcjsam@g.ucla.edu

apiToken is a working cm_ 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) spread userDoc.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 from POST /api/auth/api-token/generate and 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: false on apiToken. 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 undefined unless it asks for +apiToken — and issueUserTokenForInstallation treats 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 at agentIdentityService:526, and the agent-tokens hasToken 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 vs origin/main: zero (the 3 remaining suite-level failures are the pre-existing Node-26 jsonwebtoken drift, identical on main).

Not in this PR

Also confirmed live and still open: GET /api/pg/pods returns all 229 pods instance-wide including private ones, and POST /api/pg/pods/:id/join let a non-member join a private pod with a 200. Being fixed separately.

🤖 Generated with Claude Code

…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
@lilyshen0722
lilyshen0722 merged commit e89bad2 into main Jul 28, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/apitoken-leak branch July 28, 2026 00:42
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