diff --git a/README.md b/README.md index 8db94e3..697859a 100644 --- a/README.md +++ b/README.md @@ -8,35 +8,53 @@ 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 -Create a client once with an hCaptcha service secret, then reuse it across handlers. Keep the secret only in server-side configuration. +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 import ( + "log" "net/http" + "os" + "time" hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha" ) -var verifier, err = hcaptcha.New("server-side-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.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, siteKey, w, r) + }) + server := &http.Server{Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second} + log.Fatal(server.ListenAndServe()) +} + +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 } + 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: siteKey, }) if err != nil { http.Error(w, "verification unavailable", http.StatusBadGateway) @@ -53,51 +71,43 @@ func protected(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. -`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. +`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. -## Browser integration +[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. -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. +`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. -```html - -
-``` +## Browser integration + +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. ## 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. +- 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). - ## Example and testing -Run the included React/Vite browser and Go backend example: +From the repository root, run package tests: ```sh -cd example -cp .env.local.example .env.local -npm install -./run.sh +go test ./... ``` -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. - -Run package tests with: +Run the included React/Vite browser and Go backend example: ```sh -go test ./... +cd example +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. 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. 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 df64559..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" @@ -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/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..6aea966 100644 --- a/hcaptcha/hcaptcha.go +++ b/hcaptcha/hcaptcha.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "time" @@ -15,6 +16,9 @@ import ( const ( siteverifyEndpoint = "https://api.hcaptcha.com/siteverify" defaultTimeout = time.Second + initialRetryDelay = 100 * time.Millisecond + maxRetryDelay = time.Second + maxErrorBodyBytes = 2 << 10 ) // Result contains the Siteverify response. The basic response includes success @@ -35,6 +39,11 @@ type Request struct { // Recommended. RemoteIP string SiteKey string + + // 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 } // Client verifies tokens using the secret passed to New. A Client is safe to @@ -91,10 +100,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 +132,33 @@ 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 < 200 || response.StatusCode >= 300 { + _, _ = io.CopyN(io.Discard, response.Body, maxErrorBodyBytes+1) + status := response.StatusCode + 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 { - return nil, fmt.Errorf("hcaptcha: decode siteverify response: %w", err) + 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, nil + return result, false, nil } diff --git a/hcaptcha/hcaptcha_test.go b/hcaptcha/hcaptcha_test.go index a63cfb6..4d15434 100644 --- a/hcaptcha/hcaptcha_test.go +++ b/hcaptcha/hcaptcha_test.go @@ -6,8 +6,10 @@ import ( "io" "net/http" "net/http/httptest" + "net/http/httptrace" "net/url" "reflect" + "strings" "sync" "sync/atomic" "testing" @@ -33,6 +35,122 @@ 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}, + {"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 + 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 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", maxErrorBodyBytes+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) { + 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 { @@ -81,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() @@ -113,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)