openclaw/src/infra/net/fetch-guard.ssrf.test.ts
2026-02-21 23:45:49 +01:00

152 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { fetchWithSsrFGuard } from "./fetch-guard.js";
function redirectResponse(location: string): Response {
return new Response(null, {
status: 302,
headers: { location },
});
}
function okResponse(body = "ok"): Response {
return new Response(body, { status: 200 });
}
describe("fetchWithSsrFGuard hardening", () => {
type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>;
it("blocks private and legacy loopback literals before fetch", async () => {
const blockedUrls = [
"http://127.0.0.1:8080/internal",
"http://0177.0.0.1:8080/internal",
"http://0x7f000001/internal",
];
for (const url of blockedUrls) {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url,
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
}
});
it("blocks special-use IPv4 literal URLs before fetch", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "http://198.18.0.1:8080/internal",
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/"));
await expect(
fetchWithSsrFGuard({
url: "https://public.example/start",
fetchImpl,
lookupFn,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("enforces hostname allowlist policies", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "https://evil.example.org/file.txt",
fetchImpl,
policy: { hostnameAllowlist: ["cdn.example.com", "*.assets.example.com"] },
}),
).rejects.toThrow(/allowlist/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("allows wildcard allowlisted hosts", async () => {
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
const result = await fetchWithSsrFGuard({
url: "https://img.assets.example.com/pic.png",
fetchImpl,
lookupFn,
policy: { hostnameAllowlist: ["*.assets.example.com"] },
});
expect(result.response.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});
it("strips sensitive headers when redirect crosses origins", async () => {
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(redirectResponse("https://cdn.example.com/asset"))
.mockResolvedValueOnce(okResponse());
const result = await fetchWithSsrFGuard({
url: "https://api.example.com/start",
fetchImpl,
lookupFn,
init: {
headers: {
Authorization: "Bearer secret",
"Proxy-Authorization": "Basic c2VjcmV0",
Cookie: "session=abc",
Cookie2: "legacy=1",
"X-Trace": "1",
},
},
});
const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit];
const headers = new Headers(secondInit.headers);
expect(headers.get("authorization")).toBeNull();
expect(headers.get("proxy-authorization")).toBeNull();
expect(headers.get("cookie")).toBeNull();
expect(headers.get("cookie2")).toBeNull();
expect(headers.get("x-trace")).toBe("1");
await result.release();
});
it("keeps headers when redirect stays on same origin", async () => {
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(redirectResponse("/next"))
.mockResolvedValueOnce(okResponse());
const result = await fetchWithSsrFGuard({
url: "https://api.example.com/start",
fetchImpl,
lookupFn,
init: {
headers: {
Authorization: "Bearer secret",
},
},
});
const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit];
const headers = new Headers(secondInit.headers);
expect(headers.get("authorization")).toBe("Bearer secret");
await result.release();
});
});