mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-10-26 08:26:22 -04:00
fix: reject subdomains, surface rdapOnly error codes, and tighten isPrivacyContact
Subdomains and bare public suffixes (www.example.com, co.uk) were accepted as lookup inputs and returned "not registered" because the registry sees no record for them. Reject them with invalid_input before querying, naming the registrable domain so callers know what to look up instead. With rdapOnly, any RDAP failure — rate_limited, http_error, timeout — was reported as rdap_unavailable regardless. rdap_unavailable is now reserved for a TLD with no RDAP bootstrap entry; transient failures surface their own code (and phase/server) so callers can tell a throttle from a missing server. isPrivacyContact checked whether name or organization appeared in redactedFields, so a domain whose registrant had a redacted email but a visible "Google LLC" organization was flagged private. The check now asks whether the registrant's identity is actually hidden: a privacy or proxy service is named, or nothing identifying (no name, no organization) remains after redaction.
This commit is contained in:
14 files changed
+339
-52
No files matched your search
@@ -58,6 +58,7 @@ await isAvailable("likely-unregistered-thing-320485230458.com"); // => true
|
||||
- Tries RDAP first if supported by the domain’s TLD; if unavailable or fails, falls back to WHOIS (unless toggled off).
|
||||
- Result is `{ ok: boolean, record?: DomainRecord, error?: string }`.
|
||||
- `domain` may be Unicode (`münchen.de`) and may carry surrounding whitespace or a trailing dot; the lowercase punycode form is what gets queried.
|
||||
- `domain` must be a registrable name (`example.com`, `example.co.uk`), not a subdomain or a bare public suffix: `www.example.com` fails with `invalid_input` naming `example.com`, since the registry would otherwise answer "not registered". Use `toRegistrableDomain` to normalize hostnames and URLs first.
|
||||
- `toRegistrableDomain(input, options?) => string | null`
|
||||
- Normalizes a domain or URL to its registrable domain (eTLD+1).
|
||||
- Returns the registrable domain string, or `null` for IPs/invalid input; [options](https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts) are forwarded to `tldts` (e.g., `allowPrivateDomains`).
|
||||
@@ -67,7 +68,7 @@ await isAvailable("likely-unregistered-thing-320485230458.com"); // => true
|
||||
- `finalizeContact(contact, redactedHint?) => Contact`
|
||||
- Cleans a contact: drops placeholder values (recording them in `redactedFields`), sets `privacyService`, resolves `country`/`countryCode`, and sets `redacted`. Safe to re-run on an already-cleaned contact, e.g. to upgrade contacts stored by an older version.
|
||||
- `isPrivacyContact(contact) => boolean`
|
||||
- True when the contact's name/organization is a privacy service or was redacted.
|
||||
- True when the contact hides who it is: a privacy service is named, or something was withheld and no name or organization is left.
|
||||
- `normalizeEppStatus(status) => string`
|
||||
- Maps a status in either spelling to its EPP code (`"client transfer prohibited"` → `"clientTransferProhibited"`, RDAP `"active"` → `"ok"`), leaving other values as they are. Useful for upgrading statuses stored by an older version.
|
||||
|
||||
@@ -469,6 +470,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.
|
||||
- `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.
|
||||
|
||||
@@ -585,7 +587,7 @@ interface DomainRecord {
|
||||
>; // fields that were redacted (and are therefore absent)
|
||||
privacyService?: boolean; // name/organization is a privacy/proxy service (text kept), not a redaction notice
|
||||
}>;
|
||||
privacyEnabled?: boolean; // coarse: some registrant data was redacted or is behind a privacy service (name heuristics or RFC 9537 redactions); see the registrant contact's redactedFields/privacyService for which fields
|
||||
privacyEnabled?: boolean; // the registrant's identity is hidden: a privacy service, or nothing identifying left after redaction; see the registrant contact's redactedFields for which fields
|
||||
redactions?: Array<{
|
||||
name: string; // e.g. "Registrant Email"
|
||||
prePath?: string;
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("rdapOnly rate limiting", () => {
|
||||
customFetch,
|
||||
rdapOnly: true,
|
||||
});
|
||||
expect(res.errorCode).toBe("rdap_unavailable");
|
||||
expect(res.errorCode).toBe("http_error");
|
||||
expect(res.retryAfterMs).toBe(5_000);
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ describe("error reporting", () => {
|
||||
expect(vi.mocked(whoisQuery)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports rdap_unavailable with the last RDAP failure", async () => {
|
||||
it("reports the last RDAP failure's own code", async () => {
|
||||
const customFetch: FetchLike = vi.fn(async () => new Response("down", { status: 500 }));
|
||||
const res = await lookup("example.com", {
|
||||
rdapOnly: true,
|
||||
@@ -230,7 +230,7 @@ describe("error reporting", () => {
|
||||
});
|
||||
expect(res).toMatchObject({
|
||||
ok: false,
|
||||
errorCode: "rdap_unavailable",
|
||||
errorCode: "http_error",
|
||||
errorPhase: "rdap",
|
||||
errorServer: "https://rdap-b.example/",
|
||||
});
|
||||
@@ -360,3 +360,28 @@ describe("bootstrap AbortError not caused by the caller", () => {
|
||||
expect(res.record?.source).toBe("whois");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rdapOnly failure codes", () => {
|
||||
it("reports a rate limit as rate_limited, not rdap_unavailable", async () => {
|
||||
const customFetch: FetchLike = vi.fn(
|
||||
async () => new Response("slow down", { status: 429, headers: { "retry-after": "30" } }),
|
||||
);
|
||||
const res = await lookup("example.com", {
|
||||
customBootstrapData: bootstrap,
|
||||
customFetch,
|
||||
rdapOnly: true,
|
||||
});
|
||||
expect(res).toMatchObject({ ok: false, errorCode: "rate_limited", retryAfterMs: 30_000 });
|
||||
});
|
||||
|
||||
it("keeps rdap_unavailable for a TLD without RDAP", async () => {
|
||||
const customFetch: FetchLike = vi.fn(async () => rdapOk());
|
||||
const res = await lookup("example.zz", {
|
||||
customBootstrapData: bootstrap,
|
||||
customFetch,
|
||||
rdapOnly: true,
|
||||
});
|
||||
expect(res.errorCode).toBe("rdap_unavailable");
|
||||
expect(customFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -119,6 +119,21 @@ describe("lookup orchestration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects subdomains and bare public suffixes, naming the registrable domain", async () => {
|
||||
const sub = await lookup("www.example.com", { timeoutMs: 200 });
|
||||
expect(sub).toMatchObject({ ok: false, errorCode: "invalid_input" });
|
||||
expect(sub.error).toContain('look up "example.com"');
|
||||
const suffix = await lookup("co.uk", { timeoutMs: 200 });
|
||||
expect(suffix).toMatchObject({ ok: false, errorCode: "invalid_input" });
|
||||
expect(suffix.error).toContain("public suffix");
|
||||
expect(vi.mocked(rdapClient.fetchRdapDomain)).not.toHaveBeenCalled();
|
||||
// Registrable names, including sub-registry ones and unknown TLDs, still go through
|
||||
for (const name of ["example.co.uk", "google.uk.com", "uk.com", "example.notarealtld"]) {
|
||||
const res = await lookup(name, { timeoutMs: 200 });
|
||||
expect(res.errorCode, name).not.toBe("invalid_input");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects rdapOnly together with whoisOnly without querying anything", async () => {
|
||||
const res = await lookup("example.com", { rdapOnly: true, whoisOnly: true, timeoutMs: 200 });
|
||||
expect(res.ok).toBe(false);
|
||||
@@ -235,8 +250,8 @@ describe("RDAP responses that can't be read", () => {
|
||||
const merge = await import("./rdap/merge");
|
||||
vi.mocked(merge.fetchAndMergeRdapRelated).mockRejectedValueOnce(new Error("unexpected shape"));
|
||||
const res = await lookup("example.com", { rdapOnly: true, timeoutMs: 200 });
|
||||
expect(res.errorCode).toBe("rdap_unavailable");
|
||||
expect(res.error).toContain("could not read the RDAP response: unexpected shape");
|
||||
expect(res.errorCode).toBe("unparseable");
|
||||
expect(res.error).toContain("could not be read (unexpected shape)");
|
||||
expect(res.error).not.toContain("no RDAP server listed");
|
||||
});
|
||||
});
|
||||
|
||||
+48
-7
@@ -20,6 +20,22 @@ import { normalizeWhois } from "./whois/normalize";
|
||||
import { looksEmptyWhois } from "./whois/throttle";
|
||||
import { collectWhoisReferralChain } from "./whois/referral";
|
||||
|
||||
/**
|
||||
* The registrable name a domain belongs to ("example.com" for www.example.com), undefined for a
|
||||
* public suffix itself. Sub-registry names (google.uk.com) are registrable; under a TLD missing
|
||||
* from the Public Suffix List the input is taken as is.
|
||||
*/
|
||||
function registrableNameOf(domain: string): string | undefined {
|
||||
const sub = getSubRegistrySuffix(domain);
|
||||
if (sub)
|
||||
return domain
|
||||
.split(".")
|
||||
.slice(-(sub.split(".").length + 1))
|
||||
.join(".");
|
||||
const parts = getDomainParts(domain);
|
||||
return parts.isIcann ? (parts.domain ?? undefined) : domain;
|
||||
}
|
||||
|
||||
function failure(
|
||||
ctx: LookupContext,
|
||||
errorCode: LookupErrorCode,
|
||||
@@ -99,6 +115,19 @@ async function runLookup(
|
||||
return failure(ctx, "invalid_input", "Input does not look like a domain");
|
||||
}
|
||||
|
||||
// Only a registrable name has a registration: the registry would answer "not registered" for
|
||||
// www.example.com, which reads as available. Say which name to look up instead.
|
||||
const registrable = registrableNameOf(domain);
|
||||
if (registrable !== domain) {
|
||||
return failure(
|
||||
ctx,
|
||||
"invalid_input",
|
||||
registrable
|
||||
? `"${domain}" is not a registrable domain; look up "${registrable}"`
|
||||
: `"${domain}" is a public suffix, not a registrable domain`,
|
||||
);
|
||||
}
|
||||
|
||||
// Names under a sub-registry (e.g. google.uk.com) are routed by that suffix, not the ICANN one
|
||||
const tld = getSubRegistrySuffix(domain) ?? getDomainParts(domain).publicSuffix;
|
||||
if (!tld) {
|
||||
@@ -152,17 +181,29 @@ async function runLookup(
|
||||
}
|
||||
// Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly
|
||||
if (opts?.rdapOnly) {
|
||||
// Report why RDAP failed (rate_limited, timeout, ...) so callers can tell a transient
|
||||
// failure from a TLD without RDAP
|
||||
const last = lastFailedAttempt(ctx, ["rdap", "rdap_bootstrap"]);
|
||||
const detail = last
|
||||
? ` (${last.phase} ${last.server}: ${last.error})`
|
||||
: readError
|
||||
? ` (could not read the RDAP response: ${readError})`
|
||||
: " (no RDAP server listed in the IANA bootstrap)";
|
||||
if (last) {
|
||||
return failure(
|
||||
ctx,
|
||||
last.errorCode ?? "unknown",
|
||||
`RDAP lookup failed for TLD '${tld}' (${last.phase} ${last.server}: ${last.error})`,
|
||||
{ phase: last.phase, server: last.server, retryAfterMs: last.retryAfterMs },
|
||||
);
|
||||
}
|
||||
if (readError) {
|
||||
return failure(
|
||||
ctx,
|
||||
"unparseable",
|
||||
`RDAP response for TLD '${tld}' could not be read (${readError})`,
|
||||
{ phase: "rdap", server: tried.at(-1) },
|
||||
);
|
||||
}
|
||||
return failure(
|
||||
ctx,
|
||||
"rdap_unavailable",
|
||||
`RDAP not available or failed for TLD '${tld}'${detail}. Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`,
|
||||
{ phase: last?.phase, server: last?.server, retryAfterMs: last?.retryAfterMs },
|
||||
`RDAP not available for TLD '${tld}' (no RDAP server listed in the IANA bootstrap). Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { finalizeContact, mergeContacts, redactionFields } from "./contacts";
|
||||
import { finalizeContact, isPrivacyContact, mergeContacts, redactionFields } from "./contacts";
|
||||
import { isPlaceholderValue, isPrivacyName } from "./privacy";
|
||||
import * as api from "../index";
|
||||
|
||||
@@ -197,3 +197,28 @@ test("mergeContacts keeps distinct parties of the same role apart", () => {
|
||||
expect(mergeContacts([a, b, r])).toHaveLength(3);
|
||||
expect(mergeContacts([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("isPrivacyContact means the registrant's identity is hidden", () => {
|
||||
// A visible organization identifies the registrant despite a redacted name and email
|
||||
expect(
|
||||
isPrivacyContact(
|
||||
finalizeContact({
|
||||
type: "registrant",
|
||||
name: "REDACTED FOR PRIVACY",
|
||||
organization: "Google LLC",
|
||||
email: "REDACTED",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
// Nothing identifying left after redaction
|
||||
expect(
|
||||
isPrivacyContact(finalizeContact({ type: "registrant", email: "REDACTED", country: "US" })),
|
||||
).toBe(true);
|
||||
// A privacy service, named
|
||||
expect(
|
||||
isPrivacyContact(finalizeContact({ type: "registrant", organization: "Privacy Protect, LLC" })),
|
||||
).toBe(true);
|
||||
// An unredacted contact, or none at all
|
||||
expect(isPrivacyContact(finalizeContact({ type: "registrant", name: "Jane Doe" }))).toBe(false);
|
||||
expect(isPrivacyContact(undefined)).toBe(false);
|
||||
});
|
||||
+8
-5
@@ -154,12 +154,15 @@ export function finalizeContact(
|
||||
return contact;
|
||||
}
|
||||
|
||||
/** True when a registrant contact's name/organization is a privacy service or was redacted. */
|
||||
/**
|
||||
* True when a registrant contact hides who the registrant is: a privacy or proxy service is
|
||||
* named, or something was withheld and neither a name nor an organization is left. A redacted
|
||||
* email or phone beside a visible organization ("Google LLC") doesn't count.
|
||||
*/
|
||||
export function isPrivacyContact(contact: Contact | undefined): boolean {
|
||||
return (
|
||||
!!contact?.privacyService ||
|
||||
!!contact?.redactedFields?.some((f) => f === "name" || f === "organization")
|
||||
);
|
||||
if (!contact) return false;
|
||||
if (contact.privacyService) return true;
|
||||
return !contact.name && !contact.organization && !!contact.redacted;
|
||||
}
|
||||
|
||||
const IDENTITY_FIELDS = ["name", "organization", "email"] as const;
|
||||
|
||||
@@ -129,3 +129,17 @@ test("toISO refuses qualified dates", () => {
|
||||
expect(toISO("before Aug-1996")).toBeUndefined(); // .uk
|
||||
expect(toISO("Before 2001")).toBeUndefined(); // .ro
|
||||
});
|
||||
|
||||
test("toISO reads zone-less values in a given time zone", () => {
|
||||
// Each pair checked against the registry's RDAP value
|
||||
expect(toISO("30.10.1997 01:00:00", { timeZone: "Europe/Prague" })).toBe("1997-10-30T00:00:00Z");
|
||||
expect(toISO("2015.04.20 11:41:34", { timeZone: "Europe/Warsaw" })).toBe("2015-04-20T09:41:34Z"); // CEST
|
||||
expect(toISO("1998.01.26 12:00:00", { timeZone: "Europe/Warsaw" })).toBe("1998-01-26T11:00:00Z"); // CET
|
||||
expect(toISO("1999-11-15", { timeZone: "Europe/Oslo" })).toBe("1999-11-14T23:00:00Z");
|
||||
expect(toISO("30 Mar 1999", { timeZone: "Asia/Bangkok" })).toBe("1999-03-29T17:00:00Z");
|
||||
// An explicit zone wins
|
||||
expect(toISO("2024-01-01T00:00:00Z", { timeZone: "Europe/Prague" })).toBe("2024-01-01T00:00:00Z");
|
||||
expect(toISO("2024-01-01 00:00:00 +0200", { timeZone: "Europe/Prague" })).toBe(
|
||||
"2023-12-31T22:00:00Z",
|
||||
);
|
||||
});
|
||||
+75
-11
@@ -42,17 +42,36 @@ const FORMATS: Array<[RegExp, (m: RegExpMatchArray) => number | undefined]> = [
|
||||
// Zone names the formats above and the native parser don't know: "(JST)" (.jp), "CLST"/"CLT" (.cl)
|
||||
const ZONE_OFFSETS: Record<string, string> = { JST: "+0900", CLT: "-0400", CLST: "-0300" };
|
||||
|
||||
export function toISO(dateLike: string | number | Date | undefined | null): string | undefined {
|
||||
export interface ParseDateOptions {
|
||||
/**
|
||||
* IANA time zone for values that carry none ("Europe/Prague" for .cz, whose WHOIS prints local
|
||||
* times). Without it such values are read as UTC.
|
||||
*/
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export function toISO(
|
||||
dateLike: string | number | Date | undefined | null,
|
||||
opts?: ParseDateOptions,
|
||||
): string | undefined {
|
||||
if (dateLike == null) return undefined;
|
||||
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
|
||||
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
|
||||
const raw = normalizeZone(String(dateLike).trim());
|
||||
if (!raw) return undefined;
|
||||
const ms = parseAsUtc(raw);
|
||||
if (ms === undefined) return undefined;
|
||||
const local = opts?.timeZone && !hasZone(raw);
|
||||
return toIsoFromDate(new Date(local ? fromZoneWallTime(ms, opts.timeZone as string) : ms));
|
||||
}
|
||||
|
||||
/** Epoch milliseconds of a date string, reading a value without a zone as UTC. */
|
||||
function parseAsUtc(raw: string): number | undefined {
|
||||
for (const [re, build] of FORMATS) {
|
||||
const m = raw.match(re);
|
||||
if (!m) continue;
|
||||
const ms = build(m);
|
||||
if (ms !== undefined) return toIsoFromDate(new Date(ms));
|
||||
if (ms !== undefined) return ms;
|
||||
}
|
||||
// Anything else goes to the native parser (RFC 2822, "Tue Jan 01 2000", ...). It accepts any
|
||||
// word next to a year ("Before 2001" on .ro, "since 1999"), so require a month name or a
|
||||
@@ -61,13 +80,55 @@ export function toISO(dateLike: string | number | Date | undefined | null): stri
|
||||
if (!/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i.test(raw) && !/\d\D+\d/.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
// A value without a zone would be read as local time, so pin it to UTC. A numeric offset
|
||||
// counts only after a time of day: in "Aug-1996" the "-1996" is a year.
|
||||
const hasZone =
|
||||
// A value without a zone would be read as local time, so pin it to UTC
|
||||
const native = new Date(hasZone(raw) ? raw : `${raw} UTC`);
|
||||
return Number.isNaN(native.getTime()) ? undefined : native.getTime();
|
||||
}
|
||||
|
||||
/** Whether a date string names its zone. A numeric offset counts only after a time of day: in "Aug-1996" the "-1996" is a year. */
|
||||
function hasZone(raw: string): boolean {
|
||||
return (
|
||||
/\b(?:UTC?|GMT|[ECMP][SD]T)\b/i.test(raw) ||
|
||||
/\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s*(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
|
||||
const native = new Date(hasZone ? raw : `${raw} UTC`);
|
||||
return Number.isNaN(native.getTime()) ? undefined : toIsoFromDate(native);
|
||||
/\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s*(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
const zoneFormats = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/** A time zone's UTC offset in milliseconds at an instant, from the wall-clock time there. */
|
||||
function zoneOffsetMs(timeZone: string, instant: number): number {
|
||||
let format = zoneFormats.get(timeZone);
|
||||
if (!format) {
|
||||
format = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
});
|
||||
zoneFormats.set(timeZone, format);
|
||||
}
|
||||
const parts = Object.fromEntries(
|
||||
format.formatToParts(new Date(instant)).map((p) => [p.type, Number(p.value)]),
|
||||
);
|
||||
const wall = Date.UTC(
|
||||
parts.year ?? 0,
|
||||
(parts.month ?? 1) - 1,
|
||||
parts.day ?? 1,
|
||||
parts.hour ?? 0,
|
||||
parts.minute ?? 0,
|
||||
parts.second ?? 0,
|
||||
);
|
||||
return wall - Math.floor(instant / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** The instant at which a time zone's clocks show a wall-clock time (given as if it were UTC). */
|
||||
function fromZoneWallTime(wallMs: number, timeZone: string): number {
|
||||
// The offset at the guess, not at the wall time, settles daylight-saving changes
|
||||
return wallMs - zoneOffsetMs(timeZone, wallMs - zoneOffsetMs(timeZone, wallMs));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,13 +136,16 @@ export function toISO(dateLike: string | number | Date | undefined | null): stri
|
||||
* (e.g. "0-UANIC 20111004161638", "OK-UNTIL 20261004161638"): if the whole string doesn't parse,
|
||||
* try each whitespace-separated token that looks like a date.
|
||||
*/
|
||||
export function toISOFromTokens(value: string | undefined | null): string | undefined {
|
||||
export function toISOFromTokens(
|
||||
value: string | undefined | null,
|
||||
opts?: ParseDateOptions,
|
||||
): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const whole = toISO(value);
|
||||
const whole = toISO(value, opts);
|
||||
if (whole) return whole;
|
||||
for (const token of value.trim().split(/\s+/)) {
|
||||
if (!/^\d[\d\-/:.TZ+]{5,}$/.test(token)) continue;
|
||||
const iso = toISO(token);
|
||||
const iso = toISO(token, opts);
|
||||
if (iso) return iso;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -91,8 +91,41 @@ test("normalizeRdap derives privacyEnabled from registrant keywords", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
// The organization still identifies the registrant
|
||||
const rec = normalizeRdap("example.com", "com", rdap, ["https://rdap.example/"]);
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
expect(rec.privacyEnabled).toBeUndefined();
|
||||
// With only a redacted name, nothing identifies it
|
||||
const hidden = normalizeRdap(
|
||||
"example.com",
|
||||
"com",
|
||||
{
|
||||
ldhName: "example.com",
|
||||
entities: [
|
||||
{
|
||||
roles: ["registrant"],
|
||||
vcardArray: ["vcard", [["fn", {}, "text", "REDACTED FOR PRIVACY"]]],
|
||||
},
|
||||
],
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(hidden.privacyEnabled).toBe(true);
|
||||
// A privacy service counts even though it is named
|
||||
const proxy = normalizeRdap(
|
||||
"example.com",
|
||||
"com",
|
||||
{
|
||||
ldhName: "example.com",
|
||||
entities: [
|
||||
{
|
||||
roles: ["registrant"],
|
||||
vcardArray: ["vcard", [["org", {}, "text", "Domains By Proxy, LLC"]]],
|
||||
},
|
||||
],
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(proxy.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizeRdap detects transfer lock with spaced status", () => {
|
||||
@@ -182,7 +215,25 @@ test("normalizeRdap parses RFC 9537 redacted array and flags privacy", () => {
|
||||
reason: "Server policy",
|
||||
}),
|
||||
]);
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
// An email redaction doesn't hide who the registrant is
|
||||
expect(rec.privacyEnabled).toBeUndefined();
|
||||
// Removing the registrant's name with no registrant entity left does
|
||||
const removed = normalizeRdap(
|
||||
"example.com",
|
||||
"com",
|
||||
{
|
||||
ldhName: "example.com",
|
||||
redacted: [
|
||||
{
|
||||
name: { description: "Registrant Name" },
|
||||
prePath: "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='fn')]",
|
||||
method: "removal",
|
||||
},
|
||||
],
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(removed.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizeRdap separates fax from tel and keeps multiple emails", () => {
|
||||
|
||||
@@ -58,11 +58,16 @@ export function normalizeRdap(
|
||||
const resellerContact = contacts?.find((c) => c.type === "reseller");
|
||||
const reseller = resellerContact?.organization || resellerContact?.name || undefined;
|
||||
|
||||
// Derive privacy flag from registrant name/org keywords or RFC 9537 registrant redactions
|
||||
// Privacy: the registrant's identity is hidden, by its contact or, with no registrant entity
|
||||
// left, by an RFC 9537 redaction that removed its name or organization
|
||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||
const privacyEnabled =
|
||||
isPrivacyContact(registrant) ||
|
||||
!!redactions?.some((r) => redactionTargetsRole(r, "registrant"));
|
||||
const privacyEnabled = registrant
|
||||
? isPrivacyContact(registrant)
|
||||
: !!redactions?.some(
|
||||
(r) =>
|
||||
redactionTargetsRole(r, "registrant") &&
|
||||
redactionFields(r.name, r.prePath).some((f) => f === "name" || f === "organization"),
|
||||
);
|
||||
|
||||
// RDAP spells EPP statuses as words ("client transfer prohibited"): report the EPP code, as
|
||||
// WHOIS does, and keep the original in raw. Merged registry and registrar documents may list
|
||||
|
||||
+11
-8
@@ -245,10 +245,10 @@ export interface DomainRecord {
|
||||
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */
|
||||
contacts?: Contact[];
|
||||
/**
|
||||
* Coarse signal that some registrant data was redacted or is behind a privacy service, from
|
||||
* name/organization phrases or RFC 9537 registrant redactions. It does not mean the registrant's
|
||||
* identity is hidden (an email-only redaction sets it); check the registrant contact's
|
||||
* `redactedFields` and `privacyService` for precision.
|
||||
* True when the registrant's identity is hidden: a privacy or proxy service is named, or
|
||||
* something was withheld and neither a name nor an organization is left. A redacted email or
|
||||
* phone beside a visible organization doesn't set it; the registrant contact's `redactedFields`
|
||||
* says exactly which fields were withheld.
|
||||
*/
|
||||
privacyEnabled?: boolean;
|
||||
/** RFC 9537 redaction metadata reported by RDAP, if any */
|
||||
@@ -479,18 +479,21 @@ export interface LookupResult {
|
||||
/**
|
||||
* Machine-readable reason a lookup (or one attempt within it) failed.
|
||||
*
|
||||
* - `invalid_input`: the input does not look like a domain name, or the options conflict
|
||||
* (`rdapOnly` with `whoisOnly`)
|
||||
* - `invalid_input`: the input does not look like a domain name or is not a registrable one (a
|
||||
* subdomain such as www.example.com, or a public suffix), or the options conflict (`rdapOnly`
|
||||
* with `whoisOnly`)
|
||||
* - `invalid_tld`: the TLD is not valid
|
||||
* - `timeout`: any timeout, including the overall `deadlineMs`
|
||||
* - `aborted`: the caller's `AbortSignal` fired
|
||||
* - `connect_failed`: network-level failure (ECONNREFUSED, ECONNRESET, ENOTFOUND, ...)
|
||||
* - `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 the TLD has no RDAP server (when servers were tried,
|
||||
* their failure's code is reported instead)
|
||||
* - `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
|
||||
* - `unparseable`: WHOIS replied with text that is neither an availability notice nor a record,
|
||||
* 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
|
||||
|
||||
@@ -173,8 +173,16 @@ Registrar URL: http://www.registrar.test
|
||||
Registrant Name: REDACTED FOR PRIVACY
|
||||
Registrant Organization: Example Org
|
||||
`;
|
||||
// The organization still identifies the registrant
|
||||
const rec = normalizeWhois("example.com", "com", text, "whois.verisign-grs.com");
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
expect(rec.privacyEnabled).toBeUndefined();
|
||||
const hidden = normalizeWhois(
|
||||
"example.com",
|
||||
"com",
|
||||
"Domain Name: EXAMPLE.COM\nRegistrant Name: REDACTED FOR PRIVACY\nRegistrant Email: Select Request Email Form at https://example.test\n",
|
||||
"whois.registrar.test",
|
||||
);
|
||||
expect(hidden.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("isAvailableByWhois correctly identifies availability patterns", () => {
|
||||
@@ -666,3 +674,17 @@ test("WHOIS contact names skip registry handles and placeholder notices", () =>
|
||||
registrant("Registrant:\n\tNot shown, please visit www.dnsbelgium.be for webbased whois.\n"),
|
||||
).toMatchObject({ redactedFields: ["name"], redacted: true });
|
||||
});
|
||||
|
||||
test("WHOIS local times from registries in WHOIS_TIME_ZONES match their RDAP values", () => {
|
||||
// Excerpt of whois.nic.cz for nic.cz; RDAP gives 1997-10-30T00:00:00Z and 2027-03-14T23:00:00Z
|
||||
const cz = "domain: nic.cz\nregistered: 30.10.1997 01:00:00\nexpire: 15.03.2027\n";
|
||||
expect(normalizeWhois("nic.cz", "cz", cz, "whois.nic.cz")).toMatchObject({
|
||||
creationDate: "1997-10-30T00:00:00Z",
|
||||
expirationDate: "2027-03-14T23:00:00Z",
|
||||
});
|
||||
// Unlisted registries keep reading zone-less times as UTC
|
||||
expect(
|
||||
normalizeWhois("x.mk", "mk", "domain: x.mk\nregistered: 22.05.2008 14:00:00\n", undefined)
|
||||
.creationDate,
|
||||
).toBe("2008-05-22T14:00:00Z");
|
||||
});
|
||||
+12
-6
@@ -1,9 +1,10 @@
|
||||
import { toISOFromTokens } from "../lib/dates";
|
||||
import { type ParseDateOptions, toISOFromTokens } from "../lib/dates";
|
||||
import { finalizeContact, isPrivacyContact } from "../lib/contacts";
|
||||
import { mergeNameservers } from "../lib/nameservers";
|
||||
import { isEppStatus, normalizeEppStatus } from "../lib/status";
|
||||
import { parseKeyValueBlocks, parseKeyValueLines, uniqBy } from "../lib/text";
|
||||
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
|
||||
import { WHOIS_TIME_ZONES } from "./servers";
|
||||
|
||||
// Phrases registries use to say a name is not registered. They also turn up in the remarks and
|
||||
// footers of real records ("only available for registration under certain conditions" on .ir,
|
||||
@@ -76,13 +77,15 @@ export function normalizeWhois(
|
||||
// block beats a higher-priority key in a later one: FRED registries (.cz, .mk) repeat "created"
|
||||
// in every contact and nameserver block. The whole-reply map covers a header whose value sits
|
||||
// past a blank line.
|
||||
// Some registries print local times without a zone (WHOIS_TIME_ZONES)
|
||||
const dates = { timeZone: WHOIS_TIME_ZONES[tld.split(".").pop() ?? ""] };
|
||||
const dateOf = (keys: string[], latest = false) =>
|
||||
firstDate(blocks, keys, latest) ?? firstDate([map], keys, latest);
|
||||
firstDate(blocks, keys, dates, latest) ?? firstDate([map], keys, dates, latest);
|
||||
// .gg/.je list dates as sentences under "Relevant dates:", e.g. "Registered on 28th December 2018 at 05:54:43.861"
|
||||
const relevantDate = (label: RegExp) =>
|
||||
toISOFromTokens(map["relevant dates"]?.find((l) => label.test(l))?.replace(label, ""));
|
||||
toISOFromTokens(map["relevant dates"]?.find((l) => label.test(l))?.replace(label, ""), dates);
|
||||
// .tw writes "Record created on 2000-02-02 15:06:48 (UTC+8)", with no key separator
|
||||
const recordDate = (label: RegExp) => toISOFromTokens(whoisText.match(label)?.[1]);
|
||||
const recordDate = (label: RegExp) => toISOFromTokens(whoisText.match(label)?.[1], dates);
|
||||
const creationDate =
|
||||
dateOf([
|
||||
"creation date",
|
||||
@@ -254,7 +257,7 @@ export function normalizeWhois(
|
||||
statuses,
|
||||
creationDate,
|
||||
updatedDate,
|
||||
expirationDate: expirationDate ?? toISOFromTokens(okUntil),
|
||||
expirationDate: expirationDate ?? toISOFromTokens(okUntil, dates),
|
||||
deletionDate: undefined,
|
||||
transferLock,
|
||||
dnssec,
|
||||
@@ -372,11 +375,14 @@ function parseStatusLine(line: string): string[] {
|
||||
function firstDate(
|
||||
blocks: Array<Record<string, string[]>>,
|
||||
keys: string[],
|
||||
opts: ParseDateOptions,
|
||||
latest = false,
|
||||
): string | undefined {
|
||||
for (const block of blocks) {
|
||||
for (const k of keys) {
|
||||
const dates = (block[k] ?? []).map((v) => toISOFromTokens(v)).filter((d) => d !== undefined);
|
||||
const dates = (block[k] ?? [])
|
||||
.map((v) => toISOFromTokens(v, opts))
|
||||
.filter((d) => d !== undefined);
|
||||
if (dates.length) return latest ? dates.sort().at(-1) : dates[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +49,14 @@ export const WHOIS_TLD_EXCEPTIONS = {
|
||||
nu: "whois.iis.nu",
|
||||
"xn--p1ai": "whois.tcinet.ru", // .рф
|
||||
} as Record<string, string>;
|
||||
|
||||
/**
|
||||
* Time zones of registries whose WHOIS prints local times without a zone, each confirmed by
|
||||
* comparing its WHOIS dates with its RDAP ones. Other registries' zone-less times are read as UTC.
|
||||
*/
|
||||
export const WHOIS_TIME_ZONES: Record<string, string> = {
|
||||
cz: "Europe/Prague",
|
||||
no: "Europe/Oslo",
|
||||
pl: "Europe/Warsaw",
|
||||
th: "Asia/Bangkok",
|
||||
};
|
||||
Reference in new issue
Block a user