{"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":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_engine.mjs","lineNumber":394,"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":376,"sourceCodeEnd":412,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/plugins/_engine.mjs#L376-L412","documentation":"hostOk also enforces an allowlist: when the plugin declares allowedHosts (non-empty), any fetch whose URL hostname is not in that set throws before the request leaves the process. This is the least-privilege egress policy — plugins can only talk to hosts they explicitly declared.","triggerScenarios":"A plugin calling ctx.fetch() against a hostname not listed in its allowedHosts config; a provider plugin redirected/updated to a new API domain that was never added to the allowlist.","commonSituations":"A plugin fetching a CDN, webhook, or secondary API domain beyond its declared hosts; typos in allowedHosts entries; a plugin upgrade adding new endpoints while the config still lists old domains.","solutions":["Add the target hostname to the plugin's allowedHosts in its manifest/config (exact hostname match, no scheme or path)","Verify the hostname spelling matches the URL exactly (subdomains must be listed explicitly)","If the fetch is unexpected, audit the plugin code — a request you didn't anticipate may be exfiltrating data"],"exampleFix":"// config: allowedHosts: ['api.github.com']\n// before\nawait ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json');\n// Error: plugin egress to \"raw.githubusercontent.com\" is not in allowedHosts [api.github.com]\n// after: allowedHosts: ['api.github.com', 'raw.githubusercontent.com']\nawait ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json');","handlingStrategy":"validation","validationCode":"const u = new URL(target);\nconst allowed = pluginConfig.allowedHosts ?? [];\nif (allowed.length > 0 && !allowed.includes(u.hostname)) {\n  throw new Error(`host ${u.hostname} not in allowedHosts — add it to the plugin config first`);\n}","typeGuard":"const hostIsAllowed = (url, allow) => allow.length === 0 || allow.includes(new URL(url).hostname);","tryCatchPattern":"try { return await ctx.fetch(url); } catch (e) { const m = e.message.match(/plugin egress to \"([^\"]+)\" is not in allowedHosts/); if (m) { throw new Error(`${e.message} — add \"${m[1]}\" to allowedHosts or remove this call`); } throw e; }","preventionTips":["Declare every host a plugin will contact at manifest time, including CDNs and secondary APIs","Audit plugin upgrades for newly added endpoints and update allowedHosts","Treat unexpected allowlist errors as a security signal — verify the plugin isn't calling hosts you never approved"],"tags":["plugins","security","network","allowlist"],"backgroundTag":"permission-denied","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}