@sudodaksh2w ago
fix(apollo): bind user_id on consumer-project user-API-key routes
loading diff…
Closes a same-org authorization-boundary issue on the two consumer-project endpoints that consume a caller-supplied user_id.
Pre-fix attack chain (user-API-key authenticated):
team_members/list → victim org-member UUID./org/consumer/project/resolve → CONSUMER project_nano_id + deterministic identity format consumer-<uuid>-<orgNanoId>.consumer-<victimUUID>-<orgNanoId>.x-project-id=<consumer nanoId> pivots auth into the CONSUMER project (findProject.findFirst doesn't restrict to DEVELOPER when an explicit nanoId is supplied; PROJECT_API_KEY → USER_API_KEY expansion makes the route reachable).
5a. POST /tool_router/session with body user_id = forged victim id → session.config.userId pins to victim → /execute, workbench, storage, OAuth-link, and connection lookups all run under the victim's identity.
5b. GET /org/consumer/connected_toolkits?user_id=<forged> → enumerates which toolkits the victim has active in the consumer project.Fix. Bind body/query user_id to the caller's own getConsumerUserId(...) (matching the derivation in /resolve, including the clanker variant) when authenticated via a user API key against the org's CONSUMER project. Mismatch → 403.
Surgical scoping — what is NOT affected:
authMethod.apiKeyType !== 'user'./admin/tool_router/session) — skipped (getAuthContext() returns Err on admin path; admin uses adminAuthInfo).apiKeyType is undefined in those flows).user_id == own derived consumer_user_id passes the binding.Not in this PR (defense-in-depth follow-ups):
findProject.findFirst could reject non-DEVELOPER lookups for non-admin user-API-key callers (closes the pivot itself; broader blast radius)./resolve could drop project_nano_id from the response (now non-exploitable on its own).consumer-${uuid}-${org} with an opaque stored mapping (eliminates the primitive everywhere; migration-heavy).production (commit 5e7e3260e8): confirmed all five chain links from the original report still hold pre-fix — resolve.ts:42-57,158-164 exposes project_nano_id+derived consumer_user_id; get_consumer_user_id.ts:30 is deterministic; authMiddleware.ts:623-628 forwards x-project-id into findProject.findFirst:101-108 which only type-restricts on DEVELOPER when no projectId is supplied; route.ts:108-116 expands PROJECT_API_KEY → USER_API_KEY; session.ts:241-263 passes body user_id verbatim into getOrCreateToolRouterSession.session.config.userId across toolRouterV2/ — confirmed 14 call sites (execution entityId, workbench/sandbox owner, presigned-URL entityId, OAuth link-token userId, getAvailableToolkits connection joins, tracking) all transitively depend on the bound value. Binding at session-create closes all downstream paths in one place.tsc --noEmit before and after. The only session.ts error (TS2345 at line 222) is the pre-existing errorToHTTPException response-union variance — confirmed identical pre-change via git stash. connected_toolkits.ts is clean.registerCreateSession (v3, v3.1, admin, removed cookie) and connected_toolkits — verified each legitimate flow either skips the guard or satisfies it.resolve.ts derivation contract); org with no CONSUMER project (getExistingConsumerProject returns null → guard skipped, no false positives); DB error on getExistingConsumerProject (fail-open is acceptable since downstream getOrCreateToolRouterSession will also fail); race on provisioning/deletion (no exploit window).POST /api/v3/tool_router/session with , , body → expect with the new message.🤖 Generated with Claude Code
x-user-api-keyx-project-id=<CONSUMER nanoId>user_id="consumer-<other-member-uuid>-<org-nanoid>"suggestedFixuser_id="consumer-<self-uuid>-<org-nanoid>" → expect 201 (legitimate self-flow preserved).GET /api/v3/org/consumer/connected_toolkits?user_id=<forged victim id> → expect 403; with own id → expect 200 + toolkit list.user_id → expect 201 unchanged.