santifer/career-ops · error · 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 validateUrlSecurity() in liveness-browser.mjs — the SSRF egress guard — when any resolved IP for the target hostname matches PRIVATE_HOST_PATTERNS (localhost, 127/8, 10/8, 192.168/16, 172.16-31/12, 169.254/16 link-local, ::1, ::, fc00::/7 ULA, fe80::/10 link-local). This is fail-closed defense-in-depth: even though URLs come from the user's own config, the liveness checker refuses to fetch private/internal/cloud-metadata targets to prevent SSRF and internal-network probing.
Source
Thrown at liveness-browser.mjs:204
return addresses;
} catch (err) {
dnsCache.set(hostname, err);
throw err;
}
}
async function validateUrlSecurity(urlString) {
const url = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);
const hostname = url.hostname;
const host = normalizeHost(hostname);
const addresses = await resolveDnsCached(host);
for (const ip of addresses) {
const norm = normalizeHost(ip);
const mapped = extractMappedIPv4(norm);
const candidates = mapped ? [norm, mapped] : [norm];
for (const candidate of candidates) {
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(candidate))) {
throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);
}
}
}
}
export async function checkUrlLiveness(page, url, { extraSettleMs = 0 } = {}) {
const guardError = rejectPrivateOrInvalid(url);
if (guardError) {
return { result: 'uncertain', code: guardError.code, reason: guardError.reason };
}
if (page) {
page._blockedByGuard = null;
}
if (page && typeof page.route === 'function' && !page._routeInterceptorRegistered) {
page._routeInterceptorRegistered = true;
await page.route('**/*', async (route) => {
const requestUrl = route.request().url();
const errGuard = rejectPrivateOrInvalid(requestUrl);View on GitHub (pinned to 9b17a8ac97)
Solutions
- Replace the URL with the public careers subdomain — internal/staging links are intentionally blocked.
- If testing locally against a localhost dev server, that target is unsupported by design; point at a deployed URL instead.
- Investigate DNS rebinding if the hostname is genuinely public: check `dig hostname` returns only public IPs.
- Do NOT disable the guard — widen it only by changing career-ops config knowingly, never by editing the patterns to bypass.
Example fix
// before (in pipeline.md) http://localhost:3000/jobs/123 // throws: Access denied: Egress guard blocked private target IP 127.0.0.1 // after https://careers.company.com/jobs/123
Defensive patterns
Strategy: validation
Validate before calling
function isAllowedEgress(urlStr) {
let u;
try { u = new URL(urlStr); } catch { return false; }
const h = u.hostname.toLowerCase();
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
return !(h === 'localhost' || h === '::1' || h.endsWith('.local') ||
/^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h) ||
/^169\.254\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h));
}
if (!isAllowedEgress(url)) skip('Refusing private/loopback URL'); Try / catch
try {
await checkUrlLiveness(page, url);
} catch (e) {
if (e.message.includes('Egress guard blocked')) {
// internal/localhost URL — never bypass the guard; fix the source URL instead
reportBlockedUrl(url);
} else throw e;
} Prevention
- Never put http://localhost or internal IPs in portals.yml/pipeline.md — use the public careers URL.
- Run a lint pass over portal URLs rejecting private/loopback hosts.
- Treat DNS-rebinding (public host → private IP) as a security signal, not a bug to bypass.
- Do not weaken PRIVATE_HOST_PATTERNS to 'fix' this; widen config knowingly instead.
When it happens
Trigger: A pipeline/portal URL whose hostname resolves (directly or via DNS rebinding) to a private, loopback, link-local, or ULA address. The guard iterates every resolved IP, normalizes IPv6, extracts any mapped IPv4, and tests each against PRIVATE_HOST_PATTERNS; a single match throws.
Common situations: A staging/internal careers URL accidentally pasted into pipeline.md; DNS rebinding where the public record flips to 127.0.0.1 mid-session; a hostname that round-robins to an internal IP; a portal.yml entry using http://localhost:something during local testing and left in.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- DNS resolution returned no addresses for ${hostname}
- Invalid URL: ${url}
- Refusing non-HTTP(S) URL: ${url}
- Refusing private/loopback host: ${host}
- plugin egress to ${hostname} is blocked (private/loopback/me
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/78bfe81bde78a64f.
Report an issue: GitHub.