{"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":394,"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":376,"sourceCodeEnd":412,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/openrouter-runner.mjs#L376-L412","documentation":"Thrown by assertSafeRemoteUrl() when a URL host matches a loopback, link-local, private, or cloud-metadata address: localhost, ::1, *.local, 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16, or 172.16.0.0/12. This is the SSRF guard that prevents the runner (often running in a cloud context) from being directed at internal/metadata services.","triggerScenarios":"A job URL in pipeline.md/portals.yml points at a private RFC1918 host; a staging/test portal is hosted on an internal domain like https://intranet.company.local; a host that DNS-resolves to a private IP (note: this guard is hostname-string based, not resolution based, so it only catches literal private hostnames).","commonSituations":"Local development with a portal mirror on http://localhost or 192.168.x.x; a .local mDNS hostname; a cloud-metadata-style endpoint accidentally copy-pasted; an intranet-only ATS that career-ops cannot reach from outside the corporate network anyway.","solutions":["Point the entry at the public, externally-reachable URL for the same portal.","If you must fetch an internal resource, do so outside career-ops (fetchJobPage deliberately cannot reach private hosts).","For local testing, mock fetchJobPage or call parsePortals with rawOverride rather than hitting a loopback server.","Verify no portals.yml company has an api/careers_url resolving to a private range."],"exampleFix":"// before\ncareers_url: \"https://192.168.1.50/jobs\"\n// after\ncareers_url: \"https://careers.company.com/jobs\"","handlingStrategy":"validation","validationCode":"const PRIVATE_HOST_RE = /^(localhost|::1)|\\.local$|^(127\\.|10\\.|192\\.168\\.|169\\.254\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/i;\nfunction isPublicHttpUrl(s) {\n  let u;\n  try { u = new URL(s); } catch { return false; }\n  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;\n  return !PRIVATE_HOST_RE.test(u.hostname);\n}","typeGuard":"/** True only for http(s) URLs whose literal hostname is public (not private/loopback). */\nfunction isPublicHttpUrl(s) {\n  if (typeof s !== 'string') return false;\n  let u;\n  try { u = new URL(s); } catch { return false; }\n  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;\n  const h = u.hostname.toLowerCase();\n  return !(h === 'localhost' || h === '::1' || h.endsWith('.local') ||\n    /^127\\./.test(h) || /^10\\./.test(h) || /^192\\.168\\./.test(h) ||\n    /^169\\.254\\./.test(h) || /^172\\.(1[6-9]|2\\d|3[01])\\./.test(h));\n}","tryCatchPattern":null,"preventionTips":["Keep all portals.yml api/careers_url entries on public domains.","Add a CI check that scans config for private/loopback hosts.","Remember the guard is string-based — it will not catch a public hostname that DNS-resolves to a private IP."],"tags":["ssrf","network","security","private-network","openrouter-runner"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}