Skip to content
Closed
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
67 changes: 61 additions & 6 deletions apps/api/src/adapters/http/routes/driver-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const HOP_BY_HOP_HEADERS = new Set([
const LLM_PROXY_GRANT_HEADERS = new Set(["authorization", "x-api-key", "x-goog-api-key"]);
const LLM_PROXY_PATH_MARKER = "/llm/proxy/";
const LLM_PROXY_UNSAFE_PATH_ENCODING = /%(?:25|2f|5c)/iu;
const MCP_PROXY_PATH_MARKER = "/mcp/proxy/";
const MCP_PROXY_UNSAFE_PATH_ENCODING = /%(?:25|2f|5c)/iu;
const OPENAI_IMAGE_API_PATHS = new Set(["/images/edits", "/images/generations"]);

async function requireDriverActionGrant(c: Context<ApiGatewayEnvironment>) {
Expand Down Expand Up @@ -121,10 +123,15 @@ function copyProxyResponseHeaders(headers: Headers): Headers {
return nextHeaders;
}

function toUpstreamProxyUrl(request: Request, upstreamUrl: string): string {
function toUpstreamProxyUrl(request: Request, upstreamUrl: string, subPath = ""): string {
const target = new URL(upstreamUrl);
const incoming = new URL(request.url);

const trimmedSubPath = subPath.replace(/^\/+|\/+$/g, "");
if (trimmedSubPath.length > 0) {
target.pathname = target.pathname.replace(/\/+$/, "") + `/${trimmedSubPath}`;
Comment on lines +130 to +132

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the downstream endpoint's trailing slash

subPath.replace(/^\/+|\/+$/g, "") removes a significant trailing slash as well as the join separator. With upstream /base, a request to /api/driver/mcp/proxy/<id>/messages/ is forwarded to /base/messages, although /base/messages/ and /base/messages can be different endpoints. A local upstream accepting only /base/messages/ returns 200 directly and 404 through this PR. Normalize the base/suffix join without removing the validated suffix's trailing slash, including a suffix of /, and add coverage for that case.

}

for (const [key, value] of incoming.searchParams) {
if (key !== "grant") {
target.searchParams.append(key, value);
Expand Down Expand Up @@ -240,6 +247,44 @@ function extractLlmProxySubPath(pathname: string): string | null {
return subPath;
}

function extractMcpProxySubPath(pathname: string): string | null {
const markerIndex = pathname.indexOf(MCP_PROXY_PATH_MARKER);

if (markerIndex === -1) {
return null;
}

const rest = pathname.slice(markerIndex + MCP_PROXY_PATH_MARKER.length);
const slashIndex = rest.indexOf("/");
const subPath = slashIndex === -1 ? "" : rest.slice(slashIndex);

if (subPath.includes("\\") || MCP_PROXY_UNSAFE_PATH_ENCODING.test(subPath)) {
return null;
}

for (const segment of subPath.split("/")) {
let decoded: string;

try {
decoded = decodeURIComponent(segment);
} catch {
return null;
}

if (
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\") ||
decoded.includes("%")
) {
return null;
}
}

return subPath;
}

function resolveGrantedLlmProxyModelId(
method: string,
subPath: string,
Expand Down Expand Up @@ -453,6 +498,7 @@ async function proxyRuntimeMcpRequest(
upstreamAccessToken: string;
url: string;
},
subPath: string,
): Promise<Response> {
const init: RequestInit = {
headers: copyProxyRequestHeaders(
Expand All @@ -467,7 +513,7 @@ async function proxyRuntimeMcpRequest(
init.body = request.body;
}

const response = await fetch(toUpstreamProxyUrl(request, input.url), init);
const response = await fetch(toUpstreamProxyUrl(request, input.url, subPath), init);

return new Response(response.body, {
headers: copyProxyResponseHeaders(response.headers),
Expand Down Expand Up @@ -780,7 +826,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
}
});

driver.all("/mcp/proxy/:serverId", async (c) => {
const handleRuntimeMcpProxy = async (c: Context<ApiGatewayEnvironment>) => {
await cleanupDriverInstances(c.env);

let grant: Awaited<ReturnType<typeof requireDriverAuthorizationGrant>>;
Expand All @@ -797,7 +843,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
let serverId: McpServerId;

try {
serverId = toPlatformId<McpServerId>(c.req.param("serverId"), "MCP server ID");
serverId = toPlatformId<McpServerId>(c.req.param("serverId") ?? "", "MCP server ID");
} catch (error) {
const response = driverPlatformIdErrorResponse(error);
if (response !== null) {
Expand All @@ -820,6 +866,12 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
);
}

const subPath = extractMcpProxySubPath(new URL(c.req.url).pathname);

if (subPath === null) {
return Response.json({ error: "MCP proxy path is invalid." }, { status: 400 });
}

let target: Awaited<ReturnType<typeof resolveRuntimeMcpProxyTarget>>;
let toolCallId: string | null;

Expand Down Expand Up @@ -847,7 +899,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
}

try {
return await proxyRuntimeMcpRequest(c.req.raw, target);
return await proxyRuntimeMcpRequest(c.req.raw, target, subPath);
} catch {
const proxyError = createRuntimeMcpProxyError({
code: "mcp_upstream_unavailable",
Expand All @@ -857,7 +909,10 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
const details = toRuntimeMcpProxyPublicErrorDetails(proxyError);
return Response.json(runtimeMcpProxyErrorBody(details), { status: details.status });
}
});
};

driver.all("/mcp/proxy/:serverId", handleRuntimeMcpProxy);
driver.all("/mcp/proxy/:serverId/*", handleRuntimeMcpProxy);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep credential-bearing requests within admitted MCP transport targets

This wildcard accepts every HTTP method and arbitrary descendants, while resolveRuntimeMcpProxyTarget receives no subpath to authorize and proxyRuntimeMcpRequest injects the stored upstream bearer credential. With a registered URL ending in /base, a valid runtime grant now permits DELETE /api/driver/mcp/proxy/<id>/admin/config to become an authenticated DELETE /base/admin/config. I reproduced the boundary change against a local HTTP fixture: the parent returns 404 without contacting upstream; this commit forwards the credential and returns 200. If the configured prefix contains privileged non-MCP endpoints, or the registered URL is the origin root, Sandbox code gains access to those operations using the owner's credential. Keep Streamable HTTP bound to the configured endpoint; any additional legacy/custom transport targets need an explicit validated mapping and method policy. Please add a denial test proving unrelated descendants cannot receive the upstream credential.


app.route(getRuntimeDriverRoutePrefix(), driver);
}
206 changes: 206 additions & 0 deletions apps/api/tests/driver-mcp-proxy-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, spyOn, test } from "bun:test";

import { parsePlatformId } from "@mosoo/id";
import type { DriverInstanceId, McpServerId } from "@mosoo/id";
import { Hono } from "hono";

import { registerDriverRoute } from "../src/adapters/http/routes/driver-route";
import { createRuntimeActionToken } from "../src/modules/runtime/infrastructure/runtime-boot-token";
import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types";
import {
createPublicHttpContractDatabase,
createPublicHttpTestBindings,
createTestExecutionContext,
} from "./helpers/public-api-http-test-fixture";

const SERVER_ID = "01J0000000000000000000000S";

// 初始化测试环境与路由实例
async function setupTestApp() {
const database = await createPublicHttpContractDatabase();
const bindings = createPublicHttpTestBindings(database);
const app = new Hono<ApiGatewayEnvironment>();
registerDriverRoute(app);
return { app, bindings };
}

// 快速签发合法的 MCP 代理授权凭证
async function createTestGrant(bindings: ReturnType<typeof createPublicHttpTestBindings>) {
return createRuntimeActionToken(bindings, {
action: "mcp_proxy",
driverInstanceId: parsePlatformId<DriverInstanceId>("01J0000000000000000000000D", "driver ID"),
expiresAt: Date.now() + 60_000,
resourceId: parsePlatformId<McpServerId>(SERVER_ID, "server ID"),
});
}

// 拦截并记录发往 upstream 的 HTTP 请求
function captureFetch(mockResponse: Response) {
const captured: Array<{ body: string | null; headers: Headers; method: string; url: string }> =
[];
const originalFetch = globalThis.fetch;

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
captured.push({
body: request.method === "GET" ? null : await request.text(),
headers: request.headers,
method: request.method,
url: request.url,
});
return mockResponse.clone();
}) as typeof fetch;

return {
captured,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}

describe("driver MCP proxy route", () => {
test("matches base route and rejects missing authorization grant with 401", async () => {
const { app, bindings } = await setupTestApp();

const response = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}`,
{ method: "POST" },
bindings,
createTestExecutionContext(),
);

expect(response.status).toBe(401);
});

test("matches subpath route and reaches handler instead of 404", async () => {
const { app, bindings } = await setupTestApp();

const response = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/messages`,
{ method: "POST" },
bindings,
createTestExecutionContext(),
);

expect(response.status).toBe(401);
});

test("rejects path traversal attempts with 400 when authorized", async () => {
const { app, bindings } = await setupTestApp();
const grant = await createTestGrant(bindings);
const headers = { Authorization: `Bearer ${grant}` };

for (const badSubpath of ["/v1/%zz/messages", "/v1/%5csecret"]) {
const response = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}${badSubpath}`,
{ headers, method: "POST" },
bindings,
createTestExecutionContext(),
);

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "MCP proxy path is invalid." });
}
});

test("successfully proxies requests with subpath and query parameters end-to-end", async () => {
const { app, bindings } = await setupTestApp();
const grant = await createTestGrant(bindings);
const { captured, restore } = captureFetch(
new Response(JSON.stringify({ result: "mcp-tool-executed" }), {
headers: { "content-type": "application/json" },
status: 200,
}),
);

const mcpProxyService =
await import("../src/modules/runtime/application/runtime-mcp-proxy.service");
const targetSpy = spyOn(mcpProxyService, "resolveRuntimeMcpProxyTarget").mockResolvedValue({
delegationToken: null,
serverId: parsePlatformId<McpServerId>(SERVER_ID, "server id"),
upstreamAccessToken: "upstream-secret-token",
url: "https://mcp.upstream.org/base",
});

try {
const response = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/messages?apiVersion=2026&grant=internal-grant`,
{
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call" }),
headers: {
Authorization: `Bearer ${grant}`,
"Content-Type": "application/json",
},
method: "POST",
},
bindings,
createTestExecutionContext(),
);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ result: "mcp-tool-executed" });

expect(captured).toHaveLength(1);
const upstream = captured[0];
expect(upstream?.url).toBe("https://mcp.upstream.org/base/messages?apiVersion=2026");
expect(upstream?.headers.get("authorization")).toBe("Bearer upstream-secret-token");
expect(upstream?.body).toBe(JSON.stringify({ jsonrpc: "2.0", method: "tools/call" }));
} finally {
targetSpy.mockRestore();
restore();
}
});

test("handles upstream trailing slashes cleanly for base route and subpaths", async () => {
const { app, bindings } = await setupTestApp();
const grant = await createTestGrant(bindings);
const { captured, restore } = captureFetch(
new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
status: 200,
}),
);

const mcpProxyService =
await import("../src/modules/runtime/application/runtime-mcp-proxy.service");
const targetSpy = spyOn(mcpProxyService, "resolveRuntimeMcpProxyTarget").mockResolvedValue({
delegationToken: null,
serverId: parsePlatformId<McpServerId>(SERVER_ID, "server id"),
upstreamAccessToken: "upstream-secret-token",
url: "https://mcp.upstream.org/base/",
});

try {
const baseResponse = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}?tag=stable`,
{
headers: { Authorization: `Bearer ${grant}` },
method: "POST",
},
bindings,
createTestExecutionContext(),
);

expect(baseResponse.status).toBe(200);
expect(captured[0]?.url).toBe("https://mcp.upstream.org/base/?tag=stable");

const subpathResponse = await app.request(
`https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/sse`,
{
headers: { Authorization: `Bearer ${grant}` },
method: "GET",
},
bindings,
createTestExecutionContext(),
);

expect(subpathResponse.status).toBe(200);
expect(captured[1]?.url).toBe("https://mcp.upstream.org/base/sse");
expect(captured[1]?.body).toBeNull();
} finally {
targetSpy.mockRestore();
restore();
}
});
});
Loading