captchaforge 0.2.32

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! 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)"). Override to a
       plausible Intel integrated card. UNMASKED_VENDOR_WEBGL = 0x9245,
       UNMASKED_RENDERER_WEBGL = 0x9246 (debug renderer info ext). */
    try {
        const getParameterProto = WebGLRenderingContext.prototype.getParameter;
        WebGLRenderingContext.prototype.getParameter = function (parameter) {
            if (parameter === 0x9245) return 'Intel Inc.';
            if (parameter === 0x9246) return 'Intel Iris OpenGL Engine';
            return getParameterProto.apply(this, [parameter]);
        };
        if (typeof WebGL2RenderingContext !== 'undefined') {
            const getParameterProto2 = WebGL2RenderingContext.prototype.getParameter;
            WebGL2RenderingContext.prototype.getParameter = function (parameter) {
                if (parameter === 0x9245) return 'Intel Inc.';
                if (parameter === 0x9246) return 'Intel Iris OpenGL Engine';
                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);
    }
}