test(01-02): add red DNS-pinned SSRF contracts

- refuse standard embedded-private IPv6 resolver answers pre-connect
- preserve public IPv6 to IPv4 fallback connectivity
- prove followed redirects repeat the pinned DNS verdict
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:39:52 +02:00
1 parent 5569b8259b
commit e25080b214
2 files changed
+139 -18

No files matched your search

+70 -11
View File
@@ -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,
);
});
+69 -7
View File
@@ -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<typeof import("@/lib/validations/notifications")>();
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 }]);