{"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":"Error","httpStatus":null,"severity":"error","filePath":"providers/gem.mjs","lineNumber":115,"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/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveBoardId(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return null;\n  let parsed;\n  try {\n    parsed = new URL(raw);\n  } catch {\n    return null;\n  }\n  if (parsed.hostname !== 'jobs.gem.com') return null;\n  const match = parsed.pathname.match(/^\\/([^/?#]+)/);\n  return match ? match[1] : null;\n}\n","sourceCodeStart":97,"sourceCodeEnd":133,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/gem.mjs#L97-L133","documentation":"gem.mjs throws this in assertGemUrl() when the URL is valid HTTPS but its hostname is not in ALLOWED_GEM_HOSTS (currently just 'jobs.gem.com'). It is the host-allowlist half of the SSRF guard; combined with redirect:'error' it pins every Gem request to jobs.gem.com. A live throw means GEM_API_URL's hostname drifted from the allowlist.","triggerScenarios":"GEM_API_URL was changed to a mirror/CNAME host (e.g. 'https://gem-mirror.example.com/...') without adding it to ALLOWED_GEM_HOSTS; a test calls assertGemUrl('https://evil.com/...'); the allowlist set and the constant were updated out of sync.","commonSituations":"Contributor adds a regional Gem host but edits only the constant; a security test harness throws foreign hostnames at the guard to verify it; an env override pointed the provider at a proxy host.","solutions":["Restore GEM_API_URL to https://jobs.gem.com/api/public/graphql/batch.","If a legitimately different Gem host must be used, add it to ALLOWED_GEM_HOSTS in the same change and document why.","For tests asserting the guard, keep the throw as the expected behavior rather than 'fixing' the URL."],"exampleFix":"// before\nconst GEM_API_URL = 'https://jobs-gql.gem.com/api/public/graphql/batch';\nconst ALLOWED_GEM_HOSTS = new Set(['jobs.gem.com']); // mismatch\n\n// after\nconst GEM_API_URL = 'https://jobs.gem.com/api/public/graphql/batch';\nconst ALLOWED_GEM_HOSTS = new Set(['jobs.gem.com']);","handlingStrategy":"validation","validationCode":"// Startup invariant: the Gem endpoint host must be in the allowlist.\nfunction checkGemHost() {\n  const u = new URL('https://jobs.gem.com/api/public/graphql/batch');\n  if (!ALLOWED_GEM_HOSTS.has(u.hostname)) throw new Error('gem host off allowlist');\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Keep ALLOWED_GEM_HOSTS and GEM_API_URL in the same edit when changing hosts.","Add a unit test: assert(ALLOWED_GEM_HOSTS.has(new URL(GEM_API_URL).hostname)).","Treat an unexpected throw here as a possible malicious constant override and audit the source."],"tags":["ssrf-guard","host-allowlist","url-validation","gem","config"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}