From 8b14913fbdc2d46a1a2d008079a6710be4afb63d Mon Sep 17 00:00:00 2001 From: Fionera Date: Sat, 11 Jul 2026 02:37:17 +0200 Subject: [PATCH 1/7] fix(web): use named imports from @babel/core 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 --- web/build/inline-hooks.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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__"; From bcc72bfdd84ea27ed26519890d24ce502bcd0a1d Mon Sep 17 00:00:00 2001 From: Fionera Date: Sat, 11 Jul 2026 03:05:32 +0200 Subject: [PATCH 2/7] feat: add turnstile, hcaptcha, and recaptcha challenge types 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 --- berghain.go | 25 ++++ cmd/spop/config.go | 28 ++++ cmd/spop/config.yaml | 6 + validator_captcha.go | 151 ++++++++++++++++++++ validator_captcha_test.go | 287 ++++++++++++++++++++++++++++++++++++++ validators.go | 6 + 6 files changed, 503 insertions(+) create mode 100644 validator_captcha.go create mode 100644 validator_captcha_test.go diff --git a/berghain.go b/berghain.go index a192a39..15f7b9b 100644 --- a/berghain.go +++ b/berghain.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "hash" "log/slog" + "net/http" "sync" "time" ) @@ -13,12 +14,27 @@ 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 + + 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 +52,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..a76192d 100644 --- a/cmd/spop/config.go +++ b/cmd/spop/config.go @@ -54,6 +54,14 @@ 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"` } func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig { @@ -77,10 +85,30 @@ 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 + default: + if c.Sitekey != "" || c.Secret != "" || c.VerifyURL != "" { + Fatal("sitekey, secret and verify_url 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/validator_captcha.go b/validator_captcha.go new file mode 100644 index 0000000..358a0a7 --- /dev/null +++ b/validator_captcha.go @@ -0,0 +1,151 @@ +package berghain + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type captchaValidator struct { +} + +var captchaVerifyURLs = map[ValidationType]string{ + ValidationTypeTurnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + ValidationTypeHCaptcha: "https://api.hcaptcha.com/siteverify", + ValidationTypeReCaptcha: "https://www.google.com/recaptcha/api/siteverify", +} + +// 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) + + verifyURL := lc.CaptchaVerifyURL + if verifyURL == "" { + verifyURL = captchaVerifyURLs[lc.Type] + } + + 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 !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..dec925a --- /dev/null +++ b/validator_captcha_test.go @@ -0,0 +1,287 @@ +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_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") } From 8068ee5c2f37c97a49d4e7ff5619db5d1230a486 Mon Sep 17 00:00:00 2001 From: Fionera Date: Sat, 11 Jul 2026 03:23:45 +0200 Subject: [PATCH 3/7] feat: allow skipping the captcha hostname check 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 --- berghain.go | 4 ++++ cmd/spop/config.go | 9 +++++++-- validator_captcha.go | 2 +- validator_captcha_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/berghain.go b/berghain.go index 15f7b9b..b032cda 100644 --- a/berghain.go +++ b/berghain.go @@ -22,6 +22,10 @@ type LevelConfig struct { // 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 diff --git a/cmd/spop/config.go b/cmd/spop/config.go index a76192d..e2e1c1b 100644 --- a/cmd/spop/config.go +++ b/cmd/spop/config.go @@ -62,6 +62,10 @@ type LevelConfig struct { // 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 { @@ -103,9 +107,10 @@ func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig { lc.CaptchaSitekey = c.Sitekey lc.CaptchaSecret = c.Secret lc.CaptchaVerifyURL = c.VerifyURL + lc.CaptchaSkipHostnameCheck = c.SkipHostnameCheck default: - if c.Sitekey != "" || c.Secret != "" || c.VerifyURL != "" { - Fatal("sitekey, secret and verify_url are only valid for captcha types", "validator", c.Type) + 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) } } diff --git a/validator_captcha.go b/validator_captcha.go index 358a0a7..05d1086 100644 --- a/validator_captcha.go +++ b/validator_captcha.go @@ -110,7 +110,7 @@ func (captchaValidator) isValid(b *Berghain, req *ValidatorRequest, _ *Validator return fmt.Errorf("%w: %s", errCaptchaRejected, strings.Join(verdict.ErrorCodes, ", ")) } - if !captchaHostnameMatches(verdict.Hostname, req.Identifier.Host) { + if !lc.CaptchaSkipHostnameCheck && !captchaHostnameMatches(verdict.Hostname, req.Identifier.Host) { return errCaptchaHostMismatch } diff --git a/validator_captcha_test.go b/validator_captcha_test.go index dec925a..7b7155c 100644 --- a/validator_captcha_test.go +++ b/validator_captcha_test.go @@ -209,6 +209,33 @@ func Test_validatorCaptcha_POST_hostMismatch(t *testing.T) { } } +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) From bded1a3edebad5e75c6af32c98806abd6c2630b1 Mon Sep 17 00:00:00 2001 From: Fionera Date: Sat, 11 Jul 2026 03:20:13 +0200 Subject: [PATCH 4/7] feat(web): render captcha widgets for turnstile, hcaptcha, and recaptcha 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 --- web/index.html | 1 + web/src/challange/capabilities.js | 11 +++ web/src/challange/challanger.js | 3 + web/src/challange/challanges.js | 123 ++++++++++++++++++++++++++++++ web/src/challange/loader.js | 23 ++++++ web/src/style.scss | 4 + web/test/challanges.test.js | 98 ++++++++++++++++++++++++ 7 files changed, 263 insertions(+) create mode 100644 web/test/challanges.test.js 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

+