mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-10-26 08:26:22 -04:00
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:
5 files changed
+69
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user