mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
feat: add SSRF host validation, rate_limited/unparseable error codes, and referral chain warnings
- Add `isSafeWhoisReferralHost` to reject private/loopback/link-local IPs and malformed hostnames before following WHOIS referrals (SSRF guard)
- Detect WHOIS throttle replies in the referral chain: drop the registrar response, keep the registry record, and surface a warning instead of returning bad data
- Add `looksEmptyWhois` guard in the main lookup path: a "registered" record with no parseable fields now fails with `errorCode: "unparseable"` rather than resolving silently
- Map RDAP 429 responses to a structured `rate_limited` error (including `Retry-After` header) so callers can distinguish throttling from generic HTTP errors and the fallback to WHOIS is recorded in `attempts`
- Add `rate_limited` and `unparseable` to `LookupErrorCode`
- Change `collectWhoisReferralChain` to return `{ results, warnings }` instead of a bare array; warnings are merged onto the final `DomainRecord`
- Remove the `followWhoisReferrals` fallback path from `index.ts` (dead code after the chain API stabilised)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||
const BLOCKED_SUFFIXES = [".local", ".localhost", ".internal", ".localdomain", ".lan", ".home"];
|
||||
|
||||
function isPrivateIpv4(ip: string): boolean {
|
||||
const [a = 0, b = 0] = ip.split(".").map(Number);
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
a >= 224 // multicast + reserved
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped?.[1]) return isPrivateIpv4(mapped[1]);
|
||||
return (
|
||||
lower === "::" ||
|
||||
lower === "::1" ||
|
||||
/^f[cd]/.test(lower) || // unique local
|
||||
/^fe[89ab]/.test(lower) || // link-local
|
||||
lower.startsWith("ff") // multicast
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a WHOIS referral host taken from upstream response text is safe to connect to:
|
||||
* a well-formed public hostname or public IP literal, with no port, path or userinfo.
|
||||
*/
|
||||
export function isSafeWhoisReferralHost(host: string): boolean {
|
||||
const value = host.trim().replace(/\.$/, "");
|
||||
if (!value || value.length > 253) return false;
|
||||
|
||||
const ipVersion = isIP(value);
|
||||
if (ipVersion === 4) return !isPrivateIpv4(value);
|
||||
if (ipVersion === 6) return !isPrivateIpv6(value);
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "localhost" || BLOCKED_SUFFIXES.some((s) => lower.endsWith(s))) return false;
|
||||
const labels = lower.split(".");
|
||||
if (labels.length < 2) return false;
|
||||
if (!labels.every((l) => LABEL.test(l))) return false;
|
||||
// All-numeric last label means a malformed/obfuscated IP (e.g. 0x7f.1, 2130706433)
|
||||
return !/^\d+$/.test(labels[labels.length - 1] as string);
|
||||
}
|
||||
Reference in New Issue
Block a user