{"record":{"id":"83c6b17e47685658","repo":"santifer/career-ops","slug":"access-denied-egress-guard-blocked-private-target-83c6b1","errorCode":null,"errorMessage":"Access denied: Egress guard blocked private target IPv6 ${ip}","messagePattern":"Access denied: Egress guard blocked private target IPv6 (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"upskill.mjs","lineNumber":815,"sourceCode":"    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\n// suites IN-PROCESS and they therefore share its argv. Both branches were\n// reachable, and both were measured by pinning isMain to true:\n//   - ordinary argv → the aggregate branch walked the tracker and every linked","sourceCodeStart":797,"sourceCodeEnd":833,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/upskill.mjs#L797-L833","documentation":"The IPv6 arm of upskill.mjs's SSRF egress guard: thrown when the target hostname resolves to ::1 (IPv6 loopback), a link-local fe80: address, or unique-local fc00:/fd00: addresses. It runs in the same address loop right after the IPv4 regex check and exists because IPv6 private ranges are missed by the IPv4-only regex. Mixed output from dns.resolve/dns.lookup means one AAAA record is enough to trip it.","triggerScenarios":"A --url-text/bare-URL hostname with an AAAA record for ::1, fe80::, fc00::, or fd00:: — e.g. a dual-stack intranet name, a DNS64/NAT64 environment mapping names into fd00::/8 ULA space, or a hosts entry with an IPv6 literal. Also fires per-request inside the Playwright route handler for subresources/redirects resolving to IPv6 private space.","commonSituations":"IPv6-enabled corporate networks using ULA (fd00::/8) addressing; DNS64 setups; local hosts files with ::1 entries for named vhosts; CI runners with IPv6-preferencing resolvers.","solutions":["Check AAAA records explicitly: `dig AAAA <hostname>` — confirm whether the name maps into ::1/fe80::/fc00::/fd00::","Use the public posting URL whose AAAA record is a global unicast address, or force the IPv4 public name","If DNS64/ULA is rewriting resolution, fix resolver configuration or query external resolvers, then re-run (dnsCache is per-process)","Note the guard intentionally does not cover every IPv6 private form (e.g. IPv4-mapped ::ffff:10.0.0.1) — still never feed it internal targets on purpose"],"exampleFix":"# before\nnode upskill.mjs --url-text https://mirror.internal.example/jd   # AAAA fd00::5 -> blocked\n# after\nnode upskill.mjs --url-text https://example.com/careers/123","handlingStrategy":"validation","validationCode":"function isPrivateIPv6(ip) {\n  return ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:');\n}\nimport { lookup } from 'dns/promises';\nconst { address: first } = await lookup(hostname, { all: true }).then((r) => ({ address: r[0]?.address })) .catch(() => ({}));\nif (first && isPrivateIPv6(first)) throw new Error(`private IPv6 target: ${first}`);","typeGuard":"function isPrivateIPv6(ip) {\n  const lb = ip.toLowerCase();\n  return lb === '::1' || lb.startsWith('fe80:') || lb.startsWith('fc00:') || lb.startsWith('fd00:');\n}","tryCatchPattern":"try {\n  await validateUrlSecurity(url);\n} catch (err) {\n  if (String(err.message).includes('blocked private target IPv6')) {\n    throw new Error(`IPv6-private resolution refused: ${url}`); // policy: never retry internal targets\n  }\n  throw err;\n}","preventionTips":["Check AAAA records (dig AAAA) for dual-stack hostnames in ULA-heavy networks","Beware DNS64 environments mapping names into fd00::/8","Per-process DNS cache: restart the process after changing DNS entries"],"tags":["ssrf","security","ipv6","dns","egress-guard"],"backgroundTag":"ssrf-protection","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}