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.

@@ -312,6 +315,18 @@

Enforced by Berghain

only hands out the puzzle and checks the answer.

+ +
+ Captcha challenge (Turnstile, hCaptcha, reCAPTCHA) +
+

A level can require solving a captcha widget from Cloudflare Turnstile, hCaptcha or + Google reCAPTCHA. The widget appears on the verification screen; once it is satisfied, + its response is checked with the provider and you receive the same clearance cookie as + with any other level. The widget script is loaded from the provider's servers, so a + content blocker that blocks the provider will also block the verification — the + screen will tell you when that happens.

+
+
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

+