captchaforge 0.2.27

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
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
//! Patched-chromium sidecar detection + selection.
//!
//! Every existing "stealth" library lives ABOVE chromium and tries
//! to monkey-patch the headless tells from JavaScript. That works
//! for `navigator.webdriver` but fails for the dozen-odd binary-
//! level tells (CDP detection via `Page.captureScreenshot` side
//! effects, missing `Notification` API, missing media devices, no
//! battery API, distinct font hashes, …) that anti-bot stacks
//! actually look at first.
//!
//! The structural answer is to fork chromium and patch those tells
//! at the C++ level, then ship the resulting binary as a sidecar
//! that captchaforge consumers opt into. This module is the
//! **detection + selection half** of that work — when a patched
//! chromium binary is on disk, captchaforge uses it; when not, it
//! falls back to the system chromium and logs that the
//! true-stealth tier is unavailable.
//!
//! ## What this module does
//!
//! - [`PatchedChromiumDetector::detect`] — search known install
//!   locations for a `captchaforge-chromium` binary; return its
//!   absolute path + version when present.
//! - The detector's internal `--version` parser confirms we're
//!   talking to the patched binary (banner must carry the
//!   `captchaforge-chromium/` prefix), not a system chromium that
//!   happened to install under the same name.
//! - [`PatchedChromiumDetector::recommended_browser_config`] —
//!   build a chromiumoxide `BrowserConfig` pointing at the
//!   patched binary plus the
//!   [`crate::sdk::recommended_chromium_args`] anti-bot lockdown
//!   flags.
//!
//! ## What this module does NOT do
//!
//! - **Build chromium.** Forking + patching + distributing
//!   chromium is its own multi-month project. The patch list
//!   below documents what the patches need to do; the actual
//!   sources + buildbot live in a separate repo
//!   (`santhsecurity/captchaforge-chromium`, planned).
//! - **Auto-install.** When the patched binary isn't present we
//!   log + fall back. We don't curl-pipe-bash a release artefact;
//!   the operator decides when to install.
//!
//! ## Patch list (informational)
//!
//! What the patched chromium fork needs to remove vs. system
//! chromium. Each entry is a "tell" anti-bot stacks check:
//!
//! 1. `navigator.webdriver = true` — set to undefined at the
//!    Blink level (the JS-side override is detectable because the
//!    property descriptor on `Navigator.prototype` differs).
//! 2. `Page.captureScreenshot` side effects — DevTools-driven
//!    screenshot capture momentarily pauses the rendering thread;
//!    sites time `requestAnimationFrame` to detect this.
//! 3. Missing extensions on `navigator.plugins` — headless
//!    chromium ships zero plugins; real chrome has a small fixed
//!    list.
//! 4. Missing `MediaDevices.getUserMedia` failures — real chrome
//!    surfaces specific error codes; headless surfaces different
//!    ones.
//! 5. Missing `BatteryManager` API — present on real chrome,
//!    absent on headless.
//! 6. Missing `Notification.permission` — real chrome reports
//!    `default`; headless reports `denied`.
//! 7. Distinct WebGL `UNMASKED_VENDOR_WEBGL` strings — headless
//!    SwiftShader vs. real GPU. (Stealth profiles patch this at
//!    the JS level today; binary patch is more robust.)
//! 8. Distinct font enumeration — headless chromium has a
//!    smaller bundled font set than a real OS install.
//! 9. CDP runtime-domain `Runtime.enable` side effects — calling
//!    it changes `console.debug`'s prototype.
//! 10. `chrome.runtime` / `chrome.csi` / `chrome.loadTimes` — real
//!     chrome has them; headless doesn't.
//! 11. `outerHeight` / `outerWidth` mismatch — headless reports 0.
//! 12. `screen.availHeight` / `availWidth` differ from `height` /
//!     `width` on real chrome but match on headless.

#![allow(dead_code)] // module is opt-in; consumer wiring lands separately.

use std::path::{Path, PathBuf};
use std::process::Command;

/// Where on disk we look for a patched chromium binary.
///
/// In probe order — first hit wins. Operators that install the
/// binary to a non-standard location can extend the list with
/// [`PatchedChromiumDetector::with_extra_paths`].
const DEFAULT_SEARCH_PATHS: &[&str] = &[
    "/usr/local/bin/captchaforge-chromium",
    "/opt/captchaforge-chromium/bin/chromium",
    "/opt/captchaforge/chromium",
    "/Applications/captchaforge-chromium.app/Contents/MacOS/Chromium",
    "/snap/captchaforge-chromium/current/usr/bin/chromium",
];

/// One `--version` output we accept as proof we're talking to the
/// patched binary, not a system chromium that happens to share
/// the name. The patched fork prepends `captchaforge-chromium/`
/// to its version banner.
const PATCHED_VERSION_PREFIX: &str = "captchaforge-chromium/";

