mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
feat: add SSRF host validation, rate_limited/unparseable error codes, and referral chain warnings
- Add `isSafeWhoisReferralHost` to reject private/loopback/link-local IPs and malformed hostnames before following WHOIS referrals (SSRF guard)
- Detect WHOIS throttle replies in the referral chain: drop the registrar response, keep the registry record, and surface a warning instead of returning bad data
- Add `looksEmptyWhois` guard in the main lookup path: a "registered" record with no parseable fields now fails with `errorCode: "unparseable"` rather than resolving silently
- Map RDAP 429 responses to a structured `rate_limited` error (including `Retry-After` header) so callers can distinguish throttling from generic HTTP errors and the fallback to WHOIS is recorded in `attempts`
- Add `rate_limited` and `unparseable` to `LookupErrorCode`
- Change `collectWhoisReferralChain` to return `{ results, warnings }` instead of a bare array; warnings are merged onto the final `DomainRecord`
- Remove the `followWhoisReferrals` fallback path from `index.ts` (dead code after the chain API stabilised)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafeWhoisReferralHost } from "./host";
|
||||
|
||||
describe("isSafeWhoisReferralHost", () => {
|
||||
it.each([
|
||||
"whois.markmonitor.com",
|
||||
"whois.1api.net",
|
||||
"WHOIS.GODADDY.COM",
|
||||
"whois.nic.xn--p1ai",
|
||||
"8.8.8.8",
|
||||
"whois.example.com.",
|
||||
])("accepts %s", (h) => expect(isSafeWhoisReferralHost(h)).toBe(true));
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"localhost",
|
||||
"foo.localhost",
|
||||
"printer.local",
|
||||
"db.internal",
|
||||
"intranet",
|
||||
"127.0.0.1",
|
||||
"10.0.0.5",
|
||||
"172.16.0.1",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::1",
|
||||
"fe80::1",
|
||||
"fd00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"2130706433",
|
||||
"0x7f.1",
|
||||
"whois.example.com:43",
|
||||
"user@whois.example.com",
|
||||
"whois.example.com/path",
|
||||
"who is.example.com",
|
||||
"-bad.example.com",
|
||||
`${"a".repeat(64)}.example.com`,
|
||||
`${"a.".repeat(130)}com`,
|
||||
])("rejects %j", (h) => expect(isSafeWhoisReferralHost(h)).toBe(false));
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||
const BLOCKED_SUFFIXES = [".local", ".localhost", ".internal", ".localdomain", ".lan", ".home"];
|
||||
|
||||
function isPrivateIpv4(ip: string): boolean {
|
||||
const [a = 0, b = 0] = ip.split(".").map(Number);
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
a >= 224 // multicast + reserved
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped?.[1]) return isPrivateIpv4(mapped[1]);
|
||||
return (
|
||||
lower === "::" ||
|
||||
lower === "::1" ||
|
||||
/^f[cd]/.test(lower) || // unique local
|
||||
/^fe[89ab]/.test(lower) || // link-local
|
||||
lower.startsWith("ff") // multicast
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a WHOIS referral host taken from upstream response text is safe to connect to:
|
||||
* a well-formed public hostname or public IP literal, with no port, path or userinfo.
|
||||
*/
|
||||
export function isSafeWhoisReferralHost(host: string): boolean {
|
||||
const value = host.trim().replace(/\.$/, "");
|
||||
if (!value || value.length > 253) return false;
|
||||
|
||||
const ipVersion = isIP(value);
|
||||
if (ipVersion === 4) return !isPrivateIpv4(value);
|
||||
if (ipVersion === 6) return !isPrivateIpv6(value);
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "localhost" || BLOCKED_SUFFIXES.some((s) => lower.endsWith(s))) return false;
|
||||
const labels = lower.split(".");
|
||||
if (labels.length < 2) return false;
|
||||
if (!labels.every((l) => LABEL.test(l))) return false;
|
||||
// All-numeric last label means a malformed/obfuscated IP (e.g. 0x7f.1, 2130706433)
|
||||
return !/^\d+$/.test(labels[labels.length - 1] as string);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { collectWhoisReferralChain } from "./referral";
|
||||
|
||||
describe("WHOIS coalescing", () => {
|
||||
it("retains TLD data when registrar provides no details", async () => {
|
||||
const chain = await collectWhoisReferralChain("whois.nic.io", "gitpod.io", {
|
||||
const { results: chain } = await collectWhoisReferralChain("whois.nic.io", "gitpod.io", {
|
||||
followWhoisReferral: true,
|
||||
maxWhoisReferralHops: 2,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("WHOIS referral contradiction handling", () => {
|
||||
});
|
||||
|
||||
it("collects chain and does not append contradictory registrar", async () => {
|
||||
const chain = await collectWhoisReferralChain("whois.nic.io", "raindrop.io", {
|
||||
const { results: chain } = await collectWhoisReferralChain("whois.nic.io", "raindrop.io", {
|
||||
followWhoisReferral: true,
|
||||
maxWhoisReferralHops: 2,
|
||||
});
|
||||
@@ -42,3 +42,37 @@ describe("WHOIS referral contradiction handling", () => {
|
||||
expect(chain[0]?.serverQueried).toBe("whois.nic.io");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WHOIS referral safety", () => {
|
||||
it("does not query an unsafe referral host and reports a warning", async () => {
|
||||
const { whoisQuery } = await import("./client.js");
|
||||
const mocked = vi.mocked(whoisQuery);
|
||||
mocked.mockClear();
|
||||
mocked.mockImplementation(async (server: string) => ({
|
||||
serverQueried: server,
|
||||
text: "Domain Name: EVIL.COM\nCreation Date: 2013-08-20T20:30:16Z\nRegistrar WHOIS Server: 169.254.169.254\n",
|
||||
}));
|
||||
const { results, warnings } = await collectWhoisReferralChain("whois.nic.io", "evil.com", {
|
||||
followWhoisReferral: true,
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(warnings[0]).toMatch(/unsafe host/);
|
||||
});
|
||||
|
||||
it("keeps the registry record when the registrar throttles", async () => {
|
||||
const { whoisQuery } = await import("./client.js");
|
||||
vi.mocked(whoisQuery).mockImplementation(async (server: string) => ({
|
||||
serverQueried: server,
|
||||
text:
|
||||
server === "whois.nic.io"
|
||||
? "Domain Name: X.IO\nCreation Date: 2013-08-20T20:30:16Z\nRegistrar WHOIS Server: whois.1api.net\n"
|
||||
: "WHOIS LIMIT EXCEEDED",
|
||||
}));
|
||||
const { results, warnings } = await collectWhoisReferralChain("whois.nic.io", "x.io", {
|
||||
followWhoisReferral: true,
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(warnings[0]).toMatch(/rate limited/);
|
||||
});
|
||||
});
|
||||
|
||||
+34
-5
@@ -1,10 +1,13 @@
|
||||
import { throwIfAborted } from "../lib/async";
|
||||
import { classifyError, RdapperError } from "../lib/errors";
|
||||
import { type LookupContext, traced } from "../lib/trace";
|
||||
import type { LookupOptions } from "../types";
|
||||
import type { WhoisQueryResult } from "./client";
|
||||
import { whoisQuery } from "./client";
|
||||
import { extractWhoisReferral } from "./discovery";
|
||||
import { isAvailableByWhois } from "./normalize";
|
||||
import { isSafeWhoisReferralHost } from "./host";
|
||||
import { isAvailableByWhois, normalizeWhois } from "./normalize";
|
||||
import { detectWhoisThrottle, looksEmptyWhois } from "./throttle";
|
||||
|
||||
/**
|
||||
* Follow registrar WHOIS referrals up to a configured hop limit.
|
||||
@@ -28,6 +31,7 @@ export async function followWhoisReferrals(
|
||||
throwIfAborted(opts?.signal);
|
||||
const next = extractWhoisReferral(current.text);
|
||||
if (!next) break;
|
||||
if (!isSafeWhoisReferralHost(normalize(next))) break;
|
||||
const normalized = normalize(next);
|
||||
if (visited.has(normalized)) break; // cycle protection / same as current
|
||||
visited.add(normalized);
|
||||
@@ -61,12 +65,13 @@ export async function collectWhoisReferralChain(
|
||||
domain: string,
|
||||
opts?: LookupOptions,
|
||||
ctx?: LookupContext,
|
||||
): Promise<WhoisQueryResult[]> {
|
||||
): Promise<{ results: WhoisQueryResult[]; warnings: string[] }> {
|
||||
const results: WhoisQueryResult[] = [];
|
||||
const warnings: string[] = [];
|
||||
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
||||
const first = await tracedWhoisQuery(initialServer, domain, opts, ctx);
|
||||
results.push(first);
|
||||
if (opts?.followWhoisReferral === false || maxHops === 0) return results;
|
||||
if (opts?.followWhoisReferral === false || maxHops === 0) return { results, warnings };
|
||||
|
||||
const visited = new Set<string>([normalize(first.serverQueried)]);
|
||||
let current = first;
|
||||
@@ -76,6 +81,10 @@ export async function collectWhoisReferralChain(
|
||||
const next = extractWhoisReferral(current.text);
|
||||
if (!next) break;
|
||||
const normalized = normalize(next);
|
||||
if (!isSafeWhoisReferralHost(normalized)) {
|
||||
warnings.push(`Skipped WHOIS referral to unsafe host "${next.slice(0, 100)}"`);
|
||||
break;
|
||||
}
|
||||
if (visited.has(normalized)) break;
|
||||
visited.add(normalized);
|
||||
try {
|
||||
@@ -87,15 +96,28 @@ export async function collectWhoisReferralChain(
|
||||
// Do not adopt or append contradictory registrar; keep authoritative TLD only.
|
||||
break;
|
||||
}
|
||||
if (
|
||||
registeredAfter &&
|
||||
looksEmptyWhois(normalizeWhois(domain, "", res.text, res.serverQueried))
|
||||
) {
|
||||
warnings.push(`WHOIS referral ${normalized} returned no usable data`);
|
||||
break;
|
||||
}
|
||||
results.push(res);
|
||||
current = res;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
throwIfAborted(opts?.signal);
|
||||
const { code, error } = classifyError(err);
|
||||
warnings.push(
|
||||
code === "rate_limited"
|
||||
? `WHOIS referral ${normalized} rate limited the query`
|
||||
: `WHOIS referral ${normalized} failed (${error})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
hops += 1;
|
||||
}
|
||||
return results;
|
||||
return { results, warnings };
|
||||
}
|
||||
|
||||
function normalize(server: string): string {
|
||||
@@ -115,6 +137,13 @@ function tracedWhoisQuery(
|
||||
async (notes) => {
|
||||
const res = await whoisQuery(server, domain, opts);
|
||||
if (res.partial) notes.partial = true;
|
||||
if (detectWhoisThrottle(res.text)) {
|
||||
throw new RdapperError(
|
||||
"rate_limited",
|
||||
`WHOIS server ${res.serverQueried} rate limited the query`,
|
||||
{ stage: "read" },
|
||||
);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectWhoisThrottle, looksEmptyWhois } from "./throttle";
|
||||
|
||||
describe("detectWhoisThrottle", () => {
|
||||
it.each([
|
||||
"WHOIS LIMIT EXCEEDED - SEE WWW.PIR.ORG/WHOIS FOR DETAILS",
|
||||
"Quota exceeded",
|
||||
"You have exceeded the allowed number of queries. Try again later.",
|
||||
"Too many requests",
|
||||
"<!DOCTYPE html><html><body>Service Unavailable</body></html>",
|
||||
"Your IP has been blocked",
|
||||
])("flags %j", (text) => {
|
||||
expect(detectWhoisThrottle(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a long real record that mentions rate limits", () => {
|
||||
const record = `Domain Name: EXAMPLE.COM\nRegistrar: Test\n${"Name Server: NS.EXAMPLE.COM\n".repeat(100)}\nNote: queries are subject to rate limiting\n`;
|
||||
expect(detectWhoisThrottle(record)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag empty or ordinary availability text", () => {
|
||||
expect(detectWhoisThrottle(undefined)).toBe(false);
|
||||
expect(detectWhoisThrottle("No match for EXAMPLE.COM")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksEmptyWhois", () => {
|
||||
const base = { domain: "example.com", tld: "com", isRegistered: true, source: "whois" as const };
|
||||
it("is true when nothing useful was parsed", () => {
|
||||
expect(looksEmptyWhois(base)).toBe(true);
|
||||
});
|
||||
it("is false with any real field", () => {
|
||||
expect(looksEmptyWhois({ ...base, registrar: { name: "X" } })).toBe(false);
|
||||
expect(looksEmptyWhois({ ...base, nameservers: [{ host: "ns.example.com" }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DomainRecord } from "../types";
|
||||
|
||||
// Real WHOIS records are much longer; throttle notices and error pages are short.
|
||||
const MAX_THROTTLE_TEXT_LENGTH = 2048;
|
||||
|
||||
const THROTTLE_PATTERNS: RegExp[] = [
|
||||
/rate[\s-]?limit/i,
|
||||
/limit\s+exceeded/i,
|
||||
/quota\s+exceeded/i,
|
||||
/exceeded\s+.{0,40}(quer|limit|request|connection)/i,
|
||||
/too\s+many\s+(quer|request|connection)/i,
|
||||
/try\s+again\s+(later|in)/i,
|
||||
/access\s+(denied|limit)/i,
|
||||
/\b(blocked|blacklisted|banned)\b/i,
|
||||
/^\s*<(!doctype|html)\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Heuristic: does this WHOIS response look like a throttle notice or error page rather than
|
||||
* a domain record? Only short responses qualify, so a real record that mentions "rate limit"
|
||||
* in a remark is not rejected.
|
||||
*/
|
||||
export function detectWhoisThrottle(text: string | undefined): boolean {
|
||||
if (!text) return false;
|
||||
if (text.length > MAX_THROTTLE_TEXT_LENGTH) return false;
|
||||
return THROTTLE_PATTERNS.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/** True when a normalized WHOIS record carries none of the fields a real registration would. */
|
||||
export function looksEmptyWhois(record: DomainRecord): boolean {
|
||||
return !(
|
||||
record.registrar ||
|
||||
record.creationDate ||
|
||||
record.updatedDate ||
|
||||
record.expirationDate ||
|
||||
record.nameservers?.length ||
|
||||
record.statuses?.length ||
|
||||
record.contacts?.length
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user