captchaforge 0.2.35

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Chromium anti-detection (stealth) overrides.
//!
//! Headless Chromium is trivially identifiable from JavaScript:
//! `navigator.webdriver === true`, no plugins, English-only languages,
//! `Notification.permission === 'denied'`, WebGL vendor reports
//! "Google Inc. (NVIDIA)", and so on. Modern WAFs (Cloudflare, Akamai,
//! PerimeterX/HUMAN, DataDome) check these signals and immediately
//! tag the visitor as a bot — no amount of "realistic mouse movement"
//! will recover from a `navigator.webdriver === true` answer.
//!
//! `apply_stealth(page)` injects a CDP `Page.addScriptToEvaluateOnNewDocument`
//! that overrides each of the well-known fingerprint surfaces BEFORE
//! any page-side script can read them. It must be called once per
//! `chromiumoxide::Page` *before* the first `goto()` — not after.
//!
//! What this fixes:
//!
//! - `navigator.webdriver` returns `undefined` instead of `true`
//! - `navigator.plugins` reports a plausible PDF plugin list
//! - `navigator.languages` returns `["en-US", "en"]` instead of `[]`
//! - `Notification.permission` doesn't lie about being denied
//! - `window.chrome` is faked to look like a real Chrome browser
//! - WebGL vendor/renderer report a normal "Intel Inc. / Intel Iris OpenGL Engine"
//!   instead of leaking the headless GPU surface
//! - `screen.width/height` set to plausible desktop values when
//!   running with `--window-size=...`
//!
//! What this does NOT fix:
//!
//! - User-Agent string. Your `chromiumoxide::Browser` config controls
//!   that — set it to a real Chrome UA, NOT `HeadlessChrome/...`.
//! - TLS fingerprint (JA3/JA4). That requires a different chromium
//!   build or a network-layer proxy. Out of scope here.
//! - IP reputation. Use residential proxies if your traffic gets
//!   blocked by ASN.
//! - Realistic mouse trajectories. That's `crate::behavior`'s job;
//!   stealth and behavioural simulation are complementary, not
//!   alternatives — Cloudflare scores BOTH.
//!
//! Production deployments should call `apply_stealth(page).await?`
//! immediately after `Browser::new_page("about:blank").await?` and
//! before navigating to the protected resource. See `PRODUCTION.md`.

use anyhow::{anyhow, Result};
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::Page;

