{"record":{"id":"7816517c210bb1a1","repo":"santifer/career-ops","slug":"greenhouse-untrusted-hostname-parsed-hostname","errorCode":null,"errorMessage":"greenhouse: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}","messagePattern":"greenhouse: untrusted hostname \"(.+?)\" — must be one of: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/greenhouse.mjs","lineNumber":24,"sourceCode":"\nconst ALLOWED_GREENHOUSE_HOSTS = new Set([\n  'boards-api.greenhouse.io',\n  'boards.greenhouse.io',\n  'job-boards.greenhouse.io',\n  'job-boards.eu.greenhouse.io',\n]);\n\n/** @param {string} url */\nfunction assertGreenhouseUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`greenhouse: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`greenhouse: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_GREENHOUSE_HOSTS.has(parsed.hostname))\n    throw new Error(`greenhouse: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}`);\n  return url;\n}\n\n/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveApiUrl(entry) {\n  if (entry.api) {\n    assertGreenhouseUrl(entry.api);\n    return entry.api;\n  }\n  const url = entry.careers_url || '';\n  const match = url.match(/job-boards(?:\\.eu)?\\.greenhouse\\.io\\/([^/?#]+)/);\n  if (match) return `https://boards-api.greenhouse.io/v1/boards/${match[1]}/jobs`;\n  return null;\n}\n\n// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.\nfunction toEpochMs(value) {\n  if (!value) return undefined;","sourceCodeStart":6,"sourceCodeEnd":42,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/greenhouse.mjs#L6-L42","documentation":"greenhouse.mjs throws this in assertGreenhouseUrl() when the URL is valid HTTPS but its hostname is not in ALLOWED_GREENHOUSE_HOSTS ('boards-api.greenhouse.io', 'boards.greenhouse.io', 'job-boards.greenhouse.io', 'job-boards.eu.greenhouse.io'). It is the host-allowlist half of the SSRF guard; with redirect:'error' it pins every Greenhouse request to a known-good Greenhouse host. A live throw means entry.api (or a derived URL) targets a host outside that set.","triggerScenarios":"entry.api is set to a non-Greenhouse host (a proxy, a mirror, or an internal host — the SSRF case the guard exists for); a regional Greenhouse host not yet in the allowlist; a test calls assertGreenhouseUrl('https://evil.com/...').","commonSituations":"Operator points api at a corporate proxy or a different ATS by mistake; a new Greenhouse regional domain shipped but is not allowlisted; a malicious/injected api value targets an internal host.","solutions":["Set api to a URL on boards-api.greenhouse.io (e.g. https://boards-api.greenhouse.io/v1/boards/<slug>/jobs) or remove api and provide a careers_url on job-boards.greenhouse.io so the provider derives the correct API URL.","If a legitimately new Greenhouse host must be supported, add it to ALLOWED_GREENHOUSE_HOSTS with justification.","Treat an unexpected internal/private hostname here as a possible SSRF/config-injection attempt and verify the source of api."],"exampleFix":"# before\n- name: Acme\n  provider: greenhouse\n  api: https://ats-proxy.internal/greenhouse   # off-allowlist -> throws\n\n# after\n- name: Acme\n  provider: greenhouse\n  api: https://boards-api.greenhouse.io/v1/boards/acme/jobs","handlingStrategy":"validation","validationCode":"// Pre-flight: ensure a Greenhouse entry.api host is allowlisted (SSRF defense at config time).\nconst GH_HOSTS = new Set(['boards-api.greenhouse.io','boards.greenhouse.io','job-boards.greenhouse.io','job-boards.eu.greenhouse.io']);\nfunction greenhouseApiHostAllowed(entry) {\n  if (!entry.api) return true; // derived URL stays on boards-api.greenhouse.io\n  let u;\n  try { u = new URL(entry.api); } catch { return false; }\n  return GH_HOSTS.has(u.hostname);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Omit api and provide a careers_url on job-boards.greenhouse.io so the provider derives a safe boards-api URL.","Treat an off-allowlist api as a possible SSRF/config-injection attempt and audit its origin.","If a new legitimate Greenhouse host appears, add it to ALLOWED_GREENHOUSE_HOSTS with a justification comment."],"tags":["ssrf-guard","host-allowlist","url-validation","greenhouse","config"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}