Files
openclaw/src/agents/tools/web-fetch.ssrf.test.ts
Vincent Koc e4d80ed556 CI: restore main detect-secrets scan (#38438)
* Tests: stabilize detect-secrets fixtures

* Tests: fix rebased detect-secrets false positives

* Docs: keep snippets valid under detect-secrets

* Tests: finalize detect-secrets false-positive fixes

* Tests: reduce detect-secrets false positives

* Tests: keep detect-secrets pragmas inline

* Tests: remediate next detect-secrets batch

* Tests: tighten detect-secrets allowlists

* Tests: stabilize detect-secrets formatter drift
2026-03-07 10:06:35 -08:00

146 lines
4.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
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 unknown as Response;
}
function textResponse(body: string): Response {
return {
ok: true,
status: 200,
headers: makeHeaders({ "content-type": "text/plain" }),
text: async () => body,
} as unknown as Response;
}
function setMockFetch(
impl: FetchMock = async (_input: RequestInfo | URL, _init?: RequestInit) => textResponse(""),
) {
const fetchSpy = vi.fn<FetchMock>(impl);
global.fetch = withFetchPreconnect(fetchSpy);
return fetchSpy;
}
async function createWebFetchToolForTest(params?: {
firecrawl?: { enabled?: boolean; apiKey?: string };
}) {
const { createWebFetchTool } = await import("./web-tools.js");
return createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: params?.firecrawl ?? { enabled: false },
},
},
},
},
});
}
async function expectBlockedUrl(
tool: Awaited<ReturnType<typeof createWebFetchToolForTest>>,
url: string,
expectedMessage: RegExp,
) {
await expect(tool?.execute?.("call", { url })).rejects.toThrow(expectedMessage);
}
describe("web_fetch SSRF protection", () => {
const priorFetch = global.fetch;
beforeEach(() => {
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockClear();
vi.restoreAllMocks();
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret
});
await expectBlockedUrl(tool, "http://localhost/test", /Blocked hostname/i);
expect(fetchSpy).not.toHaveBeenCalled();
expect(lookupMock).not.toHaveBeenCalled();
});
it("blocks private IP literals without DNS", async () => {
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest();
const cases = ["http://127.0.0.1/test", "http://[::ffff:127.0.0.1]/"] as const;
for (const url of cases) {
await expectBlockedUrl(tool, url, /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 = setMockFetch();
const tool = await createWebFetchToolForTest();
await expectBlockedUrl(tool, "https://private.test/resource", /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 = setMockFetch().mockResolvedValueOnce(
redirectResponse("http://127.0.0.1/secret"),
);
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret
});
await expectBlockedUrl(tool, "https://example.com", /private|internal|blocked/i);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("allows public hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
setMockFetch().mockResolvedValue(textResponse("ok"));
const tool = await createWebFetchToolForTest();
const result = await tool?.execute?.("call", { url: "https://example.com" });
expect(result?.details).toMatchObject({
status: 200,
extractor: "raw",
});
});
});