fix(security): guard cowork-mcp-tools probe against SSRF (#3783)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js";
|
||||||
|
import { isLocalRequest } from "@/dashboardGuard";
|
||||||
|
|
||||||
const TIMEOUT_MS = 8000;
|
const TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
@@ -87,6 +89,14 @@ export async function POST(request) {
|
|||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
return NextResponse.json({ error: "url required" }, { status: 400 });
|
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);
|
const result = await probeMcp(url);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
61
tests/unit/cowork-mcp-ssrf-guard.test.js
Normal file
61
tests/unit/cowork-mcp-ssrf-guard.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user