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