santifer/career-ops · error

gem: untrusted hostname

Error message

gem: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GEM_HOSTS].join(', ')}

What it means

assertGemUrl enforces a hostname allowlist (ALLOWED_GEM_HOSTS) as an SSRF guard: only Gem's official board/API hosts may be fetched. A URL that parses and is HTTPS but whose hostname is not in the allowlist is rejected with this message listing the permitted hosts.

Solutions

  1. Read the allowed hosts from the error message and correct the hostname to one of them.
  2. If the host is legitimately Gem and genuinely new, update ALLOWED_GEM_HOSTS in providers/gem.mjs after verifying the domain (a deliberate code change, not a config tweak).
  3. Double-check for typos — the allowlist match is exact, no subdomain wildcards.
  4. If the URL is from an untrusted source, do not add it to the allowlist; the rejection is the guard working.

Example fix

// before
"api": "https://job-boards.gem.co.evil.com/v1/boards/acme"
// after
"api": "https://job-boards.gem.co/v1/boards/acme"
Defensive patterns

Strategy: validation

Validate before calling

import { ALLOWED_GEM_HOSTS } from './providers/gem.mjs';
const host = new URL(u).hostname;
if (!ALLOWED_GEM_HOSTS.has(host)) throw new Error(`Host ${host} not in Gem allowlist`);

Type guard

const isAllowedHost = (u) => { try { return ALLOWED_GEM_HOSTS.has(new URL(u).hostname); } catch { return false; } };

Try / catch

try { assertGemUrl(url); } catch (e) { if (e.message.includes('untrusted hostname')) console.error('SSRF guard tripped — verify this domain really belongs to Gem before allowlisting'); throw e; }

Prevention

When it happens

Trigger: Configuring a board with api: https://evil.example.com/... or a mirror/proxy host not in ALLOWED_GEM_HOSTS; a typo'd hostname (boards.gem.co instead of the allowed one); a vendor-supplied URL pointing at a custom domain.

Common situations: Adding a board whose URL lives on a newly introduced Gem domain added after this library's allowlist was written; typosquat or config-injection attempts (which the check exists to block); copy-pasting a third-party aggregator URL.

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


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/0ada6427eba2b0f1. Report an issue: GitHub.

Appendix: source

Thrown at providers/gem.mjs:127

  const body = htmlToText(posting?.descriptionHtml);
  const outro = htmlToText(posting?.jobPostSectionHtml?.outroHtml);
  const compensation = htmlToText(posting?.compensationHtml);

  const text = [intro, body, outro].filter(Boolean).join('\n\n');
  return compensation ? [text, `Compensation: ${compensation}`].filter(Boolean).join('\n\n') : text;
}

/** @param {string} url */
function assertGemUrl(url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`gem: invalid URL: ${url}`);
  }
  if (parsed.protocol !== 'https:') throw new Error(`gem: URL must use HTTPS: ${url}`);
  if (!ALLOWED_GEM_HOSTS.has(parsed.hostname))
    throw new Error(`gem: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GEM_HOSTS].join(', ')}`);
  return url;
}

/** Resolve an explicitly pinned URL for Gem's documented REST job-board API. */
function resolveRestApiUrl(entry) {
  const raw = typeof entry.api === 'string' ? entry.api : '';
  if (!raw) return null;
  let parsed;
  try {
    parsed = new URL(raw);
  } catch {
    return null;
  }
  if (parsed.protocol !== 'https:' || parsed.hostname !== 'api.gem.com') return null;
  if (!/^\/job_board\/v0\/[^/?#]+\/job_posts\/?$/.test(parsed.pathname)) return null;
  return parsed;
}

View on GitHub (pinned to aac998c7ed)