santifer/career-ops · error
greenhouse: untrusted hostname
Error message
greenhouse: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')} What it means
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.
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.
Example fix
// before (portals.yml) api: https://acme.com/greenhouse/jobs // after api: https://boards-api.greenhouse.io/acme/jobs
Defensive patterns
Strategy: validation
Validate before calling
const GH_HOSTS = new Set(['boards-api.greenhouse.io','boards.greenhouse.io','job-boards.greenhouse.io','job-boards.eu.greenhouse.io']);
function isGreenhouseHost(url) {
try { return GH_HOSTS.has(new URL(url).hostname); } catch { return false; }
} Type guard
const hasTrustedHostname = (s, hosts) => { try { return hosts.has(new URL(s).hostname); } catch { return false; } }; Try / catch
try {
await provider.fetch(entry, ctx);
} catch (err) {
if (/greenhouse: untrusted hostname/.test(err.message)) {
console.error(`Entry "${entry.name}" points at a non-Greenhouse host. Use boards-api.greenhouse.io/<token>/jobs.`);
return;
}
throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.
Related errors
- arbeitnow: untrusted hostname
- ashby: untrusted hostname
- bamboohr: untrusted hostname
- breezy: untrusted hostname
- builtin: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/7816517c210bb1a1.
Report an issue: GitHub.
Appendix: source
Thrown at providers/greenhouse.mjs:30
const ALLOWED_GREENHOUSE_HOSTS = new Set([
'boards-api.greenhouse.io',
'boards.greenhouse.io',
'job-boards.greenhouse.io',
'job-boards.eu.greenhouse.io',
]);
/** @param {string} url */
function assertGreenhouseUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`greenhouse: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`greenhouse: URL must use HTTPS: ${url}`);
if (!ALLOWED_GREENHOUSE_HOSTS.has(parsed.hostname))
throw new Error(`greenhouse: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}`);
return url;
}
/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
if (entry.api) {
assertGreenhouseUrl(entry.api);
return entry.api;
}
const url = entry.careers_url || '';
const match = url.match(/job-boards(?:\.eu)?\.greenhouse\.io\/([^/?#]+)/);
if (match) return `https://boards-api.greenhouse.io/v1/boards/${match[1]}/jobs`;
return null;
}
// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.
function toEpochMs(value) {
if (!value) return undefined;View on GitHub (pinned to aac998c7ed)