fix: reject a WHOIS reply that describes the domain the name sits under

A registry that doesn't hold a name can answer with the delegation above it: Nominet's WHOIS gave ac.uk's record (registrant JNT Association, registered before 1996, ja.net nameservers) when asked for ox.ac.uk, and it parsed as ox.ac.uk's own record. When the reply's "Domain name:" is a parent of the queried name, fail with unparseable instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 16:46:49 -04:00
1 parent 6710a2878f
commit 835a7f63b7
5 files changed
+69 -4

No files matched your search

+1 -1
View File
@@ -469,7 +469,7 @@ interface LookupResult {
- `rate_limited`: the server throttled the query (RDAP `429`, or a short WHOIS notice such as `WHOIS LIMIT EXCEEDED`). Retrying later may work.
- `blocked`: a WHOIS server refuses this client outright (e.g. `.ch`: "Requests of this client are not permitted"). Retrying will not help.
- `unparseable`: WHOIS replied with text that is neither an availability notice nor a domain record (no registrar, dates, nameservers, statuses or contacts). It is reported as a failure rather than a "registered" record.
- `unparseable`: WHOIS replied with text that is neither an availability notice nor a domain record (no registrar, dates, nameservers, statuses or contacts). It is reported as a failure rather than a "registered" record. So is a reply describing a domain the name sits under (a registry answering about `ac.uk` for `ox.ac.uk`).
- `rdap_unavailable`: with `rdapOnly`, the TLD has no RDAP server. When RDAP servers were tried and failed, the failure's own code is reported (`rate_limited`, `timeout`, `http_error`, `connect_failed`, or `unparseable` for a response that couldn't be read), with `errorPhase`/`errorServer` pointing at it.
`retryAfterMs` carries an RDAP `Retry-After` header (on `429` or `503`, as seconds or an HTTP date). It is set on the matching entry in `attempts`, and on the top-level result only when that RDAP attempt is the terminal failure, as with `rdapOnly`. Normally an RDAP failure falls through to WHOIS, so the value stays in `attempts`. The value is passed through as sent and is not capped, so clamp it before using it as a delay.
+44
View File
@@ -210,6 +210,50 @@ describe("error reporting", () => {
expect(res.attempts).toMatchObject([{ phase: "iana", ok: false, stage: "connect" }]);
});
it("fails when the WHOIS reply describes the domain the name sits under", async () => {
// Nominet's reply for ox.ac.uk, before ac.uk was routed to Jisc
vi.mocked(whoisQuery).mockResolvedValue({
serverQueried: "whois.nic.uk",
text: [
"",
" Domain name:",
" ac.uk",
"",
" Registrant:",
" The JNT Association",
"",
" Relevant dates:",
" Registered on: before Aug-1996",
"",
" Name servers:",
" ns0.ja.net.",
" ns2.ja.net.",
"",
].join("\n"),
});
const res = await lookup("ox.ac.uk", {
whoisOnly: true,
whoisHints: { "ac.uk": "whois.nic.uk" },
});
expect(res).toMatchObject({
ok: false,
errorCode: "unparseable",
errorPhase: "whois",
errorServer: "whois.nic.uk",
});
expect(res.error).toContain("describes ac.uk, not ox.ac.uk");
});
it("accepts a WHOIS reply naming the queried domain in any case", async () => {
vi.mocked(whoisQuery).mockResolvedValue({ serverQueried: "whois.example", text: whoisText });
const res = await lookup("example.com", {
whoisOnly: true,
whoisHints: { com: "whois.example" },
});
expect(res.ok, res.error).toBe(true);
expect(res.record?.isRegistered).toBe(true);
});
it("reports no_server when IANA answers without a server", async () => {
vi.mocked(whoisQuery).mockResolvedValue({
serverQueried: "whois.iana.org",
+12 -1
View File
@@ -16,7 +16,7 @@ import type {
} from "./types";
import { discoverWhoisServer, parseIanaRegistrationInfoUrl } from "./whois/discovery";
import { mergeWhoisRecords } from "./whois/merge";
import { normalizeWhois } from "./whois/normalize";
import { normalizeWhois, whoisReplyDomain } from "./whois/normalize";
import { looksEmptyWhois } from "./whois/throttle";
import { collectWhoisReferralChain } from "./whois/referral";
@@ -249,6 +249,17 @@ async function runLookup(
if (!first) {
return failure(ctx, "no_data", "No WHOIS data retrieved");
}
// A registry that doesn't hold the name may answer with the delegation it sits under (Nominet
// gave ac.uk's record for ox.ac.uk), which would pass for the name's own record
const replyDomain = chain[0] && whoisReplyDomain(chain[0].text);
if (replyDomain && domain.endsWith(`.${replyDomain}`)) {
return failure(
ctx,
"unparseable",
`WHOIS response from ${whoisServer} describes ${replyDomain}, not ${domain}`,
{ phase: "whois", server: whoisServer },
);
}
// 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(
+3 -2
View File
@@ -492,8 +492,9 @@ export interface LookupResult {
* - `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)
* - `blocked`: the WHOIS server refuses this client outright (retrying will not help)
* - `unparseable`: WHOIS replied with text that is neither an availability notice nor a record,
* or (with `rdapOnly`) an RDAP response could not be read
* - `unparseable`: WHOIS replied with text that is neither an availability notice nor a record
* (or with the record of a domain the name sits under), or (with `rdapOnly`) an RDAP response
* could not be read
* - `no_data`: a WHOIS server accepted the connection but closed it without sending anything
* - `unsupported_runtime`: WHOIS needs `node:net`, which this runtime lacks
* - `unknown`: any failure not covered above
+9
View File
@@ -54,6 +54,15 @@ export function isAvailableByWhois(text: string | undefined): boolean {
return !normalizeWhois("", "", text, undefined).isRegistered;
}
/**
* The domain a WHOIS reply describes, from its "Domain name:" or "Domain:" field (lowercase, no
* trailing dot), if it names one.
*/
export function whoisReplyDomain(text: string): string | undefined {
const name = anyValue(parseKeyValueLines(text), ["domain name", "domain"]);
return name?.trim().toLowerCase().replace(/\.$/, "") || undefined;
}
/** Whether a reply says the name is unregistered, given whether it carries registration data. */
function saysAvailable(text: string, hasRegistrationData: boolean): boolean {
if (WHOIS_AVAILABLE_STATUSES.some((re) => re.test(text))) return true;