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
- Attacker registers a MetaMCP account and obtains a session cookie
- 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)
- Attacker calls
oauth.get({ mcp_server_uuid: "<victim-uuid>" })
- Response contains the victim's
access_token, refresh_token, and code_verifier
- 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)
Summary
The
oauth.getandoauth.upserttRPC procedures do not check whether the caller owns the target MCP server. UnlikeexchangeTokenandrefreshToken— which receivectx.user.idand enforce ownership viaresolveOwnedServerUrl— thegetandupsertprocedures omit theuserIdparameter 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:
access_token,refresh_token, andcode_verifierfor any MCP server (by enumerating or guessing its UUID)upsertto overwrite tokens, potentially locking the legitimate owner outThis 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)File:
apps/backend/src/trpc/oauth.impl.ts(lines 123-150)Because
getreceives nouserId, it cannot perform any ownership check and returns the full OAuth session for anymcp_server_uuid:File:
apps/backend/src/db/serializers/oauth-sessions.serializer.ts(lines 25-37)The serializer exposes the full token payload:
Attack Scenario
mcp_server_uuid(UUIDs are not secrets — they may be exposed via public endpoints, namespace memberships, or error messages)oauth.get({ mcp_server_uuid: "<victim-uuid>" })access_token,refresh_token, andcode_verifierFix
Forward
ctx.user.idto thegetandupsertimplementations, and add an ownership check using the sameresolveOwnedServerUrlpattern already used byexchangeTokenandrefreshToken.In
packages/trpc/src/routers/frontend/oauth.ts:In
apps/backend/src/trpc/oauth.impl.ts:Add a
userIdparameter togetandupsert, and callresolveOwnedServerUrl(or inline the check) before returning the session data.References
Discovered by Shiqiang Chen (Independent Researcher)