diff --git a/CHANGELOG.md b/CHANGELOG.md index 64456af..e015860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **DuckDuckGo anomaly challenge detection.** Detect DuckDuckGo anomaly challenge walls (`//duckduckgo.com/anomaly.js`, `anomaly-modal`) in Tier 1 and the MITM proxy, escalating requests to the browser tiers rather than treating the challenge page as successful content (#119). + ## [1.5.0] - 2026-09-04 ### Changed diff --git a/apps/api/src/proxy/__tests__/directForward.test.ts b/apps/api/src/proxy/__tests__/directForward.test.ts index 660d453..bb2fbb8 100644 --- a/apps/api/src/proxy/__tests__/directForward.test.ts +++ b/apps/api/src/proxy/__tests__/directForward.test.ts @@ -49,6 +49,11 @@ const fetchFixture = (req: Request): Response => { return new Response('
', { headers: { "Content-Type": "text/html; charset=utf-8" }, }) + if (pathname === "/duckduckgo-challenge") + return new Response( + '
', + { status: 202, headers: { "Content-Type": "text/html; charset=utf-8" } }, + ) if (pathname === "/video") return new Response(chunked(Buffer.from([0, 1, 2, 3]), Buffer.from([4, 5, 6, 7])), { headers: { "Content-Type": "video/mp4" }, @@ -297,6 +302,20 @@ describe("directForwardHttp — buffered by default", () => { expect(result.body.toString()).toContain("Just a moment") }) + test("detects a 202 DuckDuckGo anomaly challenge", async () => { + const result = await directForwardHttp({ + url: `${baseUrl}/duckduckgo-challenge`, + method: "POST", + headers: {}, + }) + + expect(result.mode).toBe("buffer") + if (result.mode !== "buffer") return + expect(result.status).toBe(202) + expect(result.challengeDetected).toBe(true) + expect(result.body.toString()).toContain("anomaly-modal") + }) + test("detects a challenge in a compressed HTML response", async () => { const result = await directForwardHttp({ url: `${baseUrl}/gzip-challenge`, diff --git a/apps/api/src/proxy/directForward.ts b/apps/api/src/proxy/directForward.ts index a8505b2..820c981 100644 --- a/apps/api/src/proxy/directForward.ts +++ b/apps/api/src/proxy/directForward.ts @@ -346,7 +346,7 @@ async function readHttpResponse( // Challenge detection on the buffered body. Bounded preview keeps this cheap. const previewText = decodeForInspection(body.subarray(0, offset), headers["content-encoding"]) - const challengeType = detectChallengeType(previewText, headers) + const challengeType = detectChallengeType(previewText, headers, status) const challengeDetected = !skipChallengeDetection && isChallengeWall(status, body.length, challengeType) return { diff --git a/packages/tiers/src/index.ts b/packages/tiers/src/index.ts index b85646e..f392871 100644 --- a/packages/tiers/src/index.ts +++ b/packages/tiers/src/index.ts @@ -17,6 +17,7 @@ export { hasDataDomeCaptcha, hasDataDomeChallenge, hasDdosGuardChallenge, + hasDuckDuckGoChallenge, hasHcaptcha, hasImpervaChallenge, hasRecaptcha, diff --git a/packages/tiers/src/tiers/1.ts b/packages/tiers/src/tiers/1.ts index 15aa76a..56ecad9 100644 --- a/packages/tiers/src/tiers/1.ts +++ b/packages/tiers/src/tiers/1.ts @@ -7,6 +7,7 @@ import { hasAkamaiChallenge, hasAwsWafCaptcha, hasAwsWafChallenge, + hasDuckDuckGoChallenge, hasHcaptcha, hasRecaptcha, hasTurnstile, @@ -142,6 +143,20 @@ export async function runTier1( } } + if (hasDuckDuckGoChallenge(previewText, responseHeaders)) { + return { + tier: 1, + status: "needs-js", + durationMs: Date.now() - start, + reason: "duckduckgo-anomaly-challenge", + challenge: "duckduckgo", + responseHeaders, + contentType, + body: rawBytes, + statusCode: res.status, + } + } + // JS-only challenges: the page's static HTML is just a shell that loads the // captcha widget via