santifer/career-ops · error
collage: untrusted hostname
Error message
collage: untrusted hostname "${parsed.hostname}" — must be ${COLLAGE_API_HOST} What it means
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.
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
Example fix
# before (portals.yml) api: https://secure.collage.co/v1/positions/acme # after careers_url: https://secure.collage.co/jobs/acme
Defensive patterns
Strategy: validation
Validate before calling
// Only api.collage.co is trusted for the API host
function isTrustedCollageApiHost(v) {
try { return new URL(v).hostname === 'api.collage.co'; } catch { return false; }
}
Type guard
function isCollageApiUrl(v) {
try { const u = new URL(v); return u.protocol === 'https:' && u.hostname === 'api.collage.co'; }
catch { return false; }
} Try / catch
try {
const jobs = await collageProvider.fetch(entry, ctx);
} catch (err) {
const m = String(err.message).match(/collage: untrusted hostname "([^"]+)"/);
if (m) {
console.error(`${entry.name}: hostname ${m[1]} is not allowed — use api.collage.co, or move a secure.collage.co URL to careers_url`);
} else { throw err; }
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
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
- flowxtra: URL must use HTTPS
- workable: untrusted hostname
- a16z-speedrun-talent: URL must use HTTPS
- Access denied: Localhost or internal domain target detected.
- agentic-jobs: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/55ee02d61e8e77df.
Report an issue: GitHub.
Appendix: source
Thrown at providers/collage.mjs:19
// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// Collage HR public job-site API. A job-site address is an explicit tenant
// identifier, not a company-name slug we should guess. Entries may provide
// the exact API URL or a public Collage careers URL from which the final path
// segment is read.
const API_ORIGIN = 'https://api.collage.co';
const COLLAGE_API_HOST = 'api.collage.co';
const COLLAGE_SITE_HOST_RE = /^secure\.collage\.co$/;
/** @param {string} url */
function assertCollageApiUrl(url) {
let parsed;
try { parsed = new URL(url); } catch { throw new Error(`collage: invalid URL: ${url}`); }
if (parsed.protocol !== 'https:') throw new Error(`collage: URL must use HTTPS: ${url}`);
if (parsed.hostname !== COLLAGE_API_HOST) {
throw new Error(`collage: untrusted hostname "${parsed.hostname}" — must be ${COLLAGE_API_HOST}`);
}
if (!/^\/v1\/positions\/[^/?#]+$/.test(parsed.pathname)) {
throw new Error(`collage: API URL must be /v1/positions/<job-site-address>: ${url}`);
}
return url;
}
/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
const explicit = typeof entry.api === 'string' ? entry.api.trim() : '';
if (explicit) return assertCollageApiUrl(explicit);
const raw = typeof entry.careers_url === 'string' ? entry.careers_url.trim() : '';
if (!raw) return null;
let parsed;
try { parsed = new URL(raw); } catch { return null; }
if (parsed.protocol !== 'https:' || !COLLAGE_SITE_HOST_RE.test(parsed.hostname)) return null;
if (!/^\/jobs\/[^/]+(?:\/)?$/.test(parsed.pathname)) return null;View on GitHub (pinned to aac998c7ed)