fix(cloud): gate MCP proxy invoke on owner-or-public (cross-org IDOR) (#11838) - #11839
Conversation
… can't be cross-org invoked (#11838) The GET handler gates access with resolveMcpProxyView (allowed = isOwner || is_public; non-owner of a non-public MCP -> 404). The POST/invoke handler fetched userMcpsService.getById(mcpId) unscoped and checked only !mcp + status !== "live" — no is_public / organization_id check. So any authed user in org B who knew a private live MCP's id could POST-invoke it, hitting org A's backend + credentials while B is billed (the catalog hides private MCPs, but the id is enough). Cross-tenant IDOR. Apply the same resolveMcpProxyView gate in POST (after the status check, before billing): public MCPs stay invokable by anyone (the monetization model); private MCPs are owner-only, matching GET. Tests (mcp-proxy-refund.test.ts): non-owner + private -> 404 + no billing (red without the gate: 500, request proceeds); non-owner + public -> 200 + billed (monetization preserved). 10/0 green; typecheck + biome clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
[cloud-security] LGTM — verified against PR head
Non-blocking nit (pre-existing, NOT introduced here): in both GET and POST the No changes requested. Money+security path — ready for maintainer merge, do not self-merge. |
NubsCarson
left a comment
There was a problem hiding this comment.
[cloud-audit] LGTM — the cross-org invoke IDOR (#11838) is genuinely closed.
Verified on head 4245c59:
-
Gate applied before any action. The check sits at packages/cloud/api/mcp/proxy/[mcpId]/route.ts:169-176, immediately after the existence/status checks and BEFORE the affiliate lookup (:182), the upfront debit
reserveAndDeductCredits(:208), endpoint resolution (:277-317), and the upstream fetch (:349/:356). A blocked caller triggers no billing and no outbound request — matching the new test'sreserveAndDeductCredits/safeFetchnot-called assertions (mcp-proxy-refund.test.ts:109-121). -
Correct check, matching the read path. POST reuses the identical pure
resolveMcpProxyView(route.ts:65-74,allowed = isOwner || is_public, owner = sameorganization_id) that GET already enforces at :109-116. Same predicate, same 404 body ("MCP not found") as a missing id, so the gate itself is not an oracle. -
No bypass via other verbs/paths. This route exposes only GET (gated, :90), POST (now gated), and OPTIONS (204 no-op, :433). Swept every other cloud API route at head for unscoped user-MCP access:
/api/v1/mcps/[mcpId]GET gates owner-or-public (v1 route.ts:89-90) and PUT/DELETE passauthResult.user.organization_idinto the service (:153, :176);/api/mcp/{stream,info,list}and/api/mcps/*never touchuserMcpsService/mcpId(platform first-party MCPs). The proxy POST was the only invoke path, and it is now gated. -
Fail-closed. POST hard-requires auth (:148-152, 401), and
resolveMcpProxyViewtreats a null/undefined/mismatchedviewerOrganizationIdas non-owner (:70-72), so a missing org can never grant access — onlyis_public=trueopens the resource, which is the intended monetization model.
LOW nit (non-blocking, pre-existing, identical in GET): the status !== "live" check at :160 runs before the gate and returns a different 404 body ("MCP is not available" vs "MCP not found"), letting a non-owner distinguish a paused private MCP from a nonexistent id. Fine to leave or fold the gate ahead of the status check in a follow-up.
[cloud-audit]
|
[cloud-security] LGTM — adversarially verified (Fable-5 + independent test run in a throwaway worktree at the PR head). POST/invoke now applies the exact same |
lalalune
left a comment
There was a problem hiding this comment.
Reviewed against #11838 and the existing GET access policy. The POST path now uses the same owner-or-public gate before credit reservation, so private cross-org invoke returns 404 without billing or upstream access while public MCP monetization remains intact. The added tests cover both sides of that boundary.
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Closes #11838.
[cloud-money]— cross-tenant IDOR found by a Fable-5 IDOR hunt (hand-verified inline; the workflow was Opus-tainted so I traced the route myself).Bug (MED): GET
/api/mcp/proxy/[mcpId]gates onresolveMcpProxyView(owner-or-public), but the POST/invoke handler fetchedgetById(mcpId)unscoped and checked only existence +status, so org B could invoke org A's private live MCP — hitting A's backend/credentials while B is billed.Fix: apply the same
resolveMcpProxyViewgate in POST before billing. Public MCPs stay open (monetization model); private MCPs owner-only.Proof: non-owner+private → 404, no billing (red without the gate: request proceeds, 500); non-owner+public → 200, billed. 10/0 green, typecheck + biome clean. Security path — do not self-merge.