santifer/career-ops · error

Access denied: Localhost or internal domain target detected.

Error message

Access denied: Localhost or internal domain target detected.

What it means

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.

Source

Thrown at upskill.mjs:797

  if (failures.length > 0) {
    console.error(`upskill self-test failed: ${failures.join('; ')}`);
    process.exit(1);
  }
  console.log('upskill self-test OK (extraction, suppression guards, weighting, tiering, report parsing, known-skills comment handling)');
  process.exit(0);
}

// Helper function to enforce egress guard against SSRF (Private/Loopback IPs)
const dnsCache = new Map();

async function validateUrlSecurity(urlString) {
  const dns = await import('dns/promises');
  const url = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);
  const hostname = url.hostname;

  if (hostname === 'localhost' || hostname.endsWith('.local')) {
    throw new Error('Access denied: Localhost or internal domain target detected.');
  }

  let addresses;
  if (dnsCache.has(hostname)) {
    addresses = dnsCache.get(hostname);
  } else {
    addresses = await dns.resolve(hostname).catch(() => []);
    const lookupRes = await dns.lookup(hostname).catch(() => null);
    if (lookupRes) addresses.push(lookupRes.address);
    dnsCache.set(hostname, addresses);
  }

  for (const ip of addresses) {
    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|169\.254\.)/.test(ip)) {
      throw new Error(`Access denied: Egress guard blocked private target IP ${ip}`);
    }
    if (ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:')) {
      throw new Error(`Access denied: Egress guard blocked private target IPv6 ${ip}`);

View on GitHub (pinned to 60398d6549)

Solutions

  1. Use the public HTTPS URL of the job posting instead of a localhost/.local address
  2. If you control the test fixture, serve it from a hostname that is neither 'localhost' nor *.local and resolves publicly
  3. Pass the JD as saved text through the non-URL input paths of upskill instead of a URL
  4. Do not patch the guard out wholesale — it is SSRF protection; narrow any exception consciously

Example fix

# before
node upskill.mjs --url-text http://localhost:3000/jd.txt
# after
node upskill.mjs --url-text https://boards.example.com/jobs/1234
Defensive patterns

Strategy: validation

Validate before calling

import { isEgressBlockedHostname } from './upskill.mjs'; // or inline:
function isBlockedHost(urlString) {
  const clean = urlString.endsWith('.') ? urlString.slice(0, -1) : urlString;
  const h = new URL(clean).hostname.toLowerCase();
  return h === 'localhost' || h.endsWith('.local');
}
if (isBlockedHost(inputUrl)) throw new Error(`refusing internal target: ${inputUrl}`);

Type guard

function isPublicHttpUrl(urlString) {
  try {
    const u = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString);
    return /^https?:$/.test(u.protocol) && u.hostname !== 'localhost' && !u.hostname.endsWith('.local');
  } catch {
    return false;
  }
}

Try / catch

try {
  await validateUrlSecurity(url);
} catch (err) {
  if (String(err.message).startsWith('Access denied: Localhost')) {
    // policy block: surface to caller, do not retry
    throw new Error(`internal target refused: ${url}`);
  }
  throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20). Data as JSON: /api/errors/4ad400b40b242a2f. Report an issue: GitHub.