santifer/career-ops · error · Error
personio: untrusted hostname
Error message
personio: untrusted hostname "${parsed.hostname}" — must match <slug>.jobs.personio.(de|com) What it means
assertPersonioUrl only accepts hostnames matching PERSONIO_HOST_RE (/^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/) — i.e. <slug>.jobs.personio.de or <slug>.jobs.personio.com. The URL parsed and used HTTPS, but its hostname is outside that allowlist, so the provider refuses the request. This is an SSRF/trust boundary: the request was never sent.
Solutions
- Replace careers_url in portals.yml with the tenant's real <slug>.jobs.personio.de (or .com) host — find the slug in the Personio admin or the feed link.
- If the company genuinely uses a regional TLD or a shape the regex misses, extend PERSONIO_HOST_RE at personio.mjs line 14 and update the error message accordingly.
- Resolve vanity domains to the underlying personio host (follow the redirect manually once and hardcode the tenant host).
- Keep fetch's redirect:'error' behavior — do not 'fix' this by allowing redirects; that would defeat the SSRF guard.
Example fix
// before (portals.yml) careers_url: https://careers.acme.com/xml // after careers_url: https://acme.jobs.personio.com/xml
Defensive patterns
Strategy: validation
Validate before calling
const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/;
export function isPersonioUrl(u) {
try {
const parsed = new URL(u);
return parsed.protocol === 'https:' && PERSONIO_HOST_RE.test(parsed.hostname);
} catch { return false; }
}
if (!isPersonioUrl(entry.careers_url)) throw new Error(`personio: careers_url for ${entry.name} not on Personio allowlist`); Type guard
function isPersonioTenantUrl(u) {
if (typeof u !== 'string') return false;
try {
const parsed = new URL(u);
return parsed.protocol === 'https:' &&
/^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/.test(parsed.hostname);
} catch { return false; }
} Try / catch
try {
await personioProvider.fetch(entry, ctx);
} catch (e) {
if (String(e.message).startsWith('personio: untrusted hostname')) {
logger.warn({ entry: entry.name, host: new URL(entry.careers_url).hostname }, 'hostname not a Personio tenant — resolve vanity domain to <slug>.jobs.personio.(de|com)');
return null;
}
throw e;
} Prevention
- Store the tenant's personio subdomain, not the vanity careers domain, in portals.yml.
- Validate every entry against PERSONIO_HOST_RE at config load or in CI.
- Do not loosen redirect:'error' to work around this error — fix the hostname instead.
- When in doubt about a host shape, run audit-portals.mjs before trusting the entry.
When it happens
Trigger: Calling fetch or validate paths reaching assertPersonioUrl (personio.mjs line 26) with a careers_url whose host is e.g. acme.personio.eu, jobs.acme.com (vanity domain), acme.jobs.personio.com.evil.test, or a multi-level tenant host the regex does not cover.
Common situations: Company uses a Personio vanity domain (careers.acme.com) instead of the tenant subdomain; typo in the slug or extra subdomain level; a Personio regional TLD (.eu) not supported by the regex; or a hostile/misconfigured entry pointing off-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
- comeet: URL path must be the careers-api endpoint
- flowxtra: URL must use HTTPS
- oraclecloud: untrusted hostname
- personio: invalid URL
- personio: URL must use HTTPS
AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-16).
Data as JSON: /api/errors/4a775510f1b2e8c0.
Report an issue: GitHub.
Appendix: source
Thrown at providers/personio.mjs:26
// workable/recruitee. Per-tenant subdomains are the variable part, so the
// SSRF defence is an anchored host regex rather than a static allowlist.
//
// The feed is a flat, well-defined XML document, so it is parsed in-process
// with a tiny tag extractor (no new dependency — the repo ships none for XML).
const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\.jobs\.personio\.(de|com)$/;
/** @param {string} url */
function assertPersonioUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`personio: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`personio: URL must use HTTPS: ${url}`);
if (!PERSONIO_HOST_RE.test(parsed.hostname))
throw new Error(`personio: untrusted hostname "${parsed.hostname}" — must match <slug>.jobs.personio.(de|com)`);
return url;
}
/**
* Resolve the tenant host (e.g. `acme.jobs.personio.de`) from a careers_url.
* Returns null for non-Personio or malformed URLs.
* @param {import('./_types.js').PortalEntry} entry
*/
const PERSONIO_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/i;
function resolveHost(entry) {
// An explicit `personio: <slug>` pins the tenant directly. Needed because many
// companies embed the Personio tenant as an iframe on a branded careers page,
// so careers_url points at the company domain while the feed lives at
// <slug>.jobs.personio.de. The slug is charset-restricted here and the
// resulting URL still goes through assertPersonioUrl(), so the host allowlist
// and HTTPS check below remain the only way a request URL is accepted.
if (typeof entry.personio === 'string') {View on GitHub (pinned to e7abd431fc)