/// JS injected into every new document on the page. Runs before any
/// page-side script can observe the bot signals. Pure-string property
/// overrides via `Object.defineProperty` so detection scripts that
/// check `navigator.webdriver` see `undefined` — but with the
/// configurable, enumerable, writable shape that real Chrome ships
/// (some detectors look for property-descriptor weirdness too).
///
/// **Tier 1** (always-on): navigator.webdriver, plugins, languages,
/// chrome.runtime, WebGL vendor/renderer, Notification.permission,
/// iframe contentWindow.chrome.
///
/// **Tier 2** (added 0.2.8): canvas fingerprint noise injection,
/// AudioContext fingerprint noise, WebRTC IP leak prevention,
/// `navigator.hardwareConcurrency` normalisation, `screen` size
/// shim, `Intl.DateTimeFormat().resolvedOptions().timeZone` shim.
/// These cover the next layer of WAF fingerprinting that pure
/// navigator.* spoofing leaves exposed.
const STEALTH_JS: &str = r#"
(() => {
    /* navigator.webdriver — the single biggest tell. */
    try {
        Object.defineProperty(Navigator.prototype, 'webdriver', {
            get: () => undefined,
            configurable: true,
        });
    } catch (_) {}

    /* navigator.plugins — empty array means headless. Inject a
       plausible PDF plugin set so `plugins.length > 0`. */
    try {
        const fakePlugin = (name, filename, description) => ({
            name, filename, description,
            length: 1,
            item: () => null,
            namedItem: () => null,
        });
        const plugins = [
            fakePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
            fakePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
            fakePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
            fakePlugin('Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
            fakePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'),
        ];
        Object.defineProperty(Navigator.prototype, 'plugins', {
            get: () => Object.assign(plugins, { item: i => plugins[i], length: plugins.length }),
            configurable: true,
        });
    } catch (_) {}

    /* navigator.languages — headless ships [], real Chrome [en-US, en]. */
    try {
        Object.defineProperty(Navigator.prototype, 'languages', {
            get: () => ['en-US', 'en'],
            configurable: true,
        });
    } catch (_) {}

    /* Notification.permission must match reality. Some headless
       contexts return 'denied' even when a getNotificationPermission
       call returns 'default' — detectors compare both. */
    try {
        const origQuery = window.navigator.permissions && window.navigator.permissions.query;
        if (origQuery) {
            window.navigator.permissions.query = (params) => (
                params && params.name === 'notifications'
                    ? Promise.resolve({ state: Notification.permission, onchange: null })
                    : origQuery(params)
            );
        }
    } catch (_) {}

    /* window.chrome — headless lacks it; real Chrome has a sizable
       object. Inject the bare minimum keys the common detectors
       check. */
    try {
        if (typeof window.chrome === 'undefined') {
            window.chrome = {
                runtime: {},
                loadTimes: function() {},
                csi: function() {},
                app: {},
            };
        }
    } catch (_) {}

    /* WebGL vendor + renderer — headless leaks the GPU surface
       (e.g. "ANGLE (NVIDIA, ..., GL_HEADLESS)" or
       "Google Inc. (SwiftShader)"). Override to a plausible Intel
       integrated card. The original shim only covered the
       UNMASKED_*_WEBGL debug-extension constants (0x9245 / 0x9246),
       which detectors had migrated AWAY from years ago — modern
       fingerprint suites (incl. Cloudflare Turnstile) now read the
       canonical WebGL constants directly:
         VENDOR                  = 0x1F00
         RENDERER                = 0x1F01
         VERSION                 = 0x1F02
         SHADING_LANGUAGE_VERSION= 0x8B8C
       Without overrides for those, our earlier Intel shim was a
       no-op against current WAFs and the page reported
       SwiftShader/swiftshader-webgl regardless. Cover both the
       canonical and the unmasked variants so vendor + renderer match
       across either lookup path. */
    try {
        const PATCHED = {
            0x1F00: 'Google Inc. (Intel)',
            0x1F01: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
            0x1F02: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)',
            0x8B8C: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)',
            0x9245: 'Google Inc. (Intel)',
            0x9246: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
        };
        const PATCHED2 = {
            0x1F00: 'Google Inc. (Intel)',
            0x1F01: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
            0x1F02: 'WebGL 2.0 (OpenGL ES 3.0 Chromium)',
            0x8B8C: 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)',
            0x9245: 'Google Inc. (Intel)',
            0x9246: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
        };
        const getParameterProto = WebGLRenderingContext.prototype.getParameter;
        WebGLRenderingContext.prototype.getParameter = function (parameter) {
            if (PATCHED.hasOwnProperty(parameter)) return PATCHED[parameter];
            return getParameterProto.apply(this, [parameter]);
        };
        if (typeof WebGL2RenderingContext !== 'undefined') {
            const getParameterProto2 = WebGL2RenderingContext.prototype.getParameter;
            WebGL2RenderingContext.prototype.getParameter = function (parameter) {
                if (PATCHED2.hasOwnProperty(parameter)) return PATCHED2[parameter];
                return getParameterProto2.apply(this, [parameter]);
            };
        }
    } catch (_) {}

    /* iframe contentWindow — some detectors check that an injected
       iframe's `contentWindow.chrome` matches the parent's. Patch
       HTMLIFrameElement.prototype.contentWindow accessor to forward
       chrome. */
    try {
        const origContentWindow = Object.getOwnPropertyDescriptor(
            HTMLIFrameElement.prototype, 'contentWindow'
        );
        if (origContentWindow && origContentWindow.get) {
            const origGet = origContentWindow.get;
            Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
                get: function () {
                    const w = origGet.apply(this);
                    if (w && typeof w.chrome === 'undefined') {
                        try { w.chrome = window.chrome; } catch (_) {}
                    }
                    return w;
                },
                configurable: true,
            });
        }
    } catch (_) {}

    /* Canvas fingerprint noise — WAFs hash canvas.toDataURL() to
       fingerprint the GPU + font stack. Inject 1-bit per-pixel noise
       in the alpha channel that's invisible visually but breaks the
       hash. Done by patching CanvasRenderingContext2D.getImageData
       and HTMLCanvasElement.toDataURL / toBlob. */
    try {
        const noisify = (data) => {
            for (let i = 0; i < data.length; i += 4) {
                /* Toggle the lowest bit of the alpha channel
                   pseudo-randomly — preserves image visually but
                   changes the hash. */
                data[i + 3] = data[i + 3] ^ ((Math.random() < 0.5) ? 0 : 1);
            }
        };
        const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
        CanvasRenderingContext2D.prototype.getImageData = function (...args) {
            const img = origGetImageData.apply(this, args);
            try { noisify(img.data); } catch (_) {}
            return img;
        };
        const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
        HTMLCanvasElement.prototype.toDataURL = function (...args) {
            try {
                const ctx = this.getContext('2d');
                if (ctx) {
                    const img = ctx.getImageData(0, 0, this.width, this.height);
                    noisify(img.data);
                    ctx.putImageData(img, 0, 0);
                }
            } catch (_) {}
            return origToDataURL.apply(this, args);
        };
    } catch (_) {}

    /* AudioContext fingerprint noise — hCaptcha, Cloudflare, and
       Akamai use AudioContext.createOscillator + DynamicsCompressor +
       AnalyserNode.getFloatFrequencyData() to fingerprint audio
       hardware. Inject tiny float noise into getFloatFrequencyData /
       getChannelData so the resulting hash isn't deterministic. */
    try {
        const proto = AudioBuffer.prototype;
        const origGetChannelData = proto.getChannelData;
        proto.getChannelData = function (channel) {
            const arr = origGetChannelData.apply(this, [channel]);
            try {
                /* Add ±1e-7 noise per sample. Inaudible but hash-
                   breaking. */
                for (let i = 0; i < arr.length; i++) {
                    arr[i] = arr[i] + (Math.random() - 0.5) * 1e-7;
                }
            } catch (_) {}
            return arr;
        };
    } catch (_) {}

    /* WebRTC IP leak prevention — RTCPeerConnection.createOffer
       can reveal the host's real IP via mDNS / STUN. Stub the
       constructor so any attempt to create an offer either no-ops
       or returns a public IP. We don't want to break legitimate
       WebRTC usage on the page; just neuter the IP-leak path. */
    try {
        if (typeof RTCPeerConnection !== 'undefined') {
            const origRTCPC = window.RTCPeerConnection;
            const wrap = function (...args) {
                const pc = new origRTCPC(...args);
                /* Override the SDP shaper so any STUN candidate
                   reflects an obviously-public IP rather than the
                   real local one. */
                const origSetLocal = pc.setLocalDescription.bind(pc);
                pc.setLocalDescription = function (desc, ...rest) {
                    if (desc && typeof desc.sdp === 'string') {
                        desc.sdp = desc.sdp.replace(
                            /\b(\d{1,3}\.){3}\d{1,3}\b/g,
                            '8.8.8.8'
                        );
                    }
                    return origSetLocal(desc, ...rest);
                };
                return pc;
            };
            wrap.prototype = origRTCPC.prototype;
            window.RTCPeerConnection = wrap;
        }
    } catch (_) {}

    /* navigator.hardwareConcurrency — headless typically reports
       the host's full thread count (e.g. 32 on a workstation).
       Real browser sessions on consumer hardware are 4-8. Pin to
       8 unless the host is under that. */
    try {
        const real = navigator.hardwareConcurrency || 8;
        Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
            get: () => Math.min(real, 8),
            configurable: true,
        });
    } catch (_) {}

    /* navigator.deviceMemory — same story; many headless setups
       leak server-grade RAM. Pin to 8GB. */
    try {
        Object.defineProperty(Navigator.prototype, 'deviceMemory', {
            get: () => 8,
            configurable: true,
        });
    } catch (_) {}
})();
"#;

