{"record":{"id":"89fd92abe7275459","repo":"santifer/career-ops","slug":"plugin-egress-to-u-hostname-is-not-in-allowed","errorCode":null,"errorMessage":"plugin egress to \"${u.hostname}\" is not in allowedHosts [${[...allow].join(', ')}]","messagePattern":"plugin egress to \"(.+?)\" is not in allowedHosts \\[(.+?)\\]","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_engine.mjs","lineNumber":377,"sourceCode":" * the allowlist + per-hop re-validation + cross-host credential strip bound it.\n *\n * ADVISORY only: this binds a plugin that routes through ctx.fetch*, not one\n * that calls global fetch directly (see the trust note in README.md).\n *\n * @param {string[]} allowedHosts\n */\nfunction makeGuardedFetch(allowedHosts, { allowsLocalhost = false } = {}) {\n  const allow = new Set(allowedHosts);\n  const isLoopbackHost = (h) => /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|\\[?::1\\]?)$/i.test(h);\n  const hostOk = (u) => {\n    if (u.protocol !== 'https:') {\n      // Plain HTTP is allowed ONLY for an opted-in loopback host (local-AI\n      // providers like Ollama/LM Studio serve http://localhost:11434).\n      if (!(allowsLocalhost && u.protocol === 'http:' && isLoopbackHost(u.hostname))) {\n        throw new Error(`plugin egress must use HTTPS: ${u.href}`);\n      }\n    }\n    if (allow.size > 0 && !allow.has(u.hostname)) throw new Error(`plugin egress to \"${u.hostname}\" is not in allowedHosts [${[...allow].join(', ')}]`);\n  };\n  return async function guardedFetch(url, opts = {}) {\n    const { timeoutMs = 10_000, headers = {}, method = 'GET', body = null } = opts;\n    let current = new URL(url);\n    hostOk(current);\n    // SSRF: reject a host that resolves to a private/loopback/metadata address\n    // (re-checked on every redirect hop). Loopback allowed only when opted in.\n    await resolveAndValidate(current.hostname, { allowsLocalhost });\n    let reqHeaders = { ...headers };\n    for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n      const controller = new AbortController();\n      const timer = setTimeout(() => controller.abort(), timeoutMs);\n      let res;\n      try {\n        res = await fetch(current.href, {\n          method, headers: reqHeaders, body, redirect: 'manual', signal: controller.signal,\n        });\n      } finally {","sourceCodeStart":359,"sourceCodeEnd":395,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugins/_engine.mjs#L359-L395","documentation":"Thrown by the guardedFetch host check when the manifest declares a non-empty allowedHosts allowlist and the request's hostname is not in it. The allowlist pins each plugin to exactly the hosts it needs (least privilege); the error lists the allowed set so the gap is obvious.","triggerScenarios":"A plugin requests a host not declared in its manifest's allowedHosts; a redirect hops to an allowlisted-external host; the manifest lists api.example.com but the code hits cdn.example.com.","commonSituations":"Manifest allowlist out of date vs the plugin's actual calls; plugin hits a CDN or sibling subdomain not enumerated; a redirect leaves the allowed host set; typo in the allowlist entry.","solutions":["Add the missing hostname to the plugin manifest's allowedHosts (if the call is legitimate).","Fix the plugin code to call the already-allowed host instead.","Check redirects — the guard re-validates each hop, so a redirect target must also be allowlisted.","Re-audit the plugin's network surface before widening the allowlist."],"exampleFix":"// before: allowedHosts = ['api.example.com'], code hits cdn.example.com\nawait ctx.fetch('https://cdn.example.com/asset');\n// after: add cdn.example.com to manifest allowedHosts, or use the api host","handlingStrategy":"validation","validationCode":"function isAllowedHost(s, allowedHosts) {\n  const allow = new Set(allowedHosts);\n  if (allow.size === 0) return true;\n  return allow.has(new URL(s).hostname);\n}","typeGuard":"/** Narrows a URL to one whose hostname is in the plugin's allowlist. */\nfunction isAllowedHost(s, allowedHosts) {\n  if (typeof s !== 'string') return false;\n  const allow = new Set(allowedHosts);\n  if (allow.size === 0) return true;\n  let u;\n  try { u = new URL(s); } catch { return false; }\n  return allow.has(u.hostname);\n}","tryCatchPattern":null,"preventionTips":["Enumerate every host a plugin calls in its manifest allowedHosts.","Include CDN/asset hosts and any redirect targets.","Re-audit the plugin's network surface before widening the allowlist."],"tags":["plugins","security","allowlist","egress","least-privilege"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}