{"record":{"id":"4ad400b40b242a2f","repo":"santifer/career-ops","slug":"access-denied-localhost-or-internal-domain-target","errorCode":null,"errorMessage":"Access denied: Localhost or internal domain target detected.","messagePattern":"Access denied: Localhost or internal domain target detected\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"upskill.mjs","lineNumber":797,"sourceCode":"\n  if (failures.length > 0) {\n    console.error(`upskill self-test failed: ${failures.join('; ')}`);\n    process.exit(1);\n  }\n  console.log('upskill self-test OK (extraction, suppression guards, weighting, tiering, report parsing, known-skills comment handling)');\n  process.exit(0);\n}\n\n// Helper function to enforce egress guard against SSRF (Private/Loopback IPs)\nconst dnsCache = new Map();\n\nasync function validateUrlSecurity(urlString) {\n  const dns = await import('dns/promises');\n  const url = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);\n  const hostname = url.hostname;\n\n  if (hostname === 'localhost' || hostname.endsWith('.local')) {\n    throw new Error('Access denied: Localhost or internal domain target detected.');\n  }\n\n  let addresses;\n  if (dnsCache.has(hostname)) {\n    addresses = dnsCache.get(hostname);\n  } else {\n    addresses = await dns.resolve(hostname).catch(() => []);\n    const lookupRes = await dns.lookup(hostname).catch(() => null);\n    if (lookupRes) addresses.push(lookupRes.address);\n    dnsCache.set(hostname, addresses);\n  }\n\n  for (const ip of addresses) {\n    if (/^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|169\\.254\\.)/.test(ip)) {\n      throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);\n    }\n    if (ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:')) {\n      throw new Error(`Access denied: Egress guard blocked private target IPv6 ${ip}`);","sourceCodeStart":779,"sourceCodeEnd":815,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/upskill.mjs#L779-L815","documentation":"Thrown by upskill.mjs's SSRF egress guard (validateUrlSecurity) before any network access when the URL's hostname is exactly 'localhost' or ends with '.local'. The guard exists so a JD URL passed via --url-text (or as a bare URL argument) can never steer the tool at the local machine or an mDNS-style internal name. It is a deliberate fail-closed security block, not a malfunction.","triggerScenarios":"Running `node upskill.mjs --url-text http://localhost:3000/jd` or `node upskill.mjs https://intranet.local/posting`. The check fires on the URL() hostname after stripping one trailing dot, so 'localhost.', 'http://localhost:8080/x', and '*.local' names all trigger it. It also fires per-request inside the Playwright route handler for every subresource and redirect hop.","commonSituations":"Pointing the tool at a local dev server or LAN staging box while testing; corporate intranet hostnames under .local; pasting an internal ATS sandbox link instead of the public posting URL; a JD page whose redirects or assets reference internal .local hosts.","solutions":["Use the public HTTPS URL of the job posting instead of a localhost/.local address","If you control the test fixture, serve it from a hostname that is neither 'localhost' nor *.local and resolves publicly","Pass the JD as saved text through the non-URL input paths of upskill instead of a URL","Do not patch the guard out wholesale — it is SSRF protection; narrow any exception consciously"],"exampleFix":"# before\nnode upskill.mjs --url-text http://localhost:3000/jd.txt\n# after\nnode upskill.mjs --url-text https://boards.example.com/jobs/1234","handlingStrategy":"validation","validationCode":"import { isEgressBlockedHostname } from './upskill.mjs'; // or inline:\nfunction isBlockedHost(urlString) {\n  const clean = urlString.endsWith('.') ? urlString.slice(0, -1) : urlString;\n  const h = new URL(clean).hostname.toLowerCase();\n  return h === 'localhost' || h.endsWith('.local');\n}\nif (isBlockedHost(inputUrl)) throw new Error(`refusing internal target: ${inputUrl}`);","typeGuard":"function isPublicHttpUrl(urlString) {\n  try {\n    const u = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);\n    return /^https?:$/.test(u.protocol) && u.hostname !== 'localhost' && !u.hostname.endsWith('.local');\n  } catch {\n    return false;\n  }\n}","tryCatchPattern":"try {\n  await validateUrlSecurity(url);\n} catch (err) {\n  if (String(err.message).startsWith('Access denied: Localhost')) {\n    // policy block: surface to caller, do not retry\n    throw new Error(`internal target refused: ${url}`);\n  }\n  throw err;\n}","preventionTips":["Never pass localhost/.local or intranet URLs to upskill's URL mode","Keep a whitelist of public ATS hosts and validate input against it before invoking","For local fixtures, prefer file-based input over an HTTP URL"],"tags":["ssrf","security","egress-guard","url-validation"],"backgroundTag":"ssrf-protection","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}