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
- Use the public, internet-reachable URL of the posting instead of the internal/LAN address
- For local testing, don't route through fetchJobPage — feed the JD text directly to the evaluation step or use a jds/ capture
- 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
- 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
- Test scrapers against public pages or use direct unit fixtures instead of localhost mock servers routed through fetchJobPage
- Don't put intranet-only careers hosts in portals.yml — capture those postings manually
- Treat this block as intentional security behavior, not a bug to work around
- Review pipeline.md for internal IPs/hostnames before running scans on a corporate network
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
- Access denied: Egress guard blocked private target IP
- Access denied: Egress guard blocked private target IP
- arbeitnow: untrusted hostname
- ashby: untrusted hostname
- bamboohr: untrusted hostname
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)