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' } }] },