Ctrl K
ComunidadGuías…

src/net-safety.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import net from 'node:net';
import { lookup as dnsLookup } from 'node:dns';

function ipv4ToInt(ip) {
  const parts = ip.split('.').map(n => Number(n));
  if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
  return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
}

function ipv4InCidr(ip, base, bits) {
  const n = ipv4ToInt(ip);
  const b = ipv4ToInt(base);
  if (n == null || b == null) return false;
  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
  return (n & mask) === (b & mask);
}

function expandIpv6(ip) {
  let input = ip.toLowerCase();
  const zone = input.indexOf('%');
  if (zone !== -1) input = input.slice(0, zone);
  if (input === '::') return Array(8).fill(0);
  const [leftRaw, rightRaw] = input.split('::');
  const left = leftRaw ? leftRaw.split(':').filter(Boolean) : [];
  const right = rightRaw ? rightRaw.split(':').filter(Boolean) : [];
  const parsePart = (part) => {
    if (part.includes('.')) {
      const n = ipv4ToInt(part);
      if (n == null) return [];
      return [(n >>> 16) & 0xffff, n & 0xffff];
    }
    return [parseInt(part || '0', 16)];
  };
  const leftNums = left.flatMap(parsePart);
  const rightNums = right.flatMap(parsePart);
  const missing = 8 - leftNums.length - rightNums.length;
  if (missing < 0) return null;
  return [...leftNums, ...Array(missing).fill(0), ...rightNums].map(n => Number.isFinite(n) ? n : 0);
}

function ipv6StartsWith(ip, prefix, bits) {
  const a = expandIpv6(ip);
  const p = expandIpv6(prefix);
  if (!a || !p) return false;
  let remaining = bits;
  for (let i = 0; i < 8 && remaining > 0; i++) {
    const take = Math.min(16, remaining);
    const mask = (0xffff << (16 - take)) & 0xffff;
    if ((a[i] & mask) !== (p[i] & mask)) return false;
    remaining -= take;
  }
  return true;
}

// ─── IPv4-in-IPv6 carrier prefixes ─────────────────────────
//
// isPrivateIp must block "private space", and these prefixes CARRY an IPv4
// address in their low bits. Blocking v4 private space while letting its
// carriers through is a bypass: measured 2026-09-13, all of
// `64:ff9b:1::7f00:1` (NAT64 local-use, RFC 8215), `2002:7f00:1::1` (6to4,
// RFC 3056), `2001:0:c0a8:101::1` (Teredo, RFC 4380) and `::127.0.0.1`
// (v4-compatible) returned false while embedding loopback/RFC1918. The surface
// is real: src/image.js:423/436 gates image fetches on this, and net-safety's
// own proxy checks (:150/:158/:190/:203/:206) throw ERR_PROXY_PRIVATE_IP on it.

/**
 * Last 32 bits of an expanded IPv6 as a dotted quad — for the prefixes whose
 * embedded IPv4 sits in the LOW 32 bits: NAT64 (well-known `64:ff9b::/96` and
 * local-use `64:ff9b:1::/48`) and v4-compatible `::a.b.c.d`.
 *
 * The words are 6 and 7, NOT 4 and 5. Getting that wrong is silent: the
 * extracted "v4" reads 0.0.0.0, the caller is told the embedded address is not
 * private, and `::127.0.0.1` stays reachable. The first version of this fix had
 * exactly that defect — the tests caught it.
 */
function trailingIpv4(parts) {
  if (!parts || parts.length !== 8) return null;
  return `${parts[6] >>> 8}.${parts[6] & 255}.${parts[7] >>> 8}.${parts[7] & 255}`;
}

/** 6to4 2002::/16 — the embedded v4 is bits 16..48, i.e. words 1 and 2. */
function sixtofourIpv4(parts) {
  if (!parts || parts.length !== 8) return null;
  return `${parts[1] >>> 8}.${parts[1] & 255}.${parts[2] >>> 8}.${parts[2] & 255}`;
}

/** Teredo 2001:0::/32 — the client v4 is bits 64..96, stored one's-complemented. */
function teredoClientIpv4(parts) {
  if (!parts || parts.length !== 8) return null;
  const b = [parts[6] >>> 8, parts[6] & 255, parts[7] >>> 8, parts[7] & 255].map((x) => (~x) & 255);
  return `${b[0]}.${b[1]}.${b[2]}.${b[3]}`;
}

/**
 * `::a.b.c.d` → `::aa:bb:cc:dd`, for the v4-COMPATIBLE branch only.
 *
 * Why: a dotted quad is ONE colon-separated field, so `'::127.0.0.1'.split(':')`
 * is `['', '', '127.0.0.1']` and expandIpv6 yields fewer than 8 words, which
 * makes trailingIpv4 return null. Note the asymmetry this hides: expandIpv6 DOES
 * splice a dotted quad into two words when it sits in the right-hand group of a
 * longer address — that is why the 6to4 / Teredo branches worked first try and
 * this one did not.
 */
