Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions functions/api/whois.js
Original file line number Diff line number Diff line change
@@ -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': '*',
Expand All @@ -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;
Expand All @@ -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',
Expand All @@ -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);
}
}
14 changes: 13 additions & 1 deletion js/whois.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, `<pre class="raw">${window.escapeHtml(d.raw)}</pre>`, d.raw) +
`<div class="note">This registry doesn&#39;t publish structured RDAP data, so this is the raw whois record from ${window.escapeHtml(d.source || 'the registry')}.</div>`;
return;
}

const isIP = window.isIP(q);
let rows;
if (isIP) {
Expand Down Expand Up @@ -45,7 +53,11 @@ async function runWhois(query, panel) {
window.card(`Whois — ${q}`, `<table><tbody>${body}</tbody></table>`) +
`<div class="note">Data from RDAP. If a field is missing, some registries withhold it — try <a href="https://lookup.icann.org/en/lookup?name=${encodeURIComponent(q)}" target="_blank" rel="noopener">lookup.icann.org</a>.</div>`;
} 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 =
`<div class="summary red">${window.escapeHtml(e.message || 'Whois lookup failed.')}</div>` +
`<div class="note">Try <a href="https://lookup.icann.org/en/lookup?name=${encodeURIComponent(q)}" target="_blank" rel="noopener">lookup.icann.org</a> or the registry&#39;s own whois service.</div>`;
}
}

Expand Down
49 changes: 49 additions & 0 deletions lib/parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
24 changes: 24 additions & 0 deletions tests/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }] },
Expand Down
Loading