From 5889df0180222a11cb0cd568750ed951a254567d Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Thu, 24 Sep 2026 12:05:18 +0500 Subject: [PATCH 1/6] Read example hCaptcha secret from environment --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8db94e3..50a10dd 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,19 @@ Requires Go 1.24 or later. ## Use -Create a client once with an hCaptcha service secret, then reuse it across handlers. Keep the secret only in server-side configuration. +Set `HCAPTCHA_SECRET` in the server environment. Create a client once, then reuse it across handlers. `New` returns an error if the secret is unset. ```go package main import ( "net/http" + "os" hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha" ) -var verifier, err = hcaptcha.New("server-side-secret") +var verifier, err = hcaptcha.New(os.Getenv("HCAPTCHA_SECRET")) func protected(w http.ResponseWriter, r *http.Request) { if err != nil { From 732561f6f415ed7eedcbe06b4cbea0d7866086bf Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Thu, 24 Sep 2026 12:10:25 +0500 Subject: [PATCH 2/6] Fail README example on missing secret at startup --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 50a10dd..b30b107 100644 --- a/README.md +++ b/README.md @@ -12,32 +12,38 @@ Requires Go 1.24 or later. ## Use -Set `HCAPTCHA_SECRET` in the server environment. Create a client once, then reuse it across handlers. `New` returns an error if the secret is unset. +Set `HCAPTCHA_SECRET` in the server environment. Create a client at startup and exit if the secret is unset. Reuse the client across handlers. ```go package main import ( + "log" "net/http" "os" hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha" ) -var verifier, err = hcaptcha.New(os.Getenv("HCAPTCHA_SECRET")) - -func protected(w http.ResponseWriter, r *http.Request) { +func main() { + verifier, err := hcaptcha.New(os.Getenv("HCAPTCHA_SECRET")) if err != nil { - http.Error(w, "verification unavailable", http.StatusInternalServerError) - return + log.Fatal(err) } + http.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) { + protected(verifier, w, r) + }) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } result, err := verifier.VerifyRequest(r.Context(), hcaptcha.Request{ - Token: r.FormValue("h-captcha-response"), - SiteKey: "expected-sitekey-uuid", // Bind token to this sitekey. + Token: r.FormValue("h-captcha-response"), + SiteKey: "expected-sitekey-uuid", // Bind token to this sitekey. }) if err != nil { http.Error(w, "verification unavailable", http.StatusBadGateway) From cf2e9779db568eaebaf9dc59ac5d29c289ff4ead Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Thu, 24 Sep 2026 17:40:36 +0500 Subject: [PATCH 3/6] fixes of readme + code --- README.md | 4 ++- example/backend/main.go | 2 +- hcaptcha/example_test.go | 5 +-- hcaptcha/hcaptcha.go | 45 ++++++++++++++++++----- hcaptcha/hcaptcha_test.go | 76 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b30b107..8348f93 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ import ( func main() { verifier, err := hcaptcha.New(os.Getenv("HCAPTCHA_SECRET")) if err != nil { - log.Fatal(err) + log.Fatalf("hcaptcha init failed: %v", err) } http.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) { protected(verifier, w, r) @@ -60,6 +60,8 @@ func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request Use `Verify(token)` for the minimal case. `VerifyContext(ctx, token)` and `VerifyRequest(ctx, request)` propagate handler cancellation. `Request.Token` is required; `RemoteIP` and `SiteKey` are optional parameters sent only when non-empty. +`Request.MaxRetries` optionally retries transport failures and HTTP 429/5xx responses with exponential backoff (100 ms initially, capped at 1 s). Zero, the default, makes one attempt. For example, set `MaxRetries: 2` to allow at most three attempts. Retries stop when the context is canceled. [Siteverify tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side): a failed transport call may already have consumed the token, so a retry can return `already-seen-response`. Never treat that response as success; obtain a fresh token after a failed verification. + `Result` is `map[string]any`, retaining all fields returned by Siteverify. Treat a transport or decode `error` separately from an unsuccessful response, and fail closed in both cases. Check `success` before protected work. Inspect `error-codes` for operational logging; do not expose raw verification details to users. ## Browser integration diff --git a/example/backend/main.go b/example/backend/main.go index df64559..9fe1ffb 100644 --- a/example/backend/main.go +++ b/example/backend/main.go @@ -30,7 +30,7 @@ func main() { verifier, err := hcaptcha.New(secret) if err != nil { - log.Fatal(err) + log.Fatalf("hcaptcha init failed: %v", err) } app := application{verifier: verifier, siteKey: siteKey} diff --git a/hcaptcha/example_test.go b/hcaptcha/example_test.go index bc5f17e..4b22456 100644 --- a/hcaptcha/example_test.go +++ b/hcaptcha/example_test.go @@ -2,6 +2,7 @@ package hcaptcha_test import ( "context" + "log" "os" hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha" @@ -12,7 +13,7 @@ func ExampleClient_Verify() { token := "response token from the application request" verifier, err := hcaptcha.New(secret) if err != nil { - return + log.Fatalf("hcaptcha init failed: %v", err) } result, err := verifier.Verify(token) @@ -26,7 +27,7 @@ func ExampleClient_Verify() { func ExampleClient_VerifyRequest() { verifier, err := hcaptcha.New(os.Getenv("HCAPTCHA_SECRET")) if err != nil { - return + log.Fatalf("hcaptcha init failed: %v", err) } result, err := verifier.VerifyRequest(context.Background(), hcaptcha.Request{ Token: "response token from the application request", diff --git a/hcaptcha/hcaptcha.go b/hcaptcha/hcaptcha.go index b93d484..b4b1e84 100644 --- a/hcaptcha/hcaptcha.go +++ b/hcaptcha/hcaptcha.go @@ -15,6 +15,8 @@ import ( const ( siteverifyEndpoint = "https://api.hcaptcha.com/siteverify" defaultTimeout = time.Second + initialRetryDelay = 100 * time.Millisecond + maxRetryDelay = time.Second ) // Result contains the Siteverify response. The basic response includes success @@ -35,6 +37,10 @@ type Request struct { // Recommended. RemoteIP string SiteKey string + + // MaxRetries retries transport errors and HTTP 429/5xx with exponential backoff. + // Zero disables retries. A retried token may have been consumed by a prior attempt. + MaxRetries int } // Client verifies tokens using the secret passed to New. A Client is safe to @@ -91,10 +97,30 @@ func (client *Client) VerifyRequest(ctx context.Context, request Request) (Resul if request.Token == "" { return nil, errors.New("hcaptcha: token is required") } + if request.MaxRetries < 0 { + return nil, errors.New("hcaptcha: max retries must not be negative") + } + if ctx == nil { + ctx = context.Background() + } form := url.Values{"secret": {client.secret}, "response": {request.Token}} setString(form, "remoteip", request.RemoteIP) setString(form, "sitekey", request.SiteKey) - return client.call(ctx, client.endpoint, form) + delay := initialRetryDelay + for attempt := 0; ; attempt++ { + result, retryable, err := client.call(ctx, client.endpoint, form) + if err == nil || !retryable || attempt >= request.MaxRetries { + return result, err + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + delay = min(delay*2, maxRetryDelay) + } } func setString(form url.Values, name, value string) { @@ -103,26 +129,27 @@ func setString(form url.Values, name, value string) { } } -func (client *Client) call(ctx context.Context, endpoint string, form url.Values) (Result, error) { - if ctx == nil { - ctx = context.Background() - } +func (client *Client) call(ctx context.Context, endpoint string, form url.Values) (Result, bool, error) { httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode())) if err != nil { - return nil, fmt.Errorf("hcaptcha: create request: %w", err) + return nil, false, fmt.Errorf("hcaptcha: create request: %w", err) } httpRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpRequest.Header.Set("Accept", "application/json") response, err := client.httpClient.Do(httpRequest) if err != nil { - return nil, fmt.Errorf("hcaptcha: call siteverify: %w", err) + return nil, ctx.Err() == nil, fmt.Errorf("hcaptcha: call siteverify: %w", err) } defer func() { _ = response.Body.Close() }() + if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { + status := response.StatusCode + return nil, true, fmt.Errorf("hcaptcha: siteverify returned HTTP %d", status) + } result := Result{} if err := json.NewDecoder(response.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("hcaptcha: decode siteverify response: %w", err) + return nil, false, fmt.Errorf("hcaptcha: decode siteverify response: %w", err) } - return result, nil + return result, false, nil } diff --git a/hcaptcha/hcaptcha_test.go b/hcaptcha/hcaptcha_test.go index a63cfb6..bedd84a 100644 --- a/hcaptcha/hcaptcha_test.go +++ b/hcaptcha/hcaptcha_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "reflect" + "strings" "sync" "sync/atomic" "testing" @@ -33,6 +34,81 @@ func TestVerifyRequestEncodesAllFields(t *testing.T) { } } +func TestVerifyRequestRetries(t *testing.T) { + for _, test := range []struct { + name string + statuses []int + body string + maxRetries int + wantCalls int + wantError bool + wantSuccess bool + }{ + {"rate limit", []int{429, 200}, `{"success":true}`, 1, 2, false, true}, + {"server error", []int{503, 502, 200}, `{"success":true}`, 2, 3, false, true}, + {"exhausted", []int{503}, `{"success":true}`, 1, 2, true, false}, + {"no retries by default", []int{503}, `{"success":true}`, 0, 1, true, false}, + {"other HTTP status", []int{400}, `{"success":false}`, 2, 1, false, false}, + {"verification rejected", []int{200}, `{"success":false}`, 2, 1, false, false}, + {"malformed response", []int{200}, `{`, 2, 1, true, false}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + status := test.statuses[min(calls, len(test.statuses)-1)] + calls++ + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(test.body))}, nil + })} + result, err := newClient("secret", "https://example.test", httpClient).VerifyRequest(t.Context(), Request{Token: "token", MaxRetries: test.maxRetries}) + if (err != nil) != test.wantError { + t.Errorf("error = %v, wantError = %t", err, test.wantError) + } + if calls != test.wantCalls { + t.Errorf("calls = %d, want %d", calls, test.wantCalls) + } + if !test.wantError && result["success"] != test.wantSuccess { + t.Errorf("result = %#v", result) + } + }) + } +} + +func TestVerifyRequestRetriesTransportError(t *testing.T) { + calls := 0 + httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return nil, errors.New("network unavailable") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"success":true}`))}, nil + })} + result, err := newClient("secret", "https://example.test", httpClient).VerifyRequest(t.Context(), Request{Token: "token", MaxRetries: 1}) + if err != nil || result["success"] != true || calls != 2 { + t.Errorf("result = %#v, error = %v, calls = %d", result, err, calls) + } +} + +func TestVerifyRequestStopsRetryingOnCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + calls := 0 + httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader(""))}, nil + })} + _, err := newClient("secret", "https://example.test", httpClient).VerifyRequest(ctx, Request{Token: "token", MaxRetries: 2}) + if !errors.Is(err, context.DeadlineExceeded) || calls != 1 { + t.Errorf("error = %v, calls = %d", err, calls) + } +} + +func TestVerifyRequestRejectsNegativeRetries(t *testing.T) { + _, err := newClient("secret", "https://example.test", http.DefaultClient).VerifyRequest(t.Context(), Request{Token: "token", MaxRetries: -1}) + if err == nil || err.Error() != "hcaptcha: max retries must not be negative" { + t.Errorf("error = %v, want negative-retries error", err) + } +} + func TestNew(t *testing.T) { client, err := New("secret") if err != nil { From 43711482f6a0ea823cca313a24d3dc3c219affb3 Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Fri, 25 Sep 2026 10:20:39 +0500 Subject: [PATCH 4/6] fixes of readme + code --- README.md | 20 +++++++++++++------- hcaptcha/hcaptcha.go | 8 ++++++-- hcaptcha/hcaptcha_test.go | 40 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8348f93..9a55940 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Requires Go 1.24 or later. ## Use -Set `HCAPTCHA_SECRET` in the server environment. Create a client at startup and exit if the secret is unset. Reuse the client across handlers. +Read `HCAPTCHA_SECRET` at startup and reuse the verifier across handlers. `New` returns an error if the secret is empty; exit before serving requests. ```go package main @@ -30,7 +30,7 @@ func main() { if err != nil { log.Fatalf("hcaptcha init failed: %v", err) } - http.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc("POST /protected", func(w http.ResponseWriter, r *http.Request) { protected(verifier, w, r) }) log.Fatal(http.ListenAndServe(":8080", nil)) @@ -41,9 +41,14 @@ func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request http.Error(w, "invalid form", http.StatusBadRequest) return } + token := r.PostForm.Get("h-captcha-response") + if token == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } result, err := verifier.VerifyRequest(r.Context(), hcaptcha.Request{ - Token: r.FormValue("h-captcha-response"), - SiteKey: "expected-sitekey-uuid", // Bind token to this sitekey. + Token: token, + SiteKey: "expected-sitekey-uuid", // Require a token for this sitekey. }) if err != nil { http.Error(w, "verification unavailable", http.StatusBadGateway) @@ -60,7 +65,9 @@ func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request Use `Verify(token)` for the minimal case. `VerifyContext(ctx, token)` and `VerifyRequest(ctx, request)` propagate handler cancellation. `Request.Token` is required; `RemoteIP` and `SiteKey` are optional parameters sent only when non-empty. -`Request.MaxRetries` optionally retries transport failures and HTTP 429/5xx responses with exponential backoff (100 ms initially, capped at 1 s). Zero, the default, makes one attempt. For example, set `MaxRetries: 2` to allow at most three attempts. Retries stop when the context is canceled. [Siteverify tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side): a failed transport call may already have consumed the token, so a retry can return `already-seen-response`. Never treat that response as success; obtain a fresh token after a failed verification. +`Request.MaxRetries` sets the number of extra attempts after transport errors or HTTP 429/5xx. The default `0` makes one attempt; `2` allows three. Delays start at 100 ms, double up to 1 s, and stop on context cancellation. + +[Production tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side). If Siteverify verifies a token but its response does not reach your server, a retry may return `already-seen-response`. Treat that as failure and obtain a fresh token. `Result` is `map[string]any`, retaining all fields returned by Siteverify. Treat a transport or decode `error` separately from an unsuccessful response, and fail closed in both cases. Check `success` before protected work. Inspect `error-codes` for operational logging; do not expose raw verification details to users. @@ -78,14 +85,13 @@ Use the [Developer Guide](https://docs.hcaptcha.com/) for the complete browser-t ## Security and operations - Call Siteverify from the backend with a form-encoded POST. Do not expose the secret or accept a client-side success claim. -- Verify tokens promptly and exactly once. hCaptcha tokens are short-lived and single-use. +- Verify tokens promptly. hCaptcha tokens are short-lived and single-use. - Pass the expected `SiteKey` to prevent a token issued for another sitekey from being accepted. `hostname` is informational, not an authentication control. - Send `RemoteIP` when available, using an IP derived from a trusted-proxy policy. Do not trust forwarding headers from arbitrary clients. - Configure the hCaptcha domain allowlist and keep the secret in a secret manager or environment variable. [Rotate a leaked secret](https://docs.hcaptcha.com/#rotating-your-siteverify-secret) immediately. See hCaptcha’s [server-side verification guidance](https://docs.hcaptcha.com/#verify-the-user-response-server-side), [domain allowlist configuration](https://docs.hcaptcha.com/configuration#domain-allowlist), and [Siteverify error-code reference](https://docs.hcaptcha.com/#siteverify-error-codes-table). - ## Example and testing Run the included React/Vite browser and Go backend example: diff --git a/hcaptcha/hcaptcha.go b/hcaptcha/hcaptcha.go index b4b1e84..7407a2b 100644 --- a/hcaptcha/hcaptcha.go +++ b/hcaptcha/hcaptcha.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "time" @@ -17,6 +18,7 @@ const ( defaultTimeout = time.Second initialRetryDelay = 100 * time.Millisecond maxRetryDelay = time.Second + maxRetryBodyBytes = 2 << 10 ) // Result contains the Siteverify response. The basic response includes success @@ -38,8 +40,9 @@ type Request struct { RemoteIP string SiteKey string - // MaxRetries retries transport errors and HTTP 429/5xx with exponential backoff. - // Zero disables retries. A retried token may have been consumed by a prior attempt. + // MaxRetries is the number of extra attempts after a transport error or HTTP 429/5xx. + // Zero means no retries. Siteverify may verify a token even if this client gets an error. + // Retrying that token can fail because tokens are single-use. MaxRetries int } @@ -143,6 +146,7 @@ func (client *Client) call(ctx context.Context, endpoint string, form url.Values } defer func() { _ = response.Body.Close() }() if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { + _, _ = io.CopyN(io.Discard, response.Body, maxRetryBodyBytes+1) status := response.StatusCode return nil, true, fmt.Errorf("hcaptcha: siteverify returned HTTP %d", status) } diff --git a/hcaptcha/hcaptcha_test.go b/hcaptcha/hcaptcha_test.go index bedd84a..eca09a2 100644 --- a/hcaptcha/hcaptcha_test.go +++ b/hcaptcha/hcaptcha_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/http/httptrace" "net/url" "reflect" "strings" @@ -73,6 +74,45 @@ func TestVerifyRequestRetries(t *testing.T) { } } +func TestVerifyRequestReusesConnectionAfterServerError(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, "try again") + return + } + _, _ = io.WriteString(w, `{"success":true}`) + })) + defer server.Close() + + var reused []bool + ctx := httptrace.WithClientTrace(t.Context(), &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { reused = append(reused, info.Reused) }, + }) + result, err := newClient("secret", server.URL, server.Client()).VerifyRequest(ctx, Request{Token: "token", MaxRetries: 1}) + if err != nil || result["success"] != true { + t.Fatalf("result = %#v, error = %v", result, err) + } + if len(reused) != 2 || !reused[1] { + t.Errorf("connection reuse = %v, want [false true]", reused) + } +} + +func TestVerifyRequestBoundsErrorBodyDrain(t *testing.T) { + body := strings.NewReader(strings.Repeat("x", maxRetryBodyBytes+10)) + httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(body)}, nil + })} + _, err := newClient("secret", "https://example.test", httpClient).VerifyRequest(t.Context(), Request{Token: "token"}) + if err == nil { + t.Fatal("VerifyRequest() error = nil, want HTTP 503 error") + } + if got := body.Len(); got != 9 { + t.Errorf("unread body bytes = %d, want 9", got) + } +} + func TestVerifyRequestRetriesTransportError(t *testing.T) { calls := 0 httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { From c2f690932078d3ba2c608528ea7f73b7e1e7e319 Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Fri, 25 Sep 2026 15:08:53 +0500 Subject: [PATCH 5/6] wip --- README.md | 43 ++++++++++++++++++++++----------------- example/README.md | 17 +++++++--------- example/backend/main.go | 27 ++++-------------------- example/run.sh | 13 +++++++++--- example/src/App.tsx | 8 +++++--- example/vite.config.ts | 1 - hcaptcha/hcaptcha.go | 15 +++++++++----- hcaptcha/hcaptcha_test.go | 14 ++++++------- 8 files changed, 66 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 9a55940..9ee1e9e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Small Go client for [hCaptcha Siteverify](https://docs.hcaptcha.com/#verify-the- go get github.com/hCaptcha/hcaptcha-go/hcaptcha ``` -Requires Go 1.24 or later. +Requires Go 1.24 or later. Use a supported, patched Go toolchain in production. ## Use -Read `HCAPTCHA_SECRET` at startup and reuse the verifier across handlers. `New` returns an error if the secret is empty; exit before serving requests. +Set `HCAPTCHA_SECRET` and `HCAPTCHA_SITEKEY` in server configuration. Create the verifier at startup and reuse it across handlers. Exit before serving requests if either value is missing. ```go package main @@ -21,6 +21,7 @@ import ( "log" "net/http" "os" + "time" hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha" ) @@ -30,13 +31,18 @@ func main() { if err != nil { log.Fatalf("hcaptcha init failed: %v", err) } + siteKey := os.Getenv("HCAPTCHA_SITEKEY") + if siteKey == "" { + log.Fatal("HCAPTCHA_SITEKEY is required") + } http.HandleFunc("POST /protected", func(w http.ResponseWriter, r *http.Request) { - protected(verifier, w, r) + protected(verifier, siteKey, w, r) }) - log.Fatal(http.ListenAndServe(":8080", nil)) + server := &http.Server{Addr: ":8080", ReadHeaderTimeout: 5 * time.Second} + log.Fatal(server.ListenAndServe()) } -func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request) { +func protected(verifier *hcaptcha.Client, siteKey string, w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return @@ -48,7 +54,7 @@ func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request } result, err := verifier.VerifyRequest(r.Context(), hcaptcha.Request{ Token: token, - SiteKey: "expected-sitekey-uuid", // Require a token for this sitekey. + SiteKey: siteKey, }) if err != nil { http.Error(w, "verification unavailable", http.StatusBadGateway) @@ -65,11 +71,11 @@ func protected(verifier *hcaptcha.Client, w http.ResponseWriter, r *http.Request Use `Verify(token)` for the minimal case. `VerifyContext(ctx, token)` and `VerifyRequest(ctx, request)` propagate handler cancellation. `Request.Token` is required; `RemoteIP` and `SiteKey` are optional parameters sent only when non-empty. -`Request.MaxRetries` sets the number of extra attempts after transport errors or HTTP 429/5xx. The default `0` makes one attempt; `2` allows three. Delays start at 100 ms, double up to 1 s, and stop on context cancellation. +`New` uses a one-second HTTP timeout per attempt; use `NewWithHTTPClient` to configure it. `Request.MaxRetries` sets extra attempts after transport errors or HTTP 429/5xx. The default `0` makes one attempt; `2` allows three. Retry delays start at 100 ms, double up to 1 s, and stop on context cancellation. [Production tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side). If Siteverify verifies a token but its response does not reach your server, a retry may return `already-seen-response`. Treat that as failure and obtain a fresh token. -`Result` is `map[string]any`, retaining all fields returned by Siteverify. Treat a transport or decode `error` separately from an unsuccessful response, and fail closed in both cases. Check `success` before protected work. Inspect `error-codes` for operational logging; do not expose raw verification details to users. +`Result` is `map[string]any` and preserves the Siteverify JSON fields. `VerifyRequest` returns an error for non-2xx HTTP status, transport failure, or invalid JSON. A valid response with `success: false` has no Go error. Deny the protected action in either case. Inspect `error-codes` for operational logging; do not expose raw verification details to users. ## Browser integration @@ -77,9 +83,14 @@ This package receives the token generated by a browser or mobile integration. Ad ```html -
+
+
+ +
``` +Use the same sitekey in the widget and `HCAPTCHA_SITEKEY`. + Use the [Developer Guide](https://docs.hcaptcha.com/) for the complete browser-to-server flow and the [JavaScript configuration reference](https://docs.hcaptcha.com/configuration) for explicit rendering, callbacks, themes, and client-side errors. ## Security and operations @@ -88,7 +99,7 @@ Use the [Developer Guide](https://docs.hcaptcha.com/) for the complete browser-t - Verify tokens promptly. hCaptcha tokens are short-lived and single-use. - Pass the expected `SiteKey` to prevent a token issued for another sitekey from being accepted. `hostname` is informational, not an authentication control. - Send `RemoteIP` when available, using an IP derived from a trusted-proxy policy. Do not trust forwarding headers from arbitrary clients. -- Configure the hCaptcha domain allowlist and keep the secret in a secret manager or environment variable. [Rotate a leaked secret](https://docs.hcaptcha.com/#rotating-your-siteverify-secret) immediately. +- Enable the hCaptcha domain allowlist when available. Keep the secret in a secret manager or environment variable; [rotate a leaked secret](https://docs.hcaptcha.com/#rotating-your-siteverify-secret) immediately. See hCaptcha’s [server-side verification guidance](https://docs.hcaptcha.com/#verify-the-user-response-server-side), [domain allowlist configuration](https://docs.hcaptcha.com/configuration#domain-allowlist), and [Siteverify error-code reference](https://docs.hcaptcha.com/#siteverify-error-codes-table). @@ -98,18 +109,12 @@ Run the included React/Vite browser and Go backend example: ```sh cd example -cp .env.local.example .env.local -npm install +cp -n .env.local.example .env.local +npm ci ./run.sh ``` -The sample env uses [hCaptcha's public test keys](https://docs.hcaptcha.com/#integration-testing-test-keys), -which skip visual challenges. To use your own keys locally, follow -[the example's local hostname setup](example/README.md#run). hCaptcha's -[local development guide](https://docs.hcaptcha.com/#local-development) -lists `localhost` and `127.0.0.1` as unsupported hostnames. In this -example, a real-key challenge can still pass there after several checkbox -attempts, but that flow is unreliable. +The sample env uses [hCaptcha's public test keys](https://docs.hcaptcha.com/#integration-testing-test-keys), which skip visual challenges. For real keys, follow [the example's development hostname setup](example/README.md#run); hCaptcha does not support `localhost` or `127.0.0.1` as hostnames. Run package tests with: diff --git a/example/README.md b/example/README.md index 3a2e10f..09e9381 100644 --- a/example/README.md +++ b/example/README.md @@ -9,16 +9,14 @@ remain same-origin. ```sh cd example -cp .env.local.example .env.local -npm install +cp -n .env.local.example .env.local +npm ci ./run.sh ``` hCaptcha's [local development guide](https://docs.hcaptcha.com/#local-development) -lists `localhost` and `127.0.0.1` as unsupported hostnames. On a loopback URL, -this example can still show a challenge and pass Siteverify, though users may -need to click the checkbox a few times. The hostname warning makes that flow -unreliable. To solve challenges locally without the warning: +lists `localhost` and `127.0.0.1` as unsupported hostnames. For real keys, use a +development hostname: 1. Choose a development hostname under a domain you control, such as `captcha-dev.example.com` (replace this placeholder with your own hostname). @@ -42,11 +40,10 @@ to `ALLOWED_HOSTS` and, when enabled, the sitekey's domain allowlist. If hCaptch reports [`network-error`](https://docs.hcaptcha.com/configuration#error-codes) on the mapped hostname, inspect browser requests and blockers. -The backend binds to `127.0.0.1:8080`. It accepts `X-Forwarded-For` only from a -loopback peer (the Vite proxy), sends that IP and the expected sitekey to +The backend binds to `127.0.0.1:8080`, sends the expected sitekey to [Siteverify](https://docs.hcaptcha.com/#verify-the-user-response-server-side), -and returns the full Siteverify result for inspection. Do not copy -that diagnostic response behavior into a production endpoint. +and returns the full Siteverify result for inspection. Do not copy that +diagnostic response behavior into a production endpoint. ## Expected checks diff --git a/example/backend/main.go b/example/backend/main.go index 9fe1ffb..ea5b244 100644 --- a/example/backend/main.go +++ b/example/backend/main.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "log" - "net" "net/http" "os" "strings" @@ -30,7 +29,7 @@ func main() { verifier, err := hcaptcha.New(secret) if err != nil { - log.Fatalf("hcaptcha init failed: %v", err) + log.Fatal(err) } app := application{verifier: verifier, siteKey: siteKey} @@ -63,16 +62,15 @@ func (app application) protected(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid form"}) return } - token := strings.TrimSpace(r.FormValue("h-captcha-response")) + token := strings.TrimSpace(r.PostForm.Get("h-captcha-response")) if token == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "valid token is required"}) return } result, err := app.verifier.VerifyRequest(r.Context(), hcaptcha.Request{ - Token: token, - RemoteIP: clientIP(r), - SiteKey: app.siteKey, + Token: token, + SiteKey: app.siteKey, }) if err != nil { log.Printf("siteverify call failed: %v", err) @@ -89,23 +87,6 @@ func (app application) protected(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"siteverify": result}) } -func clientIP(r *http.Request) string { - peer, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return "" - } - peerIP := net.ParseIP(peer) - if peerIP == nil || !peerIP.IsLoopback() { - return peer - } - - forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]) - if net.ParseIP(forwarded) != nil { - return forwarded - } - return peer -} - func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") diff --git a/example/run.sh b/example/run.sh index c08f023..87d0228 100755 --- a/example/run.sh +++ b/example/run.sh @@ -11,11 +11,18 @@ set -a . ./.env.local set +a +backend_dir=$(mktemp -d) +backend_pid= cleanup() { - kill "$backend_pid" 2>/dev/null || true + if [ -n "$backend_pid" ]; then + kill "$backend_pid" 2>/dev/null || true + wait "$backend_pid" 2>/dev/null || true + fi + rm -rf "$backend_dir" } -trap cleanup EXIT INT TERM +trap cleanup EXIT -go run ./backend & +go build -o "$backend_dir/backend" ./backend +"$backend_dir/backend" & backend_pid=$! npm run dev diff --git a/example/src/App.tsx b/example/src/App.tsx index 0ce202d..14fb8b1 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -38,8 +38,7 @@ export function App() {

hCaptcha Go integration

Complete the challenge, then send the token through the Vite proxy to - the Go backend. The response below is the package's unmodified - Siteverify result. + the Go backend. The response below includes the full Siteverify result.

{!siteKey &&

VITE_HCAPTCHA_SITEKEY is missing.

} {widgetError &&

{widgetError}

} @@ -48,7 +47,10 @@ export function App() { { + setWidgetError(""); + setToken(value); + }} onExpire={() => setToken("")} onError={(error) => { setToken(""); diff --git a/example/vite.config.ts b/example/vite.config.ts index f438fe5..ea77507 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -8,7 +8,6 @@ export default defineConfig(({ mode }) => { "/api": { target: env.GO_BACKEND_URL || "http://127.0.0.1:8080", changeOrigin: false, - xfwd: true, rewrite: (path: string) => path.replace(/^\/api/, ""), }, }; diff --git a/hcaptcha/hcaptcha.go b/hcaptcha/hcaptcha.go index 7407a2b..6aea966 100644 --- a/hcaptcha/hcaptcha.go +++ b/hcaptcha/hcaptcha.go @@ -18,7 +18,7 @@ const ( defaultTimeout = time.Second initialRetryDelay = 100 * time.Millisecond maxRetryDelay = time.Second - maxRetryBodyBytes = 2 << 10 + maxErrorBodyBytes = 2 << 10 ) // Result contains the Siteverify response. The basic response includes success @@ -145,15 +145,20 @@ func (client *Client) call(ctx context.Context, endpoint string, form url.Values return nil, ctx.Err() == nil, fmt.Errorf("hcaptcha: call siteverify: %w", err) } defer func() { _ = response.Body.Close() }() - if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { - _, _ = io.CopyN(io.Discard, response.Body, maxRetryBodyBytes+1) + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.CopyN(io.Discard, response.Body, maxErrorBodyBytes+1) status := response.StatusCode - return nil, true, fmt.Errorf("hcaptcha: siteverify returned HTTP %d", status) + retryable := status == http.StatusTooManyRequests || status >= 500 + return nil, retryable, fmt.Errorf("hcaptcha: siteverify returned HTTP %d", status) } result := Result{} - if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&result); err != nil { return nil, false, fmt.Errorf("hcaptcha: decode siteverify response: %w", err) } + if err := decoder.Decode(new(json.RawMessage)); err != io.EOF { + return nil, false, errors.New("hcaptcha: siteverify response has trailing data") + } return result, false, nil } diff --git a/hcaptcha/hcaptcha_test.go b/hcaptcha/hcaptcha_test.go index eca09a2..4d15434 100644 --- a/hcaptcha/hcaptcha_test.go +++ b/hcaptcha/hcaptcha_test.go @@ -49,9 +49,11 @@ func TestVerifyRequestRetries(t *testing.T) { {"server error", []int{503, 502, 200}, `{"success":true}`, 2, 3, false, true}, {"exhausted", []int{503}, `{"success":true}`, 1, 2, true, false}, {"no retries by default", []int{503}, `{"success":true}`, 0, 1, true, false}, - {"other HTTP status", []int{400}, `{"success":false}`, 2, 1, false, false}, + {"client error", []int{400}, `{"success":true}`, 2, 1, true, false}, + {"redirect", []int{307}, `{"success":true}`, 2, 1, true, false}, {"verification rejected", []int{200}, `{"success":false}`, 2, 1, false, false}, {"malformed response", []int{200}, `{`, 2, 1, true, false}, + {"trailing response data", []int{200}, `{"success":true}{"success":false}`, 2, 1, true, false}, } { t.Run(test.name, func(t *testing.T) { calls := 0 @@ -100,7 +102,7 @@ func TestVerifyRequestReusesConnectionAfterServerError(t *testing.T) { } func TestVerifyRequestBoundsErrorBodyDrain(t *testing.T) { - body := strings.NewReader(strings.Repeat("x", maxRetryBodyBytes+10)) + body := strings.NewReader(strings.Repeat("x", maxErrorBodyBytes+10)) httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(body)}, nil })} @@ -197,7 +199,6 @@ func TestVerifyRejectsEmptyTokenBeforeRequest(t *testing.T) { func TestVerifyAcceptsEvolvingResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) _, _ = io.WriteString(w, `{"success":"now-a-string","new":{"nested":[1,true,null]},"number":1.5}`) })) defer server.Close() @@ -229,11 +230,8 @@ func TestVerifyDoesNotFollowRedirects(t *testing.T) { defer origin.Close() result, err := newClient("secret", origin.URL, origin.Client()).Verify("token") - if err != nil { - t.Fatal(err) - } - if result["success"] != true || result["redirect"] != true { - t.Errorf("result = %#v", result) + if err == nil || result != nil { + t.Errorf("result = %#v, error = %v, want redirect error", result, err) } if got := targetCalls.Load(); got != 0 { t.Errorf("redirect target calls = %d, want 0", got) From cccf556cfb59c5df78f7a03e07ce9429248112c2 Mon Sep 17 00:00:00 2001 From: Boris Zhilko Date: Fri, 25 Sep 2026 15:39:19 +0500 Subject: [PATCH 6/6] wip --- README.md | 32 +++++++++++--------------------- example/vite.config.ts | 1 + 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9ee1e9e..697859a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Requires Go 1.24 or later. Use a supported, patched Go toolchain in production. ## Use -Set `HCAPTCHA_SECRET` and `HCAPTCHA_SITEKEY` in server configuration. Create the verifier at startup and reuse it across handlers. Exit before serving requests if either value is missing. +This minimal local server uses `HCAPTCHA_SECRET` and `HCAPTCHA_SITEKEY`. Create the verifier at startup and reuse it across handlers. Exit before serving requests if either value is missing. ```go package main @@ -38,7 +38,7 @@ func main() { http.HandleFunc("POST /protected", func(w http.ResponseWriter, r *http.Request) { protected(verifier, siteKey, w, r) }) - server := &http.Server{Addr: ":8080", ReadHeaderTimeout: 5 * time.Second} + server := &http.Server{Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second} log.Fatal(server.ListenAndServe()) } @@ -73,23 +73,13 @@ Use `Verify(token)` for the minimal case. `VerifyContext(ctx, token)` and `Verif `New` uses a one-second HTTP timeout per attempt; use `NewWithHTTPClient` to configure it. `Request.MaxRetries` sets extra attempts after transport errors or HTTP 429/5xx. The default `0` makes one attempt; `2` allows three. Retry delays start at 100 ms, double up to 1 s, and stop on context cancellation. -[Production tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side). If Siteverify verifies a token but its response does not reach your server, a retry may return `already-seen-response`. Treat that as failure and obtain a fresh token. +[Production tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side). The first POST may succeed at hCaptcha even if your server times out before receiving the reply. Retrying the same token can then return `already-seen-response`; treat it as failure and obtain a fresh token. -`Result` is `map[string]any` and preserves the Siteverify JSON fields. `VerifyRequest` returns an error for non-2xx HTTP status, transport failure, or invalid JSON. A valid response with `success: false` has no Go error. Deny the protected action in either case. Inspect `error-codes` for operational logging; do not expose raw verification details to users. +`Result` decodes the Siteverify JSON object into `map[string]any`. `VerifyRequest` returns an error for non-2xx HTTP status, transport failure, or invalid JSON. A well-formed 2xx response with `success: false` has no Go error. Deny the protected action unless `result["success"] == true`. Inspect `error-codes` for operational logging; do not expose raw verification details to users. ## Browser integration -This package receives the token generated by a browser or mobile integration. Add the hCaptcha widget, submit its `h-captcha-response` field to your backend, then call this package from that backend. - -```html - -
-
- -
-``` - -Use the same sitekey in the widget and `HCAPTCHA_SITEKEY`. +This package receives the token generated by a browser or mobile integration. Submit the widget's `h-captcha-response` field to your backend, then call this package from that backend. For the standalone server above, post to `/protected` and use the value of `HCAPTCHA_SITEKEY` as the widget sitekey. The runnable React/Vite example below uses `VITE_HCAPTCHA_SITEKEY` and posts to `/api/protected`, which Vite proxies to the backend's `/protected` route. Use the [Developer Guide](https://docs.hcaptcha.com/) for the complete browser-to-server flow and the [JavaScript configuration reference](https://docs.hcaptcha.com/configuration) for explicit rendering, callbacks, themes, and client-side errors. @@ -105,6 +95,12 @@ See hCaptcha’s [server-side verification guidance](https://docs.hcaptcha.com/# ## Example and testing +From the repository root, run package tests: + +```sh +go test ./... +``` + Run the included React/Vite browser and Go backend example: ```sh @@ -115,9 +111,3 @@ npm ci ``` The sample env uses [hCaptcha's public test keys](https://docs.hcaptcha.com/#integration-testing-test-keys), which skip visual challenges. For real keys, follow [the example's development hostname setup](example/README.md#run); hCaptcha does not support `localhost` or `127.0.0.1` as hostnames. - -Run package tests with: - -```sh -go test ./... -``` diff --git a/example/vite.config.ts b/example/vite.config.ts index ea77507..f438fe5 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig(({ mode }) => { "/api": { target: env.GO_BACKEND_URL || "http://127.0.0.1:8080", changeOrigin: false, + xfwd: true, rewrite: (path: string) => path.replace(/^\/api/, ""), }, };