/// Result of a probe — either we found a patched binary at `path`
/// (with the version string we read from `--version`) or we didn't.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DetectOutcome {
    /// A patched chromium binary was located + the `--version`
    /// banner confirms it's the patched fork.
    Found { path: PathBuf, version: String },
    /// No patched binary was located. Captchaforge should fall
    /// back to the system chromium and log that the true-stealth
    /// tier is unavailable so operators know to install it.
    NotFound,
    /// A binary at `path` looked like a candidate (the file
    /// existed + was executable) but its `--version` banner did
    /// NOT carry the `captchaforge-chromium/` version-banner
    /// prefix — it's a system
    /// chromium that shares the name. Skipped to avoid a
    /// silent-quality regression where consumers think they're
    /// getting true-stealth but aren't.
    UnverifiedCandidate { path: PathBuf, version: String },
}

impl DetectOutcome {
    /// True iff we have a verified patched binary.
    pub fn is_found(&self) -> bool {
        matches!(self, DetectOutcome::Found { .. })
    }

    /// Extract the binary path when found.
    pub fn path(&self) -> Option<&Path> {
        match self {
            DetectOutcome::Found { path, .. } | DetectOutcome::UnverifiedCandidate { path, .. } => {
                Some(path.as_path())
            }
            DetectOutcome::NotFound => None,
        }
    }
}

/// Search-path-aware detector for the captchaforge-chromium sidecar.
pub struct PatchedChromiumDetector {
    extra_paths: Vec<PathBuf>,
}

impl PatchedChromiumDetector {
    pub fn new() -> Self {
        Self { extra_paths: Vec::new() }
    }

    /// Append additional search locations BEFORE the default list.
    /// Use when the operator installed to a non-standard path.
    pub fn with_extra_paths(mut self, paths: Vec<PathBuf>) -> Self {
        self.extra_paths = paths;
        self
    }

    /// Walk the configured search paths and return the first
    /// verified patched binary, or `NotFound`. Side effect: logs
    /// at `info` level when found, `debug` when fallthrough.
    pub fn detect(&self) -> DetectOutcome {
        let mut last_unverified: Option<DetectOutcome> = None;
        for raw in self.candidate_paths() {
            let path = raw.clone();
            if !path.exists() {
                continue;
            }
            // Run `--version` with a short timeout so a hung
            // binary can't wedge our probe.
            let Some(banner) = read_version(&path) else {
                continue;
            };
            if banner.contains(PATCHED_VERSION_PREFIX) {
                tracing::info!(
                    path = %path.display(),
                    version = %banner,
                    "captchaforge: patched chromium sidecar detected"
                );
                return DetectOutcome::Found {
                    path,
                    version: banner,
                };
            }
            // Looked like a candidate but didn't carry our prefix.
            // Remember it as a hint we'll surface IF nothing else
            // matches, so operators see "you have something at
            // /usr/local/bin/captchaforge-chromium but it isn't
            // the patched fork".
            last_unverified = Some(DetectOutcome::UnverifiedCandidate {
                path,
                version: banner,
            });
        }
        if let Some(outcome) = last_unverified {
            tracing::debug!(
                path = ?outcome.path(),
                "captchaforge: candidate chromium binary present but version banner \
                 does not carry the patched-fork prefix; skipping"
            );
            return outcome;
        }
        tracing::debug!(
            "captchaforge: no patched chromium sidecar detected — \
             falling back to system chromium (true-stealth tier unavailable)"
        );
        DetectOutcome::NotFound
    }

    /// Build a chromiumoxide `BrowserConfig` pointing at the
    /// patched binary + carrying the standard anti-bot lockdown
    /// flags. Returns `None` when no patched binary is present —
    /// callers fall back to `BrowserConfig::default()`.
    ///
    /// This is the production wiring entry point. Typical
    /// consumer code:
    ///
    /// ```ignore
    /// let detector = PatchedChromiumDetector::new();
    /// let config = detector.recommended_browser_config()
    ///     .unwrap_or_else(|| BrowserConfig::builder().build().unwrap());
    /// let (browser, handler) = Browser::launch(config).await?;
    /// ```
    pub fn recommended_browser_config(&self) -> Option<chromiumoxide::BrowserConfig> {
        let outcome = self.detect();
        let DetectOutcome::Found { path, .. } = outcome else {
            return None;
        };
        let mut builder = chromiumoxide::BrowserConfig::builder().chrome_executable(path);
        for arg in crate::sdk::recommended_chromium_args() {
            builder = builder.arg(arg);
        }
        builder.build().ok()
    }

    fn candidate_paths(&self) -> Vec<PathBuf> {
        let mut out: Vec<PathBuf> = self.extra_paths.clone();
        for s in DEFAULT_SEARCH_PATHS {
            out.push(PathBuf::from(s));
        }
        out
    }
}

impl Default for PatchedChromiumDetector {
    fn default() -> Self {
        Self::new()
    }
}

