diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index c3df27353..57bdf3ed0 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -1,11 +1,37 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import dns from "node:dns"; +import { fetch as undiciFetch } from "undici"; import { isPublicIp } from "@/lib/validations/notifications"; import { getPinnedPublicDispatcher, _resetPinnedDispatcherForTests, } from "../safe-fetch-dispatcher"; +const EMBEDDED_PRIVATE_DNS_ANSWERS = [ + ["IPv4-compatible loopback", "::7f00:1"], + ["IPv4-compatible metadata", "::a9fe:a9fe"], + ["6to4 RFC1918", "2002:a00:1::"], + ["6to4 metadata", "2002:a9fe:a9fe::"], + ["NAT64 WKP RFC1918", "64:ff9b::c0a8:101"], + ["NAT64 WKP metadata", "64:ff9b::a9fe:a9fe"], + ["RFC8215 local-use RFC1918", "64:ff9b:1:ac10:0:1::"], + ["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"], +] as const; + +function errorCodes(error: unknown): string[] { + if (!error || typeof error !== "object") return []; + const value = error as { + code?: unknown; + cause?: unknown; + errors?: unknown[]; + }; + return [ + ...(typeof value.code === "string" ? [value.code] : []), + ...errorCodes(value.cause), + ...(value.errors ?? []).flatMap(errorCodes), + ]; +} + describe("isPublicIp", () => { it("accepts a public IPv4 address", () => { expect(isPublicIp("203.0.113.5")).toBe(true); @@ -79,17 +105,19 @@ describe("pinnedPublicDispatcher", () => { }) as unknown as typeof dns.lookup); const dispatcher = getPinnedPublicDispatcher(); - // Drive the dispatcher through `fetch` so the integration mirrors - // what safeFetch does. The expected outcome is a connect error - // (the lookup wrapper raised ENOTFOUND). - await expect( - fetch( - "https://attacker-controlled.example.test/probe", - // Node's fetch RequestInit accepts undici's `dispatcher`; the - // DOM types do not, hence the cast. - { dispatcher } as RequestInit & { dispatcher: typeof dispatcher }, - ), - ).rejects.toThrow(); + let caught: unknown; + try { + await undiciFetch("https://attacker-controlled.example.test/probe", { + dispatcher, + }); + } catch (error) { + caught = error; + } + + // A known-denied plain IPv4 answer proves the harness can distinguish + // policy refusal from a later socket failure. + expect(caught).toBeDefined(); + expect(errorCodes(caught)).toContain("ENOTFOUND"); }, 20_000); it("forwards a public-resolving hostname to the real connector", async () => { @@ -161,4 +189,35 @@ describe("pinnedPublicDispatcher", () => { const code = (caught as NodeJS.ErrnoException)?.code; expect(code === "ENOTFOUND").toBe(false); }, 20_000); + + it.each(EMBEDDED_PRIVATE_DNS_ANSWERS)( + "refuses a hostname whose resolver returns %s before Undici connects", + async (_label, address) => { + vi.spyOn(dns, "lookup").mockImplementation((( + _hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addresses: dns.LookupAddress[], + ) => void, + ) => { + callback(null, [{ address, family: 6 }]); + }) as unknown as typeof dns.lookup); + + const dispatcher = getPinnedPublicDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://transition-answer.example.test:9/probe", { + dispatcher, + signal: AbortSignal.timeout(750), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeDefined(); + expect(errorCodes(caught)).toContain("ENOTFOUND"); + }, + 5_000, + ); }); diff --git a/src/lib/__tests__/safe-fetch-pinned.test.ts b/src/lib/__tests__/safe-fetch-pinned.test.ts index d94322e7c..ea7f7f86c 100644 --- a/src/lib/__tests__/safe-fetch-pinned.test.ts +++ b/src/lib/__tests__/safe-fetch-pinned.test.ts @@ -32,6 +32,11 @@ import http from "node:http"; import dns from "node:dns"; import type { AddressInfo } from "node:net"; +const notificationMocks = vi.hoisted(() => ({ + isPublicIp: vi.fn((_ip: string) => true), + isPublicUrl: vi.fn((_url: string) => true), +})); + // Treat every address/URL as public so the harness can reach a loopback server // through the requirePublicHost guard. The transport coupling under test is // orthogonal to IP classification (covered by safe-fetch-dispatcher.test.ts). @@ -40,8 +45,8 @@ vi.mock("@/lib/validations/notifications", async (importOriginal) => { await importOriginal(); return { ...actual, - isPublicIp: () => true, - isPublicUrl: () => true, + isPublicIp: notificationMocks.isPublicIp, + isPublicUrl: notificationMocks.isPublicUrl, }; }); @@ -50,6 +55,7 @@ import { _resetPinnedDispatcherForTests } from "../safe-fetch-dispatcher"; let server: http.Server; let port: number; +const requestedPaths: string[] = []; function mockLookupTo(addresses: dns.LookupAddress[]): void { vi.spyOn(dns, "lookup").mockImplementation((( @@ -66,6 +72,14 @@ function mockLookupTo(addresses: dns.LookupAddress[]): void { beforeAll(async () => { server = http.createServer((req, res) => { + requestedPaths.push(req.url ?? ""); + if (req.url === "/redirect") { + res.writeHead(302, { + location: `http://redirect-target.test:${port}/api`, + }); + res.end(); + return; + } if (req.url === "/stream") { res.writeHead(200, { "content-type": "text/plain" }); res.write("chunk-1;"); @@ -94,6 +108,11 @@ afterAll(async () => { afterEach(() => { vi.restoreAllMocks(); + notificationMocks.isPublicIp.mockReset(); + notificationMocks.isPublicIp.mockImplementation((_ip: string) => true); + notificationMocks.isPublicUrl.mockReset(); + notificationMocks.isPublicUrl.mockImplementation((_url: string) => true); + requestedPaths.length = 0; _resetPinnedDispatcherForTests(); }); @@ -112,12 +131,12 @@ describe("safeFetch pinned path — real transport", () => { expect(body.ok).toBe(true); }, 20_000); - it("falls back across families when the first address is a black-holed IPv6", async () => { - // First record is an unreachable IPv6 (RFC 6666 discard prefix); the - // working IPv4 record is behind it. Pre-fix this failed instantly; with - // Happy Eyeballs the connect falls back to v4 and succeeds. + it("retains fallback connectivity across public native IPv6 and IPv4", async () => { + // The first record is a genuinely public native IPv6 address, while the + // harness server is reachable through the IPv4 record behind it. The + // dispatcher must preserve both vetted families for Happy Eyeballs. mockLookupTo([ - { address: "0100::1", family: 6 }, + { address: "2606:4700:4700::1111", family: 6 }, { address: "127.0.0.1", family: 4 }, ]); @@ -130,6 +149,49 @@ describe("safeFetch pinned path — real transport", () => { expect(res.status).toBe(200); }, 20_000); + it("re-runs the pinned DNS verdict for a followed redirect destination", async () => { + const deniedRedirectAddress = "64:ff9b::7f00:1"; + const lookupHosts: string[] = []; + notificationMocks.isPublicIp.mockImplementation( + (ip: string) => ip !== deniedRedirectAddress, + ); + vi.spyOn(dns, "lookup").mockImplementation((( + hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addrs: dns.LookupAddress[], + ) => void, + ) => { + lookupHosts.push(hostname); + callback( + null, + hostname === "redirect-target.test" + ? [{ address: deniedRedirectAddress, family: 6 }] + : [{ address: "127.0.0.1", family: 4 }], + ); + }) as unknown as typeof dns.lookup); + + await expect( + safeFetch( + `http://pinned.test:${port}/redirect`, + {}, + { + requirePublicHost: true, + followRedirects: true, + timeoutMs: 5000, + }, + ), + ).rejects.toMatchObject({ kind: "network" }); + + expect(lookupHosts).toContain("pinned.test"); + expect(lookupHosts).toContain("redirect-target.test"); + expect(notificationMocks.isPublicIp).toHaveBeenCalledWith( + deniedRedirectAddress, + ); + expect(requestedPaths).toEqual(["/redirect"]); + }, 20_000); + it("streams a chunked response body via getReader() on the pinned path", async () => { mockLookupTo([{ address: "127.0.0.1", family: 4 }]);