161 lines
4.5 KiB
TypeScript
161 lines
4.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const lookupMock = vi.fn();
|
|
|
|
vi.mock("node:dns/promises", () => ({
|
|
lookup: lookupMock,
|
|
}));
|
|
|
|
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
|
return {
|
|
get: (key) => map[key.toLowerCase()] ?? null,
|
|
};
|
|
}
|
|
|
|
function redirectResponse(location: string): Response {
|
|
return {
|
|
ok: false,
|
|
status: 302,
|
|
headers: makeHeaders({ location }),
|
|
body: { cancel: vi.fn() },
|
|
} as Response;
|
|
}
|
|
|
|
function textResponse(body: string): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: makeHeaders({ "content-type": "text/plain" }),
|
|
text: async () => body,
|
|
} as Response;
|
|
}
|
|
|
|
describe("web_fetch SSRF protection", () => {
|
|
const priorFetch = global.fetch;
|
|
|
|
afterEach(() => {
|
|
// @ts-expect-error restore
|
|
global.fetch = priorFetch;
|
|
lookupMock.mockReset();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("blocks localhost hostnames before fetch/firecrawl", async () => {
|
|
const fetchSpy = vi.fn();
|
|
// @ts-expect-error mock fetch
|
|
global.fetch = fetchSpy;
|
|
|
|
const { createWebFetchTool } = await import("./web-tools.js");
|
|
const tool = createWebFetchTool({
|
|
config: {
|
|
tools: {
|
|
web: {
|
|
fetch: {
|
|
cacheTtlMinutes: 0,
|
|
firecrawl: { apiKey: "firecrawl-test" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
|
|
/Blocked hostname/i,
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(lookupMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks private IP literals without DNS", async () => {
|
|
const fetchSpy = vi.fn();
|
|
// @ts-expect-error mock fetch
|
|
global.fetch = fetchSpy;
|
|
|
|
const { createWebFetchTool } = await import("./web-tools.js");
|
|
const tool = createWebFetchTool({
|
|
config: {
|
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
|
},
|
|
});
|
|
|
|
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
|
|
/private|internal|blocked/i,
|
|
);
|
|
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
|
|
/private|internal|blocked/i,
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(lookupMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks when DNS resolves to private addresses", async () => {
|
|
lookupMock.mockImplementation(async (hostname: string) => {
|
|
if (hostname === "public.test") {
|
|
return [{ address: "93.184.216.34", family: 4 }];
|
|
}
|
|
return [{ address: "10.0.0.5", family: 4 }];
|
|
});
|
|
|
|
const fetchSpy = vi.fn();
|
|
// @ts-expect-error mock fetch
|
|
global.fetch = fetchSpy;
|
|
|
|
const { createWebFetchTool } = await import("./web-tools.js");
|
|
const tool = createWebFetchTool({
|
|
config: {
|
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
|
},
|
|
});
|
|
|
|
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
|
|
/private|internal|blocked/i,
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks redirects to private hosts", async () => {
|
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
|
|
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
|
|
// @ts-expect-error mock fetch
|
|
global.fetch = fetchSpy;
|
|
|
|
const { createWebFetchTool } = await import("./web-tools.js");
|
|
const tool = createWebFetchTool({
|
|
config: {
|
|
tools: {
|
|
web: {
|
|
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
|
|
/private|internal|blocked/i,
|
|
);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows public hosts", async () => {
|
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
|
|
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
|
|
// @ts-expect-error mock fetch
|
|
global.fetch = fetchSpy;
|
|
|
|
const { createWebFetchTool } = await import("./web-tools.js");
|
|
const tool = createWebFetchTool({
|
|
config: {
|
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
|
},
|
|
});
|
|
|
|
const result = await tool?.execute?.("call", { url: "https://example.com" });
|
|
expect(result?.details).toMatchObject({
|
|
status: 200,
|
|
extractor: "raw",
|
|
});
|
|
});
|
|
});
|