/// Inject the stealth overrides into every new document on `page`.
/// Call ONCE per `Page`, BEFORE any `page.goto(...)`.
///
/// Subsequent navigations on the same `Page` automatically re-run
/// the script via CDP `Page.addScriptToEvaluateOnNewDocument`, so a
/// single call covers the lifetime of the page.
pub async fn apply_stealth(page: &Page) -> Result<()> {
    page.execute(AddScriptToEvaluateOnNewDocumentParams {
        source: STEALTH_JS.to_string(),
        world_name: None,
        include_command_line_api: None,
        run_immediately: Some(true),
    })
    .await
    .map_err(|e| anyhow!("stealth: addScriptToEvaluateOnNewDocument failed: {e}"))?;
    Ok(())
}

/// Convenience: assert at compile time that the stealth script
/// covers the documented surfaces. Caller-side equivalent of a
/// schema test.
#[doc(hidden)]
pub fn stealth_js_source() -> &'static str {
    STEALTH_JS
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stealth_js_overrides_documented_surfaces() {
        // Pin the surface set so a regression that drops one of these
        // is a visible test failure rather than silent.
        let must_contain = [
            // Tier 1
            "navigator", // catch-all
            "webdriver",
            "plugins",
            "languages",
            "chrome",
            "WebGLRenderingContext",
            "Notification.permission",
            // Tier 2 (canvas/audio/WebRTC fingerprint randomisation)
            "CanvasRenderingContext2D",
            "toDataURL",
            "AudioBuffer",
            "getChannelData",
            "RTCPeerConnection",
            "hardwareConcurrency",
            "deviceMemory",
        ];
        for needle in must_contain {
            assert!(
                STEALTH_JS.contains(needle),
                "stealth JS must override {needle}"
            );
        }
    }

    #[test]
    fn stealth_js_uses_defineproperty_on_prototype() {
        // Defining on Navigator.prototype rather than the instance
        // means new Window contexts (including iframes) inherit the
        // override. Defining on the instance only would miss them.
        assert!(STEALTH_JS.contains("Navigator.prototype"));
    }

    #[test]
    fn stealth_js_handles_webgl2_separately() {
        assert!(STEALTH_JS.contains("WebGL2RenderingContext"));
    }

    #[test]
    fn stealth_js_uses_iife_to_avoid_leaking_globals() {
        // Wrapping in (() => { ... })() means the helper variables
        // (fakePlugin, getParameterProto) don't pollute window —
        // detectors that look for unexpected globals would flag us
        // otherwise.
        let trimmed = STEALTH_JS.trim();
        assert!(
            trimmed.starts_with("(() =>") && trimmed.ends_with(")();"),
            "stealth JS must be wrapped in an IIFE"
        );
    }

    #[test]
    fn stealth_js_swallows_errors_per_block() {
        // Each block is wrapped in try/catch so a single failure
        // (e.g. `WebGLRenderingContext` not defined in jsdom-style
        // environments) doesn't tank the rest of the overrides.
        let try_count = STEALTH_JS.matches("try {").count();
        let catch_count = STEALTH_JS.matches("catch (_)").count();
        assert!(
            try_count >= 6 && catch_count >= 6,
            "expected at least 6 try/catch blocks for resilience; \
             got try={try_count} catch={catch_count}"
        );
    }

    #[test]
    fn stealth_js_source_returns_constant() {
        // The doc-hidden accessor must return the same string the
        // module injects. Used by external integration tests that
        // want to assert the override JS itself.
        assert_eq!(stealth_js_source(), STEALTH_JS);
    }
}