evOo-local/packages/adapters/src/web-ssrf.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

451 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { assertSafeWebUrl, fetchSafeWebText, isBlockedHostname } from "./web-ssrf.js";
const publicResolver = async () => [{ address: "203.0.113.10", family: 4 as const }];
describe("web SSRF policy", () => {
it.each([
"http://localhost/x",
"https://127.0.0.1/x",
"http://10.0.0.1/x",
"https://192.168.1.1/x",
"http://169.254.169.254/latest/meta-data",
"https://[::1]/",
"http://[::]/",
"file:///etc/passwd",
"gopher://example.test/1",
"ftp://example.test/file",
])("rejects unsafe URL %s", async (url) => {
await expect(assertSafeWebUrl(url, publicResolver)).rejects.toThrow();
});
it("accepts public http and https URLs", async () => {
await expect(assertSafeWebUrl("https://example.test/page", publicResolver)).resolves.toEqual(
new URL("https://example.test/page"),
);
await expect(assertSafeWebUrl("http://example.test/page", publicResolver)).resolves.toEqual(
new URL("http://example.test/page"),
);
});
it("rejects hosts that resolve privately", async () => {
await expect(
assertSafeWebUrl("https://example.test/page", async () => [
{ address: "10.1.2.3", family: 4 as const },
]),
).rejects.toThrow(/private address/i);
});
it("blocks private hostnames before DNS", () => {
expect(isBlockedHostname("localhost")).toBe(true);
expect(isBlockedHostname("127.0.0.1")).toBe(true);
expect(isBlockedHostname("10.2.3.4")).toBe(true);
expect(isBlockedHostname("192.168.0.9")).toBe(true);
expect(isBlockedHostname("169.254.1.1")).toBe(true);
expect(isBlockedHostname("::1")).toBe(true);
expect(isBlockedHostname("example.test")).toBe(false);
});
it("re-validates redirect targets and blocks redirect-to-private", async () => {
const fetchMock: typeof fetch = async (input) => {
const href = String(input);
if (href === "https://example.test/start") {
return new Response(null, {
status: 302,
headers: { location: "http://127.0.0.1/secret" },
});
}
throw new Error(`unexpected fetch ${href}`);
};
await expect(
fetchSafeWebText("https://example.test/start", {
fetch: fetchMock,
resolveHostname: publicResolver,
}),
).rejects.toThrow(/private|internal/i);
});
it("follows a public redirect and returns the final body", async () => {
const fetchMock: typeof fetch = async (input) => {
const href = String(input);
if (href === "https://example.test/start") {
return new Response(null, {
status: 302,
headers: { location: "https://example.test/final" },
});
}
if (href === "https://example.test/final") {
return new Response("<html><title>Ok</title><body>hello</body></html>", {
status: 200,
headers: { "content-type": "text/html" },
});
}
throw new Error(`unexpected fetch ${href}`);
};
const result = await fetchSafeWebText("https://example.test/start", {
fetch: fetchMock,
resolveHostname: publicResolver,
});
expect(result.url).toBe("https://example.test/final");
expect(result.body).toContain("hello");
});
it("drops caller-provided headers on cross-origin redirects", async () => {
let finalHeaders = new Headers();
const fetchMock: typeof fetch = async (input, init) => {
const url = new URL(String(input));
if (url.origin === "https://example.test") {
return new Response(null, {
status: 302,
headers: { location: "https://other.example.test/final" },
});
}
finalHeaders = new Headers(init?.headers);
return new Response("ok", { status: 200 });
};
await fetchSafeWebText("https://example.test/start", {
fetch: fetchMock,
resolveHostname: publicResolver,
headers: {
Authorization: "Bearer stored",
"X-Api-Key": "stored-key",
"X-Trace-Id": "trace-1",
},
});
expect(finalHeaders.get("authorization")).toBeNull();
expect(finalHeaders.get("x-api-key")).toBeNull();
expect(finalHeaders.get("x-trace-id")).toBeNull();
});
it("drives the guarded dispatcher with a fetch from the same undici", async () => {
// See remote-mcp.test: failing inside the lookup proves the request was
// dispatched through the Agent rather than rejected by a mismatched fetch.
let resolutions = 0;
await expect(
fetchSafeWebText("https://example.test/start", {
resolveHostname: async () => {
resolutions += 1;
if (resolutions > 1) throw new Error("lookup reached");
return [{ address: "203.0.113.10", family: 4 as const }];
},
}),
).rejects.toMatchObject({ cause: { message: "lookup reached" } });
});
it("rejects oversized Content-Length before reading", async () => {
const fetchMock: typeof fetch = async () =>
new Response("ignored", {
status: 200,
headers: { "content-length": String(10 * 1024 * 1024) },
});
await expect(
fetchSafeWebText("https://example.test/big", {
fetch: fetchMock,
resolveHostname: publicResolver,
maxBytes: 1024,
}),
).rejects.toThrow(/too large/i);
});
it("aborts while streaming once maxBytes is exceeded", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(600).fill(65));
controller.enqueue(new Uint8Array(600).fill(66));
controller.close();
},
});
const fetchMock: typeof fetch = async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "text/plain" },
});
await expect(
fetchSafeWebText("https://example.test/stream", {
fetch: fetchMock,
resolveHostname: publicResolver,
maxBytes: 1000,
}),
).rejects.toThrow(/too large/i);
});
it("readBodyCapped stops once the byte budget is exceeded", async () => {
const { readBodyCapped } = await import("./web-ssrf.js");
let pulls = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
if (pulls === 1) {
controller.enqueue(new Uint8Array(400).fill(1));
return;
}
if (pulls === 2) {
controller.enqueue(new Uint8Array(400).fill(2));
return;
}
controller.enqueue(new Uint8Array(400).fill(3));
controller.close();
},
});
const response = new Response(stream, { status: 200 });
await expect(readBodyCapped(response, 500)).rejects.toThrow(/too large/i);
// Second chunk already exceeds; a third pull must not be needed.
expect(pulls).toBeLessThanOrEqual(2);
});
it("readBodyCapped does not wait for a hanging stream cancellation", async () => {
const { readBodyCapped } = await import("./web-ssrf.js");
const cancel = vi.fn(() => new Promise<void>(() => undefined));
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(501));
},
cancel,
}),
);
await expect(readBodyCapped(response, 500)).rejects.toThrow(/too large/i);
expect(cancel).toHaveBeenCalledOnce();
});
it("accepts a streamed body that stays under maxBytes", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("hello "));
controller.enqueue(new TextEncoder().encode("world"));
controller.close();
},
});
const fetchMock: typeof fetch = async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "text/plain" },
});
const result = await fetchSafeWebText("https://example.test/ok", {
fetch: fetchMock,
resolveHostname: publicResolver,
maxBytes: 100,
});
expect(result.body).toBe("hello world");
});
it("uses one deadline across the whole redirect chain", async () => {
let hops = 0;
const fetchMock: typeof fetch = async (input, init) => {
hops += 1;
const signal = init?.signal;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 80);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason ?? new Error("aborted"));
},
{ once: true },
);
});
const href = String(input);
if (href.endsWith("/a")) {
return new Response(null, {
status: 302,
headers: { location: "https://example.test/b" },
});
}
if (href.endsWith("/b")) {
return new Response(null, {
status: 302,
headers: { location: "https://example.test/c" },
});
}
return new Response("done", { status: 200 });
};
await expect(
fetchSafeWebText("https://example.test/a", {
fetch: fetchMock,
resolveHostname: publicResolver,
timeoutMs: 100,
}),
).rejects.toThrow();
// Per-hop 100ms would allow all three hops; one shared deadline stops earlier.
expect(hops).toBeLessThan(3);
});
it("cancels redirect response bodies before following the next hop", async () => {
let cancelled = false;
const redirectBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("redirect-body"));
},
cancel() {
cancelled = true;
},
});
const fetchMock: typeof fetch = async (input) => {
const href = String(input);
if (href.endsWith("/start")) {
return new Response(redirectBody, {
status: 302,
headers: { location: "https://example.test/final" },
});
}
return new Response("ok", { status: 200 });
};
const result = await fetchSafeWebText("https://example.test/start", {
fetch: fetchMock,
resolveHostname: publicResolver,
});
expect(result.body).toBe("ok");
expect(cancelled).toBe(true);
});
it("aborts a hanging response body under the shared deadline", async () => {
const hangingBody = new ReadableStream<Uint8Array>({
start() {
// never enqueues or closes — simulates a fetch body that ignores abort
},
});
const fetchMock: typeof fetch = async () =>
new Response(hangingBody, {
status: 200,
headers: { "content-type": "text/plain" },
});
let destroyed = false;
const started = Date.now();
await expect(
fetchSafeWebText("https://example.test/hang", {
fetch: fetchMock,
resolveHostname: publicResolver,
timeoutMs: 40,
destroy: () => {
destroyed = true;
},
}),
).rejects.toThrow();
expect(destroyed).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("aborts when fetch itself ignores the abort signal", async () => {
const fetchMock: typeof fetch = async () =>
new Promise(() => {
// never settles — ignores init.signal
});
let destroyed = false;
const started = Date.now();
await expect(
fetchSafeWebText("https://example.test/hang-fetch", {
fetch: fetchMock,
resolveHostname: publicResolver,
timeoutMs: 40,
destroy: () => {
destroyed = true;
},
}),
).rejects.toThrow();
expect(destroyed).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("finishes when the deadline fires despite hanging body cancel and close", async () => {
let bodyCancelStarted = false;
let cleanupStarted = false;
const hangingBody = new ReadableStream<Uint8Array>({
start() {
// never enqueues or closes
},
cancel() {
bodyCancelStarted = true;
return new Promise(() => {
// never settles
});
},
});
const fetchMock: typeof fetch = async () =>
new Response(hangingBody, {
status: 302,
headers: { location: "https://example.test/next" },
});
let destroyed = false;
const started = Date.now();
await expect(
fetchSafeWebText("https://example.test/start", {
fetch: fetchMock,
resolveHostname: publicResolver,
timeoutMs: 40,
cleanup: () => {
cleanupStarted = true;
return new Promise(() => {
// hanging dispatcher.close()
});
},
destroy: () => {
destroyed = true;
},
}),
).rejects.toThrow();
expect(bodyCancelStarted).toBe(true);
expect(cleanupStarted).toBe(true);
expect(destroyed).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("swallows a rejecting destroy promise without extending the deadline", async () => {
const hangingBody = new ReadableStream<Uint8Array>({
start() {
// never enqueues or closes
},
});
const fetchMock: typeof fetch = async () =>
new Response(hangingBody, {
status: 200,
headers: { "content-type": "text/plain" },
});
let destroyCalled = false;
const started = Date.now();
await expect(
fetchSafeWebText("https://example.test/hang", {
fetch: fetchMock,
resolveHostname: publicResolver,
timeoutMs: 40,
destroy: () => {
destroyCalled = true;
return Promise.reject(new Error("destroy failed"));
},
}),
).rejects.toThrow();
expect(destroyCalled).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("aborts stalled DNS under the shared deadline", async () => {
let resolveCalls = 0;
const stalledResolver = () =>
new Promise<Array<{ address: string; family: number }>>((resolve) => {
resolveCalls += 1;
setTimeout(() => resolve([{ address: "203.0.113.10", family: 4 }]), 500);
});
await expect(
fetchSafeWebText("https://example.test/slow-dns", {
fetch: async () => new Response("nope", { status: 200 }),
resolveHostname: stalledResolver,
timeoutMs: 50,
}),
).rejects.toThrow();
expect(resolveCalls).toBe(1);
});
});