/** * liveness-browser.mjs — Playwright-driven liveness check for a single URL. * * Shared by check-liveness.mjs (CLI tool) and scan.mjs (--verify flag). * Returns the same shape as classifyLiveness: { result, reason }. */ import { classifyLiveness, jobIdToken, JOB_ID_PATTERN } from './liveness-core.mjs'; import { BROWSER_LIKE_USER_AGENT } from './user-agent.mjs'; import { SINKHOLE_ADDRESSES } from './providers/_ip-guard.mjs'; import { browserLaunchOptions } from './lib/browser-launch.mjs'; const NAVIGATE_TIMEOUT_MS = 15_000; // Single-page-app ATS render the posting after domcontentloaded, so the page // is read repeatedly until the verdict stops being a "not rendered yet" code; // a single early read would call a live posting insufficient_content. // Measured over 386 loads of 188 posting URLs: the first decisive verdict // arrived at p50 1785ms, p95 3071ms and at most 3308ms outside iCIMS (whose // frames have their own poll below); 17 reads 250ms apart cover 4000ms. Bounded by a count rather than a clock so a // page double whose waitForTimeout returns at once still terminates. // Once a read is decisive no later read flipped a live posting to closed, so // stopping at the first decisive read is safe. Stopping when the text stops // changing is NOT: a spinner holds the text constant on live pages. const HYDRATION_POLL_MS = 250; const HYDRATION_MAX_POLLS = 16; export const HYDRATING_CODES = new Set(['insufficient_content', 'no_apply_control']); // Upper bound on the extra wait for same-origin child frames to populate, and // the poll interval inside it. Only spent when the page poll ends undecided // while such a frame is present. const FRAME_CONTENT_TIMEOUT_MS = 6_000; const FRAME_CONTENT_POLL_MS = 500; // A failed navigation is not over when page.goto rejects: Chromium commits its // error page (chrome-error://chromewebdata/) just after, 1-69 ms later over 36 // failed loads (a host with no address, four kinds of TLS failure). Playwright // reports a goto that is still waiting when that commit lands as "interrupted // by another navigation" (microsoft/playwright#35944), and Chromium goes on // loading the URL that goto asked for, whose late commit then interrupts the // goto after it. On the one page that check-liveness and scan --verify share, // one such failure turned the checks after it into navigation_errors. So a // check whose navigation failed waits for that commit before it returns, up to // this long. A goto that timed out has nothing to wait for: no error page // commits, and the next goto cancels it. const FAILED_NAVIGATION_COMMIT_MS = 2_000; // The query parameters that carry a posting's own id, from the providers that // put it there: UltiPro's opportunityId, SchoolSpring's and Deutsche Bahn's // jobId, Tencent's postId, AppliTrack's AppliTrackJobId, Meituan's jobUnionId, // and gh_jid on a careers page that embeds Greenhouse. Any other query value, // such as a campaign or tracking number, is not the posting's id. const POSTING_ID_PARAMS = new Set(['opportunityid', 'jobid', 'postid', 'applitrackjobid', 'jobunionid', 'gh_jid']); // BambooHR's client bundle can throw during first paint — a failed // /globals/locale request followed by an uncaught TypeError reading // `hasPasskey` on null — which halts the SPA on its bare loading spinner for // the whole hydration poll, so a live posting reads as insufficient_content. The // posting itself is untouched; a reload clears it. This is a shared // front-end bug across every *.bamboohr.com tenant (not one company's // board) and common enough to matter: rerun checkUrlLiveness in a loop // against any live *.bamboohr.com posting URL with this branch disabled to // see the current failure rate. // // Scoped to this one host on purpose, not a generic "flaky ATS" mechanism: no // other provider has shown this failure signature, and retrying every // insufficient-content verdict on every host would pay an extra page load on // every genuinely dead posting for no evidence of benefit elsewhere. If a // second ATS turns up the same symptom, generalize then. const BAMBOOHR_HOSTS = [/(^|\.)bamboohr\.com$/]; function isBambooHrHost(hostname) { return BAMBOOHR_HOSTS.some((pattern) => pattern.test(hostname)); } /** * Same-origin test used to decide whether a child frame is part of the posting * or somebody else's widget. Deliberately strict: about:blank, data: frames, * tag managers and ad iframes all fail it, so only the ATS's own embedded * document contributes text and apply controls. */ export function sameOrigin(frameUrl, pageUrl) { try { const a = new URL(frameUrl); const b = new URL(pageUrl); if (a.protocol !== 'http:' && a.protocol !== 'https:') return false; return a.origin === b.origin; } catch { return false; } } // The default Playwright headless UA contains "HeadlessChrome", which Cloudflare // and similar WAFs flag — portals like pracuj.pl then serve a 403 challenge page // instead of the posting. Presenting a normal desktop Chrome UA clears the wall // headlessly (the scan parser scripts/parsers/pracuj-jobs.mjs relies on the same // trick), so the common case never needs the slower headed-browser fallback. export const LIVENESS_CONTEXT_OPTIONS = { userAgent: BROWSER_LIKE_USER_AGENT, locale: 'en-US', }; // Open a page in a context that already presents a realistic UA. Both callers use // this instead of browser.newPage() so headless checks aren't instantly bot-walled. export async function newLivenessPage(browser) { const context = await browser.newContext(LIVENESS_CONTEXT_OPTIONS); return context.newPage(); } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Throttle delay with jitter: a value in [baseMs, 2*baseMs). Spacing requests out // (and randomizing the gap) keeps a bulk run under rate-based WAF thresholds — // pracuj.pl's Cloudflare flags the session after ~2 rapid hits, after which even // headed retries are blocked. A randomized gap also avoids a fixed-cadence // fingerprint. Returns 0 for a non-positive base (throttling disabled). export function jitteredDelayMs(baseMs) { if (!baseMs || baseMs <= 0) return 0; return baseMs + Math.floor(Math.random() * baseMs); } // Per-host spacing for a browser batch. A WAF rate limit is per site, so a run // across many sites only needs to space out repeat visits to the SAME host // (exact hostname, `www.` ignored; careers.x.com and jobs.x.com count as two): // the gap since that host's last browser check ended counts toward its // jittered delay, and a host seen for the first time waits nothing. With a global gap, 641 URLs // over ~40 hosts cost 5-10 s each (~1.5 h); per host, only same-site runs wait. const hostKey = (url) => { try { return new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return String(url); } }; export function createHostSpacer(baseMs, { now = Date.now, delay = jitteredDelayMs } = {}) { const lastVisit = new Map(); return { /** Milliseconds to wait before a browser check of `url`. */ waitBefore(url) { if (!baseMs || baseMs <= 0) return 0; const last = lastVisit.get(hostKey(url)); if (last == null) return 0; return Math.max(0, delay(baseMs) - (now() - last)); }, /** Record that `url`'s host was just visited by the browser. */ visited(url) { lastVisit.set(hostKey(url), now()); }, }; } // Defensive guards: URLs come from ATS feeds (mostly trusted) but a misconfigured // portals.yml entry or a hijacked feed shouldn't be able to point Playwright at // internal infrastructure. Only allow http(s) and reject loopback/private/link-local. // // The hostname coming out of `new URL(...)` needs normalization before the regex // pass, because the WHATWG URL parser surfaces several encodings that bypass a // naive match against `parsed.hostname`: // 1. IPv6 hosts are serialized with brackets — `new URL('http://[::1]/').hostname` // is `'[::1]'`, so a regex like `/^::1$/` never fires unless brackets are stripped. // 2. FQDN trailing dot is preserved — `localhost.` reaches the network as // localhost, but `/^localhost$/` doesn't match it. // 3. IPv4-mapped IPv6 (`::ffff:127.0.0.1` or the hex form `::ffff:7f00:1`) // routes to the embedded IPv4 in Chromium, so the embedded address must // also be matched against the IPv4 block list. // `0.0.0.0` and the all-zeros IPv6 `::` both reach loopback on Linux and need // explicit entries; the original list omitted them. // // IPv6 ULA is fc00::/7 (RFC 4193): both fc00::/8 and fd00::/8. IPv6 // link-local is fe80::/10 (RFC 4291): fe80–febf, not only the fe80: prefix. const PRIVATE_HOST_PATTERNS = [ /^localhost$/, /^localhost\.localdomain$/, /^0\.0\.0\.0$/, /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /^::1$/, /^::$/, /^f[cd][0-9a-f]{2}:/, /^fe[89ab][0-9a-f]:/, ]; // Lowercase, strip IPv6 brackets, strip FQDN trailing dot. The `hostname` // returned by `new URL(...)` is already percent-decoded and IDNA-normalized, // but it preserves brackets around IPv6 hosts and trailing dots on FQDNs. function normalizeHost(rawHostname) { if (!rawHostname) return ''; let h = String(rawHostname).toLowerCase(); if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1); if (h.endsWith('.')) h = h.slice(0, -1); return h; } // IPv4-mapped IPv6 (RFC 4291 §2.5.5.2): `::ffff:0:0/96` routes to the embedded // IPv4 address. Two textual forms — dotted (`::ffff:127.0.0.1`) and pure-hex // (`::ffff:7f00:1`). Return the embedded IPv4 in dotted-decimal form, or null // if `host` is not an IPv4-mapped IPv6. function extractMappedIPv4(host) { const dotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); if (dotted) return dotted[1]; const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (hex) { const a = parseInt(hex[1], 16); const b = parseInt(hex[2], 16); return `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`; } return null; } // Returns null when the URL is safe to fetch, otherwise a structured guard // result with a stable `code` (used for routing in scan.mjs) plus a human // `reason`. Stable codes — not regex on reason strings — drive downstream // dispatch so the wording can change freely without breaking callers. // // Exported for unit tests; the main entry point is checkUrlLiveness. export function rejectPrivateOrInvalid(url) { let parsed; try { parsed = new URL(url); } catch { return { code: 'invalid_url', reason: 'invalid URL' }; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return { code: 'unsupported_protocol', reason: `unsupported protocol ${parsed.protocol}` }; } const host = normalizeHost(parsed.hostname); const mappedIPv4 = extractMappedIPv4(host); const candidates = mappedIPv4 ? [host, mappedIPv4] : [host]; for (const candidate of candidates) { if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(candidate))) { return { code: 'blocked_host', reason: `blocked host ${parsed.hostname}` }; } } return null; } const dnsCache = new Map(); // Hold failures for 30 s, the same TTL as providers/_dns-cache.mjs, so an outage // is not pinned for the whole process. Unlike that cache, this includes NXDOMAIN: // resolveViaDns turns all lookup failures into an empty answer. const DNS_NEGATIVE_TTL_MS = 30_000; // Real DNS: resolve4 + resolve6 + lookup, each tolerant of its own failure, so a // host that only answers on one of the three still yields an address list. async function resolveViaDns(hostname) { const dns = await import('dns/promises'); const [ipv4, ipv6, lookupList] = await Promise.all([ dns.resolve4(hostname).catch(() => []), dns.resolve6(hostname).catch(() => []), dns.lookup(hostname, { all: true }).catch(() => []) ]); return Array.from(new Set([ ...ipv4, ...ipv6, ...lookupList.map(item => item.address) ])); } let hostResolver = resolveViaDns; /** * Swap the resolver the egress guard uses, returning a restore function. * * `dns/promises` is imported dynamically and the guard calls the ESM namespace * bindings, which are immutable — monkey-patching the module object has no * effect on them (#2386). Without this seam a test can only ever reach the * "host resolved to nothing" branch: the real resolver returns an empty list * for the synthetic hostname, the guard blocks on that, and the loopback * rejection the test exists to cover never runs. The memo cache is cleared on * every swap, in both directions, so a verdict computed under one resolver can * never be served to the next. * * @param {((hostname: string) => Promise)|null} resolver - Resolver to * install, or null to restore the real DNS one. * @returns {() => void} Restores the resolver in place before this call. */ export function setHostResolver(resolver) { const previous = hostResolver; hostResolver = resolver ?? resolveViaDns; dnsCache.clear(); return () => { hostResolver = previous; dnsCache.clear(); }; } async function resolveDnsCached(hostname) { if (dnsCache.has(hostname)) { const cached = dnsCache.get(hostname); if (Array.isArray(cached)) return cached; if (Date.now() < cached.expiresAt) throw cached.error; dnsCache.delete(hostname); } try { const addresses = await hostResolver(hostname); if (addresses.length === 0) { // Tagged so the route guard can tell "this host does not exist" apart from // "this host resolves into private space". The first is a dead third-party // script, the second is an egress-guard hit. Only the second says anything // about the page being checked. const missing = new Error(`DNS resolution returned no addresses for ${hostname}`); missing.livenessCode = 'dns_no_addresses'; throw missing; } dnsCache.set(hostname, addresses); return addresses; } catch (err) { if (!Array.isArray(dnsCache.get(hostname))) { dnsCache.set(hostname, { error: err, expiresAt: Date.now() + DNS_NEGATIVE_TTL_MS }); } throw err; } } // Second layer of the egress guard: `rejectPrivateOrInvalid` only sees the // literal host, so a public hostname that *resolves* to private space still // gets through it. Resolve and re-check every address before the request is // allowed out. Exported so other Playwright callers (archive-posting.mjs) wire // up the same two-layer guard instead of growing a second implementation. export async function validateUrlSecurity(urlString) { const url = new URL(urlString.endsWith('.') ? urlString.slice(0, -1) : urlString); const hostname = url.hostname; const host = normalizeHost(hostname); const addresses = await resolveDnsCached(host); // Every private address, not only the first: whether a DNS filter gave the // answer depends on all of them. const blocked = []; for (const ip of addresses) { const norm = normalizeHost(ip); const mapped = extractMappedIPv4(norm); const candidates = mapped ? [norm, mapped] : [norm]; const hit = candidates.find((candidate) => PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(candidate))); if (hit) blocked.push({ ip, hit }); } if (blocked.length > 0) { const denied = new Error(`Access denied: Egress guard blocked private target IP ${blocked[0].ip}`); if (blocked.every(({ hit }) => SINKHOLE_ADDRESSES.has(hit))) denied.livenessCode = 'dns_sinkhole'; throw denied; } } // Rediscovery can navigate a newly created page before its first liveness // check. Install the same URL/DNS guards before that search as well. let suppressedSubresourceBlocks = 0; /** How many subresource requests were aborted for having no usable address since the last call (then resets). */ export function takeSuppressedBlockCount() { const n = suppressedSubresourceBlocks; suppressedSubresourceBlocks = 0; return n; } export async function installLivenessRouteGuard(page) { if (page && typeof page.route === 'function' && !page._routeInterceptorRegistered) { page._routeInterceptorRegistered = true; await page.route('**/*', async (route) => { const requestUrl = route.request().url(); const errGuard = rejectPrivateOrInvalid(requestUrl); if (errGuard) { console.warn(`Blocked request to restricted destination: ${requestUrl}`); page._blockedByGuard = errGuard; return route.abort('blockedbyclient'); } try { await validateUrlSecurity(requestUrl); return route.continue(); } catch (err) { // A host that resolves to nothing is a DEAD THIRD-PARTY SCRIPT, not a // statement about the posting. Measured 2026-08-14 over a 217-URL // recheck: 78 live postings were returned as `uncertain` because an // analytics or ad host on the page no longer exists — 53 on // personalisation.visitorqueue.com, 17 on s7.addthis.com (AddThis was // shut down in 2023), the rest on fluidads and cloudfront. One was // opened by hand to confirm: 11,178 characters of live posting and a // working apply control, called uncertain because of a dead tracker. // // A tracker the user's own DNS filter blocks says nothing about the // posting either. validateUrlSecurity tags that answer `dns_sinkhole`. // Measured 2026-10-11 over 35 postings on 19 boards, with Control D // as the resolver: 20 read blocked_host because of Google Analytics, // DoubleClick, Cloudflare Insights, LinkedIn's scripts and the like, and // 11 of the 20 had a visible apply control. AdGuard DNS: 12 blocked_host. // // The request is still aborted either way, so the egress guard loses // nothing. Only the VERDICT stops being poisoned, and only for a // subresource: if the main document itself has no usable address, that is a // real finding about the posting and still counts. // // It counts as `uncertain` under its DNS tag, though, not as `blocked_host`. // That code says the URL points into private space, and `scan --verify` // records it as skipped_blocked_host, for good. No usable address can just // as well be a DNS outage or the user's own filter, which say nothing lasting // about the URL. The catch in checkUrlLiveness treats a DNS failure as // transient for the same reason, and `--verify` keeps this verdict as it // keeps a navigation_error. // // Whether this was the main document or a subresource can only be asked // of a real Playwright request. Callers may pass a lighter route double // (the test suite does, with request() returning just a url()), and for // those the answer is unknowable — so default to TRUE, which keeps the // pre-existing behaviour of poisoning the verdict. The relaxation only // applies where we can positively establish it was a subresource. const request = typeof route?.request === 'function' ? route.request() : null; const canTell = typeof request?.isNavigationRequest === 'function' && typeof request?.frame === 'function' && typeof page?.mainFrame === 'function'; const isMainDocument = canTell ? request.isNavigationRequest() && request.frame() === page.mainFrame() : true; const noUsableAddress = err?.livenessCode === 'dns_no_addresses' || err?.livenessCode === 'dns_sinkhole'; // A subresource with no usable address is counted, not logged: with a DNS // filter on, every page logged a line per tracker request (440 lines over // 142 postings on 2026-10-16) and buried the verdicts. Everything that // bears on a verdict — the main document, a private address — still logs. if (noUsableAddress && !isMainDocument) suppressedSubresourceBlocks++; else console.warn(`Blocked request to restricted destination (DNS): ${requestUrl} - ${err.message}`); if (!noUsableAddress) { page._blockedByGuard = { code: 'blocked_host', reason: err.message }; } else if (isMainDocument) { page._blockedByGuard = { code: err.livenessCode, reason: err.message }; } return route.abort('blockedbyclient'); } }); } } // One navigation of the page (a goto or a reload). If it fails, wait for the // main frame to commit Chromium's error page, so the page is settled for // whatever navigates it next (see FAILED_NAVIGATION_COMMIT_MS). No other // navigation ends the wait: a child frame's, or the old page changing its own // URL (a fragment or history change fires framenavigated too), would let the // next goto start before the error page commits and interrupts it. A lighter // page double without waitForEvent skips the wait. async function navigateSettled(page, navigate) { try { return await navigate(); } catch (err) { const canWait = typeof page.waitForEvent === 'function' && typeof page.mainFrame === 'function'; if (canWait && err?.name !== 'TimeoutError') { await page .waitForEvent('framenavigated', { predicate: (frame) => frame === page.mainFrame() && frame.url().startsWith('chrome-error:'), timeout: FAILED_NAVIGATION_COMMIT_MS, }) .catch(() => {}); } throw err; } } // A navigation that times out is retried once with a longer budget. A slow // careers page (block.xyz took >15 s on a cold load) is live but was answered // `uncertain` and stayed unresolved in the queue. Only a timeout earns the retry: // a refused connection, a DNS failure or a guard block fails exactly the same way // the second time, and the guard verdict must win as before. export const NAVIGATE_RETRY_TIMEOUT_MS = 30_000; export async function gotoWithTimeoutRetry(page, url, { timeoutMs = NAVIGATE_TIMEOUT_MS, retryTimeoutMs = NAVIGATE_RETRY_TIMEOUT_MS, } = {}) { const go = (timeout) => navigateSettled(page, () => page.goto(url, { waitUntil: 'domcontentloaded', timeout })); try { return await go(timeoutMs); } catch (err) { if (err?.name !== 'TimeoutError' || page?._blockedByGuard || !(retryTimeoutMs > timeoutMs)) throw err; return go(retryTimeoutMs); } } export const extractApplyControls = ({ postingId, postingSegment, pageUrl, idPattern }) => { // Page chrome (nav, header, footer) is skipped because its links are the // site's own: Jobs, Careers, Sign in. A link to this posting's own page, // on the page's own origin, is not chrome, wherever it sits; a link to // another site says nothing about this page's posting, even with its id // in the path. Tech in Asia puts its only Apply control, // "APPLY FOR JOB" -> /jobs//apply, in the page header: measured // 2026-10-22, 20 of 20 live postings read no_apply_control without this, // and 40 of 40 closed ones answer "Job not found" with no such link. Only // the path counts, so a sign-in link that carries the posting in its // return address (?returnUrl=/jobs/) is still chrome, and so is a // link back into this page under another query, fragment or trailing // slash, such as a skip link to #main-content. A sign-in or sign-up page // is chrome even under the posting's own path (/jobs//login): it is // the site's sign-in, not the posting. In a frame, the page the // frame sits in is this page too; its address is passed in, because a // frame sandboxed without allow-same-origin cannot read window.top. The // id has to be the link's own: its last path segment that carries an id, // whole, so posting 12345 claims neither /jobs/123456/apply nor // /jobs/job-12345/apply, nor /jobs/12345/related/67890/apply, whose own // posting is 67890. A posting whose own segment carries the id with more // around it claims that whole segment too: posting job-12345 claims // /jobs/job-12345/apply. const idBearing = new RegExp(idPattern, 'i'); const signIn = /^(?:log-?in|sign[-_]?(?:in|up)|register)(?:\.\w+)?$/; const linksToPosting = (element) => { const href = postingId && element.getAttribute('href'); if (!href) return false; try { const target = new URL(href, document.baseURI); const pageKey = (u) => u.origin + u.pathname.replace(/\/+$/, ''); const thisPage = [location.href, pageUrl].map((u) => pageKey(new URL(u))); if (thisPage.includes(pageKey(target))) return false; if (target.origin !== new URL(pageUrl).origin) return false; const segments = target.pathname.toLowerCase().split('/'); if (segments.some((s) => signIn.test(s))) return false; const own = segments.filter((s) => idBearing.test(s)).pop(); return own === postingId || own === postingSegment; } catch { return false; } }; // A design-system button is a custom element: UKG Pro (UltiPro) renders // Apply as Apply now, with no role on it. The // native