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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-09 09:53:39 -03:00
committed by GitHub
parent 61cb52399e
commit 356fd5d606
6 changed files with 757 additions and 52 deletions

View File

@@ -266,11 +266,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string {
}
function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
// Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low"
// reasoningEffort on the free tier; the others omit it (duck.ai applies its own default).
// `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it
// returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an
// otherwise byte-identical payload (200 with the field, 400 without, repeated).
// The live duck.ai bundle always sends one, so there is no "let the server
// pick a default" path any more.
if (model === "claude-haiku-4-5") return { reasoningEffort: "low" };
if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" };
return { reasoningEffort: null };
return { reasoningEffort: "none" };
}
function extractDuckDuckGoFeVersion(html: string): string | null {
@@ -368,7 +371,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
private warmed = false;
private seeded = false;
private feVersion = DEFAULT_FE_VERSION;
private pendingVqdHash1: string | null = null;
private readonly cookieJar = new Map<string, string>();
@@ -574,7 +576,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
await this.warmSession(mergedSignal);
await this.seedChallengeChain(upstreamModel, mergedSignal);
// NOTE: the throwaway "seed" chat POST that used to run here has been removed.
// It existed to coax a usable challenge out of the upstream while the solver
// was broken; now that the solver reproduces a real browser's probe vectors
// exactly, the first real request succeeds on its own. Keeping it only doubled
// the chat calls per user request against an IP-rate-limited endpoint, which
// showed up as spurious 429 ERR_RATE_LIMIT.
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
clearTimeout(timeout);
@@ -783,41 +790,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
);
}
private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> {
if (this.seeded || signal.aborted) return;
this.seeded = true;
const seedMessages = [{ role: "user", content: "hi" }];
const previousPending = this.pendingVqdHash1;
try {
const vqdHeaders = await this.acquireAuthHeaders(signal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
this.pendingVqdHash1 = previousPending;
return;
}
const response = await fetch(CHAT_URL, {
method: "POST",
headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), {
Accept: "text/event-stream",
"Content-Type": "application/json",
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
"x-fe-signals": makeDuckDuckGoFeSignals(),
"x-fe-version": this.feVersion,
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
}),
body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)),
signal,
});
this.rememberResponseCookies(response);
if (response.ok) this.rememberChallengeHeader(response);
else this.pendingVqdHash1 = previousPending;
await response.body?.cancel().catch(() => {});
} catch (error) {
void error;
this.pendingVqdHash1 = previousPending;
}
}
private async processResponse(
response: Response,
streaming: boolean,

View File

@@ -5,12 +5,38 @@ import { createHash } from "node:crypto";
import vm from "node:vm";
import { parseFragment, serialize } from "parse5";
// WARNING: the contents of this template literal are NOT TypeScript — they are plain
// script-mode JavaScript executed via `vm.runInContext`. `vm.runInContext` compiles in
// script (non-module) mode, so an `export` keyword anywhere in here is a hard
// SyntaxError that kills the whole solver. A refactor that mass-added `export` to the
// five `function` declarations below silently broke every DuckDuckGo chat request
// (solve threw -> unsolved challenge sent -> HTTP 418 ERR_CHALLENGE). Do not add
// `export`/`import` to this string; `duckduckgo-challenge-split.test.ts` guards this.
export const CHALLENGE_STUBS = String.raw`
var __ua = __DDG_REAL_UA__;
var __HTML_LOOKUP = __DDG_HTML_LOOKUP__;
export function __makeHtmlElement(tag) {
// Browser-fidelity shims for the DDG "am I a real browser" probes.
// In a browser every built-in stringifies as native code; under a plain vm
// context the user-land re-declarations below would otherwise leak their source.
function __nativeFn(fn, name){
Object.defineProperty(fn, 'name', { value: name, configurable: true });
fn.toString = function(){ return 'function ' + name + '() { [native code] }'; };
return fn;
}
__nativeFn(parseInt, 'parseInt');
__nativeFn(parseFloat, 'parseFloat');
__nativeFn(isNaN, 'isNaN');
__nativeFn(encodeURIComponent, 'encodeURIComponent');
__nativeFn(decodeURIComponent, 'decodeURIComponent');
// NOTE: do NOT seal Math. Real Chromium reports Object.isSealed(Math) === false,
// and at least one challenge variant probes exactly that; sealing it here made
// the vector differ from the browser by one and failed the challenge.
function __makeHtmlElement(tag) {
var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' };
var el = {
// Instantiate against the real per-tag constructor so
// document.createElement('div') instanceof HTMLDivElement holds.
var el = Object.create(__ctorForTag(tag).prototype);
Object.assign(el, {
tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1,
children: [], childNodes: [], classList: [], dataset: {},
offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1,
@@ -19,9 +45,9 @@ export function __makeHtmlElement(tag) {
getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; },
hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; },
addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; },
querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; },
querySelectorAll: function(s){ if (s === '*') { return __makeNodeList(state._qsaCount); } return __makeNodeList(0); },
cloneNode: function(){ return __makeHtmlElement(tag); }
};
});
Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true });
Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true });
Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + '</' + tag + '>'; }, enumerable: true });
@@ -30,7 +56,7 @@ export function __makeHtmlElement(tag) {
Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true });
return el;
}
export function __mkObj(name, base) {
function __mkObj(name, base) {
base = base || {};
return new Proxy(base, {
get: function(t, k) {
@@ -54,18 +80,105 @@ export function __mkObj(name, base) {
has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; }
});
}
export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\s*display\s*:\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' });
var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' });
var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' });
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
// document.body keeps a LIVE children collection: challenges append a node and
// assert body.children.length grew by exactly 1, then remove it again.
var __bodyKids = [];
Object.defineProperty(__bodyKids, 'constructor', { value: HTMLCollection, enumerable: false, configurable: true });
var __body = __mkObj('body', {
appendChild: function(c){ __bodyKids.push(c); return c; },
removeChild: function(c){ var i = __bodyKids.indexOf(c); if (i !== -1) __bodyKids.splice(i, 1); return c; },
contains: function(c){ return __bodyKids.indexOf(c) !== -1; },
querySelector: function(s){ return s === '#jsa' ? __iframeEl : null; },
querySelectorAll: function(s){ return s === '#jsa' ? [__iframeEl] : __makeNodeList(0); },
children: __bodyKids, childNodes: __bodyKids,
tagName: 'BODY', nodeName: 'BODY', nodeType: 1
});
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return __makeNodeList(__bodyKids.length + 3); }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __body, head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } });
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
// Object.prototype.toString.call(window) must be "[object Window]".
try { window[Symbol.toStringTag] = 'Window'; } catch (e) {}
// In a browser a sloppy-mode function called with no receiver gets the global
// object, and challenges assert (function(){return this;})() === window.
// In a vm context that is the context's own global, so alias it to window.
try {
var __g = (function(){ return this; })();
if (__g && __g !== window) {
Object.defineProperty(__g, Symbol.toStringTag, { value: 'Window', configurable: true });
// Copy by VALUE, not via accessors. Two reasons:
// 1) the var top/self/navigator/... declarations further down are hoisted,
// so those names already exist on the vm global and an "in" guard would
// skip them, leaving window.navigator undefined;
// 2) accessors closing over the window binding would recurse once it is
// rebound to __g below.
// The stub window is static, so a value copy is equivalent.
var __winStub = window;
for (var __k in __winStub) {
try { __g[__k] = __winStub[__k]; } catch (e) {}
}
// hasOwnProperty is probed for the __DDG_* markers; keep the stub's version.
try { __g.hasOwnProperty = function(k){ return __winStub.hasOwnProperty(k); }; } catch (e) {}
window = __g;
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
}
} catch (e) {}
var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history;
var __R = null, __E = null;
export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
// Real DOM constructor chain. Some DDG challenge variants assert
// HTMLDivElement.prototype instanceof HTMLElement and
// HTMLElement.prototype instanceof Element, so these cannot be flat
// unrelated stubs — the prototype links have to be real.
function __DomClass(name, parent){
var c = function(){};
if (parent) c.prototype = Object.create(parent.prototype);
c.prototype.constructor = c;
Object.defineProperty(c, 'name', { value: name, configurable: true });
c.toString = function(){ return 'function ' + name + '() { [native code] }'; };
return c;
}
var EventTarget = __DomClass('EventTarget', null);
var Node = __DomClass('Node', EventTarget);
var Element = __DomClass('Element', Node);
var HTMLElement = __DomClass('HTMLElement', Element);
var HTMLDivElement = __DomClass('HTMLDivElement', HTMLElement);
var HTMLIFrameElement = __DomClass('HTMLIFrameElement', HTMLElement);
var HTMLLIElement = __DomClass('HTMLLIElement', HTMLElement);
var HTMLUnknownElement = __DomClass('HTMLUnknownElement', HTMLElement);
var Document = __DomClass('Document', Node);
var HTMLDocument = __DomClass('HTMLDocument', Document);
var NodeList = __DomClass('NodeList', null);
var HTMLCollection = __DomClass('HTMLCollection', null);
// Map a tag name to the constructor a browser would use, so
// document.createElement('div') instanceof HTMLDivElement holds.
function __ctorForTag(tag){
var t = String(tag||'div').toLowerCase();
if (t === 'div') return HTMLDivElement;
if (t === 'iframe') return HTMLIFrameElement;
if (t === 'li') return HTMLLIElement;
return HTMLElement;
}
// A NodeList-like: array-shaped but NOT a real Array, with .constructor.name
// === 'NodeList' — challenges check both !Array.isArray(x) and the ctor name.
function __makeNodeList(length){
var nl = Object.create(NodeList.prototype);
var n = length|0;
for (var i = 0; i < n; i++) nl[i] = __makeHtmlElement('div');
Object.defineProperty(nl, 'length', { value: n, enumerable: false, configurable: true });
nl.item = function(i){ return this[i] || null; };
nl.forEach = function(fn, thisArg){ for (var i = 0; i < n; i++) fn.call(thisArg, this[i], i, this); };
nl[Symbol.iterator] = function(){ var i = 0, self = this; return { next: function(){ return i < n ? { value: self[i++], done: false } : { value: undefined, done: true }; } }; };
return nl;
}
function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
// NOTE: HTMLElement / HTMLDivElement / HTMLIFrameElement / Element / Node /
// Document / HTMLDocument / NodeList are defined above via __DomClass with a
// REAL prototype chain — do not redeclare them here or the instanceof probes break.
var Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); };
var getComputedStyle = __getComputedStyle;
`;
@@ -90,9 +203,16 @@ export function buildHtmlLookup(js: string): Record<string, { html: string; coun
if (seen.has(html)) continue;
seen.add(html);
const fragment = parseFragment(html);
// `count` backs `element.querySelectorAll('*').length` for an element whose
// innerHTML is `html`. `querySelectorAll('*')` on a container returns its
// DESCENDANTS, and `countHtmlElements` already excludes the `#document-fragment`
// root, so the fragment's element count IS the descendant count. The former
// `- 1` undercounted by one (verified against a real browser: for
// `<li><div></li><li></div` Chromium reports 3, this returned 2), which
// corrupted every probe that multiplies by that length.
lookup[html] = {
html: serialize(fragment),
count: Math.max(0, countHtmlElements(fragment) - 1),
count: countHtmlElements(fragment),
};
}
return lookup;
@@ -102,14 +222,33 @@ export function sha256Base64(value: string): string {
return createHash("sha256").update(value, "utf8").digest("base64");
}
// Shape of the object a DDG challenge program resolves to.
type DuckDuckGoChallengeResult = {
client_hashes?: unknown;
meta?: unknown;
[key: string]: unknown;
};
/**
* Origin the solved challenge claims to come from. The duck.ai frontend stamps
* `meta.origin` with its own origin and the upstream cross-checks it.
*/
export const DUCKDUCKGO_CHALLENGE_ORIGIN = "https://duck.ai";
/**
* `meta.stack` mimics the frontend's captured Error stack. The upstream only
* requires a plausible stack that points at the duck.ai bundle — verified by
* ablation: a generic bundle path is accepted, omitting the field is not.
*/
function buildChallengeStack(origin: string, bundlePath: string): string {
const url = `${origin}${bundlePath}`;
return `Error\nat l (${url}:2:1695625)\nat async ${url}:2:1519117`;
}
export async function solveDuckDuckGoChallenge(
challenge: string,
userAgent: string
userAgent: string,
options: { origin?: string; bundlePath?: string } = {}
): Promise<string> {
// SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext.
// The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout
@@ -121,14 +260,31 @@ export async function solveDuckDuckGoChallenge(
);
const context = vm.createContext({});
vm.runInContext(stubs, context, { timeout: 5000 });
const startedAt = Date.now();
const result = (await vm.runInContext(js, context, {
timeout: 5000,
})) as DuckDuckGoChallengeResult;
const elapsedMs = Date.now() - startedAt;
const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : [];
if (clientHashes.length === 0)
throw new Error("DuckDuckGo challenge returned empty client_hashes");
clientHashes[0] = userAgent;
result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash)));
// The real frontend augments the challenge's own `meta` with origin / stack /
// duration before sending it back. Omitting them yields 418 ERR_CHALLENGE even
// when every client_hash is correct (confirmed by capturing a real browser's
// x-vqd-hash-1 header, which always carries all three).
const origin = options.origin ?? DUCKDUCKGO_CHALLENGE_ORIGIN;
const bundlePath = options.bundlePath ?? "/dist/duckai-dist/entry.duckai.js";
const meta = (result.meta ?? {}) as Record<string, unknown>;
result.meta = {
...meta,
origin,
stack: buildChallengeStack(origin, bundlePath),
duration: String(elapsedMs),
};
return Buffer.from(JSON.stringify(result), "utf8").toString("base64");
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,258 @@
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/);
});

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import vm from "node:vm";
// Split-guard for the duckduckgo-web challenge-solver extraction.
// The anti-abuse challenge solver + FE signals live in duckduckgo-web/challenge.ts
@@ -35,3 +36,81 @@ test("makeDuckDuckGoFeSignals returns a base64 string", async () => {
assert.equal(typeof out, "string");
assert.ok(out.length > 0);
});
// Regression guard: CHALLENGE_STUBS is browser-emulation source executed by
// `vm.runInContext`, which compiles in SCRIPT mode — module syntax is a hard
// SyntaxError there. A refactor once mass-added `export` to the `function`
// declarations inside this template literal (they look like ordinary top-level
// TS functions), which made every solve throw. The executor swallows solve
// failures and sends the raw unsolved challenge, so DuckDuckGo answered every
// chat request with HTTP 418 ERR_CHALLENGE while the site worked fine in a
// browser from the same IP. The three tests below fail on that class of bug.
test("CHALLENGE_STUBS contains no module syntax (vm runs it in script mode)", async () => {
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
assert.doesNotMatch(
CHALLENGE_STUBS,
/(^|[\s;{}])(export|import)[\s{*]/,
"CHALLENGE_STUBS must not use export/import — vm.runInContext compiles in script mode"
);
});
test("CHALLENGE_STUBS compiles as a script", async () => {
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
// Placeholders are substituted before execution; do the same here so the
// source is syntactically complete.
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace(
"__DDG_HTML_LOOKUP__",
"{}"
);
assert.doesNotThrow(() => new vm.Script(source), "CHALLENGE_STUBS must parse in script mode");
});
test("CHALLENGE_STUBS evaluates and defines the browser stubs the challenge probes", async () => {
const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace(
"__DDG_HTML_LOOKUP__",
"{}"
);
const context = vm.createContext({});
vm.runInContext(source, context, { timeout: 5000 });
// A real DDG challenge reads these; if the stubs silently failed to evaluate
// they would all be undefined and the solver would produce garbage.
assert.equal(
vm.runInContext("navigator.userAgent", context),
"test-ua",
"navigator.userAgent must carry the injected UA"
);
assert.equal(vm.runInContext("typeof document.querySelector", context), "function");
assert.equal(vm.runInContext("document.getElementById('jsa').tagName", context), "IFRAME");
assert.equal(vm.runInContext("typeof getComputedStyle", context), "function");
assert.equal(vm.runInContext("window.top === window", context), true);
});
// End-to-end guard on the solver itself, using a stand-in challenge that mimics
// the real one's contract: an async IIFE returning { server_hashes, client_hashes,
// signals, meta }. This exercises the full stubs -> vm -> hash -> base64 path
// without hitting the network.
test("solveDuckDuckGoChallenge solves a representative challenge payload", async () => {
const { solveDuckDuckGoChallenge, sha256Base64 } =
await import("../../open-sse/executors/duckduckgo-web/challenge.ts");
const fakeChallenge = `(async function(){
return {
server_hashes: ["s1", "s2"],
client_hashes: [navigator.userAgent, document.getElementById('jsa').tagName],
signals: {},
meta: { v: "4", challenge_id: "test" }
};
})()`;
const ua = "Mozilla/5.0 (X11; Linux x86_64) TestAgent/1.0";
const solved = await solveDuckDuckGoChallenge(
Buffer.from(fakeChallenge, "utf8").toString("base64"),
ua
);
const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8"));
assert.deepEqual(decoded.server_hashes, ["s1", "s2"], "server_hashes pass through untouched");
// Slot 0 is overwritten with the real UA before hashing, then every slot is sha256+base64.
assert.deepEqual(decoded.client_hashes, [sha256Base64(ua), sha256Base64("IFRAME")]);
assert.equal(decoded.meta.challenge_id, "test");
});

View File

@@ -0,0 +1,134 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { DuckDuckGoWebExecutor } from "../../open-sse/executors/duckduckgo-web.ts";
/**
* Regression: duckchat/v1/chat now REQUIRES a `reasoningEffort` field.
*
* The executor previously omitted it for most models on the assumption that the
* upstream would apply its own default. It does not: an otherwise byte-identical
* payload returns 200 with the field and 400 ERR_BAD_REQUEST without it
* (A/B verified live against duck.ai, repeated). The live duck.ai bundle always
* sends one, so every outgoing payload must carry it.
*
* These tests capture the executor's real outgoing request body by stubbing
* fetch, so they assert on the wire format rather than on internal helpers.
*/
type Captured = { url: string; body: Record<string, unknown> };
async function captureChatPayload(model: string): Promise<Captured> {
const realFetch = globalThis.fetch;
const captured: Captured[] = [];
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? "");
if (url.includes("/duckchat/v1/status")) {
// Hand back a trivially solvable challenge so the executor proceeds to the
// chat POST without touching the network.
const challenge = Buffer.from(
`(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`,
"utf8"
).toString("base64");
return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } });
}
if (url.includes("/duckchat/v1/chat")) {
captured.push({ url, body: JSON.parse(String(init.body)) });
return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
// Warm-up fetches (homepage, country.json, auth/token).
return new Response("", { status: 200 });
}) as typeof globalThis.fetch;
try {
const executor = new DuckDuckGoWebExecutor();
await executor.execute({
model,
body: { messages: [{ role: "user", content: "Say OK" }] },
stream: false,
} as never);
} finally {
globalThis.fetch = realFetch;
}
assert.ok(captured.length > 0, "executor never issued a chat request");
return captured[captured.length - 1];
}
test("chat payload always carries reasoningEffort", async () => {
const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini");
assert.ok(
Object.prototype.hasOwnProperty.call(body, "reasoningEffort"),
"omitting reasoningEffort yields 400 ERR_BAD_REQUEST upstream"
);
assert.equal(typeof body.reasoningEffort, "string");
assert.notEqual(body.reasoningEffort, "");
});
test("default models send reasoningEffort 'none'", async () => {
const { body } = await captureChatPayload("duckduckgo-web/gpt-5.4-mini");
assert.equal(body.model, "gpt-5.4-mini");
assert.equal(body.reasoningEffort, "none");
});
test("reasoning models keep their 'low' effort", async () => {
const haiku = await captureChatPayload("duckduckgo-web/claude-haiku-4-5");
assert.equal(haiku.body.model, "claude-haiku-4-5");
assert.equal(haiku.body.reasoningEffort, "low");
const oss = await captureChatPayload("duckduckgo-web/gpt-oss-120b");
assert.equal(oss.body.model, "tinfoil/gpt-oss-120b");
assert.equal(oss.body.reasoningEffort, "low");
});
test("retired model ids are still aliased to live wire ids", async () => {
const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini");
assert.equal(body.model, "gpt-5.4-mini");
});
test("executor issues exactly one chat request per call", async () => {
// A throwaway "seed" chat POST used to run before the real one, doubling the
// request volume against an IP-rate-limited endpoint and causing spurious 429s.
const realFetch = globalThis.fetch;
let chatCalls = 0;
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? "");
if (url.includes("/duckchat/v1/status")) {
const challenge = Buffer.from(
`(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`,
"utf8"
).toString("base64");
return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } });
}
if (url.includes("/duckchat/v1/chat")) {
chatCalls++;
void init;
return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return new Response("", { status: 200 });
}) as typeof globalThis.fetch;
try {
const executor = new DuckDuckGoWebExecutor();
await executor.execute({
model: "duckduckgo-web/gpt-5.4-mini",
body: { messages: [{ role: "user", content: "Say OK" }] },
stream: false,
} as never);
} finally {
globalThis.fetch = realFetch;
}
assert.equal(chatCalls, 1, "expected exactly one POST /duckchat/v1/chat per user request");
});