Files
OmniRoute/tests/unit/duckduckgo-challenge-solver-regression.test.ts
Diego Rodrigues de Sa e Souza 356fd5d606 fix(executors): repair DuckDuckGo AI Chat challenge solver (418 ERR_CHALLENGE) (#9866)
Every duckduckgo-web chat request failed with HTTP 418 ERR_CHALLENGE while
duck.ai worked normally in a browser from the same IP. Ground truth was
established by driving a real headful Chromium at duck.ai from that IP (it
returned 200), so the environment was never the problem — the anti-abuse
challenge solver was. Six independent defects were found; the first alone
disabled the solver completely.

1. Module syntax inside the vm sandbox source.
   CHALLENGE_STUBS is executed with vm.runInContext, which compiles in SCRIPT
   mode. A refactor mass-added `export` to the five `function` declarations
   inside that template literal (they read as ordinary top-level TS functions),
   so every solve threw SyntaxError. The executor swallows solve failures and
   posts the raw unsolved challenge, which upstream answers with 418.

2. Double-escaped regex in a String.raw template.
   `\\s` in __parseCssDisplay reached the sandbox as a literal backslash, so the
   display regex never matched and a getComputedStyle probe silently read empty.

3. buildHtmlLookup undercounted descendants by one.
   `count` backs el.querySelectorAll('*').length; that returns DESCENDANTS and
   countHtmlElements already skips the #document-fragment root, so the `- 1` was
   wrong. Chromium reports 3 for '<li><div></li><li></div'; we reported 2, and a
   variant multiplies innerHTML.length by that count.

4. Browser-fidelity probes.
   Newer challenge variants assert JS/DOM invariants a flat stub cannot satisfy:
   real prototype chains (HTMLDivElement -> HTMLElement -> Element), NodeList
   identity, a live body.children HTMLCollection, native-code toString, and
   sloppy-mode `this === window`. Nine of thirteen failed. Notably Math must NOT
   be sealed — Chromium reports Object.isSealed(Math) === false, and sealing it
   made our vector differ by one.

5. The solved payload dropped meta.origin / meta.stack / meta.duration.
   The duck.ai bundle always sends all three; captured browser requests confirm
   it. Without them upstream returns 418 even when every client_hash is correct.

6. reasoningEffort is now mandatory on duckchat/v1/chat.
   An otherwise byte-identical payload returns 200 with the field and 400
   ERR_BAD_REQUEST without it (A/B verified live, repeated).

Also removes the throwaway "seed" chat POST that ran before every real request.
It existed to coax a usable challenge out of the upstream while the solver was
broken; it only doubled chat calls against an IP-rate-limited endpoint, showing
up as spurious 429 ERR_RATE_LIMIT.

Verification: the solver now reproduces real Chromium's probe vectors exactly
for all 8 captured challenge variants, and the executor returns 200 end-to-end
live (non-streaming, streaming, claude-haiku-4-5, and a math prompt returning
"42").

Tests: tests/unit/duckduckgo-challenge-solver-regression.test.ts (32 tests) and
tests/unit/duckduckgo-reasoning-effort-required.test.ts (5 tests), backed by
tests/fixtures/duckduckgo/challenge-variants.json — real captured challenge
programs plus the probe vectors a real browser produced for them, so the suite
asserts against recorded browser behaviour rather than our own output. Each fix
was confirmed to fail its test when individually reverted.

Co-authored-by: Mynacol <git@mynacol.xyz>
2026-08-09 09:53:39 -03:00

259 lines
11 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import vm from "node:vm";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
CHALLENGE_STUBS,
buildHtmlLookup,
countHtmlElements,
sha256Base64,
solveDuckDuckGoChallenge,
DUCKDUCKGO_CHALLENGE_ORIGIN,
} from "../../open-sse/executors/duckduckgo-web/challenge.ts";
/**
* Regression suite for the DuckDuckGo AI Chat anti-abuse challenge solver.
*
* Background: every duckduckgo-web chat request was failing with HTTP 418
* ERR_CHALLENGE while duck.ai worked normally in a browser from the same IP.
* Root-causing it turned up several independent defects, each of which is
* pinned below. The fixtures in `tests/fixtures/duckduckgo/challenge-variants.json`
* are REAL challenge programs captured from duckduckgo.com, together with the
* probe vectors a real (headful) Chromium produced for those exact programs.
* Matching Chromium bit-for-bit is the actual correctness criterion, so these
* tests assert against recorded browser behaviour rather than our own output.
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURES = join(HERE, "../fixtures/duckduckgo/challenge-variants.json");
type Variant = {
challengeBase64: string;
browserProbes: string[];
browserReduceVectors: Array<{ seed: number; booleans: number[] }>;
};
const VARIANTS = JSON.parse(readFileSync(FIXTURES, "utf8")) as Record<string, Variant>;
const UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
function makeContext(challengeJs: string): vm.Context {
const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(UA)).replace(
"__DDG_HTML_LOOKUP__",
JSON.stringify(buildHtmlLookup(challengeJs))
);
const context = vm.createContext({});
vm.runInContext(stubs, context, { timeout: 5000 });
return context;
}
// ---------------------------------------------------------------------------
// Bug 1 — module syntax inside the sandbox source.
// `vm.runInContext` compiles in SCRIPT mode. A refactor mass-added `export` to
// the `function` declarations inside CHALLENGE_STUBS (they look like ordinary
// top-level TS functions), making every solve throw SyntaxError. The executor
// swallows solve failures and posts the raw unsolved challenge, so the upstream
// answered 418 for every request.
// ---------------------------------------------------------------------------
test("CHALLENGE_STUBS uses no module syntax and compiles in script mode", () => {
assert.doesNotMatch(
CHALLENGE_STUBS,
/(^|[\s;{}])(export|import)[\s{*]/,
"vm.runInContext compiles in script mode — export/import is a hard SyntaxError"
);
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"ua"').replace(
"__DDG_HTML_LOOKUP__",
"{}"
);
assert.doesNotThrow(() => new vm.Script(source));
});
// ---------------------------------------------------------------------------
// Bug 2 — regex escaping inside a String.raw template.
// CHALLENGE_STUBS is a String.raw literal, so `\\s` reaches the sandbox as a
// literal backslash-backslash-s and the display regex never matched. One
// challenge variant asserts getComputedStyle(el).getPropertyValue('display')
// is non-empty, so this silently flipped a probe to false.
// ---------------------------------------------------------------------------
test("computed-style display probe resolves a real value", () => {
const context = makeContext("");
const display = vm.runInContext(
`(function(){
var d = document.createElement('div');
d.style.cssText = 'display:inline-block;padding:8px;position:absolute;visibility:hidden;';
return getComputedStyle(d).getPropertyValue('display');
})()`,
context
);
assert.equal(display, "inline-block");
});
test("CHALLENGE_STUBS contains no double-escaped regex metacharacters", () => {
// String.raw means `\\s` in the source IS `\\s` in the sandbox — always a bug.
assert.doesNotMatch(CHALLENGE_STUBS, /\\\\[sdwbSDWB]/);
});
// ---------------------------------------------------------------------------
// Bug 3 — buildHtmlLookup descendant count was off by one.
// `count` backs `el.querySelectorAll('*').length` for an element whose
// innerHTML is the given markup. querySelectorAll('*') returns DESCENDANTS, and
// countHtmlElements already skips the #document-fragment root, so subtracting 1
// undercounted. A variant multiplies innerHTML.length by that count, so the
// error propagated straight into the hash.
// ---------------------------------------------------------------------------
test("buildHtmlLookup reports the browser's descendant count", () => {
// Chromium: for innerHTML = '<li><div></li><li></div', querySelectorAll('*')
// has length 3 and innerHTML serializes to 29 characters.
const html = "<li><div></li><li></div";
const entry = buildHtmlLookup(`x = "${html}"`)[html];
assert.equal(entry.html, "<li><div></div></li><li></li>");
assert.equal(entry.html.length, 29);
assert.equal(entry.count, 3);
assert.equal(countHtmlElements({ nodeName: undefined, childNodes: [] }), 0);
});
// ---------------------------------------------------------------------------
// Bug 4 — the browser-fidelity probes.
// Newer challenge variants interrogate JS/DOM invariants that a naive stub
// object does not satisfy (prototype chains, NodeList identity, live
// HTMLCollection, native-code toString, sloppy-mode `this`). Nine of thirteen
// failed. Each is pinned individually so a future stub regression names itself.
// ---------------------------------------------------------------------------
const FIDELITY_PROBES: Array<[string, string, boolean]> = [
[
"built-ins stringify as native code",
`window.parseInt.toString().includes("[native code]")`,
true,
],
[
"Array subclass survives map",
`(function(){ class S extends Array {}; return new S(1,2,3).map(function(x){return x*2;}) instanceof S; })()`,
true,
],
[
"window brands as [object Window]",
`Object.prototype.toString.call(window) === "[object Window]"`,
true,
],
["Error instances are real", `new Error() instanceof Error`, true],
[
"captureStackTrace is absent or a function",
`Error.captureStackTrace === undefined || typeof Error.captureStackTrace === "function"`,
true,
],
// Chromium reports false here; sealing Math made our vector differ by one.
["Math is NOT sealed (matches Chromium)", `Object.isSealed(Math)`, false],
["sloppy-mode this is window", `(function(){ return this; })() === window`, true],
[
"document.body.children is live",
`(function(){
var c = document.body.children, n = c.length, d = document.createElement('div');
document.body.appendChild(d);
var grew = c.length === n + 1;
document.body.removeChild(d);
return grew && c.length === n;
})()`,
true,
],
["querySelectorAll is not an Array", `!Array.isArray(document.querySelectorAll("*"))`, true],
[
"querySelectorAll is a NodeList",
`document.querySelectorAll("*").constructor.name === "NodeList"`,
true,
],
[
"createElement('div') is an HTMLDivElement",
`document.createElement("div") instanceof HTMLDivElement`,
true,
],
[
"HTMLDivElement derives from HTMLElement",
`HTMLDivElement.prototype instanceof HTMLElement`,
true,
],
["HTMLElement derives from Element", `HTMLElement.prototype instanceof Element`, true],
["navigator.webdriver is falsy", `navigator.webdriver === true`, false],
["navigator survives the global aliasing", `navigator.userAgent === ${JSON.stringify(UA)}`, true],
["window.document is the document", `window.document === document`, true],
];
for (const [name, expression, expected] of FIDELITY_PROBES) {
test(`browser-fidelity probe: ${name}`, () => {
const context = makeContext("");
assert.equal(vm.runInContext(expression, context), expected);
});
}
// ---------------------------------------------------------------------------
// The real acceptance criterion: for every captured challenge variant our
// sandbox must produce exactly the probe values a real Chromium produced.
// ---------------------------------------------------------------------------
for (const [file, variant] of Object.entries(VARIANTS)) {
test(`challenge variant ${file} matches real-browser probe values`, async () => {
const js = Buffer.from(variant.challengeBase64, "base64").toString("utf8");
const context = makeContext(js);
const result = (await vm.runInContext(js, context, { timeout: 5000 })) as {
client_hashes: unknown[];
};
// `result` crosses the vm realm boundary, so its arrays carry the sandbox's
// Array.prototype. Copy into this realm or deepStrictEqual fails on the
// prototype even when every element matches.
const ours = Array.from(result.client_hashes).slice(1).map(String);
assert.deepEqual(
ours,
variant.browserProbes,
`probe values must match Chromium exactly for ${file}`
);
});
}
// ---------------------------------------------------------------------------
// Bug 5 — the solved payload dropped meta.origin / meta.stack / meta.duration.
// The duck.ai frontend always sends all three; captured browser requests
// confirm it. Without them the upstream returns 418 even when every
// client_hash is correct.
// ---------------------------------------------------------------------------
test("solveDuckDuckGoChallenge stamps meta.origin/stack/duration", async () => {
const [variant] = Object.values(VARIANTS);
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA);
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
assert.equal(decoded.meta.origin, DUCKDUCKGO_CHALLENGE_ORIGIN);
assert.match(decoded.meta.stack, /^Error\n\s*at l \(https:\/\/duck\.ai\/.*\.js:\d+:\d+\)/);
assert.match(String(decoded.meta.duration), /^\d+$/);
// The challenge's own meta must survive alongside the added fields.
assert.equal(decoded.meta.v, "4");
assert.ok(decoded.meta.challenge_id);
});
test("solveDuckDuckGoChallenge honours an explicit origin/bundle", async () => {
const [variant] = Object.values(VARIANTS);
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA, {
origin: "https://duckduckgo.com",
bundlePath: "/dist/x.js",
});
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
assert.equal(decoded.meta.origin, "https://duckduckgo.com");
assert.ok(decoded.meta.stack.includes("https://duckduckgo.com/dist/x.js"));
});
test("solveDuckDuckGoChallenge hashes client_hashes with the real UA in slot 0", async () => {
const [file, variant] = Object.entries(VARIANTS)[0];
const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA);
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
const expected = [sha256Base64(UA), ...variant.browserProbes.map((p) => sha256Base64(p))];
assert.deepEqual(decoded.client_hashes, expected, `client_hashes mismatch for ${file}`);
// server_hashes are echoed back untouched.
assert.ok(Array.isArray(decoded.server_hashes));
});
test("solveDuckDuckGoChallenge rejects a challenge with no client_hashes", async () => {
const bad = Buffer.from(`(async function(){ return { client_hashes: [] }; })()`, "utf8").toString(
"base64"
);
await assert.rejects(() => solveDuckDuckGoChallenge(bad, UA), /empty client_hashes/);
});