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

  1. 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.
  2. 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.
  3. Resolve vanity domains to the underlying personio host (follow the redirect manually once and hardcode the tenant host).
  4. 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

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


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)