{"record":{"id":"88d131c2a38875db","repo":"santifer/career-ops","slug":"refusing-to-archive-restricted-destination-after-r","errorCode":null,"errorMessage":"refusing to archive restricted destination after redirect: ${postGuard.reason}","messagePattern":"refusing to archive restricted destination after redirect: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"archive-posting.mjs","lineNumber":327,"sourceCode":"  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    }\n\n    // Give SPAs (Ashby, Lever, Workday) time to hydrate\n    await page.waitForTimeout(2000);\n\n    const pageTitle = await page.title();\n    const h1Text = await page.$eval('h1', el => el.innerText.trim()).catch(() => '');\n    const urlCompany = extractCompanyFromUrl(url);\n\n    // Parse page title first — it usually has \"Role | Company\" or \"Company | Role\".\n    // Fall back to h1 for the role when the page title doesn't yield one cleanly.\n    const detected = parsePageTitle(pageTitle);\n    const resolvedCompany = overrideCompany || companyHint || detected.company || urlCompany || 'unknown';\n    const resolvedRole = overrideRole || roleHint || detected.role || h1Text || 'job';\n\n    // Strip noisy prefixes common on Greenhouse/Lever (\"Job Application for …\")\n    const company = resolvedCompany.replace(/^job\\s+application\\s+for\\s+/i, '').trim();\n    const role = resolvedRole.replace(/^job\\s+application\\s+for\\s+/i, '').trim();","sourceCodeStart":309,"sourceCodeEnd":345,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/archive-posting.mjs#L309-L345","documentation":"Thrown by archiveUrl() after page.goto() settles: it re-runs rejectPrivateOrInvalid() on page.url(), the URL actually landed on. This is defence-in-depth behind the per-request route guard (which aborts private redirect hops as 'blockedbyclient') — a public first-hop URL that ends up on a private/loopback/invalid destination is rejected before any content is saved.","triggerScenarios":"A public job URL that 30x-redirects to an internal host (open redirect, vendor SSO bouncing to an intranet FQDN that matches a private pattern, or a misconfigured shortlink); any case where the settled URL differs from the requested one and lands on a blocked host.","commonSituations":"ATS links that bounce through an SSO domain resolving to private space; a posting service redirecting to an internal staging host; tampered or rotting shortlinks in an old pipeline.md queue.","solutions":["Trace the chain yourself (curl -sIL <url>) and pass the final public URL directly to archive-posting","If the final destination is legitimately internal, archive it manually into jds/ — the guard is intentional and not bypassable via this tool","If a public posting unexpectedly redirects to a private host, treat the link as broken/untrusted, drop it from the pipeline, and note it in the tracker"],"exampleFix":"# before\nnode archive-posting.mjs https://short.example.com/j/9317   # 302 -> http://10.2.3.4/ats/9317\n\n# after\ncurl -sIL https://short.example.com/j/9317 | grep -i '^location'\nnode archive-posting.mjs https://ats.acme-public.com/careers/9317   # archive the real public destination","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await archiveUrl(browser, url);\n} catch (e) {\n  if (/restricted destination after redirect/.test(e.message)) {\n    // redirect target unknowable beforehand: resolve the chain out-of-band,\n    // archive the final PUBLIC url, or fall back to a manual capture.\n    console.warn(`redirect guard: ${url} -> private destination; skipping`);\n    continue; // next queue entry\n  }\n  throw e;\n}","preventionTips":["Pre-resolve shortlinks with curl -sIL before queueing so redirects to private space are filtered up front","Watch for SSO-wrapped application links — they are the usual source of private redirect targets","Log blocked-after-redirect URLs separately; they often indicate a dead or tampered posting worth dropping from the pipeline"],"tags":["security","ssrf","redirects","network","playwright","archive-posting"],"backgroundTag":"ssrf-redirect-blocked","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}