{"record":{"id":"0ada6427eba2b0f1","repo":"santifer/career-ops","slug":"gem-untrusted-hostname-parsed-hostname-mus","errorCode":null,"errorMessage":"gem: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_GEM_HOSTS].join(', ')}","messagePattern":"gem: untrusted hostname \"(.+?)\" — must be one of: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/gem.mjs","lineNumber":127,"sourceCode":"  const body = htmlToText(posting?.descriptionHtml);\n  const outro = htmlToText(posting?.jobPostSectionHtml?.outroHtml);\n  const compensation = htmlToText(posting?.compensationHtml);\n\n  const text = [intro, body, outro].filter(Boolean).join('\\n\\n');\n  return compensation ? [text, `Compensation: ${compensation}`].filter(Boolean).join('\\n\\n') : text;\n}\n\n/** @param {string} url */\nfunction assertGemUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`gem: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`gem: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_GEM_HOSTS.has(parsed.hostname))\n    throw new Error(`gem: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_GEM_HOSTS].join(', ')}`);\n  return url;\n}\n\n/** Resolve an explicitly pinned URL for Gem's documented REST job-board API. */\nfunction resolveRestApiUrl(entry) {\n  const raw = typeof entry.api === 'string' ? entry.api : '';\n  if (!raw) return null;\n  let parsed;\n  try {\n    parsed = new URL(raw);\n  } catch {\n    return null;\n  }\n  if (parsed.protocol !== 'https:' || parsed.hostname !== 'api.gem.com') return null;\n  if (!/^\\/job_board\\/v0\\/[^/?#]+\\/job_posts\\/?$/.test(parsed.pathname)) return null;\n  return parsed;\n}\n","sourceCodeStart":109,"sourceCodeEnd":145,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/gem.mjs#L109-L145","documentation":"assertGemUrl enforces a hostname allowlist (ALLOWED_GEM_HOSTS) as an SSRF guard: only Gem's official board/API hosts may be fetched. A URL that parses and is HTTPS but whose hostname is not in the allowlist is rejected with this message listing the permitted hosts.","triggerScenarios":"Configuring a board with api: https://evil.example.com/... or a mirror/proxy host not in ALLOWED_GEM_HOSTS; a typo'd hostname (boards.gem.co instead of the allowed one); a vendor-supplied URL pointing at a custom domain.","commonSituations":"Adding a board whose URL lives on a newly introduced Gem domain added after this library's allowlist was written; typosquat or config-injection attempts (which the check exists to block); copy-pasting a third-party aggregator URL.","solutions":["Read the allowed hosts from the error message and correct the hostname to one of them.","If the host is legitimately Gem and genuinely new, update ALLOWED_GEM_HOSTS in providers/gem.mjs after verifying the domain (a deliberate code change, not a config tweak).","Double-check for typos — the allowlist match is exact, no subdomain wildcards.","If the URL is from an untrusted source, do not add it to the allowlist; the rejection is the guard working."],"exampleFix":"// before\n\"api\": \"https://job-boards.gem.co.evil.com/v1/boards/acme\"\n// after\n\"api\": \"https://job-boards.gem.co/v1/boards/acme\"","handlingStrategy":"validation","validationCode":"import { ALLOWED_GEM_HOSTS } from './providers/gem.mjs';\nconst host = new URL(u).hostname;\nif (!ALLOWED_GEM_HOSTS.has(host)) throw new Error(`Host ${host} not in Gem allowlist`);","typeGuard":"const isAllowedHost = (u) => { try { return ALLOWED_GEM_HOSTS.has(new URL(u).hostname); } catch { return false; } };","tryCatchPattern":"try { assertGemUrl(url); } catch (e) { if (e.message.includes('untrusted hostname')) console.error('SSRF guard tripped — verify this domain really belongs to Gem before allowlisting'); throw e; }","preventionTips":["Only add hosts to ALLOWED_GEM_HOSTS after verifying domain ownership","Never accept board URLs from untrusted input (postings, emails) without this check","Treat allowlist rejections as a security signal, not an inconvenience","Keep the allowlist minimal — exact hosts, no wildcards"],"tags":["security","ssrf","url","allowlist"],"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"}