{"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":null,"httpStatus":null,"severity":"error","filePath":"providers/greenhouse.mjs","lineNumber":30,"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":12,"sourceCodeEnd":48,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/greenhouse.mjs#L12-L48","documentation":"assertGreenhouseUrl allowlists the hostname to known Greenhouse board hosts: boards-api.greenhouse.io, boards.greenhouse.io, job-boards.greenhouse.io and job-boards.eu.greenhouse.io. Any other hostname throws this error. This SSRF-style guard ensures the scanner only ever talks to the real Greenhouse API, even if a config entry points somewhere else.","triggerScenarios":"An api: or careers_url that resolves to a different host — a custom careers domain (acme.com), a typo like boards-api.greenhouse.com, a redirect target, or a proxy/localhost URL someone configured for testing.","commonSituations":"Users assume any Greenhouse-hosted board URL works, but custom vanity domains must be converted to the canonical boards-api.greenhouse.io form; also typos (.com vs .io) and pointing the entry at the job-boards UI HTML page instead of the JSON API.","solutions":["Rewrite the entry to the canonical API host, e.g. https://boards-api.greenhouse.io/<board-token>/jobs (or /jobs?content=true).","If your board lives on a vanity domain, find the board token (visible in the page's Greenhouse embed) and use boards-api.greenhouse.io with it.","Check the hostname for typos against the four allowlisted hosts printed in the error message.","If a legitimate new Greenhouse regional host is missing, add it to ALLOWED_GREENHOUSE_HOSTS in providers/greenhouse.mjs."],"exampleFix":"// before (portals.yml)\napi: https://acme.com/greenhouse/jobs\n// after\napi: https://boards-api.greenhouse.io/acme/jobs","handlingStrategy":"validation","validationCode":"const GH_HOSTS = new Set(['boards-api.greenhouse.io','boards.greenhouse.io','job-boards.greenhouse.io','job-boards.eu.greenhouse.io']);\nfunction isGreenhouseHost(url) {\n  try { return GH_HOSTS.has(new URL(url).hostname); } catch { return false; }\n}","typeGuard":"const hasTrustedHostname = (s, hosts) => { try { return hosts.has(new URL(s).hostname); } catch { return false; } };","tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (err) {\n  if (/greenhouse: untrusted hostname/.test(err.message)) {\n    console.error(`Entry \"${entry.name}\" points at a non-Greenhouse host. Use boards-api.greenhouse.io/<token>/jobs.`);\n    return;\n  }\n  throw err;\n}","preventionTips":["Convert vanity careers domains to canonical boards-api.greenhouse.io/<token>/jobs form.","Check the error message's allowlist before editing config — it lists every valid host.","Beware .com vs .io typos in greenhouse hostnames.","Never point provider entries at localhost/proxy hosts in shared config."],"tags":["url","security","allowlist","ssrf","greenhouse"],"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"}