diff --git a/src/app/api/cli-tools/cowork-mcp-tools/route.js b/src/app/api/cli-tools/cowork-mcp-tools/route.js index 5cc3d3a1..539dcd70 100644 --- a/src/app/api/cli-tools/cowork-mcp-tools/route.js +++ b/src/app/api/cli-tools/cowork-mcp-tools/route.js @@ -1,6 +1,8 @@ "use server"; import { NextResponse } from "next/server"; +import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js"; +import { isLocalRequest } from "@/dashboardGuard"; const TIMEOUT_MS = 8000; @@ -87,6 +89,14 @@ export async function POST(request) { if (!url || typeof url !== "string") { return NextResponse.json({ error: "url required" }, { status: 400 }); } + // SSRF guard for remote callers; local host keeps self-hosted MCP servers. + if (!isLocalRequest(request)) { + try { + assertPublicUrl(url); + } catch { + return NextResponse.json({ error: "URL not allowed" }, { status: 400 }); + } + } const result = await probeMcp(url); return NextResponse.json(result); } catch (e) { diff --git a/tests/unit/cowork-mcp-ssrf-guard.test.js b/tests/unit/cowork-mcp-ssrf-guard.test.js new file mode 100644 index 00000000..663c13b8 --- /dev/null +++ b/tests/unit/cowork-mcp-ssrf-guard.test.js @@ -0,0 +1,61 @@ +/** + * SSRF guard on POST /api/cli-tools/cowork-mcp-tools (#3782). + * + * Remote callers must not be able to force server-side fetches to + * internal URLs; local-host use (self-hosted MCP servers) keeps working. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ + NextResponse: { + json: (body, init) => + new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { "content-type": "application/json" }, + }), + }, +})); + +const { POST } = await import( + "../../src/app/api/cli-tools/cowork-mcp-tools/route.js" +); + +function remoteRequest(url) { + return new Request("http://gateway.example.com/api/cli-tools/cowork-mcp-tools", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url }), + }); +} + +describe("cowork-mcp-tools SSRF guard", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects loopback URLs from remote callers without fetching", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const res = await POST(remoteRequest("http://127.0.0.1:18731/internal-admin")); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "URL not allowed" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects private-network URLs from remote callers", async () => { + for (const url of ["http://10.0.0.5/mcp", "http://192.168.1.1/mcp", "http://localhost:3000/mcp"]) { + const res = await POST(remoteRequest(url)); + expect(res.status, `should reject ${url}`).toBe(400); + } + }); + + it("still requires a url", async () => { + const res = await POST( + new Request("http://gateway.example.com/api/cli-tools/cowork-mcp-tools", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + expect(res.status).toBe(400); + }); +});