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:
@@ -40,6 +40,47 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("throttle and empty-response guards", () => {
|
||||||
|
const ianaThen = (text: string) => async (server: string) => ({
|
||||||
|
serverQueried: server,
|
||||||
|
text: server === "whois.iana.org" ? "whois: whois.verisign-grs.com\n" : text,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with rate_limited when the registry throttles", async () => {
|
||||||
|
vi.mocked(whoisQuery).mockImplementation(ianaThen("WHOIS LIMIT EXCEEDED"));
|
||||||
|
const res = await lookup("example.com", { whoisOnly: true });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.errorCode).toBe("rate_limited");
|
||||||
|
expect(res.errorPhase).toBe("whois");
|
||||||
|
expect(res.errorServer).toBe("whois.verisign-grs.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with unparseable when a long reply has no fields and no availability phrase", async () => {
|
||||||
|
vi.mocked(whoisQuery).mockImplementation(ianaThen(`${"lorem ipsum ".repeat(300)}\n`));
|
||||||
|
const res = await lookup("example.com", { whoisOnly: true });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.errorCode).toBe("unparseable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports availability for a genuine not-found reply", async () => {
|
||||||
|
vi.mocked(whoisQuery).mockImplementation(ianaThen("No match for EXAMPLE.COM"));
|
||||||
|
const res = await lookup("example.com", { whoisOnly: true });
|
||||||
|
expect(res.ok, res.error).toBe(true);
|
||||||
|
expect(res.record?.isRegistered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies RDAP 429 as rate_limited and falls through to WHOIS", async () => {
|
||||||
|
const customFetch: FetchLike = vi.fn(
|
||||||
|
async () => new Response("slow down", { status: 429, headers: { "retry-after": "30" } }),
|
||||||
|
);
|
||||||
|
vi.mocked(whoisQuery).mockImplementation(ianaThen(whoisText));
|
||||||
|
const res = await lookup("example.com", { customBootstrapData: bootstrap, customFetch });
|
||||||
|
expect(res.ok, res.error).toBe(true);
|
||||||
|
expect(res.attempts[0]).toMatchObject({ errorCode: "rate_limited" });
|
||||||
|
expect(res.attempts[0]?.error).toContain("Retry-After: 30");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("attempts trace", () => {
|
describe("attempts trace", () => {
|
||||||
it("records a failed RDAP base followed by a WHOIS success, in order", async () => {
|
it("records a failed RDAP base followed by a WHOIS success, in order", async () => {
|
||||||
const customFetch: FetchLike = vi.fn(async () => new Response("nope", { status: 503 }));
|
const customFetch: FetchLike = vi.fn(async () => new Response("nope", { status: 503 }));
|
||||||
|
|||||||
+14
-15
@@ -21,7 +21,7 @@ vi.mock("./rdap/merge.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./whois/client.js", () => ({
|
vi.mock("./whois/client.js", () => ({
|
||||||
whoisQuery: vi.fn(async () => ({
|
whoisQuery: vi.fn(async () => ({
|
||||||
text: "Domain Name: EXAMPLE.COM",
|
text: "Domain Name: EXAMPLE.COM\nRegistrar: Test Registrar",
|
||||||
serverQueried: "whois.verisign-grs.com",
|
serverQueried: "whois.verisign-grs.com",
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@@ -29,14 +29,11 @@ vi.mock("./whois/client.js", () => ({
|
|||||||
vi.mock("./whois/referral.js", async () => {
|
vi.mock("./whois/referral.js", async () => {
|
||||||
const client = await import("./whois/client.js");
|
const client = await import("./whois/client.js");
|
||||||
return {
|
return {
|
||||||
followWhoisReferrals: vi.fn(
|
|
||||||
async (server: string, domain: string, opts?: import("./types").LookupOptions) =>
|
|
||||||
client.whoisQuery(server, domain, opts),
|
|
||||||
),
|
|
||||||
collectWhoisReferralChain: vi.fn(
|
collectWhoisReferralChain: vi.fn(
|
||||||
async (server: string, domain: string, opts?: import("./types").LookupOptions) => [
|
async (server: string, domain: string, opts?: import("./types").LookupOptions) => ({
|
||||||
await client.whoisQuery(server, domain, opts),
|
results: [await client.whoisQuery(server, domain, opts)],
|
||||||
],
|
warnings: [],
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -60,7 +57,6 @@ vi.mock("./lib/domain.js", async () => {
|
|||||||
|
|
||||||
import { lookup } from ".";
|
import { lookup } from ".";
|
||||||
import * as rdapClient from "./rdap/client";
|
import * as rdapClient from "./rdap/client";
|
||||||
import type { WhoisQueryResult } from "./whois/client";
|
|
||||||
import * as whoisClient from "./whois/client";
|
import * as whoisClient from "./whois/client";
|
||||||
import * as discovery from "./whois/discovery";
|
import * as discovery from "./whois/discovery";
|
||||||
import * as whoisReferral from "./whois/referral";
|
import * as whoisReferral from "./whois/referral";
|
||||||
@@ -167,12 +163,15 @@ describe("WHOIS referral & includeRaw", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes rawWhois when includeRaw is true", async () => {
|
it("includes rawWhois when includeRaw is true", async () => {
|
||||||
vi.mocked(whoisReferral.followWhoisReferrals).mockImplementation(
|
vi.mocked(whoisReferral.collectWhoisReferralChain).mockResolvedValueOnce({
|
||||||
async (_server: string, _domain: string): Promise<WhoisQueryResult> => ({
|
results: [
|
||||||
text: "Domain Name: EXAMPLE.COM\nRegistrar: Registrar LLC",
|
{
|
||||||
serverQueried: "whois.registrar.test",
|
text: "Domain Name: EXAMPLE.COM\nRegistrar: Registrar LLC",
|
||||||
}),
|
serverQueried: "whois.registrar.test",
|
||||||
);
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
const res = await lookup("example.com", {
|
const res = await lookup("example.com", {
|
||||||
timeoutMs: 200,
|
timeoutMs: 200,
|
||||||
|
|||||||
+20
-14
@@ -16,7 +16,8 @@ import type {
|
|||||||
import { discoverWhoisServer, parseIanaRegistrationInfoUrl } from "./whois/discovery";
|
import { discoverWhoisServer, parseIanaRegistrationInfoUrl } from "./whois/discovery";
|
||||||
import { mergeWhoisRecords } from "./whois/merge";
|
import { mergeWhoisRecords } from "./whois/merge";
|
||||||
import { normalizeWhois } from "./whois/normalize";
|
import { normalizeWhois } from "./whois/normalize";
|
||||||
import { collectWhoisReferralChain, followWhoisReferrals } from "./whois/referral";
|
import { looksEmptyWhois } from "./whois/throttle";
|
||||||
|
import { collectWhoisReferralChain } from "./whois/referral";
|
||||||
|
|
||||||
function failure(
|
function failure(
|
||||||
ctx: LookupContext,
|
ctx: LookupContext,
|
||||||
@@ -172,19 +173,12 @@ async function runLookup(
|
|||||||
|
|
||||||
// Query the TLD server first; optionally follow registrar referrals (multi-hop)
|
// Query the TLD server first; optionally follow registrar referrals (multi-hop)
|
||||||
// Collect the chain and coalesce so we don't lose details when a registrar returns minimal/empty data.
|
// Collect the chain and coalesce so we don't lose details when a registrar returns minimal/empty data.
|
||||||
const chain = await collectWhoisReferralChain(whoisServer, domain, opts, ctx);
|
const { results: chain, warnings } = await collectWhoisReferralChain(
|
||||||
if (chain.length === 0) {
|
whoisServer,
|
||||||
// Fallback to previous behavior as a safety net
|
domain,
|
||||||
const res = await followWhoisReferrals(whoisServer, domain, opts, ctx);
|
opts,
|
||||||
const record: DomainRecord = normalizeWhois(
|
ctx,
|
||||||
domain,
|
);
|
||||||
tld,
|
|
||||||
res.text,
|
|
||||||
res.serverQueried,
|
|
||||||
!!opts?.includeRaw,
|
|
||||||
);
|
|
||||||
return { ok: true, record, attempts: ctx.attempts };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize all WHOIS texts in the chain and merge conservatively
|
// Normalize all WHOIS texts in the chain and merge conservatively
|
||||||
const normalizedRecords = chain.map((r) =>
|
const normalizedRecords = chain.map((r) =>
|
||||||
@@ -194,7 +188,19 @@ async function runLookup(
|
|||||||
if (!first) {
|
if (!first) {
|
||||||
return failure(ctx, "no_data", "No WHOIS data retrieved");
|
return failure(ctx, "no_data", "No WHOIS data retrieved");
|
||||||
}
|
}
|
||||||
|
// A "registered" answer with no fields at all is an error page or throttle notice, not a record
|
||||||
|
if (first.isRegistered && looksEmptyWhois(first)) {
|
||||||
|
return failure(
|
||||||
|
ctx,
|
||||||
|
"unparseable",
|
||||||
|
`WHOIS response from ${whoisServer} contained no recognizable domain data`,
|
||||||
|
{ phase: "whois", server: whoisServer },
|
||||||
|
);
|
||||||
|
}
|
||||||
const mergedRecord = rest.length ? mergeWhoisRecords(first, rest) : first;
|
const mergedRecord = rest.length ? mergeWhoisRecords(first, rest) : first;
|
||||||
|
if (warnings.length) {
|
||||||
|
mergedRecord.warnings = [...(mergedRecord.warnings ?? []), ...warnings];
|
||||||
|
}
|
||||||
return { ok: true, record: mergedRecord, attempts: ctx.attempts };
|
return { ok: true, record: mergedRecord, attempts: ctx.attempts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export async function fetchRdapDomain(
|
|||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
return { url, json: null, notFound: true };
|
return { url, json: null, notFound: true };
|
||||||
}
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retryAfter = res.headers.get("retry-after");
|
||||||
|
throw new RdapperError(
|
||||||
|
"rate_limited",
|
||||||
|
`RDAP 429 rate limited${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const bodyText = await res.text().catch(() => "");
|
const bodyText = await res.text().catch(() => "");
|
||||||
throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ export interface LookupResult {
|
|||||||
* - `http_error`: RDAP responded with a non-2xx status other than 404
|
* - `http_error`: RDAP responded with a non-2xx status other than 404
|
||||||
* - `rdap_unavailable`: `rdapOnly` was set and no RDAP server worked
|
* - `rdap_unavailable`: `rdapOnly` was set and no RDAP server worked
|
||||||
* - `no_server`: IANA answered but no WHOIS server exists for the TLD
|
* - `no_server`: IANA answered but no WHOIS server exists for the TLD
|
||||||
|
* - `rate_limited`: the server throttled the query (RDAP 429, or a WHOIS throttle notice)
|
||||||
|
* - `unparseable`: WHOIS replied with text that is neither an availability notice nor a record
|
||||||
* - `unsupported_runtime`: WHOIS needs `node:net`, which this runtime lacks
|
* - `unsupported_runtime`: WHOIS needs `node:net`, which this runtime lacks
|
||||||
*/
|
*/
|
||||||
export type LookupErrorCode =
|
export type LookupErrorCode =
|
||||||
@@ -404,6 +406,8 @@ export type LookupErrorCode =
|
|||||||
| "rdap_unavailable"
|
| "rdap_unavailable"
|
||||||
| "no_server"
|
| "no_server"
|
||||||
| "no_data"
|
| "no_data"
|
||||||
|
| "rate_limited"
|
||||||
|
| "unparseable"
|
||||||
| "unsupported_runtime"
|
| "unsupported_runtime"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
describe("WHOIS coalescing", () => {
|
||||||
it("retains TLD data when registrar provides no details", async () => {
|
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,
|
followWhoisReferral: true,
|
||||||
maxWhoisReferralHops: 2,
|
maxWhoisReferralHops: 2,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("WHOIS referral contradiction handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("collects chain and does not append contradictory registrar", async () => {
|
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,
|
followWhoisReferral: true,
|
||||||
maxWhoisReferralHops: 2,
|
maxWhoisReferralHops: 2,
|
||||||
});
|
});
|
||||||
@@ -42,3 +42,37 @@ describe("WHOIS referral contradiction handling", () => {
|
|||||||
expect(chain[0]?.serverQueried).toBe("whois.nic.io");
|
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 { throwIfAborted } from "../lib/async";
|
||||||
|
import { classifyError, RdapperError } from "../lib/errors";
|
||||||
import { type LookupContext, traced } from "../lib/trace";
|
import { type LookupContext, traced } from "../lib/trace";
|
||||||
import type { LookupOptions } from "../types";
|
import type { LookupOptions } from "../types";
|
||||||
import type { WhoisQueryResult } from "./client";
|
import type { WhoisQueryResult } from "./client";
|
||||||
import { whoisQuery } from "./client";
|
import { whoisQuery } from "./client";
|
||||||
import { extractWhoisReferral } from "./discovery";
|
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.
|
* Follow registrar WHOIS referrals up to a configured hop limit.
|
||||||
@@ -28,6 +31,7 @@ export async function followWhoisReferrals(
|
|||||||
throwIfAborted(opts?.signal);
|
throwIfAborted(opts?.signal);
|
||||||
const next = extractWhoisReferral(current.text);
|
const next = extractWhoisReferral(current.text);
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
|
if (!isSafeWhoisReferralHost(normalize(next))) break;
|
||||||
const normalized = normalize(next);
|
const normalized = normalize(next);
|
||||||
if (visited.has(normalized)) break; // cycle protection / same as current
|
if (visited.has(normalized)) break; // cycle protection / same as current
|
||||||
visited.add(normalized);
|
visited.add(normalized);
|
||||||
@@ -61,12 +65,13 @@ export async function collectWhoisReferralChain(
|
|||||||
domain: string,
|
domain: string,
|
||||||
opts?: LookupOptions,
|
opts?: LookupOptions,
|
||||||
ctx?: LookupContext,
|
ctx?: LookupContext,
|
||||||
): Promise<WhoisQueryResult[]> {
|
): Promise<{ results: WhoisQueryResult[]; warnings: string[] }> {
|
||||||
const results: WhoisQueryResult[] = [];
|
const results: WhoisQueryResult[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
||||||
const first = await tracedWhoisQuery(initialServer, domain, opts, ctx);
|
const first = await tracedWhoisQuery(initialServer, domain, opts, ctx);
|
||||||
results.push(first);
|
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)]);
|
const visited = new Set<string>([normalize(first.serverQueried)]);
|
||||||
let current = first;
|
let current = first;
|
||||||
@@ -76,6 +81,10 @@ export async function collectWhoisReferralChain(
|
|||||||
const next = extractWhoisReferral(current.text);
|
const next = extractWhoisReferral(current.text);
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
const normalized = normalize(next);
|
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;
|
if (visited.has(normalized)) break;
|
||||||
visited.add(normalized);
|
visited.add(normalized);
|
||||||
try {
|
try {
|
||||||
@@ -87,15 +96,28 @@ export async function collectWhoisReferralChain(
|
|||||||
// Do not adopt or append contradictory registrar; keep authoritative TLD only.
|
// Do not adopt or append contradictory registrar; keep authoritative TLD only.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
registeredAfter &&
|
||||||
|
looksEmptyWhois(normalizeWhois(domain, "", res.text, res.serverQueried))
|
||||||
|
) {
|
||||||
|
warnings.push(`WHOIS referral ${normalized} returned no usable data`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
results.push(res);
|
results.push(res);
|
||||||
current = res;
|
current = res;
|
||||||
} catch {
|
} catch (err) {
|
||||||
throwIfAborted(opts?.signal);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
hops += 1;
|
hops += 1;
|
||||||
}
|
}
|
||||||
return results;
|
return { results, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(server: string): string {
|
function normalize(server: string): string {
|
||||||
@@ -115,6 +137,13 @@ function tracedWhoisQuery(
|
|||||||
async (notes) => {
|
async (notes) => {
|
||||||
const res = await whoisQuery(server, domain, opts);
|
const res = await whoisQuery(server, domain, opts);
|
||||||
if (res.partial) notes.partial = true;
|
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;
|
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