fix: merge related RDAP documents only when they describe the queried domain

A registrar document for an IDN carries unicodeName ("münchen.com"), which was
compared with the punycode query and never matched, so gTLD IDNs silently lost
the registrar's data (contacts, on thin .com/.net). Meanwhile documents with no
name at all, like the entity or help objects behind "entity"/"registrar" links,
passed and had their statuses and events merged into the domain.

Require a domain object whose ldhName/unicodeName matches the query in punycode
form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 15:14:14 -04:00
1 parent 9b63505181
commit 217876263c
2 files changed
+89 -9

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { fetchAndMergeRdapRelated } from "./merge";
/** A customFetch serving fixed JSON documents by URL. */
function serve(docs: Record<string, unknown>) {
return vi.fn(async (url: string | URL | Request) => {
const doc = docs[typeof url === "string" ? url : url instanceof URL ? url.href : url.url];
return doc
? new Response(JSON.stringify(doc), { status: 200 })
: new Response("not found", { status: 404 });
});
}
const related = (href: string) => ({
rel: "related",
type: "application/rdap+json",
href,
});
describe("fetchAndMergeRdapRelated", () => {
it("merges an IDN registrar doc whose unicodeName matches the punycode query", async () => {
const base = {
objectClassName: "domain",
ldhName: "XN--MNCHEN-3YA.COM",
links: [related("https://rdap.registrar.test/domain/xn--mnchen-3ya.com")],
};
const customFetch = serve({
"https://rdap.registrar.test/domain/xn--mnchen-3ya.com": {
objectClassName: "domain",
ldhName: "xn--mnchen-3ya.com",
unicodeName: "münchen.com",
entities: [{ roles: ["registrant"], vcardArray: ["vcard", [["fn", {}, "text", "X"]]] }],
},
});
const { merged } = await fetchAndMergeRdapRelated("xn--mnchen-3ya.com", base, { customFetch });
expect((merged as { entities?: unknown[] }).entities).toHaveLength(1);
});
it("does not merge a linked entity document into the domain", async () => {
const base = {
objectClassName: "domain",
ldhName: "example.com",
status: ["client transfer prohibited"],
links: [{ rel: "entity", type: "application/rdap+json", href: "https://rdap.test/entity/1" }],
};
const customFetch = serve({
"https://rdap.test/entity/1": {
objectClassName: "entity",
handle: "1",
status: ["active"],
events: [{ eventAction: "last changed", eventDate: "2010-01-01T00:00:00Z" }],
},
});
const { merged } = await fetchAndMergeRdapRelated("example.com", base, { customFetch });
expect(merged).toMatchObject({ status: ["client transfer prohibited"] });
expect((merged as { events?: unknown[] }).events ?? []).toHaveLength(0);
});
it("does not merge a document for another domain", async () => {
const base = { ldhName: "example.com", links: [related("https://rdap.test/domain/other.com")] };
const customFetch = serve({
"https://rdap.test/domain/other.com": {
objectClassName: "domain",
ldhName: "other.com",
status: ["active"],
},
});
const { merged } = await fetchAndMergeRdapRelated("example.com", base, { customFetch });
expect((merged as { status?: unknown }).status).toBeUndefined();
});
});
+18 -9
View File
@@ -1,4 +1,5 @@
import { resolveTimeoutMs, throwIfAborted, withTimeout } from "../lib/async";
import { toAsciiDomain } from "../lib/domain";
import { RdapperError } from "../lib/errors";
import { resolveFetch } from "../lib/fetch";
import { uniqBy } from "../lib/text";
@@ -94,13 +95,7 @@ export async function fetchAndMergeRdapRelated(
try {
const { json } = await fetchRdapUrl(url, opts, ctx);
tried.push(url);
// only accept docs that appear related to the same domain when possible
// if ldhName/unicodeName present, they should match the queried domain (case-insensitive)
const ldh = str((json as Json)?.ldhName).toLowerCase();
const uni = str((json as Json)?.unicodeName).toLowerCase();
if (ldh && !sameDomain(ldh, domain)) continue;
if (uni && !sameDomain(uni, domain)) continue;
fetchedDocs.push(json);
if (describesDomain(json, domain)) fetchedDocs.push(json);
} catch {
// caller abort / deadline stops the lookup; other failures are recorded in attempts
throwIfAborted(opts?.signal);
@@ -151,6 +146,20 @@ function toStringArray(val: unknown): string[] {
function uniqStrings(arr: string[]): string[] {
return Array.from(new Set(arr));
}
function sameDomain(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
/**
* Whether a fetched document is this domain's object, so its data can be merged: not an entity,
* help, or error document, and named after the queried (ASCII) domain. unicodeName is compared
* in its punycode form.
*/
function describesDomain(doc: unknown, domain: string): boolean {
const d = (doc ?? {}) as Json;
const objectClass = str(d.objectClassName).toLowerCase();
if (objectClass && objectClass !== "domain") return false;
const ldh = str(d.ldhName);
const uni = str(d.unicodeName);
if (!ldh && !uni) return objectClass === "domain";
const target = domain.toLowerCase();
if (ldh && toAsciiDomain(ldh) !== target) return false;
if (uni && toAsciiDomain(uni) !== target) return false;
return true;
}