feat: add turnstile, hcaptcha, and recaptcha challenge types - #78
Merged
Conversation
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>
This was referenced Jul 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
turnstile,hcaptcha, andrecaptcha(v2 checkbox) as additional challenge types alongsidenoneandpow— 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.Server (
validator_captcha.go){"c","t","k"}with the sitekey — no per-request state; security binding stays in the existing cookie HMAC. Web protocol numbering skipst:2(reserved for worker POW), so captcha types aret:3/4/5.secret/response/remoteip), requiressuccessand a hostname match against the request identity (subdomain-aware fortrusted_domains). Fails closed on provider errors. First validator doing network I/O — the hotvalidatepath is untouched; new injectableBerghain.HTTPClientwith a 5s default timeout.type: turnstile|hcaptcha|recaptcha+sitekey/secret, optionalverify_urloverride and a test-onlyskip_hostname_check(provider test keys report a fixedhostname: example.com— confirmed against the live Cloudflare API). Validated at startup withFatal.Web (
web/src/challange/)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.turnstile.ready()throws for async-loaded scripts;grecaptchaqueuesready()callbacks (useReady); hCaptcha signals readiness via a namedonload=callback (useOnload).Integration
examples/haproxy/berghain.cfg:validateandchallengenow run as separate SPOE agents —berghainkeepstimeout processing 100ms,berghain_challengegets6sfor the siteverify round-trip.:18081) with aturnstilelevel using Cloudflare's always-passing dummy keys, driven by the same chromedp flow as the POW test.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../test/e2e/run.shlocally (HAProxy 3.4):TestBrowserSolvesChallenge(POW) andTestBrowserSolvesTurnstileChallengeboth pass against the real Turnstile endpoints;haproxy -cvalidates the example config.🤖 Generated with Claude Code