captchaforge 0.2.26

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
//! Per-vendor token shape validators ("oracles").
//!
//! After every solve, the chain receives a string the page or its
//! iframes claim is the captcha response token. Today the chain
//! accepts any non-empty string. That's wrong: WAFs deliberately
//! return short "soft-failure" decoy tokens (4-12 chars, no
//! structure) to make scrapers report success when they actually
//! failed. The decoys make the *next* request fail with a fresh
//! challenge — by which time the scraper has already moved on.
//!
//! A token oracle says "this string LOOKS like a real
//! `cf-turnstile-response` / `g-recaptcha-response` / etc. token"
//! based on:
//!
//! - **Length**: every vendor has a documented length range. CF
//!   Turnstile = ~330-380 chars. reCAPTCHA v2 = ~400-2000 chars.
//!   hCaptcha = ~100-2400 chars. A 12-char "token" is always a
//!   decoy.
//! - **Character set**: most vendor tokens are URL-safe base64 or
//!   base64url + a small set of separators. A token containing
//!   spaces, newlines, or `<` is never real.
//! - **Structural prefix**: CF Turnstile tokens start with a stable
//!   `0.` or recent rev `1.` prefix. reCAPTCHA tokens have a
//!   recognisable header structure. Decoys don't match.
//! - **Entropy floor**: real tokens are high-entropy random strings
//!   from the WAF's signing key. Decoys are typically low-entropy
//!   placeholder text. We compute the byte-frequency-distance from
//!   uniform; below a threshold means "this isn't a real token".
//!
//! No oracle is perfect — vendor revisions change formats. The
//! oracle returns [`TokenShape`] with three buckets: `Plausible`
//! (passes all checks), `Suspect` (one or more soft signals fail
//! but it might still work), `Decoy` (clearly not a real token).
//! Callers downgrade success on `Decoy`.

/// Result of running a token through a vendor oracle.
///
/// Three buckets, ordered from most-likely-real to most-likely-fake:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenShape {
    /// All length / charset / prefix / entropy checks passed —
    /// this is a real token from the vendor's signing key with
    /// near-certainty.
    Plausible,
    /// At least one soft signal failed (length is at the edge of
    /// the documented range, or the prefix is unrecognised but the
    /// charset + entropy look real). Caller should still report
    /// success but flag for re-verification.
    Suspect,
    /// Strong evidence the token is a decoy: too short, wrong
    /// charset, low entropy, or contains markers that real tokens
    /// never contain (whitespace, HTML tags, "error" / "blocked"
    /// substrings).
    Decoy,
}

/// One vendor's token shape oracle.
///
/// Returned by [`for_vendor`]. Apply with [`TokenOracle::classify`]
/// on a candidate token to get a [`TokenShape`].
pub struct TokenOracle {
    vendor: &'static str,
    min_len: usize,
    max_len: usize,
    /// Character predicate — returns true for chars that are
    /// allowed in a real token from this vendor.
    char_ok: fn(char) -> bool,
    /// Optional vendor-specific extra check (prefix, suffix, JWT
    /// structure). Returns Some(failure-message) on rejection,
    /// None when passing.
    extra: fn(&str) -> Option<&'static str>,
    /// Minimum Shannon-entropy (bits per byte) for the token to
    /// pass the entropy check. Real high-entropy tokens hit
    /// 5.5-6.0; decoys cluster below 4.5.
    min_entropy_bits_per_byte: f32,
}

impl TokenOracle {
    /// Borrow the vendor name for telemetry.
    pub fn vendor(&self) -> &'static str {
        self.vendor
    }

    /// Classify `token` against this vendor's shape contract.
    pub fn classify(&self, token: &str) -> TokenShape {
        // Cheapest-first short-circuiting:
        // length → charset → entropy → vendor-extra.
        if token.is_empty() {
            return TokenShape::Decoy;
        }
        if token.len() < self.min_len {
            return TokenShape::Decoy;
        }
        if token.len() > self.max_len {
            // Over-length is more often a copy-paste / wrong-field
            // bug than a soft-failure; flag as Suspect rather than
            // outright Decoy.
            return TokenShape::Suspect;
        }
        let mut bad_chars = 0usize;
        for c in token.chars() {
            if !(self.char_ok)(c) {
                bad_chars += 1;
                if bad_chars > 2 {
                    return TokenShape::Decoy;
                }
            }
        }
        let entropy = shannon_entropy_bits_per_byte(token.as_bytes());
        if entropy < self.min_entropy_bits_per_byte - 1.0 {
            return TokenShape::Decoy;
        }
        if entropy < self.min_entropy_bits_per_byte {
            return TokenShape::Suspect;
        }
        if let Some(_failure) = (self.extra)(token) {
            return TokenShape::Suspect;
        }
        TokenShape::Plausible
    }
}

