# captchaforge
> ## ⚠️ DO NOT USE: UNDER ACTIVE DEVELOPMENT
>
> This crate is **not production-ready**: broad live-vendor success across
> real anti-bot sites is **not yet benchmarked or guaranteed**. The
> architecture is in place (vendor solvers, retry-loop iframe walking, VLM
> provider abstraction, real-WAF bench harness) and the two historical
> blockers below have been resolved or disproven, but a measured success
> rate on the full real-site set is still pending.
>
> **The earlier "0%, cross-origin click never delivers / iframe never
> attaches" diagnosis was empirically DISPROVEN.** A deterministic oracle
> (`foxdriver/tests/cross_origin_click.rs`, 3/3 live) proves a trusted
> top-context click routes into a *doubly-nested* cross-origin OOPIF
> (the Turnstile/hCaptcha/reCAPTCHA shape) and arrives `isTrusted = true`,
> and that BiDi `input.performActions` is accepted inside a child-iframe
> context. The real bug was synthetic JS clicks (`el.click()`,
> `isTrusted = false`) at scored controls, every solver path now
> locates-in-JS and clicks **trusted** in Rust.
>
> | Real-WAF fixture | Status |
> |---|---|
> | Cloudflare Turnstile, managed (CF demo) | ✅ auto-passes with reynard (token obtained, no click needed) |
> | Cross-origin trusted click delivery (nested OOPIF) | ✅ proven (`cross_origin_click`: trusted positives + an `isTrusted=false` negative) |
> | Cloudflare Turnstile, always-block (`2x…AB`) | ✅ INTEGRITY, refuses to fake-pass |
> | Cloudflare Turnstile, force-interactive (`3x…FF`) | ⚪ DEAD ORACLE, differentially proven that *no* Firefox (stock or reynard) builds the challenge iframe for this test key on a self-hosted page; there is nothing to click |
>
> The deeper wall was always **fingerprint** (TLS JA3/JA4 + automation
> coherence), not the click. That is now substantially addressed: the
> reynard stealth engine (`navigator.webdriver = false` natively, FF-150-exact
> TLS) is the **default** launch engine, and a persona coherence gate is
> enforced JS-to-TCP at every launch. Broad live-vendor proof across real
> sites is the remaining open work.
>
> **Do not depend on this for any real workload.** The repo is
> private. Reach out to the Santh team if you have a use case that
> justifies early collaboration.
[](https://github.com/santhreal/captchaforge/actions/workflows/ci.yml)
[](#license)
Automatic CAPTCHA detection and multi-strategy solving for Firefox/BiDi
live pages (`runtime_foxdriver::Page`, re-exported as
`captchaforge::Page`). Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image
grids, audio challenges, sliders, plus a community-extensible TOML rule
pack for vendors like AWS WAF Captcha, DataDome, Akamai Bot Manager,
PerimeterX/HUMAN, Arkose/FunCaptcha, Geetest v3+v4, Friendly Captcha,
MTCaptcha, and WordPress math captchas.
> Part of the [Santh](https://santh.dev) security research ecosystem.
> **Beta**, used in production by [wafrift](https://github.com/santhreal/wafrift)
> and [golemn-browser](https://github.com/santhreal/golemn) but the
> public API may shift before 1.0. Pin the version in `Cargo.toml`.
## Quick start
Add to `Cargo.toml`:
```toml
[dependencies]
captchaforge = { git = "https://github.com/santhreal/captchaforge", branch = "main" }
tokio = { version = "1", features = ["full"] }
```
### CLI
A standalone binary ships with the workspace:
```sh
cargo install --path cli # builds `captchaforge`
captchaforge doctor # probe Ollama / Whisper / Tesseract
captchaforge setup --auto # auto-pull default vision model
captchaforge selftest # confirm the 11-solver chain wires up
captchaforge transcribe sample.wav # smoke-test the STT backend
captchaforge solve https://example.com # solve a captcha on a real URL
captchaforge serve --port 8080 # HTTP API for non-Rust embeds
```
The `serve` mode exposes:
- `GET /doctor`: capabilities JSON + install hints (works headless)
- `POST /solve`: `{"url": "https://...", "headless": true}` → solve result
- `POST /transcribe`: `{"audio_b64": "..."}` → `{"raw","normalized"}`
Use `serve` to embed solving in any language that speaks HTTP.
Meridian should not use the standalone `solve`/`serve` browser path for
auth-preserving hunting; it calls CaptchaForge inside the existing Guise
Firefox/BiDi page through `guise-bridge`.
### Library
The shortest path from "fresh page" to "solved captcha" is one call
[`captchaforge::solve_url`] wires stealth + named profile before
navigation, warms the loaded page, then runs `auto_solve`:
```rust
use captchaforge::prelude::*;
async fn open(page: &captchaforge::Page) -> anyhow::Result<()> {
match solve_url(page, "https://example.com",
Some(StealthProfile::FirefoxLinux)).await? {
None => println!("no captcha"),
Some(r) if r.success => println!("solved via {:?} ({}ms)", r.method, r.time_ms),
Some(r) => println!("failed: {:?}", r.method),
}
Ok(())
}
```
If you already navigated and just want the detect-then-solve loop on
the current page, use [`captchaforge::auto_solve`]:
```rust
async fn handle_captcha(page: &captchaforge::Page) -> anyhow::Result<()> {
// Returns Ok(None) for no-captcha pages.
if let Some(outcome) = captchaforge::auto_solve(page).await? {
if outcome.success {
tracing::info!(method = ?outcome.method, "captcha solved");
} else if let Some(screenshot) = outcome.screenshot {
tracing::warn!(bytes = screenshot.len(), "human review needed");
}
}
Ok(())
}
```
For high-stakes flows (checkout, account creation, bot-blocked
public APIs) wrap the call in
[`captchaforge::auto_solve_with_retries`], it dismisses
cookie-consent banners between attempts and retries on transient
failure. Pair with [`captchaforge::dismiss_chat_widget`] when an
Intercom / Drift / HubSpot launcher overlaps the captcha widget.
For full control, telemetry, token cache, custom solvers, drop down
to the explicit chain:
```rust
use std::sync::Arc;
use captchaforge::{detect, solver::{CaptchaSolverChain, TokenCache}};
let cache = Arc::new(TokenCache::new()); // 60s TTL by default
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache); // hit cache before solving
let info = detect::detect(page).await?;
if detect::is_captcha(&info) {
let outcome = chain.solve(page, &info).await;
// ...
}
```
## Capability map
Each capability has its own module + at least one production
wiring point. Re-export paths are at the crate root; the internal
home is one of `captchaforge::*`.
| One-call solve / prepare / dismiss helpers | `sdk` | `captchaforge::{solve_url, …}` re-exports |
| 158-vendor TOML rule pack | `detect::rules` | `ProviderRegistry::with_built_in_rules` |
| 15 named browser stealth profiles | `stealth_profiles` | `prepare_page(_, Some(profile))` |
| Real-human mouse-trace sampler (8-trace corpus + jitter) | `mouse_sampler` | opt-in; `behavior::mouse_move_human` ships the legacy path |
| Bezier mouse / hover-dwell / touch-swipe / pointer-jitter / triple-click | `behavior` | bench-suite + production solvers |
| Audio captcha DSP (denoise + bandpass + normalise) | `audio_dsp` | `AudioCaptchaSolver::transcribe` |
| DOM/frame graph (BFS / deepest-captcha / ancestors) | `frame_graph` | opt-in for solver authors |
| Multi-hop captcha planner | `solver::planner` | opt-in via `MultiHopPlanner` |
| Read-only artifact pre-flight handle | `solver::speculation` | opt-in via `PreflightHandle` |
| Per-vendor token shape oracle | `solver::token_shapes` | chain post-solve check (auto) |
| Adversarial training corpus | `training_corpus` | `chain.with_training_corpus(...)` |
| VLM training dataset (content-addressed) | `vlm_dataset` | training pipeline (`training/`) |
| Mouse-trace ingest store | `trace_ingest` | server side of `B1` |
| Vendor JS scraper + probe-surface diff | `vendor_scraper` | `captchaforge scrape-vendors` CLI |
| TOML rule hot-reload (mtime-poll, atomic swap) | `rule_watcher` | opt-in via `HotReloadRegistry::start` |
| Patched-Chromium sidecar detector | `chromium_sidecar` | opt-in legacy diagnostic via `PatchedChromiumDetector` |
| Per-solver telemetry + Prometheus exposition | `telemetry` | `chain.with_telemetry(...)` + `serve /metrics` |
| Network-layer WAF challenge replay (no browser) | crate `captchaforge-challenge` | direct dep |
| Pure-data rule schema (no browser dep) | crate `captchaforge-rules` | direct dep for static-analysis tools |
## Architecture surface (post-extraction)
- **`detect::Detector`**, single per-vendor detector trait. Eleven
built-ins (`TurnstileDetector`, `RecaptchaDetector`, …) live one per
file under `src/detect/`. Add a new detector by writing
`src/detect/<vendor>.rs` and registering it.
- **`detect::rules`**, community-extensible TOML rule layer. Drop a
`[[provider]]` entry in `rules/community.toml` (or your own file)
and the vendor is detected without writing any Rust. Bundled pack
covers DataDome, Akamai, PerimeterX/HUMAN, Arkose/FunCaptcha,
Geetest v3+v4, AWS WAF Captcha, Friendly Captcha, MTCaptcha,
WordPress math captchas.
- **`provider::CaptchaProvider`**, bundles a detector + the
recommended solver methods for that vendor in one trait. Built-in
providers wired via macro; new providers implement both traits in
one file.
- **`provider::ProviderRegistry::with_built_in_rules()`**, single
call returning the built-in detectors PLUS the bundled community
rule pack, all priority-sorted.
- **`solver::CaptchaSolverChain`**, multi-strategy chain with cache
+ telemetry + pattern-store routing. See "Strategies" below.
- **`solver::TokenCache`**, per-`(domain, captcha_type)` solved
token cache with configurable TTL. Cache hits skip the solver run
entirely and report through telemetry as `solver: "TokenCache"`.
- **`telemetry::SolverTelemetry`**, implement to capture every
solver attempt as a `SolveEvent`. Default is `NoopTelemetry`.
Production deployments plug in Prometheus / OTel / SQLite / Sentry.
- **`solver::PatternStore`** (per-domain learned routing).
`save_to_path` / `load_from_path` / `merge` for cross-restart and
cross-instance sharing.
- **`captchaforge::auto_solve()`**, top-level one-call API for the
90% case. Auto-detects local backends (Ollama VLM / Whisper / Tesseract)
on first call and logs the capability summary so operators see what's
wired without reading the source.
- **`captchaforge::backends::probe()`**, non-blocking parallel probe
of Ollama (`/api/tags`), `whisper` / `whisper-cli` on PATH,
`OPENAI_API_KEY` env, `tesseract` on PATH. Returns a `Capabilities`
struct with `install_hints()` for any missing backend so consumers
can tell their users exactly what to install.
- **`captchaforge::backends::Capabilities::ensure_vlm_model()`**
spawns `ollama pull llama3.2-vision:11b` when Ollama is reachable
but no vision model is present. Auto-install for the SDK consumer.
## Strategies
For each detected captcha the chain runs through a layered short
circuit + strategy stack. First match wins.
0. **Token cache**: if a `TokenCache` is installed and holds a
fresh `(domain, captcha_type)` token (default 60s TTL), return it
immediately tagged as `CrowdSourced`. Telemetry sees this as a
`Success` with `solver: "TokenCache"`. Skip the rest of the chain.
1. **Provider routing**: for `DetectedCaptcha::Custom` (TOML rule
layer), only solvers whose `SolveMethod` appears in the bundled
provider's `recommended_solver_methods` are eligible, in declared
order. Built-in detectors fall back to the standard `supports()`
filter.
2. **Pattern store ordering**: for built-in kinds, solvers eligible
under (1) get re-ordered so the historically-best method for this
domain runs first. PatternStore can be persisted across restarts
(`save_to_path` / `load_from_path`) and merged across worker
instances (`merge`).
3. **Behavioural**: realistic mouse movement + timing for Turnstile
and reCAPTCHA v3. Solvers verify the response token is actually
present before reporting `success`. On success, captures session
cookies into `CaptchaSolveResult::cookies` for replay.
4. **Vision-LLM**: screenshot the challenge, hand it to a multimodal
model ([Ollama](https://ollama.com) `qwen3-vl:30b` out of the box,
configurable), parse the model's grid / click answer, simulate the
mouse. Captures session cookies on success.
5. **Audio bypass**: click the accessibility audio challenge, run
STT, type the transcribed answer, then verify the reCAPTCHA token
was accepted. Captures session cookies on success.
6. **Telemetry**: every attempt at every layer fires a `SolveEvent`
to the installed `SolverTelemetry` impl (default `NoopTelemetry`).
Outcomes: `Success`, `Failure`, `Error`, `Timeout`. Cache hits
surface as `Success` with `solver: "TokenCache"` so dashboards
distinguish solved-from-scratch from cache-served.
7. **Human fallback**: when every solver fails, return unsolved
with a base64 JPEG screenshot in `CaptchaSolveResult::screenshot`
so a downstream human-in-the-loop can finish.
Apply captured cookies to subsequent navigations via
`captchaforge::cookies::apply_to_page(&page, &result.cookies)` so the
WAF/vendor's trusted session is preserved and the next request does
not re-trigger the captcha.
You can plug in your own solver by implementing the
[`CaptchaSolver`](src/solver.rs) trait and adding it to the chain via
`chain.add_solver(...)` before `default_chain()` strategies.
## Detection coverage
Built-in detectors (one per file under `src/detect/`):
- Cloudflare Turnstile (`.cf-turnstile`, `[data-turnstile-sitekey]`,
`script[src*="challenges.cloudflare.com"]`)
- Cloudflare bot interstitial ("Just a moment", `#challenge-form`)
- reCAPTCHA v2 + v3 (`.g-recaptcha`, `[data-sitekey]`,
`script[src*="google.com/recaptcha"]`)
- hCaptcha (`.h-captcha`, `[data-hcaptcha-sitekey]`,
`script[src*="hcaptcha.com"]`)
- Generic image / audio CAPTCHAs
- PoW CAPTCHAs (ALTCHA, mCaptcha, Friendly Captcha, Cap.dev)
- Slider / swipe / rotate / drag CAPTCHAs
- Canvas / SVG / math / icon / color CAPTCHAs
- Multi-step / wizard CAPTCHAs
- Shadow-DOM-hidden CAPTCHAs
Bundled community rule pack (`rules/community.toml`, registered
via `ProviderRegistry::with_built_in_rules()`):
- AWS WAF Captcha
- DataDome
- Akamai Bot Manager
- PerimeterX / HUMAN
- Arkose Labs / FunCaptcha
- Geetest v3 + v4
- Friendly Captcha
- MTCaptcha
- WordPress math captchas (Captcha by BestWebSoft, generic forms)
Adding a new family is two lines of TOML, see `rules/community.toml`
for the schema. Per-rule positive + negative HTML fixtures live under
`tests/rule_fixtures/<vendor>/`; the `community_rules` integration
test asserts every rule fires on its positive and rejects its
negative AND doesn't cross-fire on any other vendor's negative.
## Configuration
Two layers, lightest to heaviest:
**Env vars (zero-config)**
- `CAPTCHAFORGE_VLM_ENDPOINT`: Ollama endpoint URL.
- `CAPTCHAFORGE_VLM_MODEL`: VLM model name.
- `CAPTCHAFORGE_THIRDPARTY_API_KEY`: 2captcha-compatible service key.
- `CAPTCHAFORGE_THIRDPARTY_ENDPOINT`: override the service base URL.
- `CAPTCHAFORGE_CONFIG`: explicit Tier-A config file path.
**Tier-A operational config (`.captchaforge.toml`)**
Drop `.captchaforge.toml` in your project (or use the discovery search
path: `$CAPTCHAFORGE_CONFIG`, `./.captchaforge.toml`,
`./captchaforge.toml`, `$XDG_CONFIG_HOME/captchaforge/config.toml`).
The schema covers chain timeouts, cache TTL, VLM endpoint/model,
third-party service selection, and every `SolveConfig` field. See
`.captchaforge.toml.example` for the reference. Unknown keys are a
parse error so typos surface instead of silently being ignored.
```toml
per_solver_timeout_ms = 60000
[cache]
ttl_seconds = 90
[vlm]
endpoint = "http://gpu-host:11434"
[third_party]
service = "cap_solver"
api_key = "..."
```
**Builder methods (fine-grained)**
For fine-grained control inside one process, use the builder methods
they always beat env + TOML.
```rust
use captchaforge::solver::{VlmCaptchaSolver, CaptchaSolverChain};
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(
VlmCaptchaSolver::new()
.with_endpoint("http://my-gpu-host:11434")
.with_model("qwen3-vl:30b"),
);
```
Audio STT endpoint is similarly configurable on `AudioCaptchaSolver`.
**Third-party solver service**
`ThirdPartyCaptchaSolver` talks the 2captcha-compatible HTTP protocol
used by 2captcha, CapMonster, and CapSolver, every TOML-bundled vendor
that recommends `ThirdPartyService` now actually has a solver to land
on. Without an API key configured, `supports()` returns false across
the board so the solver is skipped instead of issuing 401s.
```rust
use captchaforge::solver::{ThirdPartyCaptchaSolver, CaptchaSolverChain};
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(
// Pick the service flavour, then pass the key:
ThirdPartyCaptchaSolver::cap_solver().with_api_key("..."),
);
```
## CLI
```sh
cargo install --path cli
captchaforge inspect-rules # list bundled + extra rules
captchaforge detect https://example.com/login # navigate, run detector
captchaforge solve https://example.com/login # navigate, run full chain
captchaforge inspect-rules --rules my-pack.toml # additive extra rules
captchaforge rules # 158-vendor coverage table
captchaforge profiles # 15 named StealthProfiles
captchaforge corpus stats /var/lib/cf/corpus # adversarial-training samples
captchaforge dataset stats /var/lib/cf/datasets # VlmDataset shape
captchaforge scrape-vendors --out report.txt # diff vendor JS probe surface
captchaforge validate-rules # static-check every TOML rule
captchaforge serve --port 8080 # HTTP API (incl. /metrics)
```
## Telemetry
Two batteries-included impls plus the trait for custom sinks.
```rust
use std::sync::Arc;
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::telemetry::{JsonTelemetry, MetricsTelemetry};
// Option 1: route every event through `tracing::info!` with structured
// fields. Pairs with `tracing-subscriber`'s JSON layer for ELK / Loki
// / Datadog without any extra integration code.
let chain = CaptchaSolverChain::default_chain()
.with_telemetry(Arc::new(JsonTelemetry));
// Option 2: in-process Prometheus-shaped aggregator. `serve` wires
// this into `/metrics` and `/metrics.json` automatically.
let metrics = Arc::new(MetricsTelemetry::new());
let chain = CaptchaSolverChain::default_chain()
.with_telemetry(metrics.clone());
println!("{}", metrics.snapshot().to_prometheus());
```
The chain fires one event per solver attempt at every outcome
(`Success` / `Failure` / `Error` / `Timeout`). Cache hits also fire as
`Success` with `solver: "TokenCache"` so dashboards distinguish
solved-from-scratch from cache-served.
## Adversarial training loop
Every failed solve auto-appends to a `TrainingCorpus` when one is
configured. After ~1 week of production traffic you have a labelled-
by-failure dataset ready to feed back into a fine-tune.
```rust
use std::sync::Arc;
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::training_corpus::TrainingCorpus;
let corpus = Arc::new(TrainingCorpus::open("/var/lib/captchaforge/corpus")?);
let chain = CaptchaSolverChain::default_chain()
.with_training_corpus(corpus.clone());
```
The Python compute side lives in [`training/`](./training/README.md):
Dockerfile + `train_lora.py` (LoRA fine-tune of Qwen2-VL-7B on a
captchaforge VlmDataset), `distill.py` (7B teacher → 2B student
distillation), `auto_label.py` (GPT-4V / Claude vision teacher
labelling), `bench_compare.py` (CI gate for model promotion based on
per-vendor regression bound), `promote_to_ollama.sh` (HF → GGUF →
Ollama registration).
## Hot-reload TOML rules
Operators drop a new vendor rule into the watched dir; the registry
atomically swaps in <2s without a process restart.
```rust
use std::time::Duration;
use captchaforge::rule_watcher::HotReloadRegistry;
let watcher = HotReloadRegistry::start("/etc/captchaforge/rules.d", Duration::from_secs(2))?;
let registry = watcher.current(); // Arc<ProviderRegistry>, atomically swapped on TOML change
```
Bad TOML keeps the previous registry active and logs at warn
(no restart spam). The `validate-rules` CLI subcommand pre-flights
every rule before promotion.
## Network-layer challenge replay
For WAF challenges that are JS-only (no visual captcha), the
`captchaforge-challenge` workspace crate solves them in pure HTTP
typically 50-150ms instead of the 8-12s a browser round-trip
takes, no GPU, no browser binary. Cloudflare interstitial reference
impl ships today; DataDome / Akamai / Imperva / Kasada slot into the
same `ChallengeSolver` trait as their JS is reverse-engineered.
## Origin and licensing
Extracted from [golemn-browser](https://github.com/santhreal/golemn)
(originally GPL-3.0). The original author (Santh Project) re-licensed
the extracted slice as MIT OR Apache-2.0 so it can be embedded in
MIT/Apache-licensed downstream tools (notably
[wafrift](https://github.com/santhreal/wafrift) for managed-WAF
challenge handling and any future Santh tool that needs a captcha
layer).
## Testing
```sh
cargo test --workspace # unit + integration (no chromium required)
cargo test --doc # doctest in lib.rs
cargo test --test community_rules # asserts every TOML rule fires on its
# positive HTML fixture, rejects its negative,
# and doesn't cross-fire on other vendors' negatives
cargo test --test integration_chain_wires # cache + telemetry + chain wires
cargo test --test wirings # production wirings: H-series modules
# actually reachable from the chain (oracle,
# corpus, hot-reload, telemetry, planner)
cargo test --test property_token_shapes # ~10k randomised cases per
# token-oracle invariant (decoy never
# Plausible, classify deterministic, etc.)
```
### Offline module micro-bench
```sh
cd bench
cargo run --release -- --suite modules
```
In-process measurements (no browser) of the H-series hot paths:
- token-oracle classify (1k iters)
- token-oracle resolve (1k)
- frame-graph construction (1k)
- mouse-sampler trace synthesis (1k)
- training-corpus append (100)
Total runtime <1s. Output uses the same regression-delta tooling as
the live-browser suites, so per-module slowdowns surface in the
`bench-baseline.json` diff. CI runs this on every PR via the
`--suite quick` aggregate.
## Local proof (benches)
```sh
cargo run --example architecture_proof --release
```
Prints a markdown report covering the latency-critical paths added by
the architecture sweep:
- TokenCache get hit / miss
- ProviderRegistry::find_by_kind for Custom kinds
- RuleDetector probe generation (one-shot at registration)
- Per-rule HTML fixture match latency (one row per bundled vendor)
Browser-free, runs in well under a second after the release build.
Use to sanity-check that no future change regresses the hot path.
The test suite does **not** require a real browser binary or a real
Ollama server, browser-bound paths are pure Rust unit tests
against the JavaScript / Rust glue layer. Tests that exercise the
real headless browser live in
[`wafrift-captchaforge-bridge`](https://github.com/santhreal/wafrift/tree/main/crates/captchaforge-bridge)
where a live browser runtime is available.
## License
MIT OR Apache-2.0 at your option. Copyright 2026 CORUM COLLECTIVE LLC.
## Scorecard badges
These measure **structural** coverage, that the solver chain, the community
rule set, the challenge-fingerprint catalogue, and the test/property suites are
complete and wired. They are **not** a live-vendor success rate: a measured
pass rate against real anti-bot sites is still open work (see the banner at the
top). A 100% here means "every stage/fixture we model is exercised", not "we
beat every real CAPTCHA".





