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 discoveredView on GitHub (pinned to 60398d6549)
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
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
- 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
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
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Access denied: Egress guard blocked private target IP ${ip}
- plugin egress: ${hostname} resolves to a blocked address (${
- Access denied: Egress guard blocked private target IPv6 ${ip
- Access denied: Localhost or internal domain target detected.
- Blocked request to restricted destination (DNS): ${reques
AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20).
Data as JSON: /api/errors/5181f29b3e573a7b.
Report an issue: GitHub.