From 390af195ba7180bd9dbd51bd540dcf9e4b91cec9 Mon Sep 17 00:00:00 2001 From: Chris Muench <8158602+chrismuench@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:21:54 -0700 Subject: [PATCH] Whois: fix ccTLD lookups (RDAP overrides + classic whois fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whois failed for many ccTLDs (.de, .ch, .io, .eu, .at, .dk, …) because rdap.org only knows TLDs in the IANA RDAP bootstrap, and our 502 error response was replaced by Cloudflare's bare "error code: 502" page, so users just saw "Whois lookup failed (502)". - RDAP_BOOTSTRAP_OVERRIDES: registries that run public RDAP but aren't in the bootstrap (de/ch/li via DENIC & SWITCH, io/sh/ac via Identity Digital) — verified working and parseable. - TLDs with no RDAP at all now fall back to classic whois over TCP 43 (cloudflare:sockets): whois.iana.org referral -> registry server -> raw text, rendered in a
 with a source note.
- Upstream failures now map to 4xx (404/429/424), never 502/504, so the
  JSON error message survives Cloudflare and reaches the browser; the
  frontend shows it plus a lookup.icann.org fallback link.
- Smoke tests for rdapTarget/rdapFailure/parseWhoisReferral; e2e tests
  for the .de override and .eu raw fallback.

Co-Authored-By: Claude Fable 5 
---
 functions/api/whois.js | 89 ++++++++++++++++++++++++++++++++++++++----
 js/whois.js            | 14 ++++++-
 lib/parse.mjs          | 49 +++++++++++++++++++++++
 tests/e2e.mjs          | 12 ++++++
 tests/smoke.mjs        | 24 ++++++++++++
 5 files changed, 180 insertions(+), 8 deletions(-)

diff --git a/functions/api/whois.js b/functions/api/whois.js
index e936d7e..d167b0a 100644
--- a/functions/api/whois.js
+++ b/functions/api/whois.js
@@ -1,6 +1,14 @@
 // functions/api/whois.js — RDAP proxy (avoids CORS). No user input is ever logged.
+//
+// Lookup order for domains:
+//   1. RDAP via rdap.org (plus a few registries missing from the IANA
+//      bootstrap — see RDAP_BOOTSTRAP_OVERRIDES in lib/parse.mjs).
+//   2. If the TLD has no RDAP at all, classic whois over TCP 43: ask
+//      whois.iana.org for the TLD's referral server, then query it and
+//      return the raw text.
 
-import { parseRdapDomain, parseRdapIP, isBlockedHost } from '../../lib/parse.mjs';
+import { connect } from 'cloudflare:sockets';
+import { parseRdapDomain, parseRdapIP, isBlockedHost, rdapTarget, rdapFailure, parseWhoisReferral } from '../../lib/parse.mjs';
 
 const CORS = {
   'Access-Control-Allow-Origin': '*',
@@ -16,6 +24,66 @@ function json(body, status = 200) {
 }
 
 const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
+// Strict enough to be safe to write into a TCP whois query verbatim.
+const WHOIS_SAFE = /^[a-z0-9.-]{1,253}$/i;
+const WHOIS_MAX_BYTES = 65536;
+
+async function whoisQuery(server, query, timeoutMs = 10000) {
+  const socket = connect({ hostname: server, port: 43 });
+  let timer;
+  const timeout = new Promise((_, reject) => {
+    timer = setTimeout(() => reject(new Error('whois timeout')), timeoutMs);
+  });
+  const work = (async () => {
+    const writer = socket.writable.getWriter();
+    await writer.write(new TextEncoder().encode(query + '\r\n'));
+    // Don't close the writable side — workerd tears down the whole socket on
+    // FIN. Whois servers reply after CRLF and close the connection themselves.
+    writer.releaseLock();
+    const reader = socket.readable.getReader();
+    const chunks = [];
+    let total = 0;
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      chunks.push(value);
+      total += value.length;
+      if (total >= WHOIS_MAX_BYTES) break;
+    }
+    const buf = new Uint8Array(Math.min(total, WHOIS_MAX_BYTES));
+    let off = 0;
+    for (const c of chunks) {
+      const n = Math.min(c.length, buf.length - off);
+      buf.set(c.subarray(0, n), off);
+      off += n;
+      if (off >= buf.length) break;
+    }
+    return new TextDecoder('utf-8', { fatal: false }).decode(buf);
+  })();
+  try {
+    return await Promise.race([work, timeout]);
+  } finally {
+    clearTimeout(timer);
+    try { socket.close(); } catch (e) { /* already closed */ }
+  }
+}
+
+// Classic whois for TLDs with no RDAP. Returns { raw, source } or null.
+async function whoisFallback(q) {
+  if (!WHOIS_SAFE.test(q)) return null;
+  const tld = q.toLowerCase().replace(/\.$/, '').split('.').pop();
+  try {
+    const server = parseWhoisReferral(await whoisQuery('whois.iana.org', tld));
+    if (!server) return null;
+    const raw = (await whoisQuery(server, q)).trim();
+    if (!raw) return null;
+    return { raw, source: server };
+  } catch (e) {
+    // No user data in log.
+    console.error('whois fallback failed.');
+    return null;
+  }
+}
 
 export async function onRequest(context) {
   const { request } = context;
@@ -27,12 +95,9 @@ export async function onRequest(context) {
   const m4 = IPV4.exec(q);
   const isIP = (m4 && m4.slice(1).every((o) => Number(o) <= 255)) || q.includes(':');
   if (isIP && isBlockedHost(q)) return json({ error: 'Private or reserved addresses (RFC1918) are not publicly registered.' }, 400);
-  const target = isIP
-    ? `https://rdap.org/ip/${encodeURIComponent(q)}`
-    : `https://rdap.org/domain/${encodeURIComponent(q)}`;
 
   try {
-    const res = await fetch(target, {
+    const res = await fetch(rdapTarget(q, isIP), {
       redirect: 'follow',
       headers: {
         Accept: 'application/rdap+json, application/json',
@@ -42,12 +107,22 @@ export async function onRequest(context) {
     if (!res.ok) {
       // No user data in log.
       console.error('RDAP fetch failed:', res.status);
-      return json({ error: `Registry returned ${res.status}. Some registries limit RDAP access.` }, 502);
+      // 404 straight from rdap.org (no redirect happened) means the TLD has
+      // no RDAP service in the IANA bootstrap — try classic whois instead.
+      let bootstrapMiss = false;
+      try { bootstrapMiss = new URL(res.url).hostname === 'rdap.org'; } catch (e) { /* ignore */ }
+      if (!isIP && res.status === 404 && bootstrapMiss) {
+        const fallback = await whoisFallback(q);
+        if (fallback) return json(fallback);
+      }
+      const fail = rdapFailure(res.status, res.url, q, isIP);
+      return json({ error: fail.error }, fail.status);
     }
     const data = await res.json();
     return json(isIP ? parseRdapIP(data) : parseRdapDomain(data));
   } catch (e) {
     console.error('RDAP request error.');
-    return json({ error: 'Could not reach the RDAP registry. Try again shortly.' }, 502);
+    // 424, not 502 — Cloudflare swallows 502/504 bodies (see rdapFailure).
+    return json({ error: 'Could not reach the RDAP registry. Try again shortly.' }, 424);
   }
 }
diff --git a/js/whois.js b/js/whois.js
index 7fc0905..753aa46 100644
--- a/js/whois.js
+++ b/js/whois.js
@@ -15,6 +15,14 @@ async function runWhois(query, panel) {
     const d = await res.json();
     if (d.error) throw new Error(d.error);
 
+    // TLDs without RDAP fall back to the registry's classic whois — raw text.
+    if (d.raw) {
+      panel.innerHTML =
+        window.card(`Whois — ${q}`, `
${window.escapeHtml(d.raw)}
`, d.raw) + + `
This registry doesn't publish structured RDAP data, so this is the raw whois record from ${window.escapeHtml(d.source || 'the registry')}.
`; + return; + } + const isIP = window.isIP(q); let rows; if (isIP) { @@ -45,7 +53,11 @@ async function runWhois(query, panel) { window.card(`Whois — ${q}`, `${body}
`) + `
Data from RDAP. If a field is missing, some registries withhold it — try lookup.icann.org.
`; } catch (e) { - window.showError(panel, e.message || 'Whois lookup failed.'); + // Error plus a fallback link — many ccTLD registries (.de, .ch, .io, …) + // don't publish RDAP, so point at ICANN's lookup instead of a dead end. + panel.innerHTML = + `
${window.escapeHtml(e.message || 'Whois lookup failed.')}
` + + `
Try lookup.icann.org or the registry's own whois service.
`; } } diff --git a/lib/parse.mjs b/lib/parse.mjs index c39599e..8a17347 100644 --- a/lib/parse.mjs +++ b/lib/parse.mjs @@ -142,6 +142,55 @@ export function parseTenantDomains(xml, cap = 200) { return out.sort(); } +// ---------- RDAP target selection (whois.js) ---------- +// Registries that run public RDAP but aren't in the IANA bootstrap registry +// rdap.org relies on, so rdap.org 404s them. Verified working July 2026. +// (.eu, .at, .be, .nz, .dk, .se offer no public RDAP at all — those still get +// the "registry does not publish RDAP" error.) +export const RDAP_BOOTSTRAP_OVERRIDES = { + de: 'https://rdap.denic.de/domain/', + ch: 'https://rdap.nic.ch/domain/', + li: 'https://rdap.nic.li/domain/', + io: 'https://rdap.identitydigital.services/rdap/domain/', + sh: 'https://rdap.identitydigital.services/rdap/domain/', + ac: 'https://rdap.identitydigital.services/rdap/domain/', +}; + +export function rdapTarget(q, isIP) { + if (isIP) return `https://rdap.org/ip/${encodeURIComponent(q)}`; + const tld = String(q).toLowerCase().replace(/\.$/, '').split('.').pop(); + const base = RDAP_BOOTSTRAP_OVERRIDES[tld]; + return base ? base + encodeURIComponent(q) : `https://rdap.org/domain/${encodeURIComponent(q)}`; +} + +// Extract the referral whois server from an IANA "whois.iana.org" TLD answer +// (a line like "whois: whois.eu"). Returns '' when the TLD has none. +export function parseWhoisReferral(text) { + const m = /^whois:\s+(\S+)\s*$/im.exec(String(text || '')); + const server = m ? m[1].toLowerCase() : ''; + return /^[a-z0-9.-]{1,253}$/.test(server) ? server : ''; +} + +// ---------- RDAP upstream failure shaping (whois.js) ---------- +// rdap.org 404s in two distinct ways: when the TLD has no RDAP service in the +// IANA bootstrap registry it answers 404 itself (no redirect), otherwise it +// redirects to the registry, whose 404 means the name isn't registered. +// Never map failures to 502/504: Cloudflare replaces those with its own +// plain-text error page, so the browser would never see the JSON message. +export function rdapFailure(status, finalUrl, query, isIP) { + if (status === 404) { + let atBootstrap = false; + try { atBootstrap = new URL(finalUrl || '').hostname === 'rdap.org'; } catch (e) { /* ignore */ } + if (!isIP && atBootstrap) { + const tld = String(query || '').split('.').pop().toLowerCase(); + return { status: 404, error: `The .${tld} registry publishes no RDAP data, and no public whois answer could be retrieved either.` }; + } + return { status: 404, error: isIP ? 'No registration found for this IP address.' : 'Domain not found — it may be unregistered.' }; + } + if (status === 429) return { status: 429, error: 'The registry is rate-limiting lookups. Try again shortly.' }; + return { status: 424, error: `Registry returned ${status}. Some registries limit RDAP access.` }; +} + // ---------- SSRF host guard (shared with headers.js) ---------- export function isBlockedHost(host) { host = (host || '').toLowerCase().trim(); diff --git a/tests/e2e.mjs b/tests/e2e.mjs index d8b92db..15990cd 100644 --- a/tests/e2e.mjs +++ b/tests/e2e.mjs @@ -68,6 +68,18 @@ async function main() { okUpstream('whois domain 200', status, status === 200, `status ${status}`); okUpstream('whois domain has field', status, json && (json.registrar !== undefined || json.domain), JSON.stringify(json)); } + // whois — RDAP override for a registry missing from the IANA bootstrap (.de) + { + const { status, json } = await getJson('/api/whois?q=heise.de'); + okUpstream('whois .de override 200', status, status === 200, `status ${status}`); + okUpstream('whois .de has domain field', status, json && json.domain, JSON.stringify(json)); + } + // whois — classic port-43 fallback for a TLD with no RDAP at all (.eu) + { + const { status, json } = await getJson('/api/whois?q=europa.eu'); + okUpstream('whois .eu raw fallback 200', status, status === 200, `status ${status}`); + okUpstream('whois .eu returns raw text', status, json && json.raw && json.source, JSON.stringify(json).slice(0, 120)); + } // whois missing param — this is OUR validation, always checked { const { status, json } = await getJson('/api/whois'); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index d301155..f594c6b 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -179,6 +179,30 @@ eq('rdap ip org', rdapIP.org, 'Cloudflare, Inc.'); eq('rdap ip nested abuse', rdapIP.abuse, 'abuse@cloudflare.com'); eq('rdap ip cidr range', rdapIP.cidr, '104.16.0.0 – 104.31.255.255'); +// ================= lib/parse.mjs: RDAP target + failure shaping ================= +eq('rdap target com via rdap.org', parse.rdapTarget('example.com', false), 'https://rdap.org/domain/example.com'); +eq('rdap target ip', parse.rdapTarget('1.1.1.1', true), 'https://rdap.org/ip/1.1.1.1'); +eq('rdap target de override', parse.rdapTarget('heise.de', false), 'https://rdap.denic.de/domain/heise.de'); +eq('rdap target io override', parse.rdapTarget('github.io', false), 'https://rdap.identitydigital.services/rdap/domain/github.io'); +eq('rdap target trailing dot + case', parse.rdapTarget('Heise.DE.', false), 'https://rdap.denic.de/domain/Heise.DE.'); + +const noRdapTld = parse.rdapFailure(404, 'https://rdap.org/domain/example.eu', 'example.eu', false); +eq('rdap failure bootstrap miss is 404', noRdapTld.status, 404); +check('rdap failure bootstrap miss names tld', noRdapTld.error.includes('.eu'), noRdapTld.error); +const notFound = parse.rdapFailure(404, 'https://rdap.verisign.com/com/v1/domain/nope.com', 'nope.com', false); +eq('rdap failure registry 404 is not-found', notFound.error, 'Domain not found — it may be unregistered.'); +eq('rdap failure ip 404', parse.rdapFailure(404, 'https://rdap.arin.net/ip/x', '203.0.113.9', true).error, 'No registration found for this IP address.'); +eq('rdap failure 429 passthrough', parse.rdapFailure(429, 'https://rdap.org/domain/x.com', 'x.com', false).status, 429); +// 5xx must be remapped — Cloudflare replaces 502/504 bodies with its own page. +eq('rdap failure 502 remapped to 424', parse.rdapFailure(502, 'https://rdap.org/domain/x.com', 'x.com', false).status, 424); + +// ================= lib/parse.mjs: whois referral ================= +eq('whois referral parsed', parse.parseWhoisReferral('% IANA WHOIS server\nrefer: whois.eu\nwhois: whois.eu\nstatus: ACTIVE'), 'whois.eu'); +eq('whois referral case-insensitive', parse.parseWhoisReferral('WHOIS: WHOIS.NIC.AT\n'), 'whois.nic.at'); +eq('whois referral absent', parse.parseWhoisReferral('% IANA WHOIS server\nstatus: ACTIVE'), ''); +eq('whois referral garbage rejected', parse.parseWhoisReferral('whois: not a host!\n'), ''); +eq('whois referral empty input', parse.parseWhoisReferral(''), ''); + // ================= lib/parse.mjs: bgpview shaping ================= const asnFromIp = parse.shapeAsnFromIp({ data: { ip: '1.1.1.1', prefixes: [{ prefix: '1.1.1.0/24', name: 'APNIC-LABS', asn: { asn: 13335, name: 'CLOUDFLARENET', description: 'Cloudflare', country_code: 'US' } }] },