/// Look up the oracle for a given vendor name. Vendor names are
/// canonical lowercase + hyphens, matching the
/// [`crate::detect::DetectedCaptcha`] lowercasing convention:
/// `"cloudflare-turnstile"`, `"recaptcha-v2"`, `"hcaptcha"`,
/// `"recaptcha-v3"`, `"recaptcha-enterprise"`.
///
/// Returns `None` when the vendor isn't recognised (caller should
/// fall back to "any non-empty string" semantics).
///
/// # Example
///
/// ```rust
/// use captchaforge::solver::token_shapes::{for_vendor, TokenShape};
///
/// let oracle = for_vendor("cloudflare-turnstile").unwrap();
/// // 8-char string is way under CF Turnstile's documented length.
/// assert_eq!(oracle.classify("decoy123"), TokenShape::Decoy);
/// ```
pub fn for_vendor(vendor: &str) -> Option<TokenOracle> {
    match vendor {
        "cloudflare-turnstile" | "turnstile" => Some(TokenOracle {
            vendor: "cloudflare-turnstile",
            min_len: 200,
            max_len: 2048,
            char_ok: is_base64url_or_dot,
            extra: turnstile_extra,
            min_entropy_bits_per_byte: 5.0,
        }),
        "recaptcha-v2" | "recaptcha" => Some(TokenOracle {
            vendor: "recaptcha-v2",
            min_len: 200,
            max_len: 8192,
            char_ok: is_base64url_or_dot_or_dash_or_underscore,
            extra: recaptcha_v2_extra,
            min_entropy_bits_per_byte: 5.0,
        }),
        "recaptcha-v3" => Some(TokenOracle {
            vendor: "recaptcha-v3",
            min_len: 200,
            max_len: 8192,
            char_ok: is_base64url_or_dot_or_dash_or_underscore,
            extra: |_| None,
            min_entropy_bits_per_byte: 5.0,
        }),
        "recaptcha-enterprise" => Some(TokenOracle {
            vendor: "recaptcha-enterprise",
            min_len: 200,
            max_len: 8192,
            char_ok: is_base64url_or_dot_or_dash_or_underscore,
            extra: |_| None,
            min_entropy_bits_per_byte: 5.0,
        }),
        "hcaptcha" => Some(TokenOracle {
            vendor: "hcaptcha",
            min_len: 80,
            max_len: 4096,
            char_ok: is_base64url_or_dot_or_dash_or_underscore,
            extra: |_| None,
            min_entropy_bits_per_byte: 4.5,
        }),
        _ => None,
    }
}

fn is_base64url_or_dot(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
}

fn is_base64url_or_dot_or_dash_or_underscore(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+' || c == '/' || c == '='
}

/// Cloudflare Turnstile tokens carry a stable `0.` or `1.` revision
/// prefix as of Q1-2026. A token without one of these prefixes is
/// flagged Suspect (it might be a future revision; we don't want
/// to outright reject).
fn turnstile_extra(token: &str) -> Option<&'static str> {
    if token.starts_with("0.") || token.starts_with("1.") {
        None
    } else {
        Some("turnstile token missing 0./1. revision prefix")
    }
}

/// reCAPTCHA v2 tokens are typically JWT-shaped (header.payload.sig)
/// — three base64url segments separated by exactly two dots. Pure-
/// numeric or single-segment strings are decoys.
fn recaptcha_v2_extra(token: &str) -> Option<&'static str> {
    let dot_count = token.bytes().filter(|b| *b == b'.').count();
    if dot_count >= 2 {
        None
    } else {
        Some("recaptcha v2 token must have JWT-shape (>=2 dots)")
    }
}

