santifer/career-ops · error
breezy: untrusted hostname
Error message
breezy: untrusted hostname "${parsed.hostname}" — must match <tenant>.breezy.hr What it means
assertBreezyUrl throws this when the URL is valid https but its hostname does not match the pattern /^[a-z0-9][a-z0-9-]*\.breezy\.hr$/. Because Breezy tenants live on per-customer subdomains, the SSRF guard uses a regex allowlist of <tenant>.breezy.hr shapes instead of a static list; a host outside that shape could be an arbitrary fetch target and is rejected.
Solutions
- Set the entry URL to the real tenant board: 'https://<tenant>.breezy.hr' (single lowercase subdomain, exactly one dot before breezy.hr).
- Verify the tenant subdomain by loading the board in a browser — it is what appears before .breezy.hr.
- Lowercase the hostname; the regex rejects uppercase letters.
- If the company is not on Breezy HR at all, remove the entry from the breezy provider's scope or fix the provider detection in portals.yml.
Example fix
# before careers_url: https://acme.breezy.com # after careers_url: https://acme.breezy.hr
Defensive patterns
Strategy: validation
Validate before calling
const BREEZY_HOST_RE = /^[a-z0-9][a-z0-9-]*\.breezy\.hr$/;
function isTrustedBreezyHost(url) {
try { return BREEZY_HOST_RE.test(new URL(url).hostname); } catch { return false; }
}
if (!isTrustedBreezyHost(entry.careers_url)) throw new Error(`config: not a breezy.hr board: ${entry.careers_url}`); Type guard
function isBreezyHost(hostname) { return /^[a-z0-9][a-z0-9-]*\.breezy\.hr$/.test(hostname); } Try / catch
try {
await provider.fetch(entry, ctx);
} catch (err) {
if (String(err.message).startsWith('breezy: untrusted hostname')) {
console.warn(`Skipping ${entry.name}: not a <tenant>.breezy.hr board — check portals.yml`);
return null;
}
throw err;
} Prevention
- Confirm the company actually hosts its board on breezy.hr before adding the entry.
- Use the tenant subdomain exactly as it appears in the browser address bar, lowercased.
- Run provider.detect() on every entry at startup to catch host-shape mismatches before fetching.
- Never bypass the hostname guard with a proxy or hosts-file trick.
When it happens
Trigger: A portals entry pointing at 'https://acme.breezy.com', 'https://breezy.hr' (no tenant), 'https://acme.vendor.breezy.hr' (multi-level subdomain), uppercase 'https://Acme.breezy.hr', or any unrelated host passed as api:/careers_url into the breezy provider's fetch().
Common situations: Company moved from Breezy to another ATS but the config entry kept the old URL shape; copy-pasting the company's main website instead of the board URL; wrong provider assignment — an entry for Greenhouse/Lever routed to the breezy provider; typo in the tenant subdomain.
Related errors
- builtin: untrusted hostname
- careerviet: untrusted hostname
- arbeitnow: untrusted hostname
- ashby: untrusted hostname
- bamboohr: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/4b05f01049371a13.
Report an issue: GitHub.
Appendix: source
Thrown at providers/breezy.mjs:28
// Breezy boards expose every published position as a public JSON array at
// `<tenant>.breezy.hr/json` — title, absolute url, location, and a published
// date, all in the list payload at zero token cost (no per-job request, so the
// scanner stays zero-token). Breezy's authenticated REST API (api.breezy.hr) is
// intentionally NOT used; only the public board feed.
const BREEZY_HOST_RE = /^[a-z0-9][a-z0-9-]*\.breezy\.hr$/;
/** @param {string} url */
function assertBreezyUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`breezy: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`breezy: URL must use HTTPS: ${url}`);
if (!BREEZY_HOST_RE.test(parsed.hostname)) {
throw new Error(`breezy: untrusted hostname "${parsed.hostname}" — must match <tenant>.breezy.hr`);
}
return url;
}
/**
* Resolve the tenant origin (`https://<tenant>.breezy.hr`) from an entry.
* Honours an explicit `api:` URL, else parses `careers_url`.
* @param {import('./_types.js').PortalEntry} entry
* @returns {string | null}
*/
function resolveOrigin(entry) {
const rawApi = typeof entry.api === 'string' ? entry.api : '';
const rawCareers = typeof entry.careers_url === 'string' ? entry.careers_url : '';
const raw = (rawApi || rawCareers).trim();
if (!raw) return null;
let parsed;
try {
parsed = new URL(raw);View on GitHub (pinned to aac998c7ed)