← back to Rentv Adintel

lib/compliance/fetch-guard.js

248 lines

'use strict';
/**
 * Fetch guard — SSRF protection for EVERY outbound fetch (spec §32, §6.4).
 *
 * Guarantees:
 *   - LinkedIn hosts are hard-blocked (§6.3/§6.4) — the app NEVER fetches them.
 *   - No fetch may resolve to a private / loopback / link-local / metadata IP
 *     (§32 "DNS/IP checks blocking private, loopback, metadata, and link-local").
 *   - Automated public web research is OFF unless explicitly enabled by env
 *     (ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true').
 *   - A descriptive, admin-contact User-Agent is always sent (§23).
 *   - Per-host token bucket enforces a minimum delay / max requests per minute.
 *   - Redirects are re-checked against the same guard (no redirect to a blocked
 *     host or private IP).
 */

const dns = require('dns').promises;
const T = require('../types');

// ---------------------------------------------------------------------------
// IP range checks
// ---------------------------------------------------------------------------

/** Parse an IPv4 "a.b.c.d" → 32-bit unsigned int, or null if not IPv4. */
function ipv4ToInt(ip) {
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
  if (!m) return null;
  const parts = m.slice(1).map(Number);
  if (parts.some((p) => p > 255)) return null;
  return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
}

function inCidr(ipInt, netStr, bits) {
  const net = ipv4ToInt(netStr);
  if (net == null || ipInt == null) return false;
  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
  return (ipInt & mask) === (net & mask);
}

/**
 * isBlockedIp(ip) — true for any private / loopback / link-local / metadata /
 * unspecified address (§32). Covers both IPv4 and the IPv6 forms we care about.
 */
function isBlockedIp(ip) {
  if (!ip || typeof ip !== 'string') return true; // fail closed
  const addr = ip.trim().toLowerCase().replace(/^\[|\]$/g, '');

  // --- IPv6 ---
  if (addr.includes(':')) {
    if (addr === '::' || addr === '::0' || addr === '0:0:0:0:0:0:0:0') return true; // unspecified
    if (addr === '::1' || addr === '0:0:0:0:0:0:0:1') return true;                   // loopback
    // fc00::/7  — unique local (fc.. / fd..)
    if (/^f[cd][0-9a-f]{0,2}:/.test(addr)) return true;
    // fe80::/10 — link-local
    if (/^fe[89ab][0-9a-f]?:/.test(addr) || /^fe80:/.test(addr)) return true;
    // IPv4-mapped ::ffff:a.b.c.d — unwrap and re-check as IPv4
    const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
    if (mapped) return isBlockedIp(mapped[1]);
    return false; // other global IPv6 — allowed
  }

  // --- IPv4 ---
  const n = ipv4ToInt(addr);
  if (n == null) return true; // not a parseable IPv4 → fail closed

  if (inCidr(n, '10.0.0.0', 8)) return true;        // private
  if (inCidr(n, '172.16.0.0', 12)) return true;     // private
  if (inCidr(n, '192.168.0.0', 16)) return true;    // private
  if (inCidr(n, '127.0.0.0', 8)) return true;       // loopback
  if (inCidr(n, '169.254.0.0', 16)) return true;    // link-local (incl. 169.254.169.254 metadata)
  if (inCidr(n, '0.0.0.0', 8)) return true;         // "this" network / 0.0.0.0
  if (inCidr(n, '100.64.0.0', 10)) return true;     // CGNAT (also used by tailnets) — treat as internal
  return false;
}

/** True when host === blocked OR endsWith .blocked (subdomain) — §6.4. */
function isLinkedInHost(host) {
  if (!host) return false;
  const h = host.toLowerCase();
  return T.LINKEDIN_BLOCKED_HOSTS.some((b) => h === b.toLowerCase() || h.endsWith(`.${b.toLowerCase()}`));
}

// ---------------------------------------------------------------------------
// assertFetchAllowed
// ---------------------------------------------------------------------------

/**
 * assertFetchAllowed(url) — throws unless the URL is safe to fetch.
 * Rejects: non-http(s), LinkedIn hosts, and hosts that resolve (ANY A/AAAA
 * record) to a blocked IP range. Resolves DNS via dns.promises.lookup(all).
 */