/// Shannon entropy in bits per byte. Real high-entropy tokens
/// score 5.0-6.5; ASCII text scores 3.5-4.5; low-entropy decoys
/// (`"FAILED"`, `"0".repeat(20)`) score below 3.0.
///
/// Pure function, no IO; bench-friendly.
fn shannon_entropy_bits_per_byte(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let mut freq = [0u32; 256];
    for b in bytes {
        freq[*b as usize] += 1;
    }
    let len = bytes.len() as f32;
    let mut entropy = 0.0f32;
    for f in freq.iter() {
        if *f == 0 {
            continue;
        }
        let p = (*f as f32) / len;
        entropy -= p * p.log2();
    }
    entropy
}

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

    #[test]
    fn empty_token_is_always_decoy_regardless_of_vendor() {
        for vendor in ["cloudflare-turnstile", "recaptcha-v2", "hcaptcha", "recaptcha-v3"] {
            let o = for_vendor(vendor).unwrap();
            assert_eq!(o.classify(""), TokenShape::Decoy, "{vendor} on empty token");
        }
    }

    #[test]
    fn for_vendor_recognises_canonical_aliases() {
        // Some call sites use the long form (`cloudflare-turnstile`),
        // others the short (`turnstile`) — both must resolve.
        assert!(for_vendor("turnstile").is_some());
        assert!(for_vendor("cloudflare-turnstile").is_some());
        assert!(for_vendor("recaptcha").is_some());
        assert!(for_vendor("recaptcha-v2").is_some());
    }

    #[test]
    fn for_vendor_returns_none_for_unknown_vendor() {
        assert!(for_vendor("totally-not-a-vendor").is_none());
        assert!(for_vendor("").is_none());
        // Case-sensitive — canonical form is lowercase+hyphen.
        assert!(for_vendor("Cloudflare-Turnstile").is_none());
    }

    #[test]
    fn turnstile_short_decoy_is_decoy() {
        let o = for_vendor("cloudflare-turnstile").unwrap();
        assert_eq!(o.classify("decoy"), TokenShape::Decoy);
        assert_eq!(o.classify("0.short"), TokenShape::Decoy);
    }

    #[test]
    fn turnstile_realistic_long_random_token_is_plausible() {
        // 250-char base64url string starting with `0.`. Mimics the
        // shape of a real CF Turnstile response token.
        let mut tok = String::from("0.");
        for i in 0..248u32 {
            // Spread across the base64url alphabet to keep entropy
            // realistic.
            const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
            tok.push(ALPH[(i * 17 % 64) as usize] as char);
        }
        let o = for_vendor("cloudflare-turnstile").unwrap();
        assert_eq!(
            o.classify(&tok),
            TokenShape::Plausible,
            "synthetic CF-shaped token must be Plausible"
        );
    }

    #[test]
    fn turnstile_long_token_without_revision_prefix_is_suspect_not_decoy() {
        // Length + charset + entropy are fine; only the `0.`/`1.`
        // prefix is missing. Future CF revisions might drop this
        // prefix — flag Suspect rather than Decoy so we don't
        // false-positive against a future-format real token.
        let mut tok = String::with_capacity(250);
        for i in 0..250u32 {
            const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
            tok.push(ALPH[(i * 17 % 64) as usize] as char);
        }
        let o = for_vendor("cloudflare-turnstile").unwrap();
        assert_eq!(o.classify(&tok), TokenShape::Suspect);
    }

    #[test]
    fn recaptcha_v2_jwt_shape_passes() {
        // 200+ chars across 3 dot-separated segments mimicking a
        // JWT. Should classify Plausible.
        let mut tok = String::with_capacity(300);
        for i in 0..100u32 {
            const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
            tok.push(ALPH[(i * 13 % 64) as usize] as char);
        }
        tok.push('.');
        for i in 0..100u32 {
            const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
            tok.push(ALPH[(i * 19 % 64) as usize] as char);
        }
        tok.push('.');
        for i in 0..50u32 {
            const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
            tok.push(ALPH[(i * 23 % 64) as usize] as char);
        }
        let o = for_vendor("recaptcha-v2").unwrap();
        assert_eq!(o.classify(&tok), TokenShape::Plausible);
    }

    #[test]
    fn recaptcha_v2_single_segment_is_suspect() {
        // Long high-entropy random string but no dots — almost
        // certainly not a real reCAPTCHA token (those are JWT-
        // shaped). Flag Suspect.
        let tok: String = (0..300u32)
            .map(|i| {
                const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
                ALPH[(i * 17 % 62) as usize] as char
            })
            .collect();
        let o = for_vendor("recaptcha-v2").unwrap();
        assert_eq!(o.classify(&tok), TokenShape::Suspect);
    }

    #[test]
    fn token_with_whitespace_is_decoy() {
        // Real tokens never contain whitespace. A "token" that's
        // a sentence ("FAILED could not solve") is a decoy.
        let o = for_vendor("cloudflare-turnstile").unwrap();
        let mut decoy = String::from("0.this is not a real token because it has spaces");
        // Pad to length range so we hit the charset check, not the
        // length check first.
        while decoy.len() < 250 {
            decoy.push_str(" decoy");
        }
        assert_eq!(o.classify(&decoy), TokenShape::Decoy);
    }

    #[test]
    fn low_entropy_repeating_is_decoy() {
        // 250 chars of `aaaaaa...` — passes length + charset, fails
        // entropy. Realistic decoy shape from misconfigured WAFs.
        let o = for_vendor("cloudflare-turnstile").unwrap();
        let decoy: String = "a".repeat(250);
        assert_eq!(o.classify(&decoy), TokenShape::Decoy);
    }

    #[test]
    fn shannon_entropy_of_uniform_random_is_near_eight() {
        // True uniform-random over 256 symbols → 8.0 bits/byte.
        // We don't go quite that high but >7 with a rotating
        // alphabet of size 256.
        let bytes: Vec<u8> = (0u8..=255).collect();
        let e = shannon_entropy_bits_per_byte(&bytes);
        assert!(e > 7.5, "entropy of full-alphabet was {e}");
    }

    #[test]
    fn shannon_entropy_of_constant_is_zero() {
        let bytes = [b'a'; 100];
        let e = shannon_entropy_bits_per_byte(&bytes);
        assert!(e < 0.001);
    }

    #[test]
    fn over_long_token_is_suspect_not_decoy() {
        // Way over the documented max — more often a wrong-field
        // bug ("we grabbed the JWT bearer token from a different
        // part of the page") than an attack. Flag Suspect.
        let o = for_vendor("hcaptcha").unwrap();
        let huge: String = (0..10_000u32)
            .map(|i| {
                const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
                ALPH[(i as usize) % ALPH.len()] as char
            })
            .collect();
        assert_eq!(o.classify(&huge), TokenShape::Suspect);
    }
}