{"record":{"id":"5181f29b3e573a7b","repo":"santifer/career-ops","slug":"access-denied-egress-guard-blocked-private-target-5181f2","errorCode":null,"errorMessage":"Access denied: Egress guard blocked private target IP ${ip}","messagePattern":"Access denied: Egress guard blocked private target IP (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"upskill.mjs","lineNumber":812,"sourceCode":"  const hostname = url.hostname;\n\n  if (hostname === 'localhost' || hostname.endsWith('.local')) {\n    throw new Error('Access denied: Localhost or internal domain target detected.');\n  }\n\n  let addresses;\n  if (dnsCache.has(hostname)) {\n    addresses = dnsCache.get(hostname);\n  } else {\n    addresses = await dns.resolve(hostname).catch(() => []);\n    const lookupRes = await dns.lookup(hostname).catch(() => null);\n    if (lookupRes) addresses.push(lookupRes.address);\n    dnsCache.set(hostname, addresses);\n  }\n\n  for (const ip of addresses) {\n    if (/^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|169\\.254\\.)/.test(ip)) {\n      throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);\n    }\n    if (ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:')) {\n      throw new Error(`Access denied: Egress guard blocked private target IPv6 ${ip}`);\n    }\n  }\n  return url.toString();\n}\n\n// --- CLI ---\n// Everything below runs ONLY when upskill.mjs is the process entry point.\n//\n// Without this guard the module tail was unconditional, so `import\n// { knownSkillsText } from './upskill.mjs'` re-parsed the IMPORTER's argv and ran\n// one of these branches. That made the pure helpers above un-unit-testable despite\n// their \"exported for unit testing\" docblocks — every assertion about them had to\n// live inside --self-test.\n//\n// Under tests/ it also broke the harness, because test-all.mjs imports discovered","sourceCodeStart":794,"sourceCodeEnd":830,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/upskill.mjs#L794-L830","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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","If a VPN or split-horizon DNS is rewriting the name, disconnect or query an external resolver to confirm the real address","Flush the process's view by re-running the command — dnsCache is per-process, so fixing DNS resolution and re-running clears the block","Only after confirming the target is legitimately public, adjust local DNS/hosts so the name resolves to its public IP"],"exampleFix":"# before\nnode upskill.mjs --url-text https://ats.internal.example/jobs/99   # resolves to 10.0.0.5 -> blocked\n# after\nnode upskill.mjs --url-text https://ats.example.com/jobs/99    # public A record","handlingStrategy":"validation","validationCode":"import { isPrivateV4 } from 'ip-bigint'; // or a small regex twin of the guard\nconst PRIVATE4 = /^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|169\\.254\\.)/;\nasync function assertPublicHost(hostname) {\n  const dns = await import('dns/promises');\n  const addrs = await dns.resolve(hostname).catch(() => []);\n  const lo = await dns.lookup(hostname).catch(() => null);\n  if (lo) addrs.push(lo.address);\n  if (addrs.some((a) => PRIVATE4.test(a))) throw new Error(`private target: ${hostname}`);\n}","typeGuard":"function isPrivateIPv4(ip) {\n  return /^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|169\\.254\\.)/.test(ip);\n}","tryCatchPattern":"try {\n  await validateUrlSecurity(url);\n} catch (err) {\n  if (String(err.message).includes('Egress guard blocked private target IP')) {\n    // DNS resolves internally: fix resolver or switch URL — retrying unchanged will fail again\n    throw new Error(`hostname resolves privately, refusing: ${url}`);\n  }\n  throw err;\n}","preventionTips":["Resolve candidate URLs with dig/nslookup before feeding them in","Watch for VPN split-horizon DNS and wildcard-ISP DNS when a public name blocks","Remember resolution results are cached per process — fix DNS, then re-run in a fresh process"],"tags":["ssrf","security","dns","private-ip","egress-guard"],"backgroundTag":"ssrf-protection","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}