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
use crate::Page;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{info, warn};
use super::{
screenshot_b64, AkamaiInterstitialSolver, ArkoseSolver, AudioCaptchaSolver, AwsWafSolver,
BehavioralCaptchaSolver, CaptchaInfo, CaptchaSolveResult, CaptchaSolver, CaptchaType,
CloudflareInterstitialSolver, CrnnTextSolver, DataDomeSolver, GeeTestSolver, ImpervaSolver,
KasadaSolver, MathCaptchaSolver, PatternStore, PerimeterXSolver, PowCaptchaSolver,
RecaptchaAudioSolver, SliderCaptchaSolver, SolveMethod, ThirdPartyCaptchaSolver, TokenCache,
TurnstileInteractiveSolver, VlmCaptchaSolver, WaitForTokenSolver, YoloGridSolver,
};
use crate::captcha_detect::DetectedCaptcha;
use crate::provider::ProviderRegistry;
use crate::solver::util::{detected_to_type, extract_domain};
use crate::telemetry::{NoopTelemetry, SolveEvent, SolveOutcome, SolverTelemetry};
// Law-5 responsibility split: the core `solve` orchestration lives in `solve`,
// solver selection/ordering in `selection`, and the test module in `tests`. The
// chain's construction, builders, token-cache short-circuit, and the
// kind→oracle helpers stay here. Submodules reach this module via `use super::*`.
mod selection;
mod solve;
#[cfg(test)]
#[path = "chain/tests.rs"]
mod tests;
/// Runtime configuration for the solver chain.
#[derive(Debug, Clone, Copy)]
pub struct ChainConfig {
/// Maximum time allowed for a single solver attempt (ms).
pub per_solver_timeout_ms: u64,
/// Whether to capture a screenshot when all solvers fail.
pub screenshot_on_failure: bool,
/// Verify outcome via [`crate::solver::oracle`], snapshot page
/// state before and after each solver attempt and downgrade
/// `success` to `false` when the oracle classifies the result as
/// HardBlock / Recycled / SilentFail. On by default, the cost
/// is two cheap BiDi evals per attempt and it eliminates the
/// "solver lied" failure mode entirely. Opt out only for synthetic
/// fixtures where you want to inspect the raw solver claim.
pub verify_outcome: bool,
/// Whether the oracle's `Unknown` verdict is treated as
/// success-preserving (default `false`: STRICT) or
/// success-keeping (compatibility for BiDi-flaky environments).
///
/// `Unknown` happens when the post-solve snapshot returns all
/// empty fields (rare in production, only when the page is
/// already torn down). Treating it as success was the historical
/// behavior; it lets opaque outcomes through as "verified". The
/// strict default (`false`) downgrades `Unknown` to failure so
/// the chain falls through to the next solver. Set to `true`
/// only when running against environments where snapshot BiDi
/// flakiness is expected.
pub allow_unknown_outcome: bool,
}
impl Default for ChainConfig {
fn default() -> Self {
Self {
per_solver_timeout_ms: 180_000,
screenshot_on_failure: true,
verify_outcome: true,
allow_unknown_outcome: false,
}
}
}
/// Tries registered solvers in order; returns the first successful result.
/// If all solvers fail, returns an unsolved result signalling human fallback.
pub struct CaptchaSolverChain {
pub(crate) solvers: Vec<Box<dyn CaptchaSolver>>,
/// Optional crowd-sourced pattern store for reordering by domain intelligence.
pub(crate) patterns: PatternStore,
pub(crate) config: ChainConfig,
/// Telemetry sink, fires once per solver attempt. Default is
/// [`NoopTelemetry`] so existing callers see no change; opt in via
/// [`Self::with_telemetry`] to capture metrics.
pub(crate) telemetry: Arc<dyn SolverTelemetry>,
/// Per-domain solved-token cache. `None` disables caching (the
/// default, backward-compatible). Opt in via
/// [`Self::with_token_cache`].
pub(crate) cache: Option<Arc<TokenCache>>,
/// Optional provider registry. When installed, [`DetectedCaptcha::Custom`]
/// captchas (the TOML-rule layer) route solvers through the
/// vendor's recommended_solver_methods, without it, Custom
/// captchas detect successfully but never get solved because the
/// built-in solvers' `supports()` returns false for Custom kinds.
pub(crate) providers: Option<Arc<ProviderRegistry>>,
/// Optional adversarial training corpus. When installed, every
/// failed solve auto-appends a [`crate::training_corpus::TrainingSample`]
/// for downstream re-training pipelines. Default is `None` so
/// existing callers see no behaviour delta. Opt in via
/// [`Self::with_training_corpus`].
pub(crate) training_corpus: Option<Arc<crate::training_corpus::TrainingCorpus>>,
}
impl CaptchaSolverChain {
/// Number of solvers wired into the chain. Useful for `selftest`
/// CLI / observability.
pub fn solver_count(&self) -> usize {
self.solvers.len()
}
/// Names of every solver in the chain, in execution order. Used
/// by the CLI's `selftest` to confirm the expected wiring.
pub fn solver_names(&self) -> Vec<&'static str> {
self.solvers.iter().map(|s| s.name()).collect()
}
/// Build the default chain in the recommended order:
/// WaitForToken → Behavioral → VLM → Audio → ThirdParty.
///
/// `WaitForTokenSolver` runs first with a short max-wait
/// (3 seconds), the common case in production is a passive
/// auto-pass widget (Turnstile passive mode, reCAPTCHA v3,
/// hCaptcha invisible) where the response field populates on
/// its own. When that happens the chain short-circuits and the
/// expensive solvers (mouse simulation, VLM, third-party API)
/// never run. When the field stays empty, the chain falls
/// through to behavioural simulation as before, no behaviour
/// regression for sites that need real interaction.
#[must_use = "default_chain returns the chain by value; assign or chain it."]
pub fn default_chain() -> Self {
let mut chain = Self {
solvers: Vec::new(),
patterns: PatternStore::default(),
config: ChainConfig::default(),
telemetry: Arc::new(NoopTelemetry),
cache: None,
providers: None,
training_corpus: None,
};
chain.add_solver(WaitForTokenSolver::new().with_max_wait_ms(3_000));
// Cloudflare interstitial, the "Just a moment..." 5-second
// JS challenge page that fronts CF-protected sites BEFORE
// any Turnstile widget. Solver waits for CF's own challenge
// to complete and harvests the cf_clearance cookie. With
// proper stealth, the challenge actually passes; without
// stealth, this returns failure within the budget and the
// chain falls through to behavioural simulation.
chain.add_solver(CloudflareInterstitialSolver::new());
// Akamai Bot Manager interstitial, fronts ~30% of the
// Fortune-500 web footprint with a sensor-data POST that
// sets the _abck cookie. With a coherent stealth profile,
// Akamai's own JS measurements pass; we just wait for the
// sensor POST and harvest the cookie. Returns failure on
// suspicious / blocked outcomes so the chain falls through.
chain.add_solver(AkamaiInterstitialSolver::new());
// DataDome, largest pure-play bot-management vendor outside
// CF/Akamai (~7%% of top-1M). Cookie-pass + interstitial wait;
// yields to SliderCaptchaSolver via screenshot+failure when
// it sees the captcha-delivery.com slider iframe.
chain.add_solver(DataDomeSolver::new());
// PerimeterX / HUMAN Security: _px3 cookie + sensor-script
// wait. Yields to VLM via screenshot+failure when the
// press-and-hold (#px-captcha) widget appears.
chain.add_solver(PerimeterXSolver::new());
// GeeTest v3 + v4 token-watch, dominant CN bot-management
// vendor. Returns success on the v3 triple
// (challenge/validate/seccode) or v4 quad
// (lot_number/pass_token/gen_time/captcha_output) populating;
// falls through to slider/VLM for the actual interaction.
chain.add_solver(GeeTestSolver::new());
// AWS WAF Captcha (cookie-pass watcher for `aws-waf-token`).
// Yields to slider/VLM via screenshot+failure when the
// visible puzzle iframe surfaces. Surfaces iv+context as
// JSON solution payload when the SDK stages them before the
// cookie lands.
chain.add_solver(AwsWafSolver::new());
// Arkose Labs / FunCaptcha, silent enforcement token-watch
// for verification-token / fc-token. Yields to VLM via
// screenshot+failure when the rotation/orientation puzzle
// iframe surfaces.
chain.add_solver(ArkoseSolver::new());
// Imperva (Incapsula / ABP), reese84 (modern) + incap_ses (classic)
// clearance watcher with incident-page block detection. Detected as
// Custom("imperva_incapsula"/"incapsula_protect"); falls through to the
// generic interstitial wait when it can't confirm clearance.
chain.add_solver(ImpervaSolver::new());
// Kasada: KP_UIDz / KPSDK clearance watcher (best-effort: it confirms
// the browser's own ips.js POW landed, it does not reverse the POW).
// Detected as Custom("kasada"); strictly additive over the generic wait.
chain.add_solver(KasadaSolver::new());
// Math captcha is cheap (parses page text, computes, types).
// Runs second so trivial WP-style captchas are handled
// before heavier solvers fire.
chain.add_solver(MathCaptchaSolver::new());
// PoW solver handles ALTCHA / Friendly / MCaptcha / Cap.dev
// by computing the SHA-256 proof in-page or polling for the
// widget's own worker. Faster than waiting for the chain to
// fall through to behavioural simulation.
chain.add_solver(PowCaptchaSolver::new());
// Slider solver covers GeeTest / DataDome / PerimeterX /
// AWS WAF Captcha / Akamai's slider variant. Generic
// gap-detection + bezier drag with overshoot.
chain.add_solver(SliderCaptchaSolver::new());
// Turnstile-interactive, dedicated solver for the
// managed/interactive Turnstile widget. Locates the
// cross-origin iframe by geometry, warms up the mouse,
// clicks with realistic timing, escalates to VLM when
// Cloudflare turns the screws. Runs BEFORE the generic
// BehavioralCaptchaSolver so Turnstile-specific logic wins.
chain.add_solver(TurnstileInteractiveSolver::new());
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(YoloGridSolver::new());
chain.add_solver(CrnnTextSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
// reCAPTCHA-dedicated audio fallback. Runs BEFORE the generic
// AudioCaptchaSolver so the v2 bframe path (cross-origin
// iframe, generic page.find_element can't see it) gets first
// crack at any RecaptchaV2 detection. Falls through cleanly
// when the bframe isn't open or the audio path is rate-limited,
// letting the generic AudioCaptchaSolver and ThirdParty take
// their turns.
chain.add_solver(RecaptchaAudioSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
// ThirdParty appears in the chain unconditionally but its
// `supports()` returns false unless an API key is configured
// (via CAPTCHAFORGE_THIRDPARTY_API_KEY), so a no-key install
// sees no behaviour change. With a key present, every TOML
// vendor that recommends ThirdPartyService now actually has
// a solver to land on.
chain.add_solver(ThirdPartyCaptchaSolver::two_captcha());
chain
}
#[must_use = "empty returns the chain by value; assign or chain it. An unowned empty chain has no solvers and can't solve anything."]
pub fn empty() -> Self {
Self {
solvers: Vec::new(),
patterns: PatternStore::default(),
config: ChainConfig::default(),
telemetry: Arc::new(NoopTelemetry),
cache: None,
providers: None,
training_corpus: None,
}
}
/// Install an adversarial-training corpus. Every failed solve
/// auto-appends a [`crate::training_corpus::TrainingSample`]
/// for downstream re-training. Best-effort: a corpus write
/// failure logs at debug level and does NOT propagate (we'd
/// rather drop a sample than fail an end-user solve over a
/// disk-full event).
#[must_use = "with_training_corpus returns a new chain by value; assign or chain it. Dropping the result silently discards the corpus install."]
pub fn with_training_corpus(
mut self,
corpus: Arc<crate::training_corpus::TrainingCorpus>,
) -> Self {
self.training_corpus = Some(corpus);
self
}
/// Install a [`ProviderRegistry`] so the chain can route
/// [`DetectedCaptcha::Custom`] captchas through their declared
/// `recommended_solver_methods`. Without a registry installed,
/// Custom captchas (the TOML rule layer) detect successfully but
/// no built-in solver's `supports()` returns true for them, so
/// the chain returns "unsolved" without trying anything.
#[must_use = "with_provider_registry returns a new chain by value; assign or chain it."]
pub fn with_provider_registry(mut self, providers: Arc<ProviderRegistry>) -> Self {
self.providers = Some(providers);
self
}
/// Install a per-domain solved-token cache. When set, `solve()`
/// checks the cache first and returns a `CrowdSourced` result
/// when a fresh token exists, skipping the solver chain entirely.
/// Production deployments hit the cache for the typical
/// 60-second token-validity window and avoid redundant solves
/// against the same site.
#[must_use = "with_token_cache returns a new chain by value; assign or chain it."]
pub fn with_token_cache(mut self, cache: Arc<TokenCache>) -> Self {
self.cache = Some(cache);
self
}
/// Append a solver to the chain.
pub fn add_solver<S: CaptchaSolver + 'static>(&mut self, solver: S) {
self.solvers.push(Box::new(solver));
}
/// Provide a custom pattern store (e.g. loaded from disk / shared across workers).
#[must_use = "with_pattern_store returns a new chain by value; assign or chain it."]
pub fn with_pattern_store(mut self, store: PatternStore) -> Self {
self.patterns = store;
self
}
/// Provide a custom chain configuration.
#[must_use = "with_config returns a new chain by value; assign or chain it."]
pub fn with_config(mut self, config: ChainConfig) -> Self {
self.config = config;
self
}
/// Install a telemetry sink. Called once per solver attempt with a
/// [`SolveEvent`] describing outcome + timing + metadata. Used by
/// production deployments to capture per-domain success rates,
/// p50/p95 latency, and fallback frequency for routing decisions
/// outside the local pattern store.
#[must_use = "with_telemetry returns a new chain by value; assign or chain it. Dropping silently discards the telemetry hook."]
pub fn with_telemetry(mut self, telemetry: Arc<dyn SolverTelemetry>) -> Self {
self.telemetry = telemetry;
self
}
/// Check the token cache for a fresh entry matching this captcha
/// without invoking any solver. Returns `Some` on cache hit (and
/// fires a `Success` telemetry event tagged with
/// `solver: "TokenCache"`); `None` on miss or when no cache is
/// installed.
///
/// Useful as a public API: callers that want to short-circuit
/// before reaching for a `Page` (e.g. retry loops that already
/// know the captcha info) can poll this directly. Internally,
/// [`Self::solve`] calls this as its first step.
pub fn cached_solution(&self, captcha_info: &CaptchaInfo) -> Option<CaptchaSolveResult> {
let cache = self.cache.as_ref()?;
let domain = extract_domain(&captcha_info.page_url);
let captcha_type = detected_to_type(&captcha_info.kind);
let entry = cache.get(&domain, &captcha_type)?;
let elapsed_ms = 0;
self.telemetry.record(&SolveEvent {
solver: "TokenCache",
captcha_type: &captcha_type,
kind: &captcha_info.kind,
domain: &domain,
outcome: SolveOutcome::Success,
time_ms: elapsed_ms,
confidence: Some(1.0),
method: &SolveMethod::CrowdSourced,
});
Some(CaptchaSolveResult {
solution: entry.token().to_owned(),
confidence: 1.0,
method: SolveMethod::CrowdSourced,
time_ms: elapsed_ms,
success: true,
screenshot: None,
// Replay the cookies captured at the original solve so the
// WAF/vendor's trusted session rides along with the token.
// Empty when the cache entry was put via the back-compat
// no-cookies path.
cookies: entry.cookies().to_vec(),
verified_outcome: None,
})
}
}
/// Canonical short vendor name for a [`DetectedCaptcha`] kind
/// used by the training-corpus persistence path so all samples
/// from "Cloudflare Turnstile" land under the same vendor key
/// regardless of how the detector phrased it. For
/// [`DetectedCaptcha::Custom`] entries the rule-pack-supplied name
/// IS the canonical key (and is already lowercase + hyphen-safe).
fn detected_kind_canonical_name(kind: &crate::captcha_detect::DetectedCaptcha) -> String {
use crate::captcha_detect::DetectedCaptcha as K;
match kind {
K::Turnstile => "cloudflare-turnstile".to_string(),
K::RecaptchaV2 => "recaptcha-v2".to_string(),
K::RecaptchaV3 => "recaptcha-v3".to_string(),
K::HCaptcha => "hcaptcha".to_string(),
K::ImageCaptcha => "image-captcha".to_string(),
K::AudioCaptcha => "audio-captcha".to_string(),
K::SliderCaptcha => "slider-captcha".to_string(),
K::CanvasCaptcha => "canvas-captcha".to_string(),
K::MultiStepCaptcha => "multi-step".to_string(),
K::PowCaptcha => "pow-captcha".to_string(),
K::ShadowDomCaptcha => "shadow-dom".to_string(),
K::Custom(name) => name.clone(),
K::None => "none".to_string(),
}
}
/// Map a [`DetectedCaptcha`] kind to its [`crate::solver::token_shapes`]
/// oracle, when one exists. Returns `None` for kinds without a
/// documented token shape (e.g. canvas / slider / multi-step
/// captchas whose "token" is application-defined). Single source
/// of truth for the chain's E2 wiring; keeps the kind→oracle
/// matcher in one place so adding a new vendor oracle only
/// touches `token_shapes::for_vendor` AND this matcher.
fn oracle_for_kind(
kind: &crate::captcha_detect::DetectedCaptcha,
) -> Option<crate::solver::token_shapes::TokenOracle> {
use crate::captcha_detect::DetectedCaptcha as K;
let vendor = match kind {
K::Turnstile => "cloudflare-turnstile",
K::RecaptchaV2 => "recaptcha-v2",
K::RecaptchaV3 => "recaptcha-v3",
K::HCaptcha => "hcaptcha",
// Custom rules from the TOML pack, name-match against the
// bundled enterprise / vendor variants we registered in E2.
K::Custom(name) => match name.as_str() {
"recaptcha_enterprise" | "recaptcha-enterprise" => "recaptcha-enterprise",
"hcaptcha_enterprise" | "hcaptcha-enterprise" => "hcaptcha",
_ => return None,
},
_ => return None,
};
crate::solver::token_shapes::for_vendor(vendor)
}