santifer/career-ops · error · Error

Refusing private/loopback host

Error message

Refusing private/loopback host: ${host}

What it means

Third gate of assertSafeRemoteUrl: after protocol validation, the hostname is checked against loopback, link-local (169.254/16, including the 169.254.169.254 cloud-metadata endpoint), private (10/8, 172.16/12, 192.168/16), localhost, ::1, and .local mDNS names. A match throws 'Refusing private/loopback host'. Even though the URLs come from the user's own config, the library fails closed to prevent SSRF against internal infrastructure.

Solutions

  1. Use the public, internet-reachable URL of the posting instead of the internal/LAN address
  2. For local testing, don't route through fetchJobPage — feed the JD text directly to the evaluation step or use a jds/ capture
  3. If the company genuinely hosts careers only on an internal host, capture the posting manually (paste/save the text) rather than pointing the scanner at it
  4. Temporarily expose a test server via a public tunnel (with caution) only if you must exercise fetchJobPage end-to-end

Example fix

// before (testing against local mock)
url: http://localhost:3000/jobs/42

// after: point at the real posting, or capture locally
url: https://jobs.example.com/postings/42
// ...or save JD text to jds/ and pass local:jds/example-42.md
Defensive patterns

Strategy: validation

Validate before calling

const BLOCKED = /^(localhost$|::1$|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)|\.local$/i;
function isPublicHost(s) {
  try { return !BLOCKED.test(new URL(s).hostname.toLowerCase()); } catch { return false; }
}
// if (!isPublicHost(url)) skip entry;

Type guard

function isPublicHttpUrl(v) {
  try {
    const u = new URL(v);
    if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
    return !/^(localhost|::1|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname.toLowerCase());
  } catch { return false; }
}

Try / catch

try {
  const text = await fetchJobPage(url);
} catch (e) {
  if (e.message.startsWith('Refusing private/loopback host')) {
    console.error(`${e.message} — use the public URL or a jds/ capture for internal postings`);
    return null;
  }
  throw e;
}

Prevention

When it happens

Trigger: fetchJobPage is pointed at a host like http://localhost:3000/jobs, http://127.0.0.1:8080, http://192.168.1.10/careers, http://10.0.0.5/, http://169.254.169.254/latest/meta-data, or http://myserver.local — any private/loopback/link-local address in portals.yml, pipeline.md, or a passed URL.

Common situations: Developer testing the pipeline against a locally running mock job server, an internal careers portal behind the corporate firewall configured in portals.yml, a staging ATS reachable only on the LAN, or a copy-pasted internal IP from an intranet posting.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


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

Appendix: source

Thrown at openrouter-runner.mjs:417

}

// ---------------------------------------------------------------------------
// Job page content fetcher (Playwright-first, plain fetch fallback)
// ---------------------------------------------------------------------------
// Reject unsafe fetch targets (SSRF defense-in-depth): http(s) only, never
// loopback / link-local / private / cloud-metadata hosts. URLs come from the
// user's own portals.yml / pipeline.md, but we still fail closed.
function assertSafeRemoteUrl(url) {
  let u;
  try { u = new URL(url); } catch { throw new Error(`Invalid URL: ${url}`); }
  if (u.protocol !== 'https:' && u.protocol !== 'http:') {
    throw new Error(`Refusing non-HTTP(S) URL: ${url}`);
  }
  const host = u.hostname.toLowerCase();
  const blocked = host === 'localhost' || host === '::1' || host.endsWith('.local') ||
    /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
    /^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
  if (blocked) throw new Error(`Refusing private/loopback host: ${host}`);
  return u;
}

async function fetchJobPage(url) {
  assertSafeRemoteUrl(url);
  let chromium;
  try {
    ({ chromium } = await import('playwright'));
  } catch {
    console.warn('[fetch] Playwright unavailable — falling back to plain fetch.');
  }

  if (chromium) {
    let browser;
    try {
      browser = await chromium.launch({ headless: true });
      const page = await browser.newPage();
      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });

View on GitHub (pinned to aac998c7ed)