diff --git a/README.md b/README.md
index 26459db..9345aca 100644
--- a/README.md
+++ b/README.md
@@ -16,12 +16,41 @@ browsers that really know how to dance!
## Supported CAPTCHAs
- None (Simple JS execute)
- POW
+- [Turnstile](https://developers.cloudflare.com/turnstile/)
+- [hCaptcha](https://www.hcaptcha.com/)
+- [reCAPTCHA v2](https://developers.google.com/recaptcha)
## Planned support
- Simple Captcha (Including Sound)
-- [hCaptcha](https://www.hcaptcha.com/)
-- [reCatpcha](https://developers.google.com/recaptcha?hl=de)
-- [Turnstile](https://developers.cloudflare.com/turnstile/)
+
+## Captcha challenge types
+
+The `turnstile`, `hcaptcha` and `recaptcha` (v2 checkbox) level types render the provider widget
+on the challenge page and exchange its response token for a Berghain cookie after verifying it
+against the provider:
+
+```yaml
+default:
+ levels:
+ - duration: 12h
+ type: turnstile # or hcaptcha / recaptcha
+ sitekey:
+ secret:
+```
+
+Things to know when enabling a captcha level:
+
+- The Berghain agent verifies tokens against the provider's `siteverify` endpoint, so it needs
+ outbound HTTPS access. Verification fails closed: if the provider is unreachable, the challenge
+ fails and the visitor can retry. `verify_url` overrides the endpoint, e.g. for `recaptcha.net`.
+- Challenge verification does a network round-trip, so the SPOE challenge group needs a larger
+ `timeout processing` than the validate path. The example config runs the two groups as separate
+ agents (`berghain` at 100ms, `berghain_challenge` at 6s) for this reason.
+- The token is only accepted when the provider-reported hostname matches the request identity
+ (subdomains included). Provider *test keys* report a fixed hostname, so tests can set
+ `skip_hostname_check: true`; production setups should never need it.
+- Visitors' browsers load the widget script from the provider's domain. If you serve the challenge
+ page with a Content-Security-Policy, allow the provider in `script-src` and `frame-src`.
## Example setup with HAProxy
To start berghain locally you can follow these easy steps:
diff --git a/berghain.go b/berghain.go
index a192a39..b032cda 100644
--- a/berghain.go
+++ b/berghain.go
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"hash"
"log/slog"
+ "net/http"
"sync"
"time"
)
@@ -13,12 +14,31 @@ type LevelConfig struct {
Countdown int
Duration time.Duration
Type ValidationType
+
+ // Captcha configuration, required for the turnstile, hcaptcha and
+ // recaptcha validation types.
+ CaptchaSitekey string
+ CaptchaSecret string
+ // CaptchaVerifyURL overrides the provider siteverify endpoint,
+ // e.g. for regional endpoints or tests.
+ CaptchaVerifyURL string
+ // CaptchaSkipHostnameCheck disables binding the provider-reported
+ // hostname to the request identity. Provider test keys report a
+ // fixed hostname, so tests need this; production setups do not.
+ CaptchaSkipHostnameCheck bool
+
+ captchaBodyOnce sync.Once
+ captchaBody []byte
}
type Berghain struct {
Levels []*LevelConfig
TrustedDomains []string
+ // HTTPClient is used for captcha siteverify requests.
+ // Defaults to a client with a 5 second timeout.
+ HTTPClient *http.Client
+
secret []byte
hmac sync.Pool
}
@@ -36,6 +56,15 @@ func NewBerghain(secret []byte) *Berghain {
}
}
+var defaultHTTPClient = &http.Client{Timeout: 5 * time.Second}
+
+func (b *Berghain) httpClient() *http.Client {
+ if b.HTTPClient != nil {
+ return b.HTTPClient
+ }
+ return defaultHTTPClient
+}
+
func (b *Berghain) acquireHMAC() hash.Hash {
return b.hmac.Get().(hash.Hash)
}
diff --git a/cmd/spop/config.go b/cmd/spop/config.go
index 4dadde0..e2e1c1b 100644
--- a/cmd/spop/config.go
+++ b/cmd/spop/config.go
@@ -54,6 +54,18 @@ type LevelConfig struct {
Countdown *int `yaml:"countdown"`
Duration time.Duration `yaml:"duration"`
Type string `yaml:"type"`
+
+ // Captcha settings, required for the turnstile, hcaptcha and
+ // recaptcha types.
+ Sitekey string `yaml:"sitekey"`
+ Secret string `yaml:"secret"`
+ // VerifyURL overrides the provider siteverify endpoint,
+ // e.g. for regional endpoints or tests.
+ VerifyURL string `yaml:"verify_url"`
+ // SkipHostnameCheck disables binding the provider-reported hostname
+ // to the request identity. Provider test keys report a fixed
+ // hostname, so tests need this; production setups do not.
+ SkipHostnameCheck bool `yaml:"skip_hostname_check"`
}
func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig {
@@ -77,10 +89,31 @@ func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig {
lc.Type = berghain.ValidationTypeNone
case "pow":
lc.Type = berghain.ValidationTypePOW
+ case "turnstile":
+ lc.Type = berghain.ValidationTypeTurnstile
+ case "hcaptcha":
+ lc.Type = berghain.ValidationTypeHCaptcha
+ case "recaptcha":
+ lc.Type = berghain.ValidationTypeReCaptcha
default:
Fatal("unknown validation type", "validator", c.Type)
}
+ switch lc.Type {
+ case berghain.ValidationTypeTurnstile, berghain.ValidationTypeHCaptcha, berghain.ValidationTypeReCaptcha:
+ if c.Sitekey == "" || c.Secret == "" {
+ Fatal("captcha types require a sitekey and a secret", "validator", c.Type)
+ }
+ lc.CaptchaSitekey = c.Sitekey
+ lc.CaptchaSecret = c.Secret
+ lc.CaptchaVerifyURL = c.VerifyURL
+ lc.CaptchaSkipHostnameCheck = c.SkipHostnameCheck
+ default:
+ if c.Sitekey != "" || c.Secret != "" || c.VerifyURL != "" || c.SkipHostnameCheck {
+ Fatal("sitekey, secret, verify_url and skip_hostname_check are only valid for captcha types", "validator", c.Type)
+ }
+ }
+
return &lc
}
diff --git a/cmd/spop/config.yaml b/cmd/spop/config.yaml
index e9eeaa6..fc39457 100644
--- a/cmd/spop/config.yaml
+++ b/cmd/spop/config.yaml
@@ -22,3 +22,9 @@ frontend:
- duration: 10s
type: pow
countdown: 0
+ # captcha levels (turnstile, hcaptcha, recaptcha) verify the widget
+ # token against the provider, so the agent needs outbound HTTPS access
+ - duration: 12h
+ type: turnstile
+ sitekey: 1x00000000000000000000AA # dummy sitekey, always passes
+ secret: 1x0000000000000000000000000000000AA # dummy secret, always passes
diff --git a/docs/index.html b/docs/index.html
index c19f77a..98070b1 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -123,7 +123,10 @@ Your IP is bound, not stored
No third-party CDN
The verification screen and any proof-of-work run in your own browser and are served from
the same site you are visiting. There are no external trackers, fonts or scripts loaded
- from someone else's servers — and this help page follows the same rule.
+ from someone else's servers — and this help page follows the same rule. The one
+ exception: if the operator enables a captcha level, the widget script is loaded from that
+ captcha provider (Cloudflare Turnstile, hCaptcha or Google reCAPTCHA) and the provider's
+ own privacy policy applies to it.
diff --git a/examples/haproxy/berghain.cfg b/examples/haproxy/berghain.cfg
index a0e739c..31973ed 100644
--- a/examples/haproxy/berghain.cfg
+++ b/examples/haproxy/berghain.cfg
@@ -7,18 +7,32 @@ spoe-agent berghain
timeout processing 100ms
use-backend berghain_spop
log global
- groups validate challenge
+ groups validate
spoe-message validate
# The order is relevant, as haproxy is sending them in-order
args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) cookie=req.cook(berghain)
+spoe-group validate
+ messages validate
+
+# The challenge group runs as its own agent: captcha levels verify the
+# widget token against the provider over HTTPS, so challenge processing
+# needs a far larger timeout than the per-request validate path.
+[berghain_challenge]
+spoe-agent berghain_challenge
+ option var-prefix berghain
+ option set-on-error error
+ timeout hello 100ms
+ timeout idle 10m
+ timeout processing 6s
+ use-backend berghain_spop
+ log global
+ groups challenge
+
spoe-message challenge
# The order is relevant, as haproxy is sending them in-order
args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) method=method body=req.body
-spoe-group validate
- messages validate
-
spoe-group challenge
messages challenge
diff --git a/examples/haproxy/haproxy.cfg b/examples/haproxy/haproxy.cfg
index 6837f35..adfcc6a 100644
--- a/examples/haproxy/haproxy.cfg
+++ b/examples/haproxy/haproxy.cfg
@@ -52,11 +52,11 @@ backend app_backend
backend berghain_http
mode http
- filter spoe engine berghain config examples/haproxy/berghain.cfg
+ filter spoe engine berghain_challenge config examples/haproxy/berghain.cfg
acl is_challenge_path path /cdn-cgi/challenge-platform/challenge
- http-request send-spoe-group berghain challenge if is_challenge_path
+ http-request send-spoe-group berghain_challenge challenge if is_challenge_path
http-request return status 501 if { var(txn.berghain.error) -m found }
acl has_token var(txn.berghain.token) -m found
diff --git a/test/e2e/browser_test.go b/test/e2e/browser_test.go
index e27d2b0..e91ca6a 100644
--- a/test/e2e/browser_test.go
+++ b/test/e2e/browser_test.go
@@ -17,8 +17,9 @@ import (
)
const (
- defaultBaseURL = "http://localhost:18080"
- backendBody = "Berghain E2E backend reached"
+ defaultBaseURL = "http://localhost:18080"
+ defaultTurnstileURL = "http://localhost:18081"
+ backendBody = "Berghain E2E backend reached"
)
func baseURL() string {
@@ -28,6 +29,13 @@ func baseURL() string {
return defaultBaseURL
}
+func turnstileURL() string {
+ if value := os.Getenv("BERGHAIN_E2E_TURNSTILE_URL"); value != "" {
+ return strings.TrimRight(value, "/")
+ }
+ return defaultTurnstileURL
+}
+
func requireChallengePage(t *testing.T, url string) {
t.Helper()
@@ -51,7 +59,19 @@ func requireChallengePage(t *testing.T, url string) {
}
func TestBrowserSolvesChallenge(t *testing.T) {
- url := baseURL()
+ solveChallengeInBrowser(t, baseURL())
+}
+
+// TestBrowserSolvesTurnstileChallenge drives the full captcha flow with
+// Cloudflare's always-passing dummy keys, so it needs egress to the real
+// Turnstile script and siteverify endpoints.
+func TestBrowserSolvesTurnstileChallenge(t *testing.T) {
+ solveChallengeInBrowser(t, turnstileURL())
+}
+
+func solveChallengeInBrowser(t *testing.T, url string) {
+ t.Helper()
+
requireChallengePage(t, url)
options := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...)
diff --git a/test/e2e/haproxy.cfg b/test/e2e/haproxy.cfg
index e5ead4c..721369a 100644
--- a/test/e2e/haproxy.cfg
+++ b/test/e2e/haproxy.cfg
@@ -29,14 +29,32 @@ frontend e2e
default_backend app_backend
+frontend e2e_turnstile
+ bind 127.0.0.1:18081
+
+ http-request set-var(req.berghain.level) int(1)
+
+ filter spoe engine berghain config examples/haproxy/berghain.cfg
+
+ acl berghain_path path /cdn-cgi/challenge-platform/challenge
+ http-request send-spoe-group berghain validate if !berghain_path
+ http-request return status 501 if { var(txn.berghain.error) -m found }
+
+ acl berghain_valid var(txn.berghain.valid) -m bool
+ http-request return status 403 content-type "text/html" file "web/dist/default/index.html" if !berghain_valid !berghain_path
+ http-request wait-for-body time 5s if berghain_path METH_POST
+ use_backend berghain_http if berghain_path
+
+ default_backend app_backend
+
backend app_backend
http-request return status 200 content-type "text/plain" string "Berghain E2E backend reached"
backend berghain_http
- filter spoe engine berghain config examples/haproxy/berghain.cfg
+ filter spoe engine berghain_challenge config examples/haproxy/berghain.cfg
acl is_challenge_path path /cdn-cgi/challenge-platform/challenge
- http-request send-spoe-group berghain challenge if is_challenge_path
+ http-request send-spoe-group berghain_challenge challenge if is_challenge_path
http-request return status 501 if { var(txn.berghain.error) -m found }
acl has_token var(txn.berghain.token) -m found
diff --git a/test/e2e/run.sh b/test/e2e/run.sh
index b520b28..b00a880 100755
--- a/test/e2e/run.sh
+++ b/test/e2e/run.sh
@@ -36,20 +36,23 @@ berghain_pid=$!
haproxy -db -f test/e2e/haproxy.cfg >"$run_dir/haproxy.log" 2>&1 &
haproxy_pid=$!
-ready=""
-for _ in $(seq 1 30); do
- status="$(curl --max-time 1 --silent --output /dev/null --write-out '%{http_code}' http://localhost:18080/ || true)"
- if [[ $status == 403 ]]; then
- ready=1
- break
+for port in 18080 18081; do
+ ready=""
+ for _ in $(seq 1 30); do
+ status="$(curl --max-time 1 --silent --output /dev/null --write-out '%{http_code}' "http://localhost:$port/" || true)"
+ if [[ $status == 403 ]]; then
+ ready=1
+ break
+ fi
+ sleep 1
+ done
+ if [[ -z "$ready" ]]; then
+ echo "E2E stack did not serve the challenge page on port $port" >&2
+ exit 1
fi
- sleep 1
done
-if [[ -z "$ready" ]]; then
- echo "E2E stack did not serve the challenge page" >&2
- exit 1
-fi
export BERGHAIN_E2E_BASE_URL=http://localhost:18080
+export BERGHAIN_E2E_TURNSTILE_URL=http://localhost:18081
cd test/e2e
go test -count=1 -tags=e2e -v .
diff --git a/test/e2e/spop.yaml b/test/e2e/spop.yaml
index deb7b27..badd89f 100644
--- a/test/e2e/spop.yaml
+++ b/test/e2e/spop.yaml
@@ -6,3 +6,14 @@ default:
- duration: 2m
type: pow
countdown: 1
+
+frontend:
+ e2e_turnstile:
+ levels:
+ - duration: 2m
+ type: turnstile
+ countdown: 1
+ sitekey: 1x00000000000000000000AA # dummy sitekey, always passes
+ secret: 1x0000000000000000000000000000000AA # dummy secret, always passes
+ # the dummy keys report hostname example.com instead of localhost
+ skip_hostname_check: true
diff --git a/validator_captcha.go b/validator_captcha.go
new file mode 100644
index 0000000..7e42e3c
--- /dev/null
+++ b/validator_captcha.go
@@ -0,0 +1,154 @@
+package berghain
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+type captchaValidator struct {
+}
+
+// Providers accept tokens of a few kilobytes; reCAPTCHA tokens are the
+// largest at around two to three kilobytes.
+const validatorCaptchaMaxTokenLength = 8 << 10
+
+// The provider verdict is a small JSON document; limit reads defensively.
+const validatorCaptchaMaxVerdictLength = 64 << 10
+
+var (
+ errCaptchaRejected = fmt.Errorf("captcha token rejected")
+ errCaptchaHostMismatch = fmt.Errorf("captcha hostname mismatch")
+ errCaptchaUnavailable = fmt.Errorf("captcha provider unavailable")
+)
+
+// captchaChallengeBody returns the static challenge response for a captcha
+// level. Unlike POW, the challenge embeds no per-request state: the security
+// binding happens when the solved token is exchanged for a cookie.
+func (lc *LevelConfig) captchaChallengeBody() []byte {
+ lc.captchaBodyOnce.Do(func() {
+ body, err := json.Marshal(struct {
+ Countdown int `json:"c"`
+ Type int `json:"t"`
+ Sitekey string `json:"k"`
+ }{
+ Countdown: lc.Countdown,
+ Type: int(lc.Type) - 1, // the web protocol counts types from zero
+ Sitekey: lc.CaptchaSitekey,
+ })
+ if err != nil {
+ panic(err)
+ }
+ lc.captchaBody = body
+ })
+ return lc.captchaBody
+}
+
+func (captchaValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
+ lc := b.LevelConfig(req.Identifier.Level)
+
+ body := lc.captchaChallengeBody()
+ if len(body) > len(resp.Body.WriteBytes()) {
+ return fmt.Errorf("captcha challenge body exceeds response buffer: %d bytes", len(body))
+ }
+ copy(resp.Body.WriteNBytes(len(body)), body)
+
+ return nil
+}
+
+func (captchaValidator) isValid(b *Berghain, req *ValidatorRequest, _ *ValidatorResponse) error {
+ if len(req.Body) == 0 {
+ return ErrEmpty
+ }
+ if len(req.Body) > validatorCaptchaMaxTokenLength {
+ return ErrInvalidLength
+ }
+
+ lc := b.LevelConfig(req.Identifier.Level)
+
+ // All three providers implement the same siteverify contract:
+ // hCaptcha and Turnstile clone the reCAPTCHA API on purpose.
+ verifyURL := lc.CaptchaVerifyURL
+ if verifyURL == "" {
+ switch lc.Type {
+ case ValidationTypeTurnstile:
+ verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
+ case ValidationTypeHCaptcha:
+ verifyURL = "https://api.hcaptcha.com/siteverify"
+ case ValidationTypeReCaptcha:
+ verifyURL = "https://www.google.com/recaptcha/api/siteverify"
+ }
+ }
+
+ form := url.Values{
+ "secret": {lc.CaptchaSecret},
+ "response": {string(req.Body)},
+ "remoteip": {req.Identifier.SrcAddr.String()},
+ }
+
+ httpResp, err := b.httpClient().PostForm(verifyURL, form)
+ if err != nil {
+ // Fail closed: the client is told the challenge failed and can retry.
+ return fmt.Errorf("%w: %v", errCaptchaUnavailable, err)
+ }
+ defer httpResp.Body.Close()
+
+ if httpResp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%w: status %d", errCaptchaUnavailable, httpResp.StatusCode)
+ }
+
+ var verdict struct {
+ Success bool `json:"success"`
+ Hostname string `json:"hostname"`
+ ErrorCodes []string `json:"error-codes"`
+ }
+ if err := json.NewDecoder(io.LimitReader(httpResp.Body, validatorCaptchaMaxVerdictLength)).Decode(&verdict); err != nil {
+ return fmt.Errorf("%w: %v", errCaptchaUnavailable, err)
+ }
+
+ if !verdict.Success {
+ return fmt.Errorf("%w: %s", errCaptchaRejected, strings.Join(verdict.ErrorCodes, ", "))
+ }
+
+ if !lc.CaptchaSkipHostnameCheck && !captchaHostnameMatches(verdict.Hostname, req.Identifier.Host) {
+ return errCaptchaHostMismatch
+ }
+
+ return nil
+}
+
+// captchaHostnameMatches accepts the exact identity host or any of its
+// subdomains: trusted_domains may have collapsed the identity host to a
+// domain suffix while the provider reports the full page hostname.
+func captchaHostnameMatches(hostname string, host []byte) bool {
+ if hostname == "" || len(host) == 0 {
+ return false
+ }
+ if len(hostname) == len(host) {
+ return strings.EqualFold(hostname, string(host))
+ }
+ prefixLen := len(hostname) - len(host)
+ if prefixLen < 1 || hostname[prefixLen-1] != '.' {
+ return false
+ }
+ return strings.EqualFold(hostname[prefixLen:], string(host))
+}
+
+func validatorCaptcha(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
+ var c captchaValidator
+
+ switch req.Method {
+ case http.MethodPost:
+ if err := c.isValid(b, req, resp); err != nil {
+ return err
+ }
+ return req.Identifier.ToCookie(b, resp.Token)
+ case http.MethodGet:
+ return c.onNew(b, req, resp)
+ }
+
+ return errInvalidMethod
+}
diff --git a/validator_captcha_test.go b/validator_captcha_test.go
new file mode 100644
index 0000000..7b7155c
--- /dev/null
+++ b/validator_captcha_test.go
@@ -0,0 +1,314 @@
+package berghain
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "testing"
+ "time"
+)
+
+type captchaVerdict struct {
+ Success bool `json:"success"`
+ Hostname string `json:"hostname"`
+ ErrorCodes []string `json:"error-codes,omitempty"`
+}
+
+func newCaptchaBerghain(tb testing.TB, verifyURL string) *Berghain {
+ tb.Helper()
+
+ bh := NewBerghain(generateSecret(tb))
+ bh.Levels = []*LevelConfig{
+ {
+ Duration: time.Minute,
+ Type: ValidationTypeTurnstile,
+ CaptchaSitekey: "sitekey-under-test",
+ CaptchaSecret: "secret-under-test",
+ CaptchaVerifyURL: verifyURL,
+ },
+ }
+
+ return bh
+}
+
+func newCaptchaIdentifier() *RequestIdentifier {
+ return &RequestIdentifier{
+ SrcAddr: netip.MustParseAddr("1.2.3.4"),
+ Host: []byte("example.com"),
+ Level: 1,
+ }
+}
+
+func newSiteverifyStub(t *testing.T, verdict captchaVerdict, wantToken string) *httptest.Server {
+ t.Helper()
+
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("unexpected siteverify method: %s", r.Method)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Errorf("parsing siteverify form: %v", err)
+ }
+ if got := r.PostForm.Get("secret"); got != "secret-under-test" {
+ t.Errorf("unexpected siteverify secret: %q", got)
+ }
+ if got := r.PostForm.Get("response"); got != wantToken {
+ t.Errorf("unexpected siteverify response token: %q", got)
+ }
+ if got := r.PostForm.Get("remoteip"); got != "1.2.3.4" {
+ t.Errorf("unexpected siteverify remoteip: %q", got)
+ }
+ if err := json.NewEncoder(w).Encode(verdict); err != nil {
+ t.Errorf("encoding siteverify verdict: %v", err)
+ }
+ }))
+}
+
+func Test_validatorCaptcha_GET(t *testing.T) {
+ bh := newCaptchaBerghain(t, "http://invalid.invalid")
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodGet
+
+ if err := validatorCaptcha(bh, req, resp); err != nil {
+ t.Fatalf("validator failed: %v", err)
+ }
+
+ var challenge struct {
+ Countdown int `json:"c"`
+ Type int `json:"t"`
+ Sitekey string `json:"k"`
+ }
+ if err := json.NewDecoder(bytes.NewReader(resp.Body.ReadBytes())).Decode(&challenge); err != nil {
+ t.Fatalf("decoding challenge: %v", err)
+ }
+
+ if challenge.Type != 3 {
+ t.Errorf("invalid challenge type: %d != 3", challenge.Type)
+ }
+ if challenge.Sitekey != "sitekey-under-test" {
+ t.Errorf("invalid challenge sitekey: %q", challenge.Sitekey)
+ }
+ if challenge.Countdown != 0 {
+ t.Errorf("invalid challenge countdown: %d != 0", challenge.Countdown)
+ }
+ if resp.Token.Len() != 0 {
+ t.Errorf("challenge must not issue a token")
+ }
+}
+
+func Test_validatorCaptcha_POST(t *testing.T) {
+ const token = "widget-response-token"
+
+ stub := newSiteverifyStub(t, captchaVerdict{Success: true, Hostname: "example.com"}, token)
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte(token)
+
+ if err := validatorCaptcha(bh, req, resp); err != nil {
+ t.Fatalf("validator failed: %v", err)
+ }
+
+ if err := bh.IsValidCookie(*req.Identifier, resp.Token.ReadBytes()); err != nil {
+ t.Errorf("invalid cookie: %v", err)
+ }
+}
+
+func Test_validatorCaptcha_POST_subdomain(t *testing.T) {
+ const token = "widget-response-token"
+
+ // trusted_domains may collapse the identity host to a domain suffix
+ // while the provider reports the full page hostname.
+ stub := newSiteverifyStub(t, captchaVerdict{Success: true, Hostname: "foo.example.com"}, token)
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte(token)
+
+ if err := validatorCaptcha(bh, req, resp); err != nil {
+ t.Fatalf("validator failed: %v", err)
+ }
+
+ if err := bh.IsValidCookie(*req.Identifier, resp.Token.ReadBytes()); err != nil {
+ t.Errorf("invalid cookie: %v", err)
+ }
+}
+
+func Test_validatorCaptcha_POST_rejected(t *testing.T) {
+ const token = "widget-response-token"
+
+ stub := newSiteverifyStub(t, captchaVerdict{
+ Success: false,
+ ErrorCodes: []string{"invalid-input-response"},
+ }, token)
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte(token)
+
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, errCaptchaRejected) {
+ t.Fatalf("expected rejected token error, got: %v", err)
+ }
+
+ if resp.Token.Len() != 0 {
+ t.Errorf("rejected token must not issue a cookie")
+ }
+}
+
+func Test_validatorCaptcha_POST_hostMismatch(t *testing.T) {
+ const token = "widget-response-token"
+
+ stub := newSiteverifyStub(t, captchaVerdict{Success: true, Hostname: "evil.example.org"}, token)
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte(token)
+
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, errCaptchaHostMismatch) {
+ t.Fatalf("expected hostname mismatch error, got: %v", err)
+ }
+
+ if resp.Token.Len() != 0 {
+ t.Errorf("mismatched hostname must not issue a cookie")
+ }
+}
+
+func Test_validatorCaptcha_POST_skipHostnameCheck(t *testing.T) {
+ const token = "widget-response-token"
+
+ // Provider test keys report a fixed hostname unrelated to the page.
+ stub := newSiteverifyStub(t, captchaVerdict{Success: true, Hostname: "unrelated.example.org"}, token)
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+ bh.Levels[0].CaptchaSkipHostnameCheck = true
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte(token)
+
+ if err := validatorCaptcha(bh, req, resp); err != nil {
+ t.Fatalf("validator failed: %v", err)
+ }
+
+ if err := bh.IsValidCookie(*req.Identifier, resp.Token.ReadBytes()); err != nil {
+ t.Errorf("invalid cookie: %v", err)
+ }
+}
+
+func Test_validatorCaptcha_POST_unavailable(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ }))
+ defer stub.Close()
+
+ bh := newCaptchaBerghain(t, stub.URL)
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+ req.Body = []byte("widget-response-token")
+
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, errCaptchaUnavailable) {
+ t.Fatalf("expected unavailable error on provider 5xx, got: %v", err)
+ }
+
+ // A dead provider must fail closed as well.
+ stub.Close()
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, errCaptchaUnavailable) {
+ t.Fatalf("expected unavailable error on connection failure, got: %v", err)
+ }
+
+ if resp.Token.Len() != 0 {
+ t.Errorf("unavailable provider must not issue a cookie")
+ }
+}
+
+func Test_validatorCaptcha_POST_invalidBody(t *testing.T) {
+ bh := newCaptchaBerghain(t, "http://invalid.invalid")
+
+ req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
+ defer ReleaseValidatorRequest(req)
+ defer ReleaseValidatorResponse(resp)
+
+ req.Identifier = newCaptchaIdentifier()
+ req.Method = http.MethodPost
+
+ req.Body = nil
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, ErrEmpty) {
+ t.Errorf("expected empty body error, got: %v", err)
+ }
+
+ req.Body = bytes.Repeat([]byte{'A'}, validatorCaptchaMaxTokenLength+1)
+ if err := validatorCaptcha(bh, req, resp); !errors.Is(err, ErrInvalidLength) {
+ t.Errorf("expected invalid length error, got: %v", err)
+ }
+}
+
+func Test_captchaHostnameMatches(t *testing.T) {
+ tests := []struct {
+ hostname string
+ host string
+ want bool
+ }{
+ {"example.com", "example.com", true},
+ {"EXAMPLE.com", "example.com", true},
+ {"foo.example.com", "example.com", true},
+ {"foo.bar.example.com", "example.com", true},
+ {"example.com", "foo.example.com", false},
+ {"fooexample.com", "example.com", false},
+ {"example.org", "example.com", false},
+ {"", "example.com", false},
+ {"example.com", "", false},
+ {".example.com", "example.com", true},
+ }
+
+ for _, tt := range tests {
+ if got := captchaHostnameMatches(tt.hostname, []byte(tt.host)); got != tt.want {
+ t.Errorf("captchaHostnameMatches(%q, %q) = %v, want %v", tt.hostname, tt.host, got, tt.want)
+ }
+ }
+}
diff --git a/validators.go b/validators.go
index fff93cb..7531012 100644
--- a/validators.go
+++ b/validators.go
@@ -15,6 +15,10 @@ const (
_ ValidationType = iota
ValidationTypeNone
ValidationTypePOW
+ _ // reserved for worker-based POW, the web challenge protocol already assigns it t:2
+ ValidationTypeTurnstile
+ ValidationTypeHCaptcha
+ ValidationTypeReCaptcha
)
type ValidatorResponse struct {
@@ -70,6 +74,8 @@ func (v ValidationType) RunValidator(b *Berghain, req *ValidatorRequest, resp *V
return validatorNone(b, req, resp)
case ValidationTypePOW:
return validatorPOW(b, req, resp)
+ case ValidationTypeTurnstile, ValidationTypeHCaptcha, ValidationTypeReCaptcha:
+ return validatorCaptcha(b, req, resp)
default:
return errors.New("unknown validation type")
}
diff --git a/web/build/inline-hooks.js b/web/build/inline-hooks.js
index 0f42042..f0186d0 100644
--- a/web/build/inline-hooks.js
+++ b/web/build/inline-hooks.js
@@ -1,10 +1,8 @@
import {readFile, stat} from "node:fs/promises";
import {dirname, isAbsolute, relative, resolve, sep} from "node:path";
-import babel from "@babel/core";
+import {parseAsync, transformFromAstAsync, traverse, types} from "@babel/core";
import {searchForWorkspaceRoot} from "vite";
-const {parseAsync, transformFromAstAsync, traverse, types} = babel;
-
const MARKER_TOKEN = "@berghain:inline";
const SENTINEL = "__BERGHAIN_INLINE_PHASE__";
diff --git a/web/index.html b/web/index.html
index 7013e35..0b078b9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,6 +15,7 @@
Request on Hold
+
diff --git a/web/src/challange/capabilities.js b/web/src/challange/capabilities.js
index 3fda021..98ee27c 100644
--- a/web/src/challange/capabilities.js
+++ b/web/src/challange/capabilities.js
@@ -1,3 +1,14 @@
+/**
+ * Advice shown when a captcha provider script cannot be loaded, the
+ * most common cause being a content blocker. Unlike the capabilities
+ * below this can only be detected once loading the script has failed.
+ */
+export const captchaBlockedAdvice = Object.freeze({
+ name: "Captcha provider",
+ message: "The captcha provider script could not be loaded.",
+ fix: "Disable content blockers for this page, check your network connection, and reload.",
+});
+
const advice = {
textEncoder: {
name: "Text encoding",
diff --git a/web/src/challange/challanger.js b/web/src/challange/challanger.js
index 20b370e..d19f708 100644
--- a/web/src/challange/challanger.js
+++ b/web/src/challange/challanger.js
@@ -51,6 +51,9 @@ export async function doChallenge(){
}
}
catch (e){
+ if (e.advice){
+ loader.showCapabilities([e.advice]);
+ }
result = e.toString();
}
diff --git a/web/src/challange/challanges.js b/web/src/challange/challanges.js
index 7ac64e6..b995d7a 100644
--- a/web/src/challange/challanges.js
+++ b/web/src/challange/challanges.js
@@ -4,6 +4,8 @@
import {sha256} from "@noble/hashes/sha256";
import {bytesToHex} from "@noble/hashes/utils";
+import {captchaBlockedAdvice} from "./capabilities.js";
+import * as loader from "./loader.js";
async function doHash(data){
const input = new TextEncoder().encode(data);
@@ -61,12 +63,133 @@ async function challengeNone(){
});
}
+export const captchaProviders = Object.freeze({
+ 3: Object.freeze({
+ name: "Turnstile",
+ script: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
+ global: "turnstile",
+ }),
+ 4: Object.freeze({
+ name: "hCaptcha",
+ script: "https://js.hcaptcha.com/1/api.js?render=explicit",
+ global: "hcaptcha",
+ // hcaptcha initializes asynchronously after the script has
+ // loaded and signals readiness via a named onload callback.
+ useOnload: true,
+ }),
+ 5: Object.freeze({
+ name: "reCAPTCHA",
+ script: "https://www.google.com/recaptcha/api.js?render=explicit",
+ global: "grecaptcha",
+ // grecaptcha initializes asynchronously after the script has
+ // loaded and queues ready() callbacks until then. Turnstile and
+ // hCaptcha are usable directly; turnstile.ready() even throws
+ // when the script was loaded async.
+ useReady: true,
+ }),
+});
+
+const captchaOnloadCallback = "__berghainCaptchaLoaded";
+
+function loadProviderScript(provider, environment){
+ return new Promise((resolve, reject) => {
+ const script = environment.document.createElement("script");
+ script.src = provider.script;
+ if (provider.useOnload){
+ environment[captchaOnloadCallback] = () => {
+ delete environment[captchaOnloadCallback];
+ resolve();
+ };
+ script.src += `&onload=${captchaOnloadCallback}`;
+ }
+ else {
+ script.onload = () => resolve();
+ }
+ script.async = true;
+ script.onerror = () => reject(new Error(`Failed to load ${script.src}`));
+ environment.document.head.append(script);
+ });
+}
+
+/**
+ * Load the widget API of a captcha provider.
+ *
+ * @param {{name: string, script: string, global: string}} provider
+ * @param {object} environment
+ * @return {Promise}
+ */
+async function loadCaptchaApi(provider, environment){
+ let api;
+ try {
+ await loadProviderScript(provider, environment);
+ api = environment[provider.global];
+ }
+ catch {
+ api = undefined;
+ }
+
+ if (!api){
+ const error = new Error(`${provider.name} is unavailable.`);
+ error.advice = captchaBlockedAdvice;
+ throw error;
+ }
+
+ if (provider.useReady){
+ await new Promise((resolve) => api.ready(resolve));
+ }
+
+ return api;
+}
+
+/**
+ * Challenge captcha. Renders the provider widget and submits its
+ * response token for validation.
+ *
+ * @param {object} challenge
+ * @param {{environment?: object}} [options]
+ * @return {Promise}
+ */
+export async function challengeCaptcha(challenge, {environment = globalThis} = {}){
+ const provider = captchaProviders[challenge.t];
+ const api = await loadCaptchaApi(provider, environment);
+
+ const container = loader.showWidget();
+ let token;
+ try {
+ token = await new Promise((resolve, reject) => {
+ api.render(container, {
+ sitekey: challenge.k,
+ callback: resolve,
+ "error-callback": () => reject(new Error(`${provider.name} reported a widget error`)),
+ });
+ });
+ }
+ finally {
+ loader.hideWidget();
+ }
+
+ const response = await environment.fetch("/cdn-cgi/challenge-platform/challenge", {
+ body: token,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ method: "POST",
+ });
+ if (!response.ok){
+ throw new Error("Challenge submission failed");
+ }
+}
+
export function getChallengeSolver(challengeType){
switch (challengeType){
case 0:
return ["Please wait...", challengeNone];
case 1:
return ["Solving POW challenge...", challengePOW];
+ case 3:
+ case 4:
+ case 5:
+ return [`Waiting for ${captchaProviders[challengeType].name}...`, challengeCaptcha];
default:
throw new Error(`Unknown challenge type: ${challengeType}`);
}
diff --git a/web/src/challange/loader.js b/web/src/challange/loader.js
index 2f7c49d..e78bccf 100644
--- a/web/src/challange/loader.js
+++ b/web/src/challange/loader.js
@@ -49,6 +49,29 @@ export function showCapabilities(missing){
container.style.display = "block";
}
+/**
+ * Show the captcha widget mount point instead of the spinner.
+ *
+ * @return {HTMLDivElement} The widget container.
+ */
+export function showWidget(){
+ const widget = /** @type {HTMLDivElement} */ (document.getElementById("captcha-widget"));
+ const loader = /** @type {HTMLDivElement} */ (document.querySelector(".circle-loader"));
+ loader.style.display = "none";
+ widget.style.display = "block";
+ return widget;
+}
+
+/**
+ * Hide the captcha widget and bring the spinner back.
+ */
+export function hideWidget(){
+ const widget = /** @type {HTMLDivElement} */ (document.getElementById("captcha-widget"));
+ const loader = /** @type {HTMLDivElement} */ (document.querySelector(".circle-loader"));
+ widget.style.display = "none";
+ loader.style.display = "";
+}
+
export function setChallengeInfo(text){
const captcha = /** @type {HTMLDivElement} */ (document.querySelector(".captcha"));
captcha.innerText = text;
diff --git a/web/src/style.scss b/web/src/style.scss
index 8105d18..3cbc2e5 100644
--- a/web/src/style.scss
+++ b/web/src/style.scss
@@ -82,6 +82,10 @@ code {
font-size: 120%;
}
+.captcha-widget {
+ margin-left: 1em;
+}
+
$circleSize: 3em;
.circle-loader {
diff --git a/web/test/challanges.test.js b/web/test/challanges.test.js
new file mode 100644
index 0000000..3820f46
--- /dev/null
+++ b/web/test/challanges.test.js
@@ -0,0 +1,98 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {captchaBlockedAdvice} from "../src/challange/capabilities.js";
+import {captchaProviders, challengeCaptcha, getChallengeSolver} from "../src/challange/challanges.js";
+
+function scriptEnvironment(onScript){
+ return {
+ document: {
+ createElement: () => ({}),
+ head: {
+ append(script){
+ queueMicrotask(() => onScript(script));
+ },
+ },
+ },
+ };
+}
+
+test("describes every captcha challenge type", () => {
+ assert.deepEqual(Object.keys(captchaProviders), ["3", "4", "5"]);
+
+ for (const provider of Object.values(captchaProviders)){
+ assert.match(provider.script, /^https:\/\//);
+ assert.notEqual(provider.name, "");
+ assert.notEqual(provider.global, "");
+ }
+});
+
+test("solves captcha challenge types with the captcha solver", () => {
+ for (const [challengeType, provider] of Object.entries(captchaProviders)){
+ const [name, solver] = getChallengeSolver(Number(challengeType));
+ assert.equal(solver, challengeCaptcha);
+ assert.match(name, new RegExp(provider.name));
+ }
+
+ assert.throws(() => getChallengeSolver(99), /Unknown challenge type/);
+});
+
+test("advises about content blockers when the provider script fails to load", async() => {
+ const environment = scriptEnvironment((script) => script.onerror(new Error("blocked")));
+
+ await assert.rejects(challengeCaptcha({k: "sitekey", t: 3}, {environment}), (error) => {
+ assert.match(error.message, /Turnstile/);
+ assert.equal(error.advice, captchaBlockedAdvice);
+ return true;
+ });
+});
+
+test("advises about content blockers when the provider API is missing after load", async() => {
+ const environment = scriptEnvironment((script) => script.onload());
+
+ await assert.rejects(challengeCaptcha({k: "sitekey", t: 3}, {environment}), (error) => {
+ assert.match(error.message, /Turnstile/);
+ assert.equal(error.advice, captchaBlockedAdvice);
+ return true;
+ });
+});
+
+test("renders the widget and submits its token", async() => {
+ const requests = [];
+ const rendered = [];
+ const widget = {style: {}};
+
+ const environment = scriptEnvironment((script) => {
+ // hcaptcha signals readiness through the named onload callback.
+ const callbackName = new URL(script.src).searchParams.get("onload");
+ assert.notEqual(callbackName, null);
+ environment[callbackName]();
+ });
+ environment.fetch = async(url, options) => {
+ requests.push({options, url});
+ return {ok: true};
+ };
+ environment.hcaptcha = {
+ render(container, options){
+ rendered.push({container, options});
+ queueMicrotask(() => options.callback("widget-response-token"));
+ },
+ };
+
+ globalThis.document = {
+ getElementById: () => widget,
+ querySelector: () => ({style: {}}),
+ };
+ try {
+ await challengeCaptcha({k: "sitekey-under-test", t: 4}, {environment});
+ }
+ finally {
+ delete globalThis.document;
+ }
+
+ assert.equal(rendered[0].container, widget);
+ assert.equal(rendered[0].options.sitekey, "sitekey-under-test");
+ assert.equal(requests[0].url, "/cdn-cgi/challenge-platform/challenge");
+ assert.equal(requests[0].options.method, "POST");
+ assert.equal(requests[0].options.body, "widget-response-token");
+});