From 7f05c2c8b8ac7bb40fa2a7b75cb5597a4c070d59 Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 20 Feb 2026 20:52:11 +0800 Subject: [PATCH 1/5] fix authz-vuln-04 --- apps/mesh/src/api/app.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 569c6b5f60..63115123ff 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -301,6 +301,12 @@ export async function createApp(options: CreateAppOptions = {}) { c.set("meshContext", ctx); } + // Require authentication (user session or API key) + const user = ctx.auth.user; + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + // Get connection URL const connection = await ctx.storage.connections.findById(connectionId); if (!connection?.connection_url) { From 82f406230fb957d9efea45aa0cbe1cea9d4a68b7 Mon Sep 17 00:00:00 2001 From: decobot Date: Mon, 23 Feb 2026 09:55:54 +0800 Subject: [PATCH 2/5] add organization check, fix access controll issue --- apps/mesh/src/api/app.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 63115123ff..ac413b7aa1 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -313,6 +313,10 @@ export async function createApp(options: CreateAppOptions = {}) { return c.json({ error: "Connection not found" }, 404); } + if (connection.organization_id !== ctx.organization?.id) { + return c.json({ error: "Connection does not belong to your organization" }, 403); + } + // Get origin auth server - tries Protected Resource Metadata first, then falls back to origin root const resourceRes = await fetchProtectedResourceMetadata( connection.connection_url, From 5a0d312005cf4d1e98c9d36588bf192095bcf7fd Mon Sep 17 00:00:00 2001 From: decobot Date: Mon, 23 Feb 2026 20:38:49 +0800 Subject: [PATCH 3/5] added new test for new access control protection --- .../src/api/routes/oauth-proxy.e2e.test.ts | 160 +++++++++++++++++- 1 file changed, 152 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts index 7015c3f5c2..202961465a 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts @@ -90,6 +90,11 @@ function createMockEventBus(): EventBus { }; } +const TEST_ORG_ID = "org_test"; +const TEST_AUTH_HEADERS = { + Authorization: "Bearer test-api-key", +}; + let database: MeshDatabase; let app: Awaited>; const connectionMap = new Map(); @@ -103,8 +108,6 @@ describe("MCP OAuth Proxy E2E", () => { await createTestSchema(database.db); app = await createApp({ database, eventBus: createMockEventBus() }); - const orgId = "org_test"; - // Mock auth to allow authenticated requests spyOn(auth.api, "getMcpSession").mockResolvedValue(null); spyOn(auth.api, "verifyApiKey").mockResolvedValue({ @@ -117,7 +120,7 @@ describe("MCP OAuth Proxy E2E", () => { permissions: { self: ["COLLECTION_CONNECTIONS_LIST"] }, metadata: { organization: { - id: orgId, + id: TEST_ORG_ID, slug: "test-org", name: "Test Organization", }, @@ -134,7 +137,7 @@ describe("MCP OAuth Proxy E2E", () => { .insertInto("connections") .values({ id: connectionId, - organization_id: orgId, + organization_id: TEST_ORG_ID, created_by: "test_user", title: server.name, connection_type: "HTTP", @@ -155,7 +158,7 @@ describe("MCP OAuth Proxy E2E", () => { .insertInto("connections") .values({ id: connectionId, - organization_id: orgId, + organization_id: TEST_ORG_ID, created_by: "test_user", title: server.name, connection_type: "HTTP", @@ -172,6 +175,141 @@ describe("MCP OAuth Proxy E2E", () => { await closeDatabase(database); }); + // =========================================================================== + // Access Control - Auth & Organization checks (IDOR protection) + // =========================================================================== + + describe("Access Control", () => { + test("returns 401 for unauthenticated requests", async () => { + const connectionId = connectionMap.get(MCP_SERVERS[0].url)!; + + // Temporarily override the mock to simulate unauthenticated request + const verifyMock = spyOn(auth.api, "verifyApiKey").mockResolvedValue({ + valid: false, + error: "Invalid key", + key: null, + } as never); + + const res = await app.request( + `/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test`, + { redirect: "manual" }, + ); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + + // Restore the original mock for subsequent tests + verifyMock.mockResolvedValue({ + valid: true, + error: null, + key: { + id: "test-key-id", + name: "Test API Key", + userId: "test-user-id", + permissions: { self: ["COLLECTION_CONNECTIONS_LIST"] }, + metadata: { + organization: { + id: TEST_ORG_ID, + slug: "test-org", + name: "Test Organization", + }, + }, + }, + } as never); + }); + + test("returns 404 for non-existent connection", async () => { + const res = await app.request( + `/oauth-proxy/conn_nonexistent/authorize?response_type=code&client_id=test&state=test`, + { + redirect: "manual", + headers: TEST_AUTH_HEADERS, + }, + ); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Connection not found"); + }); + + test("returns 403 for cross-organization connection access (IDOR protection)", async () => { + // Create a connection belonging to a different organization + const crossOrgConnectionId = "conn_cross_org"; + await database.db + .insertInto("connections") + .values({ + id: crossOrgConnectionId, + organization_id: "org_other", // Different from TEST_ORG_ID + created_by: "other_user", + title: "Cross Org Server", + connection_type: "HTTP", + connection_url: "https://example.com/mcp", + status: "active", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .execute(); + + const res = await app.request( + `/oauth-proxy/${crossOrgConnectionId}/authorize?response_type=code&client_id=test&state=test`, + { + redirect: "manual", + headers: TEST_AUTH_HEADERS, + }, + ); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe( + "Connection does not belong to your organization", + ); + }); + + test("returns 403 for cross-org token endpoint access", async () => { + const res = await app.request( + `/oauth-proxy/conn_cross_org/token`, + { + method: "POST", + headers: { + ...TEST_AUTH_HEADERS, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=authorization_code&code=test_code", + }, + ); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe( + "Connection does not belong to your organization", + ); + }); + + test("returns 403 for cross-org register endpoint access", async () => { + const res = await app.request( + `/oauth-proxy/conn_cross_org/register`, + { + method: "POST", + headers: { + ...TEST_AUTH_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_name: "malicious-client", + redirect_uris: ["https://evil.com/callback"], + }), + }, + ); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe( + "Connection does not belong to your organization", + ); + }); + }); + // =========================================================================== // Step 1: Protected Resource Metadata Discovery // =========================================================================== @@ -239,7 +377,10 @@ describe("MCP OAuth Proxy E2E", () => { const connectionId = connectionMap.get(server.url)!; const res = await app.request( `/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test`, - { redirect: "manual" }, + { + redirect: "manual", + headers: TEST_AUTH_HEADERS, + }, ); // Must be a redirect (302) @@ -263,7 +404,10 @@ describe("MCP OAuth Proxy E2E", () => { const proxyResourceUrl = `http://localhost/mcp/${connectionId}`; const res = await app.request( `/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test&resource=${encodeURIComponent(proxyResourceUrl)}`, - { redirect: "manual" }, + { + redirect: "manual", + headers: TEST_AUTH_HEADERS, + }, ); expect(res.status).toBe(302); @@ -327,4 +471,4 @@ describe("MCP OAuth Proxy E2E", () => { }); } }); -}); +}); \ No newline at end of file From 3159cb4ac94e1e0e6e75c676848054dc4ae1e51e Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 25 Feb 2026 04:12:58 +0800 Subject: [PATCH 4/5] fix format --- apps/mesh/src/api/app.ts | 5 ++- .../src/api/routes/oauth-proxy.e2e.test.ts | 42 ++++++++----------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index ac413b7aa1..60332d6cce 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -314,7 +314,10 @@ export async function createApp(options: CreateAppOptions = {}) { } if (connection.organization_id !== ctx.organization?.id) { - return c.json({ error: "Connection does not belong to your organization" }, 403); + return c.json( + { error: "Connection does not belong to your organization" }, + 403, + ); } // Get origin auth server - tries Protected Resource Metadata first, then falls back to origin root diff --git a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts index 202961465a..92701d5fd0 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts @@ -267,17 +267,14 @@ describe("MCP OAuth Proxy E2E", () => { }); test("returns 403 for cross-org token endpoint access", async () => { - const res = await app.request( - `/oauth-proxy/conn_cross_org/token`, - { - method: "POST", - headers: { - ...TEST_AUTH_HEADERS, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "grant_type=authorization_code&code=test_code", + const res = await app.request(`/oauth-proxy/conn_cross_org/token`, { + method: "POST", + headers: { + ...TEST_AUTH_HEADERS, + "Content-Type": "application/x-www-form-urlencoded", }, - ); + body: "grant_type=authorization_code&code=test_code", + }); expect(res.status).toBe(403); const body = await res.json(); @@ -287,20 +284,17 @@ describe("MCP OAuth Proxy E2E", () => { }); test("returns 403 for cross-org register endpoint access", async () => { - const res = await app.request( - `/oauth-proxy/conn_cross_org/register`, - { - method: "POST", - headers: { - ...TEST_AUTH_HEADERS, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_name: "malicious-client", - redirect_uris: ["https://evil.com/callback"], - }), + const res = await app.request(`/oauth-proxy/conn_cross_org/register`, { + method: "POST", + headers: { + ...TEST_AUTH_HEADERS, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + client_name: "malicious-client", + redirect_uris: ["https://evil.com/callback"], + }), + }); expect(res.status).toBe(403); const body = await res.json(); @@ -471,4 +465,4 @@ describe("MCP OAuth Proxy E2E", () => { }); } }); -}); \ No newline at end of file +}); From e88cda5293f6ab50e1718a8cafe6b45166295789 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 25 Feb 2026 04:19:29 +0800 Subject: [PATCH 5/5] fix workspace check --- apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts index 92701d5fd0..431e7e0bb7 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts @@ -181,7 +181,7 @@ describe("MCP OAuth Proxy E2E", () => { describe("Access Control", () => { test("returns 401 for unauthenticated requests", async () => { - const connectionId = connectionMap.get(MCP_SERVERS[0].url)!; + const connectionId = connectionMap.get(MCP_SERVERS[0]!.url)!; // Temporarily override the mock to simulate unauthenticated request const verifyMock = spyOn(auth.api, "verifyApiKey").mockResolvedValue({