{"record":{"id":"0983afde2918d309","repo":"santifer/career-ops","slug":"plugin-egress-hostname-resolves-to-a-blocked-a","errorCode":null,"errorMessage":"plugin egress: ${hostname} resolves to a blocked address (${address}) — possible SSRF/rebinding","messagePattern":"plugin egress: (.+?) resolves to a blocked address \\((.+?)\\) — possible SSRF/rebinding","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"plugins/_net.mjs","lineNumber":101,"sourceCode":"    return [hostname];\n  }\n\n  if (allowsLocalhost && LOOPBACK_HOSTS.has(hostname.toLowerCase())) {\n    // Local-AI providers (Ollama/LM Studio). Resolve but allow loopback through.\n    return ['127.0.0.1'];\n  }\n\n  let addrs;\n  try {\n    addrs = await dnsLookup(hostname, { all: true });\n  } catch (err) {\n    throw new Error(`plugin egress: cannot resolve ${hostname} — ${err.message}`);\n  }\n  if (!addrs.length) throw new Error(`plugin egress: ${hostname} resolved to no addresses`);\n  for (const { address } of addrs) {\n    if (isBlockedIp(address)) {\n      if (allowsLocalhost && isLoopbackLiteral(address)) continue;\n      throw new Error(`plugin egress: ${hostname} resolves to a blocked address (${address}) — possible SSRF/rebinding`);\n    }\n  }\n  return addrs.map(a => a.address);\n}\n\nfunction isLoopbackLiteral(ip) {\n  if (ip === '::1') return true;\n  if (isIP(ip) === 4) return ip.split('.')[0] === '127';\n  return false;\n}\n","sourceCodeStart":83,"sourceCodeEnd":112,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugins/_net.mjs#L83-L112","documentation":"Thrown by `resolveAndValidate` (plugins/_net.mjs:101) when DNS resolves a hostname to at least one address in a blocked range (loopback/private/link-local/metadata/CGNAT/ULA). Even one blocked address among the resolved set triggers the throw — this is strict SSRF/rebinding protection. The message names the offending address and flags 'possible SSRF/rebinding' because a hostname resolving to a public AND a private address is a classic DNS-rebinding signature. `allowsLocalhost` bypasses only loopback literals, nothing else.","triggerScenarios":"A hostname resolves to a private/metadata IP (e.g. a rebinding domain that flips between public and 169.254.169.254), or to a mix of public and RFC1918 addresses. The loop over resolved addresses hits `isBlockedIp(address)` true and throws, after the loopback opt-in check.","commonSituations":"A DNS-rebinding attack domain; an internal hostname that legitimately resolves to a private IP but is being fetched by a plugin (not allowed); a provider whose DNS temporarily returns a private address due to a misconfigured record; split-horizon DNS where the internal view returns a private IP and the plugin runs inside that network.","solutions":["Do not point plugins at hostnames that resolve to private/metadata ranges — reconfigure to a public endpoint.","If the target is a genuine local service, use the loopback literal/hostname with `allowsLocalhost: true` in the provider manifest (this bypasses only loopback, not RFC1918/metadata).","Investigate DNS rebinding if a domain flips between public and private IPs — pin the resolver or use a trusted upstream.","Audit with `dig <hostname>` from the same host to see all resolved addresses."],"exampleFix":"// resolveAndValidate rejects: example.internal → 10.0.0.5\n// before — portals.yml\n- name: internal\n  url: https://example.internal/jobs\n// after — expose a public endpoint, or route local AI through a loopback opt-in\n- name: ollama\n  provider: ollama\n  base_url: http://127.0.0.1:11434","handlingStrategy":"validation","validationCode":"import { lookup } from 'node:dns/promises';\nimport { isBlockedIp } from './plugins/_net.mjs';\n// Detect DNS rebinding / private-resolution before the guarded fetch.\nasync function assertNoBlockedResolution(hostname) {\n  const addrs = (await lookup(hostname, { all: true })).map(a => a.address);\n  const blocked = addrs.filter(isBlockedIp);\n  if (blocked.length) {\n    throw new Error(`'${hostname}' resolves to blocked range ${blocked.join(',')} — possible rebinding; use a public endpoint.`);\n  }\n}\nawait assertNoBlockedResolution(hostname);","typeGuard":null,"tryCatchPattern":"try {\n  await resolveAndValidate(hostname);\n} catch (err) {\n  if (/possible SSRF\\/rebinding/.test(err.message)) {\n    // Security signal — never bypass; reconfigure to a public host.\n    throw err;\n  }\n}","preventionTips":["Never fetch hostnames that resolve to private/metadata ranges from plugins.","Pin DNS upstreams in environments prone to rebinding.","Audit resolved addresses with `dig` from the runtime host."],"tags":["network","ssrf","security","dns","rebinding","egress","plugin"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}