santifer/career-ops · error · Error

ashby: untrusted hostname "${parsed.hostname}" — must be one

Error message

ashby: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_ASHBY_HOSTS].join(', ')}

What it means

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.

Source

Thrown at providers/ashby.mjs:90

    min: Math.min(resolvedMin, resolvedMax),
    max: Math.max(resolvedMin, resolvedMax),
    currency: currency.toUpperCase(),
  };
}

const ALLOWED_ASHBY_HOSTS = new Set(['api.ashbyhq.com']);

/** @param {string} url */
function assertAshbyUrl(url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`ashby: invalid URL: ${url}`);
  }
  if (parsed.protocol !== 'https:') throw new Error(`ashby: URL must use HTTPS: ${url}`);
  if (!ALLOWED_ASHBY_HOSTS.has(parsed.hostname))
    throw new Error(`ashby: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_ASHBY_HOSTS].join(', ')}`);
  return url;
}

/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
  // Explicit api: wins — lets an entry keep a human-facing corporate
  // careers_url (e.g. https://openai.com/careers) while still pinning the
  // Ashby posting-api board (mirrors greenhouse's api: precedence).
  if (entry.api) {
    assertAshbyUrl(entry.api);
    return entry.api;
  }
  const url = entry.careers_url || '';
  const match = url.match(/jobs\.ashbyhq\.com\/([^/?#]+)/);
  if (!match) return null;
  return `https://api.ashbyhq.com/posting-api/job-board/${match[1]}?includeCompensation=true`;
}

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Pin `api:` to `https://api.ashbyhq.com/posting-api?compId=<tenant>` (the documented posting-API board).
  2. Keep the human-facing URL in `careers_url` and only use `api:` for the API host — `resolveApiUrl` gives `api:` precedence for exactly this reason.
  3. If a new legitimate Ashby API host appears, add it to `ALLOWED_ASHBY_HOSTS` explicitly rather than loosening the check.

Example fix

# before (rejected — careers page, not the API host)
- name: Acme
  api: https://careers.acme.com/

# after — keep careers_url for humans, api: for the board
- name: Acme
  careers_url: https://careers.acme.com/
  api: https://api.ashbyhq.com/posting-api?compId=acme
Defensive patterns

Strategy: validation

Validate before calling

const ALLOWED = new Set(['api.ashbyhq.com']);
function assertAshbyHost(u) {
  const h = new URL(u).hostname;
  if (!ALLOWED.has(h)) throw new Error(`ashby: host ${h} not in allowlist`);
}
if (entry.api) assertAshbyHost(entry.api);

Prevention

When it happens

Trigger: `!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).

Common situations: 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.

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/4e97b8da4814d99a. Report an issue: GitHub.