import { afterEach, describe, expect, test } from "bun:test"; import { isPrivateAddress, safeFetch, sanitizeUrl } from "../src/index.ts"; const realFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = realFetch; }); /** Capture what each hop actually receives, and script the redirect chain. */ function stubFetch(chain: (string | null)[]) { const seen: { url: string; host: string | null; authorization: string | null }[] = []; let hop = 0; globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const headers = new Headers(init?.headers); seen.push({ url: String(input), host: headers.get("host"), authorization: headers.get("authorization"), }); const location = chain[hop++]; return location ? new Response(null, { status: 302, headers: { location } }) : new Response("done", { status: 200 }); }) as typeof fetch; return seen; } describe("safeFetch redirect credential handling", () => { test("does not re-attach credentials after leaving the original origin", async () => { const seen = stubFetch(["https://b.example/x", "https://b.example/final", null]); await safeFetch("https://a.example/start", { headers: { authorization: "Bearer SECRET", cookie: "sid=1" }, resolver: async () => ["93.184.216.34"], }); expect(seen).toHaveLength(3); expect(seen[0]!.authorization).toBe("Bearer SECRET"); // Both hops on b.example are off-origin, including the b→b one. expect(seen[1]!.authorization).toBeNull(); expect(seen[2]!.authorization).toBeNull(); }); test("keeps credentials across same-origin redirects", async () => { const seen = stubFetch(["https://a.example/next", null]); await safeFetch("https://a.example/start", { headers: { authorization: "Bearer SECRET" }, resolver: async () => ["93.184.216.34"], }); expect(seen[1]!.authorization).toBe("Bearer SECRET"); }); test("forwardSensitiveHeaders opts back in", async () => { const seen = stubFetch(["https://b.example/x", null]); await safeFetch("https://a.example/start", { headers: { authorization: "Bearer SECRET" }, forwardSensitiveHeaders: true, resolver: async () => ["93.184.216.34"], }); expect(seen[1]!.authorization).toBe("Bearer SECRET"); }); }); describe("safeFetch DNS pinning", () => { test("connects to the validated address and preserves the Host header", async () => { const seen = stubFetch([null]); await safeFetch("https://rebind.example/data", { resolver: async () => ["93.184.216.34"], }); // The connection targets the address we checked, so a second DNS answer // cannot redirect it at an internal host. expect(seen[0]!.url).toBe("https://93.184.216.34/data"); expect(seen[0]!.host).toBe("rebind.example"); }); test("pinDns:false restores hostname dialling", async () => { const seen = stubFetch([null]); await safeFetch("https://rebind.example/data", { pinDns: false, resolver: async () => ["93.184.216.34"], }); expect(seen[0]!.url).toBe("https://rebind.example/data"); }); test("still rejects hosts that resolve into private space", async () => { stubFetch([null]); await expect( safeFetch("https://rebind.example/data", { resolver: async () => ["169.254.169.254"] }), ).rejects.toThrow(/blocked address/); }); }); describe("isPrivateAddress coverage", () => { test("catches non-canonical IPv4-mapped and site-local IPv6", () => { expect(isPrivateAddress("0:0:0:0:0:ffff:127.0.0.1")).toBe(true); expect(isPrivateAddress("::ffff:7f00:1")).toBe(true); expect(isPrivateAddress("fec0::1")).toBe(true); expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true); expect(isPrivateAddress("198.18.0.1")).toBe(true); expect(isPrivateAddress("192.0.0.192")).toBe(true); }); test("leaves public addresses alone", () => { expect(isPrivateAddress("93.184.216.34")).toBe(false); expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false); }); }); describe("sanitizeUrl protocol-relative handling", () => { test("does not hand back a protocol-relative URL verbatim", () => { // "//evil.com" in an href navigates cross-origin; returning it unchanged // would bypass every host check validateUrl just performed. expect(sanitizeUrl("//evil.com")).toBe("http://evil.com/"); expect(sanitizeUrl("//evil.com/path?a=b")).toBe("http://evil.com/path?a=b"); // Backslash spellings resolve the same way rather than passing through. expect(sanitizeUrl("\\\\evil.com")).toBe("http://evil.com/"); expect(sanitizeUrl("/\\evil.com")).toBe("http://evil.com/"); }); test("honours host policy for protocol-relative input", () => { expect(sanitizeUrl("//evil.com", { allowedHosts: ["good.com"] })).toBe("about:blank"); expect(sanitizeUrl("//good.com/x", { allowedHosts: ["good.com"] })).toBe("http://good.com/x"); }); test("genuine relative paths round-trip unchanged", () => { expect(sanitizeUrl("/safe/path")).toBe("/safe/path"); expect(sanitizeUrl("./rel")).toBe("./rel"); expect(sanitizeUrl("?q=1")).toBe("?q=1"); expect(sanitizeUrl("#frag")).toBe("#frag"); }); });