{"record":{"id":"4e97b8da4814d99a","repo":"santifer/career-ops","slug":"ashby-untrusted-hostname-parsed-hostname-m","errorCode":null,"errorMessage":"ashby: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_ASHBY_HOSTS].join(', ')}","messagePattern":"ashby: untrusted hostname \"(.+?)\" — must be one of: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/ashby.mjs","lineNumber":90,"sourceCode":"    min: Math.min(resolvedMin, resolvedMax),\n    max: Math.max(resolvedMin, resolvedMax),\n    currency: currency.toUpperCase(),\n  };\n}\n\nconst ALLOWED_ASHBY_HOSTS = new Set(['api.ashbyhq.com']);\n\n/** @param {string} url */\nfunction assertAshbyUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`ashby: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`ashby: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_ASHBY_HOSTS.has(parsed.hostname))\n    throw new Error(`ashby: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_ASHBY_HOSTS].join(', ')}`);\n  return url;\n}\n\n/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveApiUrl(entry) {\n  // Explicit api: wins — lets an entry keep a human-facing corporate\n  // careers_url (e.g. https://openai.com/careers) while still pinning the\n  // Ashby posting-api board (mirrors greenhouse's api: precedence).\n  if (entry.api) {\n    assertAshbyUrl(entry.api);\n    return entry.api;\n  }\n  const url = entry.careers_url || '';\n  const match = url.match(/jobs\\.ashbyhq\\.com\\/([^/?#]+)/);\n  if (!match) return null;\n  return `https://api.ashbyhq.com/posting-api/job-board/${match[1]}?includeCompensation=true`;\n}\n","sourceCodeStart":72,"sourceCodeEnd":108,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/ashby.mjs#L72-L108","documentation":"The host-allowlist step of ashby's SSRF guard. After scheme validation, the hostname must be in `ALLOWED_ASHBY_HOSTS` (currently only `api.ashbyhq.com`). Any other host is rejected so redirects or misconfigured URLs cannot send authenticated-looking requests elsewhere.","triggerScenarios":"`!ALLOWED_ASHBY_HOSTS.has(parsed.hostname)` — the hostname is not exactly `api.ashbyhq.com` (e.g. a corporate careers domain, a staging host, or a look-alike spoof).","commonSituations":"Setting `api:` to a company's human-facing careers URL instead of the Ashby posting-API host, pointing at a staging mirror, or a typo that changes the host.","solutions":["Pin `api:` to `https://api.ashbyhq.com/posting-api?compId=<tenant>` (the documented posting-API board).","Keep the human-facing URL in `careers_url` and only use `api:` for the API host — `resolveApiUrl` gives `api:` precedence for exactly this reason.","If a new legitimate Ashby API host appears, add it to `ALLOWED_ASHBY_HOSTS` explicitly rather than loosening the check."],"exampleFix":"# before (rejected — careers page, not the API host)\n- name: Acme\n  api: https://careers.acme.com/\n\n# after — keep careers_url for humans, api: for the board\n- name: Acme\n  careers_url: https://careers.acme.com/\n  api: https://api.ashbyhq.com/posting-api?compId=acme","handlingStrategy":"validation","validationCode":"const ALLOWED = new Set(['api.ashbyhq.com']);\nfunction assertAshbyHost(u) {\n  const h = new URL(u).hostname;\n  if (!ALLOWED.has(h)) throw new Error(`ashby: host ${h} not in allowlist`);\n}\nif (entry.api) assertAshbyHost(entry.api);","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Always set `api:` to api.ashbyhq.com; keep human URLs in `careers_url`.","Treat a careers-page URL in `api:` as a config smell.","Extend the allowlist only with explicit, documented hosts — never with a wildcard."],"tags":["ashby","ssrf","host-allowlist","security"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}