From 60911bd8d2f5dfc7cf3f4eb0571b77f7fa1c7656 Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Wed, 8 Apr 2026 23:23:56 +0800 Subject: [PATCH] fix(auth): prevent cross-tenant connection enumeration via OAuth metadata endpoints The OAuth discovery endpoints (/.well-known/oauth-protected-resource) called findById without org scoping, and returned distinct status codes (404 vs 502) that revealed whether a connection existed. Fixes both: - Passes organization ID to findById when auth context is available - Normalizes error responses to 404 for both "not found" and "proxy failed" to eliminate the enumeration oracle Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/oauth-proxy.test.ts | 34 ++++++++++++++++++-- apps/mesh/src/api/routes/oauth-proxy.ts | 17 +++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/mesh/src/api/routes/oauth-proxy.test.ts b/apps/mesh/src/api/routes/oauth-proxy.test.ts index 17bd3e17af..feb49f828a 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.test.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.test.ts @@ -238,7 +238,7 @@ describe("OAuth Proxy Routes", () => { expect(res.status).toBe(200); }); - test("returns 502 when origin fetch fails", async () => { + test("returns 404 when origin fetch fails to prevent connection enumeration", async () => { mockConnectionStorage({ connection_url: "https://origin.example.com/mcp", }); @@ -251,9 +251,10 @@ describe("OAuth Proxy Routes", () => { "/.well-known/oauth-protected-resource/mcp/conn_123", ); - expect(res.status).toBe(502); + // Returns 404 (same as "not found") to avoid leaking connection existence + expect(res.status).toBe(404); const body = await res.json(); - expect(body.error).toBe("Failed to proxy OAuth metadata"); + expect(body.error).toBe("Connection not found"); }); test("passes through error responses from origin", async () => { @@ -442,6 +443,33 @@ describe("OAuth Proxy Routes", () => { expect(body.issuer).toBeUndefined(); expect(body.authorization_endpoint).toBeUndefined(); }); + + test("returns same 404 for non-existent and unreachable connections (no enumeration oracle)", async () => { + // Non-existent connection + mockConnectionStorage(null); + const res404 = await app.request( + "/.well-known/oauth-protected-resource/mcp/conn_nonexistent", + ); + + // Existing connection with unreachable upstream + mockConnectionStorage({ + connection_url: "https://origin.example.com/mcp", + }); + global.fetch = mock(() => + Promise.reject(new Error("Connection refused")), + ) as unknown as typeof fetch; + const res502 = await app.request( + "/.well-known/oauth-protected-resource/mcp/conn_existing", + ); + + // Both should return identical 404 responses + expect(res404.status).toBe(404); + expect(res502.status).toBe(404); + const body404 = await res404.json(); + const body502 = await res502.json(); + expect(body404.error).toBe("Connection not found"); + expect(body502.error).toBe("Connection not found"); + }); }); describe("Authorization Server Metadata Proxy", () => { diff --git a/apps/mesh/src/api/routes/oauth-proxy.ts b/apps/mesh/src/api/routes/oauth-proxy.ts index 9fdeed8f9e..c3da321aea 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.ts @@ -50,7 +50,10 @@ async function getConnectionUrl( connectionId: string, ctx: MeshContext, ): Promise { - const connection = await ctx.storage.connections.findById(connectionId); + const connection = await ctx.storage.connections.findById( + connectionId, + ctx.organization?.id, + ); return connection?.connection_url ?? null; } @@ -467,10 +470,8 @@ const protectedResourceMetadataHandler = async (c: { "[oauth-proxy] Failed to proxy OAuth protected resource metadata:", err, ); - return c.json( - { error: "Failed to proxy OAuth metadata", message: err.message }, - 502, - ); + // Return 404 (same as "not found") to avoid leaking connection existence + return c.json({ error: "Connection not found" }, 404); } }; @@ -623,10 +624,8 @@ app.get( } catch (error) { const err = error as Error; console.error("[oauth-proxy] Failed to proxy auth server metadata:", err); - return c.json( - { error: "Failed to proxy auth server metadata", message: err.message }, - 502, - ); + // Return 404 (same as "not found") to avoid leaking connection existence + return c.json({ error: "Connection not found or no auth server" }, 404); } }, );