santifer/career-ops · error

Access denied: Egress guard blocked private target IP ${ip}

Error message

Access denied: Egress guard blocked private target IP ${ip}

What it means

Thrown by upskill.mjs's SSRF egress guard when the hostname of the target URL resolves (via dns.resolve plus dns.lookup, cached in dnsCache) to a private-range IPv4 address: 127.x.x.x, 10.x.x.x, 192.168.x.x, 172.16-31.x.x, or 169.254.x.x. The goal is to stop DNS-based SSRF: an outward-looking hostname that actually points into the internal network. Resolution results are cached per hostname, so one blocked lookup poisons that hostname for the process lifetime.

Source

Thrown at upskill.mjs:812

  const hostname = url.hostname;

  if (hostname === 'localhost' || hostname.endsWith('.local')) {
    throw new Error('Access denied: Localhost or internal domain target detected.');
  }

  let addresses;
  if (dnsCache.has(hostname)) {
    addresses = dnsCache.get(hostname);
  } else {
    addresses = await dns.resolve(hostname).catch(() => []);
    const lookupRes = await dns.lookup(hostname).catch(() => null);
    if (lookupRes) addresses.push(lookupRes.address);
    dnsCache.set(hostname, addresses);
  }

  for (const ip of addresses) {
    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|169\.254\.)/.test(ip)) {
      throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);
    }
    if (ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:')) {
      throw new Error(`Access denied: Egress guard blocked private target IPv6 ${ip}`);
    }
  }
  return url.toString();
}

// --- CLI ---
// Everything below runs ONLY when upskill.mjs is the process entry point.
//
// Without this guard the module tail was unconditional, so `import
// { knownSkillsText } from './upskill.mjs'` re-parsed the IMPORTER's argv and ran
// one of these branches. That made the pure helpers above un-unit-testable despite
// their "exported for unit testing" docblocks — every assertion about them had to
// live inside --self-test.
//
// Under tests/ it also broke the harness, because test-all.mjs imports discovered

View on GitHub (pinned to 60398d6549)

Solutions

  1. Check what the hostname actually resolves to from this machine: `dig +short <hostname>` / `nslookup <hostname>` — if it returns a private IP, use the truly public posting URL
  2. If a VPN or split-horizon DNS is rewriting the name, disconnect or query an external resolver to confirm the real address
  3. Flush the process's view by re-running the command — dnsCache is per-process, so fixing DNS resolution and re-running clears the block
  4. Only after confirming the target is legitimately public, adjust local DNS/hosts so the name resolves to its public IP

Example fix

# before
node upskill.mjs --url-text https://ats.internal.example/jobs/99   # resolves to 10.0.0.5 -> blocked
# after
node upskill.mjs --url-text https://ats.example.com/jobs/99    # public A record
Defensive patterns

Strategy: validation

Validate before calling

import { isPrivateV4 } from 'ip-bigint'; // or a small regex twin of the guard
const PRIVATE4 = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|169\.254\.)/;
async function assertPublicHost(hostname) {
  const dns = await import('dns/promises');
  const addrs = await dns.resolve(hostname).catch(() => []);
  const lo = await dns.lookup(hostname).catch(() => null);
  if (lo) addrs.push(lo.address);
  if (addrs.some((a) => PRIVATE4.test(a))) throw new Error(`private target: ${hostname}`);
}

Type guard

function isPrivateIPv4(ip) {
  return /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|169\.254\.)/.test(ip);
}

Try / catch

try {
  await validateUrlSecurity(url);
} catch (err) {
  if (String(err.message).includes('Egress guard blocked private target IP')) {
    // DNS resolves internally: fix resolver or switch URL — retrying unchanged will fail again
    throw new Error(`hostname resolves privately, refusing: ${url}`);
  }
  throw err;
}

Prevention

When it happens

Trigger: Any --url-text/bare-URL input whose DNS A record is in a private range — e.g. a wildcard DNS zone that maps unknown names to 192.168.1.1, a split-horizon corporate DNS returning 10.x for an intranet host, or an /etc/hosts style entry via dns.lookup. Also fires per-request in the Playwright route handler when a page subresource or redirect resolves privately.

Common situations: Corporate laptops with search-domain DNS hijacking (NXDOMAIN redirected to a router/internal IP); ISP wildcard DNS; split-horizon DNS where the same name is public outside and private inside the VPN; testing against LAN-hosted mirrors.

Understand the failure class

Related errors


AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20). Data as JSON: /api/errors/5181f29b3e573a7b. Report an issue: GitHub.