← back to Directory Core
src/compliance.ts
197 lines
// directory-core / compliance
//
// Polite HTTP fetching for vendor / public-records crawlers across verticals:
// - robots.txt parsing + 24h cache (per host)
// - per-host rate limiting (configurable, default 0.5 rps)
// - retry on 5xx with exponential backoff (4 attempts max)
// - 403/401 CAPTCHA detection — throws CAPTCHA_DETECTED rather than retrying
// in a loop (Steve's rule: never burn through hCaptcha/reCAPTCHA budgets)
//
// Source: extracted from `lawyer-directory-builder/src/lib/compliance.ts` 2026-05-04.
// Already vertical-neutral; copied verbatim with the consumer-facing
// USER_AGENT default updated to identify directory-core rather than the
// lawyer-specific source. Each consumer can override via env.
import 'dotenv/config';
import { fetch } from 'undici';
import dns from 'node:dns/promises';
// robots-parser ships incomplete types — its default export is a callable
// factory, but the published .d.ts shows a namespace. Cast through unknown.
import robotsParserImport from 'robots-parser';
const robotsParser = robotsParserImport as unknown as (url: string, body: string) => {
isAllowed(url: string, ua: string): boolean | undefined;
};
// Runtime sanity check (audit P2 2026-05-04): if robots-parser ever flips its
// ESM/CJS shape, the cast above silently breaks. Verify at module load.
if (typeof robotsParser !== 'function') {
throw new Error(
'directory-core/compliance: robots-parser default export is not callable — package shape changed?'
);
}
const USER_AGENT = process.env.USER_AGENT
|| 'directory-core/0.2 (research; contact: steveabramsdesigns@gmail.com)';
const DEFAULT_RPS = parseFloat(process.env.CRAWLER_RPS || process.env.STATE_BAR_RPS || '0.5');
// ── SSRF defense (audit P1-b 2026-05-04) ──────────────────────────────────
//
// fetchCompliant accepts arbitrary URLs from callers. Today every caller
// passes hard-coded vendor URLs, but the moment any consumer threads a
// user-supplied URL through (admin form, OAuth callback, etc.), unguarded
// fetch becomes an SSRF + cloud-metadata-credentials-exfil pivot. Block at
// the perimeter: resolve the hostname's A/AAAA records and reject if any
// fall in private/loopback/link-local/CGNAT/cloud-metadata ranges.
//
// Same gate also applies to `getRobotsFor` (it fetches /robots.txt before
// the host check, so its fetch is also a free SSRF hop).
function isPrivateAddr(addr: string): boolean {
// IPv4 private + special ranges
if (/^10\./.test(addr)) return true; // 10.0.0.0/8
if (/^192\.168\./.test(addr)) return true; // 192.168.0.0/16
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(addr)) return true; // 172.16.0.0/12
if (/^127\./.test(addr)) return true; // 127.0.0.0/8 loopback
if (/^169\.254\./.test(addr)) return true; // 169.254.0.0/16 link-local + cloud metadata
if (/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(addr)) return true; // 100.64.0.0/10 CGNAT
if (addr === '0.0.0.0') return true;
// IPv6
if (addr === '::1' || addr === '::') return true;
if (/^fe80:/i.test(addr)) return true; // link-local
if (/^fc[0-9a-f]{2}:|^fd[0-9a-f]{2}:/i.test(addr)) return true; // unique-local
return false;
}
async function assertPublicHost(urlStr: string): Promise<void> {
const u = new URL(urlStr);
// Resolve all A and AAAA records — reject if ANY is private. Using
// Promise.allSettled so a missing AAAA record (common) doesn't fail the
// whole check.
const [a, aaaa] = await Promise.allSettled([
dns.resolve4(u.hostname),
dns.resolve6(u.hostname),
]);
const addrs: string[] = [];
if (a.status === 'fulfilled') addrs.push(...a.value);
if (aaaa.status === 'fulfilled') addrs.push(...aaaa.value);
// If DNS resolution returned nothing for both families, the host is
// unresolvable — let the fetch fail naturally rather than mis-classify.
if (addrs.length === 0) return;
for (const addr of addrs) {
if (isPrivateAddr(addr)) {
const err = new Error(`SSRF blocked — ${u.hostname} resolves to private address ${addr}`) as Error & { code?: string };
err.code = 'SSRF_BLOCKED';
throw err;
}
}
}
const robotsCache = new Map<string, { robots: any; fetchedAt: number }>();
const ONE_DAY = 24 * 60 * 60 * 1000;
async function getRobotsFor(urlStr: string) {
const u = new URL(urlStr);
const cached = robotsCache.get(u.host);
if (cached && Date.now() - cached.fetchedAt < ONE_DAY) return cached.robots;
const robotsUrl = `${u.protocol}//${u.host}/robots.txt`;
let body = '';
try {
// SSRF gate (audit P1-b 2026-05-04) — robots.txt fetch is its own attack
// surface; gate it the same way as fetchCompliant.
await assertPublicHost(robotsUrl);
const res = await fetch(robotsUrl, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(10000),
});
if (res.ok) body = await res.text();
} catch { /* permissive on transport error AND on SSRF reject */ }
const robots = robotsParser(robotsUrl, body);
robotsCache.set(u.host, { robots, fetchedAt: Date.now() });
return robots;
}
export async function isAllowed(urlStr: string): Promise<boolean> {
const robots = await getRobotsFor(urlStr);
const verdict = robots.isAllowed(urlStr, USER_AGENT);
return verdict !== false;
}
const lastFetchByHost = new Map<string, number>();
const rpsByHost = new Map<string, number>();
export function setHostRateLimit(host: string, rps: number) {
rpsByHost.set(host, rps);
}
async function gateHost(host: string) {
const rps = rpsByHost.get(host) ?? DEFAULT_RPS;
const minInterval = 1000 / rps;
const last = lastFetchByHost.get(host) || 0;
const wait = Math.max(0, last + minInterval - Date.now());
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastFetchByHost.set(host, Date.now());
}
export interface FetchOpts {
respectRobots?: boolean;
accept?: string;
headers?: Record<string, string>;
timeoutMs?: number;
}
export async function fetchCompliant(urlStr: string, opts: FetchOpts = {}) {
// SSRF gate FIRST — before robots.txt, before rate-limit, before fetch.
// Audit P1-b 2026-05-04: blocks RFC1918, loopback, link-local, CGNAT, and
// cloud-metadata (169.254.169.254). Throws SSRF_BLOCKED on reject.
await assertPublicHost(urlStr);
const u = new URL(urlStr);
if (opts.respectRobots !== false) {
const allowed = await isAllowed(urlStr);
if (!allowed) {
const err = new Error(`robots.txt disallows ${urlStr}`) as Error & { code?: string };
err.code = 'ROBOTS_DISALLOWED';
throw err;
}
}
await gateHost(u.host);
const headers: Record<string, string> = {
'User-Agent': USER_AGENT,
Accept: opts.accept || 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
...(opts.headers || {}),
};
let attempt = 0;
while (true) {
attempt++;
let res: Response | undefined;
try {
res = await fetch(urlStr, { headers, signal: AbortSignal.timeout(opts.timeoutMs || 30000) }) as unknown as Response;
} catch (e) {
if (attempt >= 4) throw e;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
if (res.status === 403 || res.status === 401) {
const text = await res.text();
// Audit P2 2026-05-04: extended to cover Turnstile, Arkose, Datadome, AWS WAF Captcha.
if (/cf-challenge|recaptcha|hcaptcha|captcha|turnstile|arkose|datadome|aws-waf-token/i.test(text)) {
const err = new Error(`CAPTCHA detected at ${urlStr} — aborting per policy`) as Error & { code?: string };
err.code = 'CAPTCHA_DETECTED';
throw err;
}
return new Response(text, { status: res.status, headers: res.headers });
}
if (res.status >= 500 && attempt < 4) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
return res;
}
}