mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
5569b8259b
commit
e25080b214
2 files changed
+139
-18
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 }]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user