function v4CompatibleToHexGroups(ip) {
  const m = ip.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
  if (!m) return null;
  const n = ipv4ToInt(m[1]);
  if (n == null) return null;
  const g = (x) => x.toString(16).padStart(4, '0');
  return `::${g((n >>> 16) & 0xffff)}:${g(n & 0xffff)}`;
}

function mappedIpv4(ip) {
  const m = ip.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
  if (m) return m[1];
  const parts = expandIpv6(ip);
  if (!parts) return null;
  if (parts.slice(0, 5).every(n => n === 0) && parts[5] === 0xffff) {
    return `${parts[6] >>> 8}.${parts[6] & 255}.${parts[7] >>> 8}.${parts[7] & 255}`;
  }
  return null;
}

export function isPrivateIp(address) {
  if (!address) return false;
  const ip = String(address).replace(/^\[|\]$/g, '').toLowerCase();
  const mapped = mappedIpv4(ip);
  if (mapped) return isPrivateIp(mapped);
  const family = net.isIP(ip);
  // v4-compatible \`::a.b.c.d\` carries an IPv4 in its low bits and is not a normal
  // address family — net.isIP may report 0 for exotic spellings, which would fall
  // straight through to \`return false\`. Handled before the family switch because
  // there is no family to switch on.
  if (family === 0) {
    const cp = expandIpv6(v4CompatibleToHexGroups(ip) || ip);
    if (cp && cp.slice(0, 6).every((n) => n === 0)) {
      const compat = trailingIpv4(cp);
      if (compat && compat !== '0.0.0.0') return isPrivateIp(compat);
    }
    return false;
  }
  if (family === 4) {
    return ipv4InCidr(ip, '0.0.0.0', 8)
      || ipv4InCidr(ip, '10.0.0.0', 8)
      || ipv4InCidr(ip, '100.64.0.0', 10)
      || ipv4InCidr(ip, '127.0.0.0', 8)
      || ipv4InCidr(ip, '169.254.0.0', 16)
      || ipv4InCidr(ip, '172.16.0.0', 12)
      || ipv4InCidr(ip, '192.168.0.0', 16)
      // M2 (Grok audit, SSRF W4): IETF special-use / non-routable ranges an
      // attacker could still aim an SSRF at. 192.0.0.0/24 is NOT covered by
      // 192.168/16 (different mask). TEST-NET-1/2/3 + benchmarking + multicast
      // + reserved are all non-public and must be blocked like private space.
      || ipv4InCidr(ip, '192.0.0.0', 24)      // IETF protocol assignments
      || ipv4InCidr(ip, '192.0.2.0', 24)      // TEST-NET-1 (RFC 5737)
      || ipv4InCidr(ip, '198.18.0.0', 15)     // benchmarking (RFC 2544)
      || ipv4InCidr(ip, '198.51.100.0', 24)   // TEST-NET-2
      || ipv4InCidr(ip, '203.0.113.0', 24)    // TEST-NET-3
      || ipv4InCidr(ip, '224.0.0.0', 4)       // multicast (224/4)
      || ipv4InCidr(ip, '240.0.0.0', 4);      // reserved / future use (240/4)
  }
  if (family === 6) {
    const parts = expandIpv6(v4CompatibleToHexGroups(ip) || ip);
    // Carrier prefixes whose low bits ARE an IPv4 address: recurse on the embedded
    // v4 so private/loopback cannot be smuggled in through them. The \`64:ff9b::\`
    // branch below stays the well-known NAT64 prefix; this set adds the local-use
    // NAT64 prefix plus 6to4 / Teredo / v4-compatible.
    if (parts && parts.slice(0, 6).every((n) => n === 0)) {
      const compat = trailingIpv4(parts);
      if (compat && compat !== '0.0.0.0') return isPrivateIp(compat);
    }
    if (parts && parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001) {
      const nat64Local = trailingIpv4(parts);
      if (nat64Local) return isPrivateIp(nat64Local);
    }
    if (ipv6StartsWith(ip, '2002::', 16)) {
      const six = sixtofourIpv4(parts);
      if (six) return isPrivateIp(six);
    }
    if (ipv6StartsWith(ip, '2001:0::', 32)) {
      const teredo = teredoClientIpv4(parts);
      if (teredo) return isPrivateIp(teredo);
    }
    return ip === '::' || ip === '::1'
      || ipv6StartsWith(ip, 'fc00::', 7)
      || ipv6StartsWith(ip, 'fe80::', 10)
      || ipv6StartsWith(ip, 'ff00::', 8)      // M2: IPv6 multicast
      // NAT64 well-known prefix embeds an IPv4 in the low 32 bits — recurse on
      // the embedded v4 so 64:ff9b::<private-v4> can't smuggle SSRF past us.
      || ipv6StartsWith(ip, '64:ff9b::', 96);
  }
  return false;
}

