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() {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() {