Skip to content

feat: add turnstile, hcaptcha, and recaptcha challenge types - #78

Merged
fionera merged 7 commits into
masterfrom
feat/captcha
Jul 11, 2026
Merged

feat: add turnstile, hcaptcha, and recaptcha challenge types#78
fionera merged 7 commits into
masterfrom
feat/captcha

Conversation

@fionera

@fionera fionera commented Jul 11, 2026

Copy link
Copy Markdown
Member

Summary

Adds turnstile, hcaptcha, and recaptcha (v2 checkbox) as additional challenge types alongside none and pow — three public types backed by one shared implementation, since all providers use the same sitekey → widget token → siteverify flow. Replaces the split PRs #75/#76/#77.

The first commit is the babel 8 CI fix, shared with #74 — without it the web bundle doesn't build. Merge #74 first and it drops out of this diff, or merge this PR alone and close #74.

Server (validator_captcha.go)

  • Challenge GET serves a static per-level JSON body {"c","t","k"} with the sitekey — no per-request state; security binding stays in the existing cookie HMAC. Web protocol numbering skips t:2 (reserved for worker POW), so captcha types are t:3/4/5.
  • Challenge POST verifies the widget token against the provider's siteverify endpoint (form-encoded secret/response/remoteip), requires success and a hostname match against the request identity (subdomain-aware for trusted_domains). Fails closed on provider errors. First validator doing network I/O — the hot validate path is untouched; new injectable Berghain.HTTPClient with a 5s default timeout.
  • Config: type: turnstile|hcaptcha|recaptcha + sitekey/secret, optional verify_url override and a test-only skip_hostname_check (provider test keys report a fixed hostname: example.com — confirmed against the live Cloudflare API). Validated at startup with Fatal.

Web (web/src/challange/)

  • One shared solver for t:3–5: injects the provider script, renders the widget in explicit mode where the spinner sits, POSTs the token like the POW solver. Script URLs are hardcoded client-side so the external-domain allowlist stays auditable.
  • Provider quirks found by driving the real page (documented in the descriptor table): turnstile.ready() throws for async-loaded scripts; grecaptcha queues ready() callbacks (useReady); hCaptcha signals readiness via a named onload= callback (useOnload).
  • A blocked provider script (content blockers — the dominant real failure) surfaces actionable advice through the existing capability-advice UI.

Integration

  • examples/haproxy/berghain.cfg: validate and challenge now run as separate SPOE agents — berghain keeps timeout processing 100ms, berghain_challenge gets 6s for the siteverify round-trip.
  • e2e gains a second frontend (:18081) with a turnstile level using Cloudflare's always-passing dummy keys, driven by the same chromedp flow as the POW test.
  • README captcha section (egress, fail-closed, timeout split, hostname binding, CSP note); visitor help page documents the captcha type and the third-party-script exception.

Test plan

  • go test ./... — 8 new tests against an httptest siteverify stub: GET body shape, happy path (asserting all form fields), subdomain match, rejected token, hostname mismatch, skip-hostname flag, provider 5xx + dead provider fail closed, empty/oversized bodies, hostname-matcher table test. staticcheck v0.6.1 clean.
  • npm run lint / npm test (15/15, five new) / npm run build.
  • Drove the built bundle in headless Chrome: Turnstile dummy key auto-solves and POSTs its token; hCaptcha/reCAPTCHA widgets render with no console warnings.
  • ./test/e2e/run.sh locally (HAProxy 3.4): TestBrowserSolvesChallenge (POW) and TestBrowserSolvesTurnstileChallenge both pass against the real Turnstile endpoints; haproxy -c validates the example config.

🤖 Generated with Claude Code

fionera and others added 5 commits July 11, 2026 03:34
Babel 8 ships as native ESM without a default export, so the
dependabot bump to @babel/core 8.0.1 broke the Vite config load
in both the JS and Go (e2e) workflows. Import the four used
functions as named exports instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds three new validation types alongside none and pow, backed by one
shared captcha validator. The challenge GET serves a static per-level
body carrying the sitekey; the POST exchanges the widget response token
for a cookie after verifying it against the provider siteverify
endpoint (fail closed) and binding the reported hostname to the request
identity, accepting subdomains since trusted_domains may collapse the
identity host to a suffix.

The web protocol type numbering skips t:2, which the challenge page
capability checks already reserve for a worker-based POW.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Provider test keys report a fixed hostname unrelated to the page, so
tests cannot pass the hostname binding. Adds an explicit opt-out for
captcha levels, documented as test-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a shared captcha solver for challenge types 3 to 5. The solver
injects the provider script, renders the widget in explicit mode in
place of the spinner, and submits the response token like the POW
solver does.

Provider quirks found by driving the page in a browser: turnstile is
usable directly after script load (its ready() even throws for async
scripts), grecaptcha queues ready() callbacks until initialized, and
hcaptcha signals readiness through a named onload callback.

A blocked provider script, the most common failure with content
blockers, surfaces actionable advice through the existing capability
advice UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits the example SPOE config into separate validate and challenge
agents: captcha verification does a network round-trip to the provider,
so the challenge group needs a 6s processing timeout while the
per-request validate path stays at 100ms.

The e2e stack gains a second frontend backed by a turnstile level using
Cloudflare's always-passing dummy keys, driven by the same browser flow
as the POW test. Documents captcha configuration in the README and the
visitor help page, including the third-party script exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fionera and others added 2 commits July 11, 2026 04:06
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fionera
fionera merged commit 735cfe5 into master Jul 11, 2026
6 checks passed
@fionera
fionera deleted the feat/captcha branch July 11, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant