fix(tiers): prevent stale challenge responses

This commit is contained in:
germondai
2026-08-09 19:59:11 +02:00
parent daff2c9f0e
commit b37979996c
9 changed files with 192 additions and 63 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Keep browser-tier status, headers, content type, and raw body aligned with the latest main-frame navigation response across redirects, and prevent persistent Cloudflare challenges from being returned as successful rendered pages (#53).
- Translate Prowlarr's serialized `headers.contentType` metadata at the FlareSolverr `/v1` compatibility boundary and discard `contentLength`, allowing form POST requests to enter the scraper pipeline (#50).
- Bound Camoufox memory growth by counting every Tier 3/4 temporary context and rolling-replacing browsers at `BROWSER_RECYCLE_AFTER_CONTEXTS`, while keeping existing capacity available during warm-up. Replacement launches are serialized, cleanup is timeout-bounded, and failed launches retain the usable browser (#52).
+14 -15
View File
@@ -4,7 +4,8 @@ import { solvePageCaptchas } from "../solvers"
import { normalizeSameSite, toCookies } from "../utils/cookies"
import { hasAkamaiChallenge, isBlocked, isBrowserErrorPage, isCloudflarePage } from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import { captureResponse, isTextContentType, type MinimalResponse } from "../utils/response"
import { trackMainDocumentResponses } from "../utils/mainResponse"
import { captureResponse, isTextContentType } from "../utils/response"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
@@ -60,14 +61,7 @@ export async function runTier2(
})
}
let statusCode = 200
const mainResponseHolder: { value?: MinimalResponse } = {}
page.on("response", (res: MinimalResponse) => {
if (res.url() === url) {
statusCode = res.status()
if (!mainResponseHolder.value) mainResponseHolder.value = res
}
})
const mainResponse = trackMainDocumentResponses(page)
await page.goto(url, { waitUntil: "domcontentloaded", timeout: maxTimeout })
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => {})
@@ -83,7 +77,7 @@ export async function runTier2(
}
}
if (isCloudflarePage(html, {})) {
if (isCloudflarePage(html, mainResponse.headers)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "session-expired" }
}
@@ -93,8 +87,8 @@ export async function runTier2(
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "akamai-session-expired" }
}
if (isBlocked(statusCode, html)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
if (isBlocked(mainResponse.status, html)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: `http-${mainResponse.status}` }
}
// Attempt to solve any embedded captcha widgets (Turnstile, reCAPTCHA, hCaptcha).
@@ -106,9 +100,14 @@ export async function runTier2(
captchasSolved = result.solved
}
const finalHtml = await page.content()
if (isCloudflarePage(finalHtml, mainResponse.headers)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "session-expired" }
}
const cookies: Cookie[] = toCookies(await activeContext.cookies())
const captured = await captureResponse(mainResponseHolder.value)
const captured = await captureResponse(mainResponse.response)
return {
tier: 2,
@@ -117,10 +116,10 @@ export async function runTier2(
effectiveUrl: page.url(),
// For HTML/text content-types, `html` is the rendered DOM. For binary, leave
// empty so /scrape consumers know to use `body`/`contentType`.
html: !captured.contentType || isTextContentType(captured.contentType) ? normalizeHtml(html) : "",
html: !captured.contentType || isTextContentType(captured.contentType) ? normalizeHtml(finalHtml) : "",
...captured,
cookies,
statusCode,
statusCode: mainResponse.status,
captchasSolved: captchasSolved.length > 0 ? captchasSolved : undefined,
}
} catch (err) {
+9 -18
View File
@@ -15,8 +15,9 @@ import {
} from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import { waitForImpervaResolution } from "../utils/impervaWait"
import { trackMainDocumentResponses } from "../utils/mainResponse"
import { isHardNetworkFailure } from "../utils/network"
import { captureResponse, isTextContentType, type MinimalResponse } from "../utils/response"
import { captureResponse, isTextContentType } from "../utils/response"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
@@ -64,17 +65,7 @@ export async function runTier3(
})
}
let statusCode = 200
const mainResponseHolder: { value?: MinimalResponse } = {}
page.on("response", (res: MinimalResponse) => {
try {
const resUrl = res.url()
if (resUrl === url || resUrl.startsWith(url.replace(/\/$/, ""))) {
statusCode = res.status()
if (!mainResponseHolder.value) mainResponseHolder.value = res
}
} catch {}
})
const mainResponse = trackMainDocumentResponses(page)
// CF challenges can trigger sub-navigations that throw "navigation interrupted" —
// we catch those so we can continue. Hard failures (DNS, connection refused) are
@@ -100,7 +91,7 @@ export async function runTier3(
? await waitForImpervaResolution(page, remaining, url)
: challengeType === "akamai"
? await waitForAkamaiResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url, () => mainResponse.headers)
if (resolution !== "ok") {
return {
@@ -150,7 +141,7 @@ export async function runTier3(
return { tier: 3, status: "error", durationMs: Date.now() - start, reason: errMsg }
}
if (isCloudflarePage(html, {})) {
if (isCloudflarePage(html, mainResponse.headers)) {
const pageTitle = await page.title().catch(() => "?")
const pageUrl = page.url()
console.log(`[tier3] cloudflare-persistent: url="${pageUrl}" title="${pageTitle}" html=${html.length}b`)
@@ -171,13 +162,13 @@ export async function runTier3(
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: "akamai-persistent" }
}
if (isBlocked(statusCode, html)) {
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
if (isBlocked(mainResponse.status, html)) {
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: `http-${mainResponse.status}` }
}
const cookies: Cookie[] = toCookies(await freshCtx.cookies())
const captured = await captureResponse(mainResponseHolder.value)
const captured = await captureResponse(mainResponse.response)
return {
tier: 3,
@@ -188,7 +179,7 @@ export async function runTier3(
...captured,
cookies,
userAgent: await page.evaluate(() => navigator.userAgent).catch(() => FINGERPRINT.userAgent),
statusCode,
statusCode: mainResponse.status,
captchasSolved: captchasSolved.length > 0 ? captchasSolved : undefined,
}
} catch (err) {
+9 -17
View File
@@ -15,8 +15,9 @@ import {
} from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import { waitForImpervaResolution } from "../utils/impervaWait"
import { trackMainDocumentResponses } from "../utils/mainResponse"
import { isHardNetworkFailure } from "../utils/network"
import { captureResponse, isTextContentType, type MinimalResponse } from "../utils/response"
import { captureResponse, isTextContentType } from "../utils/response"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
@@ -66,16 +67,7 @@ export async function runTier4(
})
}
let statusCode = 200
const mainResponseHolder: { value?: MinimalResponse } = {}
page.on("response", (res: MinimalResponse) => {
try {
if (res.url() === url || res.url().startsWith(url.replace(/\/$/, ""))) {
statusCode = res.status()
if (!mainResponseHolder.value) mainResponseHolder.value = res
}
} catch {}
})
const mainResponse = trackMainDocumentResponses(page)
const gotoErr = await page
.goto(url, {
@@ -96,7 +88,7 @@ export async function runTier4(
? await waitForImpervaResolution(page, remaining, url)
: challengeType === "akamai"
? await waitForAkamaiResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url, () => mainResponse.headers)
if (resolution !== "ok") {
return {
@@ -136,7 +128,7 @@ export async function runTier4(
}
}
if (isCloudflarePage(html, {})) {
if (isCloudflarePage(html, mainResponse.headers)) {
return {
tier: 4,
status: "blocked",
@@ -163,13 +155,13 @@ export async function runTier4(
}
}
if (isBlocked(statusCode, html)) {
return { tier: 4, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
if (isBlocked(mainResponse.status, html)) {
return { tier: 4, status: "blocked", durationMs: Date.now() - start, reason: `http-${mainResponse.status}` }
}
const cookies: Cookie[] = toCookies(await proxyContext.cookies())
const captured = await captureResponse(mainResponseHolder.value)
const captured = await captureResponse(mainResponse.response)
return {
tier: 4,
@@ -180,7 +172,7 @@ export async function runTier4(
...captured,
cookies,
userAgent: await page.evaluate(() => navigator.userAgent).catch(() => FINGERPRINT.userAgent),
statusCode,
statusCode: mainResponse.status,
captchasSolved: captchasSolved.length > 0 ? captchasSolved : undefined,
}
} catch (err) {
+25 -11
View File
@@ -13,10 +13,13 @@ export async function waitForChallengeResolution(
page: Page,
timeoutMs: number,
originalUrl?: string,
responseHeaders: () => Record<string, string> = () => ({}),
): Promise<"ok" | "ip-blocked" | "timeout"> {
const deadline = Date.now() + Math.max(timeoutMs, 30_000)
let lastClickAttempt = 0
let cfClearanceAt: number | undefined
let challengeSeen = false
let inactiveSamples = 0
// Only count cf_clearance for the current domain — warm browser may have cookies from prior domains
const targetHost = (() => {
@@ -27,25 +30,36 @@ export async function waitForChallengeResolution(
}
})()
const earlyTitle = await page.title().catch(() => "")
if (earlyTitle && !CF_CHALLENGE_TITLE.test(earlyTitle)) {
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {})
return "ok"
}
// Let CF's challenge JS boot up before we start polling
await new Promise((r) => setTimeout(r, 1000))
await new Promise((r) => setTimeout(r, 300))
while (Date.now() < deadline) {
try {
const title = await page.title().catch(() => "just a moment")
if (!CF_CHALLENGE_TITLE.test(title)) {
const title = await page.title().catch(() => "")
const html = await page.content().catch(() => "")
const url = page.url()
const hasChallengeFrame = page.frames().some(isChallengeFrame)
const active =
CF_CHALLENGE_TITLE.test(title) ||
isCloudflarePage(html, responseHeaders()) ||
/\/cdn-cgi\/challenge-platform|\/cdn-cgi\/challenge\//i.test(url) ||
hasChallengeFrame
if (!active) {
inactiveSamples++
} else {
challengeSeen = true
inactiveSamples = 0
}
// Two observations prevent a transient normal title/DOM during navigation from
// declaring a challenge solved. This also applies before the first active sample.
if (inactiveSamples >= 2) {
// 'load' not 'networkidle' — networkidle stalls indefinitely on JS-heavy pages
await page.waitForLoadState("load", { timeout: 5000 }).catch(() => {})
return "ok"
}
const url = page.url()
if (/\/cdn-cgi\/error\/|error=1020|error=1015/.test(url)) return "ip-blocked"
const cookies: Array<{ name: string; domain: string }> = await page
@@ -60,7 +74,7 @@ export async function waitForChallengeResolution(
c.domain === `.${targetHost}` ||
targetHost.endsWith(c.domain.replace(/^\./, ""))),
)
if (hasDomainClearance) {
if (hasDomainClearance && challengeSeen) {
if (cfClearanceAt === undefined) {
cfClearanceAt = Date.now()
console.log("[challenge] cf_clearance obtained")
+9 -2
View File
@@ -9,7 +9,8 @@ export type ChallengeType =
| "none"
export function isCloudflarePage(html: string, headers: Record<string, string>): boolean {
if (headers["cf-mitigated"]) return true
const cfMitigated = Object.entries(headers).find(([name]) => name.toLowerCase() === "cf-mitigated")?.[1]
if (cfMitigated?.toLowerCase() === "challenge") return true
if (/<title>[^<]*(just a moment|ddos-guard|please wait|checking|attention required)[^<]*<\/title>/i.test(html))
return true
if (/checking your browser/i.test(html)) return true
@@ -20,6 +21,11 @@ export function isCloudflarePage(html: string, headers: Record<string, string>):
if (/id="cf-challenge-running"/i.test(html)) return true
// CF Turnstile interstitial wrapper
if (/id="turnstile-wrapper"/i.test(html)) return true
// Active challenge orchestration markers. Unlike the passive telemetry markers
// below, these only occur while Cloudflare is serving an interstitial.
if (/_cf_chl_opt/i.test(html)) return true
if (/id=["']challenge-form["']/i.test(html)) return true
if (/orchestrate\/chl_page/i.test(html)) return true
// DDoS-Guard
if (/ddos-guard\.net|\.ddos-guard\.net/i.test(html)) return true
// CF firewall/WAF deny page (error 1020 and friends) — static "blocked" page, not a
@@ -35,7 +41,8 @@ export function isCloudflarePage(html: string, headers: Record<string, string>):
// bot-management telemetry. Matching on the marker alone flags real pages as blocked.
// The actual challenge stub is always near-empty (nothing else can render before the
// challenge resolves), so gate on page size too.
if (html.length < 3000 && /__CF\$cv\$params/i.test(html)) return true
if (html.length < 3000 && /__CF\$cv\$params|\/cdn-cgi\/challenge-platform\/[^"']*jsd\/main\.js/i.test(html))
return true
return false
}
+40
View File
@@ -0,0 +1,40 @@
import type { Page, Response } from "patchright"
import type { MinimalResponse } from "./response"
type NavigationResponse = MinimalResponse & Pick<Response, "request">
/** Tracks the latest top-level document response across redirects. */
export class MainDocumentResponseTracker {
private latest?: NavigationResponse
constructor(private readonly page: Pick<Page, "mainFrame">) {}
observe(response: NavigationResponse): void {
try {
const request = response.request()
if (!request.isNavigationRequest() || request.frame() !== this.page.mainFrame()) return
this.latest = response
} catch {
// A response can disappear while Firefox is replacing a challenge document.
}
}
get response(): MinimalResponse | undefined {
return this.latest
}
get status(): number {
return this.latest?.status() ?? 200
}
get headers(): Record<string, string> {
return this.latest?.headers() ?? {}
}
}
export function trackMainDocumentResponses(page: Page): MainDocumentResponseTracker {
const tracker = new MainDocumentResponseTracker(page)
page.on("response", (response) => tracker.observe(response))
return tracker
}
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { isCloudflarePage } from "../src/utils/detect"
describe("Cloudflare challenge detection", () => {
test("treats cf-mitigated challenge as authoritative regardless of case or page title", () => {
const html = `<html><title>Welcome</title><body>${"content ".repeat(1000)}</body></html>`
expect(isCloudflarePage(html, { "CF-Mitigated": "Challenge" })).toBe(true)
})
test("detects active orchestration markers on large pages", () => {
const padding = "content ".repeat(1000)
expect(isCloudflarePage(`<script>window._cf_chl_opt={}</script>${padding}`, {})).toBe(true)
expect(isCloudflarePage(`<form id="challenge-form"></form>${padding}`, {})).toBe(true)
expect(
isCloudflarePage(`<script src="/cdn-cgi/challenge-platform/orchestrate/chl_page/v1"></script>${padding}`, {}),
).toBe(true)
})
test("allows passive Cloudflare telemetry on an ordinary large page", () => {
const html = `<html><title>Real page</title><body>${"content ".repeat(1000)}<script>__CF$cv$params={}</script><script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></body></html>`
expect(isCloudflarePage(html, {})).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { MainDocumentResponseTracker } from "../src/utils/mainResponse"
import { captureResponse } from "../src/utils/response"
const mainFrame = {}
const iframe = {}
const page = { mainFrame: () => mainFrame }
function response(
url: string,
status: number,
headers: Record<string, string>,
body: string,
options: { navigation?: boolean; frame?: object } = {},
) {
return {
url: () => url,
status: () => status,
headers: () => headers,
body: async () => Buffer.from(body),
request: () => ({
isNavigationRequest: () => options.navigation ?? true,
frame: () => options.frame ?? mainFrame,
}),
}
}
describe("MainDocumentResponseTracker", () => {
test("keeps the final response across cross-origin redirects with coherent metadata", async () => {
const tracker = new MainDocumentResponseTracker(page as never)
tracker.observe(
response("https://a.example/start", 302, { location: "https://b.example/final" }, "redirect") as never,
)
tracker.observe(
response("https://b.example/final", 200, { "content-type": "text/html", "x-final": "yes" }, "origin") as never,
)
expect(tracker.status).toBe(200)
expect(tracker.headers["x-final"]).toBe("yes")
const captured = await captureResponse(tracker.response)
expect(new TextDecoder().decode(captured.body)).toBe("origin")
expect(captured.contentType).toBe("text/html")
})
test("ignores subresources and iframe navigations", () => {
const tracker = new MainDocumentResponseTracker(page as never)
tracker.observe(response("https://example.com/", 200, {}, "main") as never)
tracker.observe(response("https://example.com/app.js", 404, {}, "script", { navigation: false }) as never)
tracker.observe(response("https://challenge.example/frame", 403, {}, "frame", { frame: iframe }) as never)
expect(tracker.status).toBe(200)
expect(tracker.response?.url()).toBe("https://example.com/")
})
test("retains a terminal external-scheme redirect", () => {
const tracker = new MainDocumentResponseTracker(page as never)
tracker.observe(response("https://example.com/download", 301, { location: "magnet:?xt=urn:test" }, "") as never)
expect(tracker.status).toBe(301)
expect(tracker.headers.location).toBe("magnet:?xt=urn:test")
})
})