{"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":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_engine.mjs","lineNumber":374,"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":356,"sourceCodeEnd":392,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/plugins/_engine.mjs#L356-L392","documentation":"Thrown by the guardedFetch host check in plugins/_engine.mjs when a plugin egress URL's protocol is not https:, unless it is an explicitly opted-in loopback HTTP host (allowsLocalhost + http: + a loopback hostname, to support local-AI providers like Ollama/LM Studio on http://localhost:11434). This enforces TLS for all plugin network egress by default.","triggerScenarios":"A plugin calls ctx.fetch('http://api.example.com/...') without TLS; a redirect lands on an http:// target; a plugin targets a local-AI server but allowsLocalhost was not enabled for it.","commonSituations":"Plugin author used http:// during dev and forgot to switch to https://; a third-party endpoint redirects from https to http; local Ollama/LM Studio integration without opting in allowsLocalhost in the manifest/config.","solutions":["Change the plugin's request URL to its https:// equivalent.","For a legitimate local-AI provider, declare allowsLocalhost in the plugin's network permission so http:// to loopback is permitted.","Ensure no redirect downgrades https to http (the guard re-checks each hop).","Review the plugin manifest's allowedHosts/network config."],"exampleFix":"// before\nawait ctx.fetch('http://api.example.com/data');\n// after\nawait ctx.fetch('https://api.example.com/data');","handlingStrategy":"validation","validationCode":"function isHttpsOrAllowedLoopback(s, { allowsLocalhost } = {}) {\n  const u = new URL(s);\n  if (u.protocol === 'https:') return true;\n  const loopback = /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|\\[?::1\\]?)$/i.test(u.hostname);\n  return allowsLocalhost && u.protocol === 'http:' && loopback;\n}","typeGuard":"/** Narrows a URL string to one the plugin egress guard will accept. */\nfunction isAllowedEgressUrl(s, { allowsLocalhost } = {}) {\n  if (typeof s !== 'string') return false;\n  let u;\n  try { u = new URL(s); } catch { return false; }\n  if (u.protocol === 'https:') return true;\n  const loopback = /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|\\[?::1\\]?)$/i.test(u.hostname);\n  return !!(allowsLocalhost && u.protocol === 'http:' && loopback);\n}","tryCatchPattern":null,"preventionTips":["Default all plugin fetches to https://.","Only opt in allowsLocalhost for genuine local-AI providers (Ollama/LM Studio).","Audit redirect targets — the guard re-checks each hop and will throw on a downgrade."],"tags":["plugins","security","tls","egress","ssrf"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}