{"record":{"id":"3aa9baa212c23026","repo":"santifer/career-ops","slug":"smartrecruiters-untrusted-hostname-parsed-host","errorCode":null,"errorMessage":"smartrecruiters: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_SMARTRECRUITERS_HOSTS].join(', ')}","messagePattern":"smartrecruiters: untrusted hostname \"(.+?)\" — must be one of: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/smartrecruiters.mjs","lineNumber":24,"sourceCode":"// `https://(careers|jobs).smartrecruiters.com/<slug>`. A tracked_companies\n// entry can also set `provider: smartrecruiters` explicitly to bypass\n// detection (useful when the public careers URL is a branded custom domain).\n\nconst ALLOWED_SMARTRECRUITERS_HOSTS = new Set(['api.smartrecruiters.com']);\nconst SR_CAREERS_HOSTS = new Set(['careers.smartrecruiters.com', 'jobs.smartrecruiters.com']);\nconst SR_PAGE_SIZE = 100;\nconst SR_MAX_PAGES = 50;  // safety cap (5000 postings @ 100/page)\n\nfunction assertSmartRecruitersUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`smartrecruiters: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`smartrecruiters: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_SMARTRECRUITERS_HOSTS.has(parsed.hostname)) {\n    throw new Error(`smartrecruiters: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_SMARTRECRUITERS_HOSTS].join(', ')}`);\n  }\n  return url;\n}\n\nfunction resolveSlug(entry) {\n  // entry.api takes precedence over careers_url (mirrors greenhouse/ashby) so a\n  // branded page (e.g. https://jobs.continental.com) can stay as careers_url\n  // while the SmartRecruiters slug is pinned via\n  // api: https://careers.smartrecruiters.com/<slug> in portals.yml.\n  for (const raw of [entry.api, entry.careers_url]) {\n    if (typeof raw !== 'string' || !raw) continue;\n    let parsed;\n    try {\n      parsed = new URL(raw);\n    } catch {\n      continue;\n    }\n    if (parsed.protocol !== 'https:') continue;","sourceCodeStart":6,"sourceCodeEnd":42,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/smartrecruiters.mjs#L6-L42","documentation":"assertSmartRecruitersUrl is an SSRF defense-in-depth guard: before any fetch it parses the URL built by buildPostingsUrl and rejects any whose hostname is not in the allowlist ALLOWED_SMARTRECRUITERS_HOSTS (currently just 'api.smartrecruiters.com'). In normal operation the URL is constructed internally from a slug, so this check almost never fires against config input — it exists to catch a regression in buildPostingsUrl or an off-host redirect target.","triggerScenarios":"buildPostingsUrl(slug, offset) emitted a URL whose hostname is not 'api.smartrecruiters.com', e.g. the template constant was edited, ALLOWED_SMARTRECRUITERS_HOSTS was emptied, or assertSmartRecruitersUrl was called directly with a hand-supplied URL. Not reachable from a portals.yml entry on its own.","commonSituations":"A fork changes the postings endpoint host without updating the allowlist; a test stubs buildPostingsUrl to return a different origin; someone passes a user/config URL straight into assertSmartRecruitersUrl instead of going through resolveSlug/buildPostingsUrl.","solutions":["If you forked buildPostingsUrl to target a new SmartRecruiters API host, add that exact host to ALLOWED_SMARTRECRUITERS_HOSTS at providers/smartrecruiters.mjs:10","Restore buildPostingsUrl to the shipped template: https://api.smartrecruiters.com/v1/companies/<slug>/postings?limit=...&offset=...&status=PUBLIC","If you meant to scan a company on a different ATS, switch the entry to the matching provider (greenhouse/ashby/lever/workday) instead of smartrecruiters","Do not call assertSmartRecruitersUrl with external URLs — it validates only internally-built URLs"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// assertSmartRecruitersUrl validates an internally-built URL; before calling it,\n// confirm buildPostingsUrl produced an allowlisted host so the guard never fires.\nconst built = buildPostingsUrl(slug, offset);\nconst u = new URL(built);\nif (u.hostname !== 'api.smartrecruiters.com') {\n  throw new Error(`buildPostingsUrl regression: unexpected host ${u.hostname}`);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Treat ALLOWED_SMARTRECRUITERS_HOSTS and buildPostingsUrl as a paired contract — edit one, update the other.","Never route user/config URLs into assertSmartRecruitersUrl; only internally-built URLs belong there.","Add a unit test asserting buildPostingsUrl output always passes assertSmartRecruitersUrl."],"tags":["ssrf-guard","url-validation","smartrecruiters","unreachable-defensive"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}