fix: harden url fetch dns pinning
This commit is contained in:
63
src/infra/net/ssrf.pinning.test.ts
Normal file
63
src/infra/net/ssrf.pinning.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js";
|
||||
|
||||
describe("ssrf pinning", () => {
|
||||
it("pins resolved addresses for the target hostname", async () => {
|
||||
const lookup = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
]);
|
||||
|
||||
const pinned = await resolvePinnedHostname("Example.com.", lookup);
|
||||
expect(pinned.hostname).toBe("example.com");
|
||||
expect(pinned.addresses).toEqual(["93.184.216.34", "93.184.216.35"]);
|
||||
|
||||
const first = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
||||
pinned.lookup("example.com", (err, address, family) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string, family });
|
||||
});
|
||||
});
|
||||
expect(first.address).toBe("93.184.216.34");
|
||||
expect(first.family).toBe(4);
|
||||
|
||||
const all = await new Promise<unknown>((resolve, reject) => {
|
||||
pinned.lookup("example.com", { all: true }, (err, addresses) => {
|
||||
if (err) reject(err);
|
||||
else resolve(addresses);
|
||||
});
|
||||
});
|
||||
expect(Array.isArray(all)).toBe(true);
|
||||
expect((all as Array<{ address: string }>).map((entry) => entry.address)).toEqual(
|
||||
pinned.addresses,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects private DNS results", async () => {
|
||||
const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]);
|
||||
await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
|
||||
});
|
||||
|
||||
it("falls back for non-matching hostnames", async () => {
|
||||
const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => {
|
||||
const cb = typeof options === "function" ? options : (callback as () => void);
|
||||
(cb as (err: null, address: string, family: number) => void)(null, "1.2.3.4", 4);
|
||||
});
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
fallback,
|
||||
});
|
||||
|
||||
const result = await new Promise<{ address: string }>((resolve, reject) => {
|
||||
lookup("other.test", (err, address) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string });
|
||||
});
|
||||
});
|
||||
|
||||
expect(fallback).toHaveBeenCalledTimes(1);
|
||||
expect(result.address).toBe("1.2.3.4");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
|
||||
import { Agent, type Dispatcher } from "undici";
|
||||
|
||||
type LookupCallback = (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | LookupAddress[],
|
||||
family?: number,
|
||||
) => void;
|
||||
|
||||
export class SsrFBlockedError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -101,10 +109,71 @@ export function isBlockedHostname(hostname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertPublicHostname(
|
||||
export function createPinnedLookup(params: {
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
fallback?: typeof dnsLookupCb;
|
||||
}): typeof dnsLookupCb {
|
||||
const normalizedHost = normalizeHostname(params.hostname);
|
||||
const fallback = params.fallback ?? dnsLookupCb;
|
||||
const fallbackLookup = fallback as unknown as (
|
||||
hostname: string,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
const fallbackWithOptions = fallback as unknown as (
|
||||
hostname: string,
|
||||
options: unknown,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
const records = params.addresses.map((address) => ({
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
}));
|
||||
let index = 0;
|
||||
|
||||
return ((host: string, options?: unknown, callback?: unknown) => {
|
||||
const cb: LookupCallback =
|
||||
typeof options === "function" ? (options as LookupCallback) : (callback as LookupCallback);
|
||||
if (!cb) return;
|
||||
const normalized = normalizeHostname(host);
|
||||
if (!normalized || normalized !== normalizedHost) {
|
||||
if (typeof options === "function" || options === undefined) {
|
||||
return fallbackLookup(host, cb);
|
||||
}
|
||||
return fallbackWithOptions(host, options, cb);
|
||||
}
|
||||
|
||||
const opts =
|
||||
typeof options === "object" && options !== null
|
||||
? (options as { all?: boolean; family?: number })
|
||||
: {};
|
||||
const requestedFamily =
|
||||
typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
|
||||
const candidates =
|
||||
requestedFamily === 4 || requestedFamily === 6
|
||||
? records.filter((entry) => entry.family === requestedFamily)
|
||||
: records;
|
||||
const usable = candidates.length > 0 ? candidates : records;
|
||||
if (opts.all) {
|
||||
cb(null, usable as LookupAddress[]);
|
||||
return;
|
||||
}
|
||||
const chosen = usable[index % usable.length];
|
||||
index += 1;
|
||||
cb(null, chosen.address, chosen.family);
|
||||
}) as typeof dnsLookupCb;
|
||||
}
|
||||
|
||||
export type PinnedHostname = {
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
lookup: typeof dnsLookupCb;
|
||||
};
|
||||
|
||||
export async function resolvePinnedHostname(
|
||||
hostname: string,
|
||||
lookupFn: LookupFn = dnsLookup,
|
||||
): Promise<void> {
|
||||
): Promise<PinnedHostname> {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid hostname");
|
||||
@@ -128,4 +197,46 @@ export async function assertPublicHostname(
|
||||
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
|
||||
}
|
||||
}
|
||||
|
||||
const addresses = Array.from(new Set(results.map((entry) => entry.address)));
|
||||
if (addresses.length === 0) {
|
||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: normalized,
|
||||
addresses,
|
||||
lookup: createPinnedLookup({ hostname: normalized, addresses }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher {
|
||||
return new Agent({
|
||||
connect: {
|
||||
lookup: pinned.lookup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise<void> {
|
||||
if (!dispatcher) return;
|
||||
const candidate = dispatcher as { close?: () => Promise<void> | void; destroy?: () => void };
|
||||
try {
|
||||
if (typeof candidate.close === "function") {
|
||||
await candidate.close();
|
||||
return;
|
||||
}
|
||||
if (typeof candidate.destroy === "function") {
|
||||
candidate.destroy();
|
||||
}
|
||||
} catch {
|
||||
// ignore dispatcher cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPublicHostname(
|
||||
hostname: string,
|
||||
lookupFn: LookupFn = dnsLookup,
|
||||
): Promise<void> {
|
||||
await resolvePinnedHostname(hostname, lookupFn);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user