{"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":25,"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 */\nfunction resolveHost(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return null;\n  let parsed;\n  try {\n    parsed = new URL(raw);\n  } catch {\n    return null;\n  }\n  if (parsed.protocol !== 'https:') return null;","sourceCodeStart":7,"sourceCodeEnd":43,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/personio.mjs#L7-L43","documentation":"Thrown by personio's assertPersonioUrl() when the hostname fails PERSONIO_HOST_RE (/^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/). Third SSRF gate: enforces the tenant-subdomain pattern <slug>.jobs.personio.de or <slug>.jobs.personio.com. The regex requires a non-empty slug starting with alphanumeric, allowing hyphens in the middle. Rejects bare 'jobs.personio.de', 'www.jobs.personio.de', and non-Personio domains.","triggerScenarios":"Valid HTTPS URL with wrong hostname: 'jobs.personio.de' (no tenant slug), 'personio.de' (company homepage, not career site), 'careers.acme.com' (non-Personio), or 'acme.personio.de' (missing the .jobs. segment). A common form is using the company's main domain instead of their Personio subdomain.","commonSituations":"The entry points to the company's own website instead of their Personio board. A copy-paste from a different provider entry without updating the domain. Personio introduced a new TLD or subdomain pattern not covered by the regex. The entry uses 'www.jobs.personio.de' which the slug regex rejects.","solutions":["Find the company's Personio career URL — it must be in the form https://<company-slug>.jobs.personio.de or .com.","Remove 'www.' and any extra subdomain labels — only one slug label before .jobs.personio is allowed.","If Personio added a new domain variant, update PERSONIO_HOST_RE to accept it.","Verify the provider field is 'personio' and matches the URL — a lever/greenhouse URL won't pass this check."],"exampleFix":"// before — wrong hostname shape\ncareers_url: 'https://jobs.personio.de'  // no tenant slug\ncareers_url: 'https://acme.personio.de'  // missing .jobs. segment\n\n// after\ncareers_url: 'https://acme.jobs.personio.de'","handlingStrategy":"validation","validationCode":"const PERSONIO_HOST_RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\n\n/** Check hostname matches Personio tenant pattern. */\nfunction isPersonioHost(url) {\n  try { return PERSONIO_HOST_RE.test(new URL(url).hostname); } catch { return false; }\n}\n\nif (!isPersonioHost(entry.careers_url)) {\n  console.warn(`personio entry ${entry.name} URL doesn't match <slug>.jobs.personio.(de|com)`);\n  continue;\n}","typeGuard":"/** @param {string} url @returns {boolean} */\nfunction isPersonioUrl(url) {\n  const RE = /^[a-z0-9][a-z0-9-]*\\.jobs\\.personio\\.(de|com)$/;\n  try { return RE.test(new URL(url).hostname); } catch { return false; }\n}","tryCatchPattern":"try {\n  await personioProvider.fetch(entry, ctx);\n} catch (err) {\n  if (String(err.message).includes('untrusted hostname')) {\n    console.warn(`personio entry ${entry.name} wrong host — needs <slug>.jobs.personio.(de|com)`);\n    continue;\n  }\n  throw err;\n}","preventionTips":["Ensure Personio URLs follow <slug>.jobs.personio.de or .com — no www., no bare personio.de.","Copy the career board URL from the browser, not the company homepage.","Update PERSONIO_HOST_RE if Personio adds new TLDs or subdomain patterns."],"tags":["url-validation","ssrf-guard","hostname-regex","personio","security"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}