Files
rdapper/src/whois/referral.ts
T
jake 35959b208b feat: surface retryAfterMs, add blocked error code, and harden private-address guard
- Parse `Retry-After` headers (delay-seconds or HTTP-date) into `retryAfterMs` via a new `parseRetryAfterMs` helper and expose the value on both `LookupAttempt` and `LookupResult` so callers can honour server-requested back-off without parsing the error string
- Propagate `retryAfterMs` through `classifyError`, `traced`, `failure`, and the `rdap_unavailable` path so the value surfaces on the top-level result
- Add `blocked` to `LookupErrorCode` for WHOIS servers that permanently refuse a client (distinct from `rate_limited`, which is a temporary throttle that may succeed on retry)
- Add `blockPrivateAddresses` to `WhoisTransportOptions`; when set, a custom `dns.lookup` shim rejects the connection before it opens if any resolved address is non-public, covering DNS rebinding and hostnames that resolve to private ranges
- Expand `isPrivateIp` / `isSafeWhoisReferralHost` to reject IPv4-mapped (`::ffff:7f00:1`), NAT64 (`64:ff9b::`), 6to4 (`2002:7f00::`), Teredo (`2001:0:`), deprecated site-local (`fec0::`), and bracketed IPv6 literals
2026-09-19 12:25:30 -04:00

109 lines
3.9 KiB
TypeScript

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 { isSafeWhoisReferralHost } from "./host";
import { isAvailableByWhois, normalizeWhois } from "./normalize";
import { detectWhoisRefusal, looksEmptyWhois } from "./throttle";
/**
* Collect the WHOIS referral chain starting from the TLD server.
* Always includes the initial TLD response; may include one or more registrar responses.
* Stops on contradiction (registrar claims availability) or failures.
*/
export async function collectWhoisReferralChain(
initialServer: string,
domain: string,
opts?: LookupOptions,
ctx?: LookupContext,
): 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, warnings };
const visited = new Set<string>([normalize(first.serverQueried)]);
let current = first;
let hops = 0;
while (hops < maxHops) {
throwIfAborted(opts?.signal);
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 {
const res = await tracedWhoisQuery(next, domain, opts, ctx, true);
// If registrar claims availability while TLD indicated registered, stop.
const registeredBefore = !isAvailableByWhois(current.text);
const registeredAfter = !isAvailableByWhois(res.text);
if (registeredBefore && !registeredAfter) {
// 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 (err) {
throwIfAborted(opts?.signal);
const { code, error } = classifyError(err);
warnings.push(
code === "rate_limited" || code === "blocked"
? `WHOIS referral ${normalized} ${code === "blocked" ? "blocked" : "rate limited"} the query`
: `WHOIS referral ${normalized} failed (${error})`,
);
break;
}
hops += 1;
}
return { results, warnings };
}
function normalize(server: string): string {
return server.replace(/^whois:\/\//i, "").toLowerCase();
}
/** whoisQuery wrapped so each query (TLD or registrar hop) is recorded as an attempt. */
function tracedWhoisQuery(
server: string,
domain: string,
opts?: LookupOptions,
ctx?: LookupContext,
referral = false,
): Promise<WhoisQueryResult> {
return traced(
ctx,
{ phase: "whois", server: server.replace(/^whois:\/\//i, "") },
async (notes) => {
const res = await whoisQuery(server, domain, opts, { blockPrivateAddresses: referral });
if (res.partial) notes.partial = true;
const refusal = detectWhoisRefusal(res.text);
if (refusal) {
throw new RdapperError(
refusal,
refusal === "blocked"
? `WHOIS server ${res.serverQueried} refuses requests from this client`
: `WHOIS server ${res.serverQueried} rate limited the query`,
{ stage: "read" },
);
}
return res;
},
);
}