{"record":{"id":"8c2dbb3946db31ee","repo":"santifer/career-ops","slug":"refusing-private-loopback-host-host","errorCode":null,"errorMessage":"Refusing private/loopback host: ${host}","messagePattern":"Refusing private/loopback host: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"openrouter-runner.mjs","lineNumber":417,"sourceCode":"}\n\n// ---------------------------------------------------------------------------\n// Job page content fetcher (Playwright-first, plain fetch fallback)\n// ---------------------------------------------------------------------------\n// Reject unsafe fetch targets (SSRF defense-in-depth): http(s) only, never\n// loopback / link-local / private / cloud-metadata hosts. URLs come from the\n// user's own portals.yml / pipeline.md, but we still fail closed.\nfunction assertSafeRemoteUrl(url) {\n  let u;\n  try { u = new URL(url); } catch { throw new Error(`Invalid URL: ${url}`); }\n  if (u.protocol !== 'https:' && u.protocol !== 'http:') {\n    throw new Error(`Refusing non-HTTP(S) URL: ${url}`);\n  }\n  const host = u.hostname.toLowerCase();\n  const blocked = host === 'localhost' || host === '::1' || host.endsWith('.local') ||\n    /^127\\./.test(host) || /^10\\./.test(host) || /^192\\.168\\./.test(host) ||\n    /^169\\.254\\./.test(host) || /^172\\.(1[6-9]|2\\d|3[01])\\./.test(host);\n  if (blocked) throw new Error(`Refusing private/loopback host: ${host}`);\n  return u;\n}\n\nasync function fetchJobPage(url) {\n  assertSafeRemoteUrl(url);\n  let chromium;\n  try {\n    ({ chromium } = await import('playwright'));\n  } catch {\n    console.warn('[fetch] Playwright unavailable — falling back to plain fetch.');\n  }\n\n  if (chromium) {\n    let browser;\n    try {\n      browser = await chromium.launch({ headless: true });\n      const page = await browser.newPage();\n      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });","sourceCodeStart":399,"sourceCodeEnd":435,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/openrouter-runner.mjs#L399-L435","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before (testing against local mock)\nurl: http://localhost:3000/jobs/42\n\n// after: point at the real posting, or capture locally\nurl: https://jobs.example.com/postings/42\n// ...or save JD text to jds/ and pass local:jds/example-42.md","handlingStrategy":"validation","validationCode":"const BLOCKED = /^(localhost$|::1$|127\\.|10\\.|192\\.168\\.|169\\.254\\.|172\\.(1[6-9]|2\\d|3[01])\\.)|\\.local$/i;\nfunction isPublicHost(s) {\n  try { return !BLOCKED.test(new URL(s).hostname.toLowerCase()); } catch { return false; }\n}\n// if (!isPublicHost(url)) skip entry;","typeGuard":"function isPublicHttpUrl(v) {\n  try {\n    const u = new URL(v);\n    if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;\n    return !/^(localhost|::1|127\\.|10\\.|192\\.168\\.|169\\.254\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/.test(u.hostname.toLowerCase());\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  const text = await fetchJobPage(url);\n} catch (e) {\n  if (e.message.startsWith('Refusing private/loopback host')) {\n    console.error(`${e.message} — use the public URL or a jds/ capture for internal postings`);\n    return null;\n  }\n  throw e;\n}","preventionTips":["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"],"tags":["ssrf","security","network","url","private-host"],"backgroundTag":"path-traversal-blocked","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"}