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

  1. Replace the URL with the public careers subdomain — internal/staging links are intentionally blocked.
  2. If testing locally against a localhost dev server, that target is unsupported by design; point at a deployed URL instead.
  3. Investigate DNS rebinding if the hostname is genuinely public: check `dig hostname` returns only public IPs.
  4. 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

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

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/78bfe81bde78a64f. Report an issue: GitHub.