{"record":{"id":"4a775510f1b2e8c0","repo":"santifer/career-ops","slug":"personio-untrusted-hostname-parsed-hostname","errorCode":null,"errorMessage":"personio: untrusted hostname \"${parsed.hostname}\" — must match <slug>.jobs.personio.(de|com)","messagePattern":"personio: untrusted hostname \"(.+?)\" — must match <slug>\\.jobs\\.personio\\.\\(de\\|com\\)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/personio.mjs","lineNumber":26,"sourceCode":"// workable/recruitee. Per-tenant subdomains are the variable part, so the\n// SSRF defence is an anchored host regex rather than a static allowlist.\n//\n// The feed is a flat, well-defined XML document, so it is parsed in-process\n// with a tiny tag extractor (no new dependency — the repo ships none for XML).\n\nconst PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\n\n/** @param {string} url */\nfunction assertPersonioUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`personio: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`personio: URL must use HTTPS: ${url}`);\n  if (!PERSONIO_HOST_RE.test(parsed.hostname))\n    throw new Error(`personio: untrusted hostname \"${parsed.hostname}\" — must match <slug>.jobs.personio.(de|com)`);\n  return url;\n}\n\n/**\n * Resolve the tenant host (e.g. `acme.jobs.personio.de`) from a careers_url.\n * Returns null for non-Personio or malformed URLs.\n * @param {import('./_types.js').PortalEntry} entry\n */\nconst PERSONIO_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/i;\n\nfunction resolveHost(entry) {\n  // An explicit `personio: <slug>` pins the tenant directly. Needed because many\n  // companies embed the Personio tenant as an iframe on a branded careers page,\n  // so careers_url points at the company domain while the feed lives at\n  // <slug>.jobs.personio.de. The slug is charset-restricted here and the\n  // resulting URL still goes through assertPersonioUrl(), so the host allowlist\n  // and HTTPS check below remain the only way a request URL is accepted.\n  if (typeof entry.personio === 'string') {","sourceCodeStart":8,"sourceCodeEnd":44,"githubUrl":"https://github.com/santifer/career-ops/blob/e7abd431fce9348a95261acac9e0c14779c35df8/providers/personio.mjs#L8-L44","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before (portals.yml)\ncareers_url: https://careers.acme.com/xml\n// after\ncareers_url: https://acme.jobs.personio.com/xml","handlingStrategy":"validation","validationCode":"const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\nexport function isPersonioUrl(u) {\n  try {\n    const parsed = new URL(u);\n    return parsed.protocol === 'https:' && PERSONIO_HOST_RE.test(parsed.hostname);\n  } catch { return false; }\n}\nif (!isPersonioUrl(entry.careers_url)) throw new Error(`personio: careers_url for ${entry.name} not on Personio allowlist`);","typeGuard":"function isPersonioTenantUrl(u) {\n  if (typeof u !== 'string') return false;\n  try {\n    const parsed = new URL(u);\n    return parsed.protocol === 'https:' &&\n      /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/.test(parsed.hostname);\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  await personioProvider.fetch(entry, ctx);\n} catch (e) {\n  if (String(e.message).startsWith('personio: untrusted hostname')) {\n    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)');\n    return null;\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["ssrf-guard","url-validation","config","personio"],"backgroundTag":"invalid-url","analyzedSha":"e7abd431fce9348a95261acac9e0c14779c35df8","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}