{"record":{"id":"831e78bf354b7bb9","repo":"santifer/career-ops","slug":"blocked-request-to-restricted-destination-dns","errorCode":null,"errorMessage":"   Blocked request to restricted destination (DNS): ${requestUrl} - ${err.message}","messagePattern":"   Blocked request to restricted destination \\(DNS\\): (.+?) - (.+?)","errorType":"console","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"archive-posting.mjs","lineNumber":297,"sourceCode":" * catches a public hostname resolving into private space.\n *\n * @param {import('playwright').BrowserContext} context - Context to guard.\n */\nexport async function installEgressGuard(context) {\n  await context.route('**/*', async (route) => {\n    const requestUrl = route.request().url();\n\n    const verdict = rejectPrivateOrInvalid(requestUrl);\n    if (verdict) {\n      console.warn(`   Blocked request to restricted destination: ${requestUrl} (${verdict.reason})`);\n      return route.abort('blockedbyclient');\n    }\n\n    try {\n      await validateUrlSecurity(requestUrl);\n      return route.continue();\n    } catch (err) {\n      console.warn(`   Blocked request to restricted destination (DNS): ${requestUrl} - ${err.message}`);\n      return route.abort('blockedbyclient');\n    }\n  });\n}\n\nexport async function archiveUrl(browser, url, { company: companyHint, role: roleHint } = {}) {\n  console.log(`\\n🔗  ${url}`);\n\n  // Refuse before launching any navigation, so an obviously-internal target\n  // never reaches Playwright at all.\n  const preGuard = rejectPrivateOrInvalid(url);\n  if (preGuard) {\n    throw new Error(`refusing to archive restricted destination: ${preGuard.reason}`);\n  }\n\n  const context = await browser.newContext();\n  await installEgressGuard(context);\n  const page = await context.newPage();","sourceCodeStart":279,"sourceCodeEnd":315,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/archive-posting.mjs#L279-L315","documentation":"Not a thrown exception but a warn+abort in archive-posting.mjs's Playwright route handler: after the syntactic rejectPrivateOrInvalid() check passes, validateUrlSecurity(requestUrl) DNS-resolves every request URL; if it resolves to a private/loopback IP the request is aborted ('blockedbyclient') and this warning is printed. It is the per-request, per-hop continuation of the SSRF egress guard during page.archive navigation.","triggerScenarios":"Calling archiveUrl(browser, url) where the page (or any subresource/redirect) references a hostname resolving to 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1, fe80::/10, or fc00::/7 — e.g. split-horizon DNS, wildcard DNS to a router IP, or an internal CDN asset on the posting page. The main navigation still proceeds; individual requests get dropped.","commonSituations":"Archiving a posting whose careers page pulls telemetry or assets from an internal-only host; VPN split-horizon DNS making public-looking names private; DNS hijacking ISPs; localhost-linked fonts/scripts in self-hosted ATS instances.","solutions":["Verify what the blocked hostname resolves to: `dig +short <host>` — if private, the page asset genuinely is internal and its loss is usually cosmetic","Fix local DNS (disconnect VPN / remove hosts-file overrides) if the name should be public, then re-run archive-posting","Inspect the archived PDF: if a blocked subresource broke rendering, archive from the direct ATS job URL instead of the marketing careers page"],"exampleFix":"# before\nnode archive-posting.mjs https://careers.example.com/jobs/123   # asset host resolves to 10.0.0.9 -> blocked per-request\n# after\ndig +short assets.example.com        # confirm split-horizon DNS, fix resolver, then re-run:\nnode archive-posting.mjs https://boards.example.com/companies/example/jobs/123","handlingStrategy":"validation","validationCode":"import { lookup } from 'dns/promises';\nasync function resolvesPrivately(hostname) {\n  const addrs = await lookup(hostname, { all: true }).catch(() => []);\n  const priv = (a) => /^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|169\\.254\\.)/.test(a) || a === '::1' || /^(fe80:|fc00:|fd00:)/.test(a);\n  return addrs.some((x) => priv(x.address));\n}\nif (await resolvesPrivately(new URL(pageUrl).hostname)) throw new Error('target resolves privately');","typeGuard":null,"tryCatchPattern":"// route handler pattern already used by archive-posting.mjs\nawait route.request().url();\ntry {\n  await validateUrlSecurity(requestUrl);\n  await route.continue();\n} catch (err) {\n  console.warn(`blocked: ${requestUrl} (${err.message})`);\n  await route.abort('blockedbyclient'); // never follow private targets\n}","preventionTips":["Feed archive-posting only public ATS URLs, never intranet mirrors","Suspect split-horizon/wildcard DNS when public-looking names get blocked — verify with dig +short","Archive from the direct job-posting URL so blocked internal subresources cannot break the capture"],"tags":["ssrf","security","playwright","route-blocking","dns"],"backgroundTag":"ssrf-protection","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}