{"record":{"id":"55ee02d61e8e77df","repo":"santifer/career-ops","slug":"collage-untrusted-hostname-parsed-hostname-must-be-collage","errorCode":null,"errorMessage":"collage: untrusted hostname \"${parsed.hostname}\" — must be ${COLLAGE_API_HOST}","messagePattern":"collage: untrusted hostname \"(.+?)\" — must be (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/collage.mjs","lineNumber":19,"sourceCode":"// @ts-check\n/** @typedef {import('./_types.js').Provider} Provider */\n\n// Collage HR public job-site API.  A job-site address is an explicit tenant\n// identifier, not a company-name slug we should guess.  Entries may provide\n// the exact API URL or a public Collage careers URL from which the final path\n// segment is read.\n\nconst API_ORIGIN = 'https://api.collage.co';\nconst COLLAGE_API_HOST = 'api.collage.co';\nconst COLLAGE_SITE_HOST_RE = /^secure\\.collage\\.co$/;\n\n/** @param {string} url */\nfunction assertCollageApiUrl(url) {\n  let parsed;\n  try { parsed = new URL(url); } catch { throw new Error(`collage: invalid URL: ${url}`); }\n  if (parsed.protocol !== 'https:') throw new Error(`collage: URL must use HTTPS: ${url}`);\n  if (parsed.hostname !== COLLAGE_API_HOST) {\n    throw new Error(`collage: untrusted hostname \"${parsed.hostname}\" — must be ${COLLAGE_API_HOST}`);\n  }\n  if (!/^\\/v1\\/positions\\/[^/?#]+$/.test(parsed.pathname)) {\n    throw new Error(`collage: API URL must be /v1/positions/<job-site-address>: ${url}`);\n  }\n  return url;\n}\n\n/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveApiUrl(entry) {\n  const explicit = typeof entry.api === 'string' ? entry.api.trim() : '';\n  if (explicit) return assertCollageApiUrl(explicit);\n\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url.trim() : '';\n  if (!raw) return null;\n  let parsed;\n  try { parsed = new URL(raw); } catch { return null; }\n  if (parsed.protocol !== 'https:' || !COLLAGE_SITE_HOST_RE.test(parsed.hostname)) return null;\n  if (!/^\\/jobs\\/[^/]+(?:\\/)?$/.test(parsed.pathname)) return null;","sourceCodeStart":1,"sourceCodeEnd":37,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/collage.mjs#L1-L37","documentation":"assertCollageApiUrl pins the API hostname to exactly `api.collage.co`; any other host is rejected as untrusted so a mistyped or malicious portals.yml entry cannot make the scanner send requests to (or leak the job-site address to) an unrelated server. This error names the offending hostname in the message.","triggerScenarios":"A portals.yml `api:` value points at a different host — e.g. https://secure.collage.co/v1/positions/... (the careers-page host, not the API host), a mirror like api.collage.com, or a typo like api.collage.co.evil.io.","commonSituations":"Confusing the public careers host (secure.collage.co) with the API host (api.collage.co); pasting a URL from a proxy or staging environment; typosquat/malicious config. Note the provider intentionally derives the API URL from a secure.collage.co careers_url itself — but an explicit `api:` must still be the api.collage.co host.","solutions":["Point the `api:` field at https://api.collage.co/v1/positions/<job-site-address>","If all you have is a secure.collage.co/jobs/... careers page, remove `api:` and set `careers_url:` — the provider builds the correct API URL from it","Confirm the hostname has no typos or extra suffixes"],"exampleFix":"# before (portals.yml)\napi: https://secure.collage.co/v1/positions/acme\n# after\ncareers_url: https://secure.collage.co/jobs/acme","handlingStrategy":"validation","validationCode":"// Only api.collage.co is trusted for the API host\nfunction isTrustedCollageApiHost(v) {\n  try { return new URL(v).hostname === 'api.collage.co'; } catch { return false; }\n}\n","typeGuard":"function isCollageApiUrl(v) {\n  try { const u = new URL(v); return u.protocol === 'https:' && u.hostname === 'api.collage.co'; }\n  catch { return false; }\n}","tryCatchPattern":"try {\n  const jobs = await collageProvider.fetch(entry, ctx);\n} catch (err) {\n  const m = String(err.message).match(/collage: untrusted hostname \"([^\"]+)\"/);\n  if (m) {\n    console.error(`${entry.name}: hostname ${m[1]} is not allowed — use api.collage.co, or move a secure.collage.co URL to careers_url`);\n  } else { throw err; }\n}","preventionTips":["Keep the API host (api.collage.co) and the careers host (secure.collage.co) straight — they are different fields","Put secure.collage.co/jobs/<address> in careers_url, never in api","Reject non-allowlisted hostnames when validating portals.yml to catch typos and malicious entries"],"tags":["url-validation","security","ssrf-guard"],"backgroundTag":"invalid-url","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}