{"record":{"id":"81844cb0f7866812","repo":"santifer/career-ops","slug":"plugin-egress-to-hostname-is-blocked-private-l","errorCode":null,"errorMessage":"plugin egress to ${hostname} is blocked (private/loopback/metadata range)","messagePattern":"plugin egress to (.+?) is blocked \\(private/loopback/metadata range\\)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"plugins/_net.mjs","lineNumber":81,"sourceCode":"  if (a >= 224) return true;                      // multicast / reserved\n  return false;\n}\n\nconst LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);\n\n/**\n * Resolve a hostname and reject if ANY resolved address is blocked. Returns the\n * validated addresses. Throws on a blocked or unresolvable host.\n * @param {string} hostname\n * @param {{ allowsLocalhost?: boolean }} [opts]\n * @returns {Promise<string[]>}\n */\nexport async function resolveAndValidate(hostname, { allowsLocalhost = false } = {}) {\n  // An IP literal host: validate directly (no DNS).\n  if (isIP(hostname)) {\n    if (isBlockedIp(hostname)) {\n      if (allowsLocalhost && isLoopbackLiteral(hostname)) return [hostname];\n      throw new Error(`plugin egress to ${hostname} is blocked (private/loopback/metadata range)`);\n    }\n    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)) {","sourceCodeStart":63,"sourceCodeEnd":99,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugins/_net.mjs#L63-L99","documentation":"Thrown by `resolveAndValidate` (plugins/_net.mjs:81) when the destination is an IP literal (no DNS needed) and that literal falls in a blocked range. Blocked ranges include loopback (127/8, ::1), RFC1918 private (10/8, 172.16/12, 192.168/16), link-local + cloud metadata (169.254/16, incl. 169.254.169.254), CGNAT (100.64/10), unspecified, and IPv4-mapped-IPv6 variants. This is SSRF egress protection: a plugin's fetch is refused before it reaches an internal/metadata target. The `allowsLocalhost` opt-in bypasses only loopback literals, not private/metadata.","triggerScenarios":"A plugin entry (portals.yml) or its fetch target resolves to a raw IP like `http://10.0.0.5/...`, `http://169.254.169.254/latest/meta-data/`, or `http://192.168.1.1`. Since the host is already an IP, resolveAndValidate validates it directly and throws because it is in a blocked range, unless `allowsLocalhost` is set AND it is a loopback literal.","commonSituations":"A misconfigured plugin pointing at an internal service IP; an attempt (benign or hostile) to reach the AWS/GCP/Azure metadata endpoint via a plugin; a plugin targeting a private network resource during local dev without the localhost opt-in; a provider whose base URL was mistakenly set to a private IP.","solutions":["Point the plugin/provider at a public hostname instead of a raw private/metadata IP.","If the target is genuinely a local service (e.g. Ollama at 127.0.0.1), set the provider manifest's `allowsLocalhost: true` and use the loopback literal or 'localhost'.","Never target link-local (169.254.x) or RFC1918 ranges from a plugin — these are always blocked for SSRF safety; route through an allowed public endpoint instead.","Audit portals.yml and any plugin-supplied URLs for raw IP literals."],"exampleFix":"// before — portals.yml\n- name: internal-jobs\n  url: http://10.0.0.5/api/jobs   # RFC1918 → blocked\n// after — use the public hostname, or opt into localhost for a local AI provider\n- name: ollama\n  provider: ollama\n  base_url: http://127.0.0.1:11434   # allowed because manifest sets allowsLocalhost:true","handlingStrategy":"validation","validationCode":"import { isBlockedIp } from './plugins/_net.mjs';\nimport { isIP } from 'node:net';\n// Reject raw private/metadata IPs in config before they reach egress validation.\nfunction assertPublicIpLiteral(host) {\n  if (isIP(host) && isBlockedIp(host)) {\n    throw new Error(`Refusing private/metadata IP literal in config: ${host}`);\n  }\n}\nfor (const entry of portals) assertPublicIpLiteral(new URL(entry.url).hostname);","typeGuard":null,"tryCatchPattern":"try {\n  await resolveAndValidate(hostname, { allowsLocalhost: manifest.allowsLocalhost });\n} catch (err) {\n  if (/blocked \\(private\\/loopback\\/metadata/.test(err.message)) {\n    console.error(`SSRF guard rejected target: ${err.message}`);\n    // do not retry; this is a config/security issue\n    throw err;\n  }\n}","preventionTips":["Never put raw private/metadata IPs in plugin URLs.","Set `allowsLocalhost: true` only for genuine local-AI providers, and only loopback is then permitted."],"tags":["network","ssrf","security","egress","plugin"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}