santifer/career-ops · error
bamboohr: untrusted hostname
Error message
bamboohr: untrusted hostname "${parsed.hostname}" — must match <tenant>.bamboohr.com What it means
Even a valid HTTPS URL is rejected if its hostname does not match BAMBOOHR_HOST_RE — the pattern enforcing `<tenant>.bamboohr.com`. This confines requests to genuine BambooHR tenant hosts, blocking SSRF via config-supplied URLs pointing at arbitrary machines.
Solutions
- Point the entry at the real tenant host: `https://<tenant>.bamboohr.com` (find the tenant name from the company's BambooHR careers link).
- Ensure exactly one subdomain level before `bamboohr.com` — no bare domain, no extra suffix.
- If the board is proxied through a corporate domain, use the underlying BambooHR tenant URL instead.
- Only if you own the provider: adjust the regex deliberately after confirming the host is a legitimate BambooHR tenant (beware suffix-lookalikes).
Example fix
// before
assertBambooHRUrl('https://careers.exampleco.com/api'); // untrusted hostname
// after
assertBambooHRUrl('https://exampleco.bamboohr.com/careers/list'); // matches <tenant>.bamboohr.com Defensive patterns
Strategy: validation
Validate before calling
const BAMBOOHR_HOST_RE = /^[a-z0-9-]+\.bamboohr\.com$/;
function isAllowedBambooHost(url) {
try { return BAMBOOHR_HOST_RE.test(new URL(url).hostname); } catch { return false; }
} Type guard
function isBambooReadyEntry(entry) {
const origin = resolveOrigin(entry);
return origin != null && isAllowedBambooHost(origin);
} Try / catch
try {
assertBambooHRUrl(apiUrl);
} catch (err) {
if (/untrusted hostname/.test(err.message)) {
console.warn(`Entry hostname is not <tenant>.bamboohr.com: ${err.message}`);
return null;
}
throw err;
} Prevention
- Always use the tenant subdomain form `<tenant>.bamboohr.com` in config.
- Reject URLs with extra suffixes after bamboohr.com (spoof/suffix attacks).
- Use the real tenant URL rather than a corporate-domain proxy when configuring entries.
- Test hostnames against the regex in your config lint, not just at fetch time.
When it happens
Trigger: Calling assertBambooHRUrl with hosts like `bamboohr.com` (no tenant), `mycompany.example.com` (custom domain), `mycompany.bamboohr.com.evil.io` (suffix attack), or a tenant containing invalid characters.
Common situations: Config entries pointing at a company's corporate domain rather than its `<tenant>.bamboohr.com` board; spoofed/suffixed hostnames; missing tenant subdomain; BambooHR board proxied through the company's own domain.
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
- breezy: untrusted hostname
- builtin: untrusted hostname
- careerviet: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/1de9726e94688110.
Report an issue: GitHub.
Appendix: source
Thrown at providers/bamboohr.mjs:29
//
// The list endpoint (`/careers/list`) returns lightweight metadata — enough for
// the Job contract (title, url, location) at zero token cost. The full JD lives
// behind a second `/careers/<id>/detail` request, which the scanner deliberately
// skips to stay zero-token (so `description`/`postedAt` are omitted).
const BAMBOOHR_HOST_RE = /^[a-z0-9][a-z0-9-]*\.bamboohr\.com$/;
/** @param {string} url */
function assertBambooHRUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`bamboohr: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`bamboohr: URL must use HTTPS: ${url}`);
if (!BAMBOOHR_HOST_RE.test(parsed.hostname)) {
throw new Error(`bamboohr: untrusted hostname "${parsed.hostname}" — must match <tenant>.bamboohr.com`);
}
return url;
}
/**
* Resolve the tenant origin (`https://<tenant>.bamboohr.com`) 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)