santifer/career-ops · error

Access denied: Egress guard blocked private target IPv6 ${ip

Error message

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

What it means

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.

Source

Thrown at upskill.mjs:815

    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
// suites IN-PROCESS and they therefore share its argv. Both branches were
// reachable, and both were measured by pinning isMain to true:
//   - ordinary argv → the aggregate branch walked the tracker and every linked

View on GitHub (pinned to 60398d6549)

Solutions

  1. Check AAAA records explicitly: `dig AAAA <hostname>` — confirm whether the name maps into ::1/fe80::/fc00::/fd00::
  2. Use the public posting URL whose AAAA record is a global unicast address, or force the IPv4 public name
  3. If DNS64/ULA is rewriting resolution, fix resolver configuration or query external resolvers, then re-run (dnsCache is per-process)
  4. 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

Example fix

# before
node upskill.mjs --url-text https://mirror.internal.example/jd   # AAAA fd00::5 -> blocked
# after
node upskill.mjs --url-text https://example.com/careers/123
Defensive patterns

Strategy: validation

Validate before calling

function isPrivateIPv6(ip) {
  return ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:');
}
import { lookup } from 'dns/promises';
const { address: first } = await lookup(hostname, { all: true }).then((r) => ({ address: r[0]?.address })) .catch(() => ({}));
if (first && isPrivateIPv6(first)) throw new Error(`private IPv6 target: ${first}`);

Type guard

function isPrivateIPv6(ip) {
  const lb = ip.toLowerCase();
  return lb === '::1' || lb.startsWith('fe80:') || lb.startsWith('fc00:') || lb.startsWith('fd00:');
}

Try / catch

try {
  await validateUrlSecurity(url);
} catch (err) {
  if (String(err.message).includes('blocked private target IPv6')) {
    throw new Error(`IPv6-private resolution refused: ${url}`); // policy: never retry internal targets
  }
  throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


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