async function assertFetchAllowed(url) {
  let u;
  try {
    u = new URL(url);
  } catch (_e) {
    throw new Error(`fetch-guard: invalid URL "${url}"`);
  }

  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
    throw new Error(`fetch-guard: protocol "${u.protocol}" not allowed (http/https only)`);
  }

  const host = u.hostname.toLowerCase();

  // §6.4 — LinkedIn hard block.
  if (isLinkedInHost(host)) {
    throw new Error(`fetch-guard: LinkedIn host "${host}" is blocked from automated fetching (§6.3/§6.4)`);
  }

  // If the host is already a literal IP, check it directly.
  const literal = host.replace(/^\[|\]$/g, '');
  if (ipv4ToInt(literal) != null || literal.includes(':')) {
    if (isBlockedIp(literal)) {
      throw new Error(`fetch-guard: host resolves to blocked IP "${literal}" (private/loopback/metadata/link-local) (§32)`);
    }
    return { host, addresses: [literal] };
  }

  // Resolve ALL addresses; block if ANY is private (DNS-rebinding defense).
  let addresses;
  try {
    addresses = await dns.lookup(host, { all: true });
  } catch (e) {
    throw new Error(`fetch-guard: DNS lookup failed for "${host}": ${e.message}`);
  }
  if (!addresses || addresses.length === 0) {
    throw new Error(`fetch-guard: "${host}" resolved to no addresses`);
  }
  for (const a of addresses) {
    if (isBlockedIp(a.address)) {
      throw new Error(`fetch-guard: "${host}" resolves to blocked IP "${a.address}" (§32)`);
    }
  }
  return { host, addresses: addresses.map((a) => a.address) };
}

// ---------------------------------------------------------------------------
// Per-host token bucket (min delay / max requests per minute)
// ---------------------------------------------------------------------------

const buckets = new Map(); // host -> { lastAt, timestamps: number[] }

function defaultRpm() {
  const v = Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || '6');
  return Number.isFinite(v) && v > 0 ? v : 6;
}

async function throttleHost(host, { maxRequestsPerMinute, minimumDelayMs } = {}) {
  const rpm = maxRequestsPerMinute || defaultRpm();
  const minDelay = minimumDelayMs != null ? minimumDelayMs : Math.ceil(60000 / rpm);
  const now = Date.now();
  let b = buckets.get(host);
  if (!b) {
    b = { lastAt: 0, timestamps: [] };
    buckets.set(host, b);
  }
  // Purge timestamps older than 60s.
  b.timestamps = b.timestamps.filter((t) => now - t < 60000);

  // Enforce max-requests-per-minute.
  if (b.timestamps.length >= rpm) {
    const waitMs = 60000 - (now - b.timestamps[0]);
    if (waitMs > 0) await sleep(waitMs);
  }
  // Enforce minimum inter-request delay.
  const since = Date.now() - b.lastAt;
  if (b.lastAt && since < minDelay) await sleep(minDelay - since);

  const stamp = Date.now();
  b.lastAt = stamp;
  b.timestamps.push(stamp);
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

/** Test/reset helper — clears the in-memory rate-limit state. */
function _resetBuckets() {
  buckets.clear();
}

// ---------------------------------------------------------------------------
// safeFetch
// ---------------------------------------------------------------------------

/**
 * safeFetch(url, opts) — the ONLY sanctioned outbound HTTP entrypoint.
 *  - refuses unless ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true'
 *  - runs assertFetchAllowed (LinkedIn + SSRF)
 *  - sets a descriptive User-Agent from CRAWLER_USER_AGENT
 *  - throttles per host (token bucket)
 *  - follows redirects MANUALLY, re-checking each hop with assertFetchAllowed
 *
 * opts: { maxRequestsPerMinute, minimumDelayMs, maxRedirects, headers, ...fetchOpts }
 */
async function safeFetch(url, opts = {}) {
  if (process.env.ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH !== 'true') {
    throw new Error(
      'safeFetch: automated public web research is DISABLED. Set ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH=true to enable (spec §30).'
    );
  }

  const ua =
    process.env.CRAWLER_USER_AGENT ||
    'RENTV-Advertiser-Research/1.0 (+admin@rentv.com)';
  const maxRedirects = opts.maxRedirects != null ? opts.maxRedirects : 5;

  let current = url;
  for (let hop = 0; hop <= maxRedirects; hop++) {
    // Guard EVERY hop (initial + each redirect target).
    const { host } = await assertFetchAllowed(current);
    await throttleHost(host, opts);

    const headers = Object.assign({ 'user-agent': ua }, opts.headers || {});
    const fetchOpts = Object.assign({}, opts, {
      headers,
      redirect: 'manual', // we re-check redirects ourselves
    });
    delete fetchOpts.maxRedirects;
    delete fetchOpts.maxRequestsPerMinute;
    delete fetchOpts.minimumDelayMs;

    const res = await fetch(current, fetchOpts);

    // Redirect? re-check the Location target through the guard.
    if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
      if (hop === maxRedirects) {
        throw new Error(`safeFetch: too many redirects (>${maxRedirects}) from "${url}"`);
      }
      const next = new URL(res.headers.get('location'), current).toString();
      current = next; // loop re-guards it (blocks redirect-to-LinkedIn / private IP)
      continue;
    }
    return res;
  }
  throw new Error(`safeFetch: redirect loop exhausted for "${url}"`);
}

module.exports = {
  assertFetchAllowed,
  isBlockedIp,
  isLinkedInHost,
  safeFetch,
  _resetBuckets,
};