{"record":{"id":"0b0b64534a6941a4","repo":"santifer/career-ops","slug":"plugin-egress-must-use-https-u-href","errorCode":null,"errorMessage":"plugin egress must use HTTPS: ${u.href}","messagePattern":"plugin egress must use HTTPS: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_engine.mjs","lineNumber":391,"sourceCode":" * Posture note: core providers use redirect:'error' (reject ANY redirect). This\n * is the deliberately looser plugin posture — allowlist-pinned FOLLOW — because\n * keyed APIs (Notion/Google/Apify) legitimately 30x within their own host set;\n * 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, {","sourceCodeStart":373,"sourceCodeEnd":409,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/plugins/_engine.mjs#L373-L409","documentation":"The plugin sandbox's guarded fetch wrapper (hostOk) enforces HTTPS for all plugin network egress. Plain HTTP is permitted only when the plugin's allowsLocalhost opt-in is set AND the URL host is loopback (localhost, 127.x.x.x, ::1) — the carve-out for local AI providers like Ollama. Any other http:// URL throws this error before the request is made.","triggerScenarios":"A plugin calling ctx.fetch('http://example.com/...') (non-loopback host); or an http:// non-loopback URL even with allowsLocalhost enabled.","commonSituations":"A plugin pointing at an internal dev API over HTTP; hardcoded http:// URLs in plugin code after migrating to the sandbox; testing against a staging server without TLS.","solutions":["Change the plugin's URL to https://","Run a local TLS proxy or expose the service over HTTPS","If the service is genuinely local (Ollama/LM Studio), use http://localhost:<port> and ensure the plugin's allowsLocalhost opt-in is enabled in its manifest/config"],"exampleFix":"// before\nconst res = await ctx.fetch('http://api.example.com/data');\n// Error: plugin egress must use HTTPS: http://api.example.com/data\n// after\nconst res = await ctx.fetch('https://api.example.com/data');","handlingStrategy":"validation","validationCode":"const u = new URL(target);\nconst isLoopback = (h) => /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|\\[?::1\\]?)$/i.test(h);\nif (u.protocol !== 'https:' && !(allowsLocalhost && u.protocol === 'http:' && isLoopback(u.hostname))) {\n  throw new Error(`upgrade to HTTPS before calling ctx.fetch: ${u.href}`);\n}","typeGuard":"const isHttpsOrAllowedLoopback = (u, allowsLocalhost) => u.protocol === 'https:' || (allowsLocalhost && u.protocol === 'http:' && /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|\\[?::1\\]?)$/i.test(u.hostname));","tryCatchPattern":"try { return await ctx.fetch(url); } catch (e) { if (String(e.message).startsWith('plugin egress must use HTTPS')) { throw new Error(`${e.message} — switch the endpoint to https:// or run a local TLS proxy`); } throw e; }","preventionTips":["Default all plugin endpoints to https:// from the start","Only use http:// for genuinely local services (Ollama/LM Studio) on localhost","Check the egress policy in the plugin manifest before adding new endpoints"],"tags":["plugins","security","network","https"],"backgroundTag":"invalid-url","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"}