// ─── Trusted client IP (XFF hop counting) ──────────────────
//
// Single source of truth for "which IP do we trust as the real client",
// shared by src/caller-key.js (per-caller pool/cache scope + brute-force
// bucket) and src/dashboard/api.js (dashboard lockout bucket + audit logs).
// These two used to carry byte-identical private copies kept in sync only by
// a "MUST stay identical" comment — a drift here re-opens XFF-1 (an attacker
// with the shared key rotating a spoofed leftmost XFF to dodge the lockout) or
// splits the caller/dashboard views of the same client. (audit S4)
//
// Policy: X-Forwarded-For is attacker-controllable and IGNORED unless the
// operator opts in with TRUST_PROXY_X_FORWARDED_FOR=1. When trusted, the real
// client is counted from the RIGHT by the number of trusted proxy hops in
// front of us (TRUST_PROXY_HOPS, default 1) — trusted proxies APPEND the peer
// they received the connection from, so the leftmost value stays spoofable and
// must never be taken. A header shorter than the hop count can't be trusted →
// fall back to the socket peer.
//
// Both env vars are read LIVE on every call (not captured at module load) so a
// process that flips them — or a test that sets them per-case — sees the
// change, and so the two former call sites can't disagree by one reading a
// module-load const while the other reads live.
export function trustedProxyHops(env = process.env) {
  const raw = Number(env.TRUST_PROXY_HOPS);
  return Number.isInteger(raw) && raw >= 1 ? raw : 1;
}

export function trustedClientIp(req, env = process.env) {
  const remote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || '';
  if (env.TRUST_PROXY_X_FORWARDED_FOR !== '1') return remote;
  const parts = String(req?.headers?.['x-forwarded-for'] || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean);
  if (!parts.length) return remote;
  // The last `hops` entries were appended by our trusted proxy chain; the real
  // client IP is the entry just before them. If the header is shorter than the
  // configured hop count it can't be trusted — fall back to the socket peer.
  const idx = parts.length - trustedProxyHops(env);
  return (idx >= 0 ? parts[idx] : '') || remote;
}

export async function resolvePublicAddresses(hostname, lookupFn = dnsLookup) {
  const host = String(hostname || '').replace(/^\[|\]$/g, '');
  if (!host || host.toLowerCase() === 'localhost') throw new Error('ERR_PROXY_PRIVATE_HOST');
  if (net.isIP(host)) {
    if (isPrivateIp(host)) throw new Error('ERR_PROXY_PRIVATE_IP');
    return [{ address: host, family: net.isIP(host) }];
  }
  const result = await new Promise((resolve, reject) => {
    lookupFn(host, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs));
  });
  const addrs = Array.isArray(result) ? result : [result];
  for (const a of addrs) {
    if (isPrivateIp(a.address)) throw new Error('ERR_PROXY_PRIVATE_IP');
  }
  return addrs;
}

export async function validateHostFormat(hostname, lookupFn = dnsLookup) {
  const host = String(hostname || '').replace(/^\[|\]$/g, '');
  if (!host) throw new Error('ERR_INVALID_HOST');
  if (net.isIP(host)) {
    return [{ address: host, family: net.isIP(host) }];
  }
  const result = await new Promise((resolve, reject) => {
    lookupFn(host, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs));
  });
  return Array.isArray(result) ? result : [result];
}

// Resolve a proxy host and return a single VALIDATED IP literal to dial. The
// point is to close the TOCTOU / DNS-rebinding gap: validateProxyHost() resolved
// the name once for its check, but the later net.connect(host) / http CONNECT
// re-resolved it — a second lookup an attacker's DNS can answer with a private
// IP. By connecting to the literal returned here, the socket performs NO further
// resolution, so the address we vetted is exactly the address we dial.
//
// When allowPrivate is true (ALLOW_PRIVATE_PROXY_HOSTS=1) we still resolve to a
// literal (keeping the "connect the vetted address" invariant) but skip the
// private-IP rejection, matching validateHostFormat's laxer policy.
export async function resolveProxyConnectHost(hostname, { allowPrivate = false, lookupFn = dnsLookup } = {}) {
  const host = String(hostname || '').replace(/^\[|\]$/g, '');
  if (!host) throw new Error('ERR_INVALID_HOST');
  // An IP literal cannot be rebound; still enforce the private-IP policy on it.
  if (net.isIP(host)) {
    if (!allowPrivate && isPrivateIp(host)) throw new Error('ERR_PROXY_PRIVATE_IP');
    return host;
  }
  if (!allowPrivate && host.toLowerCase() === 'localhost') throw new Error('ERR_PROXY_PRIVATE_HOST');
  const result = await new Promise((resolve, reject) => {
    lookupFn(host, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs));
  });
  const addrs = (Array.isArray(result) ? result : [result]).filter(a => a && a.address);
  if (!addrs.length) throw new Error('ERR_PROXY_DNS_EMPTY');
  if (!allowPrivate) {
    // Reject if ANY resolved address is private (a rebinding answer often mixes
    // a public and a private A record); then dial the first public one.
    for (const a of addrs) {
      if (isPrivateIp(a.address)) throw new Error('ERR_PROXY_PRIVATE_IP');
    }
  }
  const pick = allowPrivate ? addrs[0] : (addrs.find(a => !isPrivateIp(a.address)) || addrs[0]);
  return pick.address;
}