Skip to content

Missing access control on oauth.get and oauth.upsert — any authenticated user can read/write any MCP server's OAuth tokens (IDOR) #345

Description

@shunfeng8421

Summary

The oauth.get and oauth.upsert tRPC procedures do not check whether the caller owns the target MCP server. Unlike exchangeToken and refreshToken — which receive ctx.user.id and enforce ownership via resolveOwnedServerUrl — the get and upsert procedures omit the userId parameter entirely, allowing any authenticated user to read or overwrite OAuth tokens (access_token, refresh_token, code_verifier) belonging to any other user's MCP server.

Impact

An attacker with a valid MetaMCP session can:

  • Read access_token, refresh_token, and code_verifier for any MCP server (by enumerating or guessing its UUID)
  • Use the stolen access_token to impersonate the victim on the upstream MCP server (e.g. GitHub, Slack, database, Salesforce)
  • Use the refresh_token to obtain fresh access tokens when the stolen one expires
  • Use upsert to overwrite tokens, potentially locking the legitimate owner out

This is a classic Insecure Direct Object Reference (IDOR) — the only protection is the UUID of the MCP server, which is not a secret (it may be exposed through public endpoints, namespaces, or logs).

Root Cause

File: packages/trpc/src/routers/frontend/oauth.ts (lines 38-51)

// get — does NOT pass ctx.user.id
get: protectedProcedure
  .input(GetOAuthSessionRequestSchema)
  .output(GetOAuthSessionResponseSchema)
  .query(async ({ input }) => {
    return await implementations.get(input);  // ❌ no userId
  }),

// upsert — does NOT pass ctx.user.id
upsert: protectedProcedure
  .input(UpsertOAuthSessionRequestSchema)
  .output(UpsertOAuthSessionResponseSchema)
  .mutation(async ({ input }) => {
    return await implementations.upsert(input);  // ❌ no userId
  }),

// exchangeToken — correctly passes ctx.user.id
exchangeToken: protectedProcedure
  .input(ExchangeOAuthTokenRequestSchema)
  .output(ExchangeOAuthTokenResponseSchema)
  .mutation(async ({ input, ctx }) => {
    return await implementations.exchangeToken(input, ctx.user.id);  // ✅
  }),

// refreshToken — correctly passes ctx.user.id
refreshToken: protectedProcedure
  .input(RefreshOAuthTokenRequestSchema)
  .output(RefreshOAuthTokenResponseSchema)
  .mutation(async ({ input, ctx }) => {
    return await implementations.refreshToken(input, ctx.user.id);  // ✅
  }),

File: apps/backend/src/trpc/oauth.impl.ts (lines 123-150)

Because get receives no userId, it cannot perform any ownership check and returns the full OAuth session for any mcp_server_uuid:

get: async (
  input: z.infer<typeof GetOAuthSessionRequestSchema>,
  // ❌ no userId parameter
): Promise<...> => {
  const session = await oauthSessionsRepository.findByMcpServerUuid(
    input.mcp_server_uuid,  // no ownership filter
  );
  // ...
  return {
    success: true,
    data: OAuthSessionsSerializer.serializeOAuthSession(session),  // includes tokens + code_verifier
  };
},

File: apps/backend/src/db/serializers/oauth-sessions.serializer.ts (lines 25-37)

The serializer exposes the full token payload:

static serializeOAuthSession(dbSession): SerializedOAuthSession {
  return {
    tokens: dbSession.tokens,  // access_token, refresh_token, etc.
    code_verifier: dbSession.code_verifier,
    // ...
  };
}

Attack Scenario

  1. Attacker registers a MetaMCP account and obtains a session cookie
  2. Attacker discovers or guesses a victim's mcp_server_uuid (UUIDs are not secrets — they may be exposed via public endpoints, namespace memberships, or error messages)
  3. Attacker calls oauth.get({ mcp_server_uuid: "<victim-uuid>" })
  4. Response contains the victim's access_token, refresh_token, and code_verifier
  5. Attacker uses these tokens to access the upstream MCP server as the victim

Fix

Forward ctx.user.id to the get and upsert implementations, and add an ownership check using the same resolveOwnedServerUrl pattern already used by exchangeToken and refreshToken.

In packages/trpc/src/routers/frontend/oauth.ts:

// get
get: protectedProcedure
  .input(GetOAuthSessionRequestSchema)
  .output(GetOAuthSessionResponseSchema)
  .query(async ({ input, ctx }) => {
    return await implementations.get(input, ctx.user.id);  // add userId
  }),

// upsert
upsert: protectedProcedure
  .input(UpsertOAuthSessionRequestSchema)
  .output(UpsertOAuthSessionResponseSchema)
  .mutation(async ({ input, ctx }) => {
    return await implementations.upsert(input, ctx.user.id);  // add userId
  }),

In apps/backend/src/trpc/oauth.impl.ts:
Add a userId parameter to get and upsert, and call resolveOwnedServerUrl (or inline the check) before returning the session data.

References


Discovered by Shiqiang Chen (Independent Researcher)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions