diff --git a/apps/api/src/api/app.ts b/apps/api/src/api/app.ts index d931d3673e..88e593e5f1 100644 --- a/apps/api/src/api/app.ts +++ b/apps/api/src/api/app.ts @@ -104,6 +104,7 @@ import { createWellKnownAuthServerRoutes, fetchAuthorizationServerMetadata, fetchProtectedResourceMetadata, + oauthProxyBodyLimit, protectedResourceMetadataHandler, } from "./routes/oauth-proxy"; import openaiCompatRoutes from "./routes/openai-compat"; @@ -1530,6 +1531,7 @@ export async function createApp(options: CreateAppOptions = {}) { // canonical mount lives under `/api/:org/oauth-proxy/...` (registered via // `createOrgScopedApi` below) and gets cross-org enforcement for free. app.use("/oauth-proxy/:connectionId/*", logDeprecatedRoute); + app.use("/oauth-proxy/:connectionId/*", oauthProxyBodyLimit); app.all("/oauth-proxy/:connectionId/*", oauthProxyHandler); // Better-Auth-served Protected Resource Metadata for the gateway-style MCP diff --git a/apps/api/src/api/routes/oauth-proxy.integration.test.ts b/apps/api/src/api/routes/oauth-proxy.integration.test.ts index c983491cef..4211d24107 100644 --- a/apps/api/src/api/routes/oauth-proxy.integration.test.ts +++ b/apps/api/src/api/routes/oauth-proxy.integration.test.ts @@ -385,6 +385,40 @@ describe("MCP OAuth Proxy E2E", () => { client_secret_expires_at: 0, }); }); + + test("rejects an oversized register body with 413 before parsing it", async () => { + const connectionId = "conn_oversized_register_body"; + await database.db + .insertInto("connections") + .values({ + id: connectionId, + organization_id: "org_test", + created_by: "test_user", + title: "Oversized register body", + connection_type: "HTTP", + connection_url: "https://mcp.invalid.example/mcp", + oauth_config: JSON.stringify({ + authorizationEndpoint: "https://auth.example/authorize", + tokenEndpoint: "https://auth.example/token", + clientId: "static-client-id", + scopes: ["mcp:connect"], + grantType: "authorization_code", + }), + status: "active", + pinned: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .execute(); + + const res = await app.request(`/oauth-proxy/${connectionId}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ padding: "x".repeat(2 * 1024 * 1024) }), + }); + + expect(res.status).toBe(413); + }); }); // =========================================================================== diff --git a/apps/api/src/api/routes/oauth-proxy.ts b/apps/api/src/api/routes/oauth-proxy.ts index 625a3dbbc4..97f3ea370d 100644 --- a/apps/api/src/api/routes/oauth-proxy.ts +++ b/apps/api/src/api/routes/oauth-proxy.ts @@ -13,6 +13,7 @@ */ import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import { oAuthProtectedResourceMetadata } from "better-auth/plugins"; import { ContextFactory } from "../../core/context-factory"; import type { StudioContext } from "../../core/studio-context"; @@ -42,6 +43,17 @@ type Variables = { studioContext: StudioContext; }; +/** + * The DCR-echo branch of `oauthProxyHandler` (app.ts) reads the request body + * as JSON before any auth check — this route is unauthenticated by design. + * Cap it like the other unauthenticated body-reading routes (jira-webhook, + * trigger-callback) so an attacker can't force large-body parses. + */ +export const oauthProxyBodyLimit = bodyLimit({ + maxSize: 1_048_576, // 1MB + onError: (c) => c.json({ error: "Payload too large" }, 413), +}); + type HonoEnv = { Variables: Variables }; // ============================================================================ diff --git a/apps/api/src/api/routes/org-scoped.ts b/apps/api/src/api/routes/org-scoped.ts index ab30efe183..69d2ac42ae 100644 --- a/apps/api/src/api/routes/org-scoped.ts +++ b/apps/api/src/api/routes/org-scoped.ts @@ -21,7 +21,10 @@ import { createDownstreamTokenRoutes } from "./downstream-token"; import { createFileUploadRoutes } from "./file-uploads"; import { createKVRoutes } from "./kv"; import { createOrgFsRoutes } from "./org-fs"; -import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; +import { + createOrgScopedWellKnownProtectedResourceRoutes, + oauthProxyBodyLimit, +} from "./oauth-proxy"; import { createSsoRoutes } from "./org-sso"; import { createProxyRoutes } from "./proxy"; import { createSelfRoutes } from "./self"; @@ -169,6 +172,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { // OAuth proxy under the org-scoped prefix; resolveOrgFromPath has run, so // the handler can enforce cross-org access (connection.organization_id // must match the resolved org). + app.use("/oauth-proxy/:connectionId/*", oauthProxyBodyLimit); app.all("/oauth-proxy/:connectionId/*", deps.oauthProxyHandler); // SSE events: GET /watch streams.