Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
createWellKnownAuthServerRoutes,
fetchAuthorizationServerMetadata,
fetchProtectedResourceMetadata,
oauthProxyBodyLimit,
protectedResourceMetadataHandler,
} from "./routes/oauth-proxy";
import openaiCompatRoutes from "./routes/openai-compat";
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/api/routes/oauth-proxy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,40 @@
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);

Check failure on line 420 in apps/api/src/api/routes/oauth-proxy.integration.test.ts

View workflow job for this annotation

GitHub Actions / storage-integration

error: expect(received).toBe(expected)

Expected: 413 Received: 200 at <anonymous> (/home/runner/work/studio/studio/apps/api/src/api/routes/oauth-proxy.integration.test.ts:420:26)
});
});

// ===========================================================================
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/api/routes/oauth-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };

// ============================================================================
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading