{"record":{"id":"9928ffc8581a53ce","repo":"santifer/career-ops","slug":"refusing-to-archive-restricted-destination-preg","errorCode":null,"errorMessage":"refusing to archive restricted destination: ${preGuard.reason}","messagePattern":"refusing to archive restricted destination: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"archive-posting.mjs","lineNumber":310,"sourceCode":"\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();\n\n  try {\n    const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });\n    const httpStatus = response?.status() ?? 0;\n\n    // Re-check where we actually landed. The route guard already inspects every\n    // redirect hop, so this is defence-in-depth: a first-hop-only check is the\n    // classic miss here, and asserting on the settled URL costs nothing.\n    const landedUrl = page.url();\n    const postGuard = rejectPrivateOrInvalid(landedUrl);\n    if (postGuard) {\n      throw new Error(`refusing to archive restricted destination after redirect: ${postGuard.reason}`);\n    }","sourceCodeStart":292,"sourceCodeEnd":328,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/archive-posting.mjs#L292-L328","documentation":"Thrown by archiveUrl() in archive-posting.mjs before any Playwright navigation starts. It runs rejectPrivateOrInvalid() (exported from liveness-browser.mjs) — the repo's SSRF/egress guard — which rejects (a) unparseable URLs, (b) non-http(s) protocols, and (c) hosts matching private/loopback ranges: localhost, 0.0.0.0, 127/8, 10/8, 172.16-31, 192.168/16, 169.254/16, ::1, ::, fc00::/7 (fc..), link-local fe80::, including ::ffff:-mapped IPv4 forms. The appended reason is one of: 'invalid URL', 'unsupported protocol <p>', 'blocked host <h>'.","triggerScenarios":"`node archive-posting.mjs http://10.0.0.7/jobs/1` or any RFC1918/link-local/localhost target; ftp:// or file:// URLs; a pasted URL missing its scheme ('boards.greenhouse.io/jobs/123' fails new URL() → 'invalid URL').","commonSituations":"Testing against a locally running ATS or a staging instance on an internal network; intranet job boards that are private by nature; stripping the https:// prefix while pasting; corporate portals behind link-local addresses.","solutions":["Archive the public https:// URL of the posting instead of an internal mirror","If the URL lost its scheme, re-add it: https://<host>/<path> — scheme-less strings fail URL parsing outright","For genuinely internal portals the guard is by design: save the page manually (print to PDF) into jds/ instead of routing it through archive-posting","Never 'fix' this by pointing at a public DNS name that resolves to a private IP — the DNS-level egress guard will still block it"],"exampleFix":"# before\nnode archive-posting.mjs http://localhost:3000/jobs/123\n\n# after\nnode archive-posting.mjs https://boards.greenhouse.io/acme/jobs/123","handlingStrategy":"validation","validationCode":"import { rejectPrivateOrInvalid } from './liveness-browser.mjs';\nfunction assertPublicHttpUrl(url) {\n  const rejected = rejectPrivateOrInvalid(url);\n  if (rejected) throw new Error(`URL refused (${rejected.code}): ${url}`);\n}\nassertPublicHttpUrl(url);\nawait archiveUrl(browser, url);","typeGuard":"function isArchivableUrl(url) {\n  try { return rejectPrivateOrInvalid(url) === null; } catch { return false; }\n}","tryCatchPattern":null,"preventionTips":["Always include the scheme (https://) — scheme-less strings fail URL parsing before any network call","Pre-screen URL lists with the exported rejectPrivateOrInvalid() to report all offenders at once instead of failing one by one","Treat the refusal as correct behavior: internal targets must be archived manually, never by widening the guard"],"tags":["security","ssrf","url-validation","network","playwright","archive-posting"],"backgroundTag":"ssrf-url-blocked","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}