/// Run `<path> --version` with a 2-second timeout and return the
/// trimmed stdout. Returns `None` on timeout / spawn failure /
/// non-zero exit / non-UTF8 output.
fn read_version(path: &Path) -> Option<String> {
    let output = Command::new(path).arg("--version").output().ok()?;
    if !output.status.success() {
        return None;
    }
    let s = String::from_utf8(output.stdout).ok()?;
    Some(s.trim().to_string())
}

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

    #[test]
    fn detect_returns_not_found_when_search_paths_empty_and_extras_unset() {
        // We can't actually disable DEFAULT_SEARCH_PATHS at test
        // time, so this asserts the WEAKER invariant: with no
        // extras + no patched binary on the host (the test
        // environment), detect must NOT crash and MUST return
        // either NotFound or UnverifiedCandidate.
        let detector = PatchedChromiumDetector::new();
        let outcome = detector.detect();
        // On dev machines without the patched fork installed,
        // outcome is NotFound. On a CI host where someone happens
        // to have installed a system chromium at one of the
        // default paths it'd be UnverifiedCandidate. Both
        // acceptable; the contract is "no panic, no false-positive
        // Found".
        match outcome {
            DetectOutcome::Found { .. } => {
                // Only legal when the test host actually has the
                // patched fork. Print so we know — don't fail the
                // test.
                println!("test host has patched fork installed (rare; benign)");
            }
            DetectOutcome::NotFound | DetectOutcome::UnverifiedCandidate { .. } => {}
        }
    }

    #[test]
    fn extra_paths_are_searched_before_defaults() {
        // Build a binary whose `--version` output carries the
        // patched prefix; point an extra-path at it; assert detect
        // returns it.
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("fake-patched-chromium");
        // tiny shell stub that prints the right banner.
        std::fs::write(
            &path,
            format!("#!/bin/sh\necho '{PATCHED_VERSION_PREFIX}1.2.3 (fake)'\n"),
        )
        .unwrap();
        // Make it executable (0o755).
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&path).unwrap().permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&path, perm).unwrap();
        }

        let detector = PatchedChromiumDetector::new().with_extra_paths(vec![path.clone()]);
        let outcome = detector.detect();
        match outcome {
            DetectOutcome::Found {
                path: found_path,
                version,
            } => {
                assert_eq!(found_path, path);
                assert!(version.contains(PATCHED_VERSION_PREFIX));
            }
            other => panic!("expected Found, got {other:?}"),
        }
    }

    #[test]
    fn unverified_candidate_returned_when_version_banner_wrong() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("system-chromium");
        std::fs::write(
            &path,
            "#!/bin/sh\necho 'Chromium 130.0.6723.116'\n",
        )
        .unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&path).unwrap().permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&path, perm).unwrap();
        }

        let detector = PatchedChromiumDetector::new().with_extra_paths(vec![path.clone()]);
        let outcome = detector.detect();
        match outcome {
            DetectOutcome::UnverifiedCandidate {
                path: found_path,
                version,
            } => {
                assert_eq!(found_path, path);
                assert!(version.contains("Chromium 130"));
                assert!(!version.contains(PATCHED_VERSION_PREFIX));
            }
            DetectOutcome::Found { .. } => {
                panic!("system chromium must NOT be classified as Found");
            }
            DetectOutcome::NotFound => {
                // Possible if the binary couldn't run on this
                // platform (e.g. Windows shell stub on Linux);
                // skip.
            }
        }
    }

    #[test]
    fn detect_outcome_helpers() {
        let f = DetectOutcome::Found {
            path: PathBuf::from("/x"),
            version: "captchaforge-chromium/1.0".into(),
        };
        assert!(f.is_found());
        assert_eq!(f.path(), Some(Path::new("/x")));

        let nf = DetectOutcome::NotFound;
        assert!(!nf.is_found());
        assert_eq!(nf.path(), None);

        let u = DetectOutcome::UnverifiedCandidate {
            path: PathBuf::from("/y"),
            version: "Chromium 130".into(),
        };
        assert!(!u.is_found());
        assert_eq!(u.path(), Some(Path::new("/y")));
    }

    #[test]
    fn candidate_paths_include_defaults_after_extras() {
        let detector = PatchedChromiumDetector::new().with_extra_paths(vec![
            PathBuf::from("/custom/path1"),
            PathBuf::from("/custom/path2"),
        ]);
        let paths = detector.candidate_paths();
        assert_eq!(paths[0], PathBuf::from("/custom/path1"));
        assert_eq!(paths[1], PathBuf::from("/custom/path2"));
        // The defaults follow.
        assert!(paths.len() > 2);
        assert!(paths.iter().any(|p| p == Path::new("/usr/local/bin/captchaforge-chromium")));
    }

    #[test]
    fn read_version_returns_none_for_missing_binary() {
        assert_eq!(
            read_version(Path::new("/path/that/definitely/does/not/exist/abc123")),
            None
        );
    }

    #[test]
    fn patched_version_prefix_is_stable_for_dashboards() {
        // Dashboards / log queries match on this exact string.
        // Changing it is a breaking change for ops.
        assert_eq!(PATCHED_VERSION_PREFIX, "captchaforge-chromium/");
    }
}