{"record":{"id":"78bfe81bde78a64f","repo":"santifer/career-ops","slug":"access-denied-egress-guard-blocked-private-target","errorCode":null,"errorMessage":"Access denied: Egress guard blocked private target IP ${ip}","messagePattern":"Access denied: Egress guard blocked private target IP (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"liveness-browser.mjs","lineNumber":204,"sourceCode":"    return addresses;\n  } catch (err) {\n    dnsCache.set(hostname, err);\n    throw err;\n  }\n}\n\nasync function validateUrlSecurity(urlString) {\n  const url = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);\n  const hostname = url.hostname;\n  const host = normalizeHost(hostname);\n  const addresses = await resolveDnsCached(host);\n  for (const ip of addresses) {\n    const norm = normalizeHost(ip);\n    const mapped = extractMappedIPv4(norm);\n    const candidates = mapped ? [norm, mapped] : [norm];\n    for (const candidate of candidates) {\n      if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(candidate))) {\n        throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);\n      }\n    }\n  }\n}\n\nexport async function checkUrlLiveness(page, url, { extraSettleMs = 0 } = {}) {\n  const guardError = rejectPrivateOrInvalid(url);\n  if (guardError) {\n    return { result: 'uncertain', code: guardError.code, reason: guardError.reason };\n  }\n  if (page) {\n    page._blockedByGuard = null;\n  }\n  if (page && typeof page.route === 'function' && !page._routeInterceptorRegistered) {\n    page._routeInterceptorRegistered = true;\n    await page.route('**/*', async (route) => {\n      const requestUrl = route.request().url();\n      const errGuard = rejectPrivateOrInvalid(requestUrl);","sourceCodeStart":186,"sourceCodeEnd":222,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/liveness-browser.mjs#L186-L222","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before (in pipeline.md)\nhttp://localhost:3000/jobs/123\n// throws: Access denied: Egress guard blocked private target IP 127.0.0.1\n\n// after\nhttps://careers.company.com/jobs/123","handlingStrategy":"validation","validationCode":"function isAllowedEgress(urlStr) {\n  let u;\n  try { u = new URL(urlStr); } catch { return false; }\n  const h = u.hostname.toLowerCase();\n  if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;\n  return !(h === 'localhost' || h === '::1' || h.endsWith('.local') ||\n    /^127\\./.test(h) || /^10\\./.test(h) || /^192\\.168\\./.test(h) ||\n    /^169\\.254\\./.test(h) || /^172\\.(1[6-9]|2\\d|3[01])\\./.test(h));\n}\nif (!isAllowedEgress(url)) skip('Refusing private/loopback URL');","typeGuard":null,"tryCatchPattern":"try {\n  await checkUrlLiveness(page, url);\n} catch (e) {\n  if (e.message.includes('Egress guard blocked')) {\n    // internal/localhost URL — never bypass the guard; fix the source URL instead\n    reportBlockedUrl(url);\n  } else throw e;\n}","preventionTips":["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."],"tags":["security","ssrf","network","liveness","validation"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}