From eeae057bb2518044f665fffdd13008ae89c57461 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 22:16:15 -0500 Subject: [PATCH] feat(go): share token verification state through Redis --- INSTALLATION.md | 18 +++++++------ README.md | 12 ++++----- charts/fcaptcha/README.md | 11 ++++---- charts/fcaptcha/values.yaml | 12 ++++----- server-go/main.go | 6 ++++- server-go/redis_state_test.go | 42 ++++++++++++++++++++++++++++++ server-go/scoring.go | 49 +++++++++++++++++++++++++++-------- server-go/siteverify.go | 45 +++++++++++++++++++++++++++----- 8 files changed, 152 insertions(+), 43 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 205ef33..7cdf47b 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -271,9 +271,11 @@ services: ``` FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now -shares PoW challenges and their atomic single-use claim across instances. Other -state remains local, so continue to run one instance until the remaining stores -and the Node/Python implementations gain shared-state support. +shares PoW challenges, token replay protection, and Siteverify idempotency +results across instances. Challenge and token claims are atomic. Rate limits, +suspicion, and fingerprints remain local, so continue to run one instance until +the remaining stores and the Node/Python implementations gain shared-state +support. Run: @@ -422,10 +424,10 @@ server { } ``` -**Important:** Do not run multiple instances yet. The Go server can share PoW -challenge state through Redis, but token replay protection, rate limiting, -suspicion, fingerprints, and idempotency are still process-local. Node and -Python remain entirely process-local. +**Important:** Do not run multiple instances yet. The Go server shares PoW, +token replay, and Siteverify idempotency state through Redis, but rate limiting, +suspicion, and fingerprints are still process-local. Node and Python remain +entirely process-local. --- @@ -437,7 +439,7 @@ Python remain entirely process-local. |----------|----------|---------|-------------| | `FCAPTCHA_SECRET` | Yes | - | Secret key for signing tokens (min 16 chars) | | `FCAPTCHA_INSECURE_DEV_MODE` | No | off | Explicitly use the public development signing key for local-only development. Never expose a server with this enabled | -| `REDIS_URL` | No | - | Go only: Redis URL used for shared PoW challenge and atomic replay state. Configuration is fail-closed; it does not yet make every store distributed | +| `REDIS_URL` | No | - | Go only: Redis URL for shared PoW, token replay, and Siteverify idempotency state. Configuration is fail-closed; it does not yet make every store distributed | | `FCAPTCHA_VERIFY_SECRET` | No | `FCAPTCHA_SECRET` | Credential your backend sends as `secret` when verifying a token. Split it from the signing key so a leaked verify credential cannot also mint tokens | | `FCAPTCHA_LEGACY_UNAUTH_VERIFY` | No | off | Restore the pre-1.22.0 behaviour where token verification accepted any caller. Migration cover for one release — see [Upgrading to 1.22.0](#upgrading-to-1220) | | `FCAPTCHA_ALLOWED_HOSTNAMES` | No | (any) | Comma-separated hostnames permitted to mint tokens, matched against the request `Origin` (then `Referer`) | diff --git a/README.md b/README.md index 5978126..1683f11 100644 --- a/README.md +++ b/README.md @@ -57,15 +57,15 @@ FCAPTCHA_SECRET=my-secret docker compose -f docker/docker-compose.yml up -d ``` FCaptcha state is currently process-local by default. The Go server can use -`REDIS_URL` to share PoW challenges and atomically consume them across replicas; +`REDIS_URL` to share PoW challenges, token replay protection, and Siteverify +idempotency results across replicas; challenge and token consumption are atomic, it refuses to start if the configured Redis service is unavailable and returns 503 rather than issuing an unpersisted challenge if Redis fails later. -This is the first shared-state slice, not yet permission to scale the entire -service horizontally: token replay protection, rate limits, suspicion history, -fingerprint history, and idempotency results remain per-process, and the Node -and Python servers do not yet use Redis. Run one replica until those stores are -also shared. +This is not yet permission to scale the entire service horizontally: rate +limits, suspicion history, and fingerprint history remain per-process, and the +Node and Python servers do not yet use Redis. Run one replica until those stores +are also shared. Kubernetes: diff --git a/charts/fcaptcha/README.md b/charts/fcaptcha/README.md index e28aa53..f89dc4f 100644 --- a/charts/fcaptcha/README.md +++ b/charts/fcaptcha/README.md @@ -38,10 +38,11 @@ to the controller and rate limiting collapses onto one address — silently. The default covers the usual in-cluster pod CIDRs; narrow it to your controller's range. -**Run one replica for now.** With the Go image, `redis.url` shares PoW challenges -and their atomic one-time claim. Token replay protection, rate limits, -suspicion, fingerprints, and idempotency are still per-pod; Node and Python do -not yet use Redis. `autoscaling` remains off until those remaining stores land. +**Run one replica for now.** With the Go image, `redis.url` shares PoW challenges, +token replay protection, and Siteverify idempotency results; challenge and token +claims are atomic. Rate limits, suspicion, and fingerprints are still per-pod; +Node and Python do not yet use Redis. `autoscaling` remains off until those +remaining stores land. The full list of deployment settings with security consequences is in [SECURITY.md](https://github.com/WebDecoy/FCaptcha/blob/main/SECURITY.md#deployment-notes-that-are-security-relevant). @@ -61,7 +62,7 @@ The full list of deployment settings with security consequences is in | `config.logVerdictsIncludeRaw` | Also log free-text detection reasons. **Can contain visitor-derived data** | `false` | | `image.repository` / `image.tag` | Container image; tag defaults to the chart's `appVersion` | `ghcr.io/webdecoy/fcaptcha` | | `replicaCount` | See the note above before raising this | `1` | -| `redis.url` / `redis.existingSecret` | Go only: shared PoW challenge and atomic claim state; remaining stores are still local | `""` | +| `redis.url` / `redis.existingSecret` | Go only: shared PoW, token replay, and Siteverify idempotency state; remaining stores are still local | `""` | | `service.type` / `service.port` | | `ClusterIP` / `80` | | `ingress.enabled` | | `false` | | `resources` | | 100m CPU / 128Mi requested | diff --git a/charts/fcaptcha/values.yaml b/charts/fcaptcha/values.yaml index 0aca798..302a116 100644 --- a/charts/fcaptcha/values.yaml +++ b/charts/fcaptcha/values.yaml @@ -68,9 +68,9 @@ extraEnv: [] ## captcha chart, and the honest answer for anything you care about keeping is a ## managed instance or a purpose-built operator. Point this at one. ## -## The Go server uses this for shared PoW challenge and atomic claim state. -## Other stores remain in memory, and Node/Python do not use it yet, so -## replicaCount still defaults to 1. +## The Go server uses this for shared PoW, token replay, and Siteverify +## idempotency state. Other stores remain in memory, and Node/Python do not use +## it yet, so replicaCount still defaults to 1. redis: url: "" existingSecret: "" @@ -147,9 +147,9 @@ tolerations: [] affinity: {} topologySpreadConstraints: [] -## Off by default because token replay protection, rate limiting, suspicion, -## fingerprints, and idempotency remain per-pod. Go can share PoW challenges, -## but that alone does not make horizontal scaling safe. +## Off by default because rate limiting, suspicion, and fingerprints remain +## per-pod. Go shares PoW, token replay, and idempotency state, but that alone +## does not make horizontal scaling safe. autoscaling: enabled: false minReplicas: 2 diff --git a/server-go/main.go b/server-go/main.go index 664dcd6..7c5c21f 100644 --- a/server-go/main.go +++ b/server-go/main.go @@ -325,7 +325,11 @@ func main() { // integration at this server should be a base-URL change and nothing else. // See siteverify.go for the adapter itself. log.Printf("allowed hostnames: %s", engine.allowedHostnames.Describe()) - compat := siteverifyHandler(engine, proxyTrust, NewIdempotencyStore(), verifySecret, requireVerifySecret) + idempotencyStore := NewIdempotencyStore() + if engine.redisClient != nil { + idempotencyStore = NewRedisIdempotencyStore(engine.redisClient) + } + compat := siteverifyHandler(engine, proxyTrust, idempotencyStore, verifySecret, requireVerifySecret) r.Post("/turnstile/v0/siteverify", compat) r.Post("/recaptcha/api/siteverify", compat) r.Post("/siteverify", compat) diff --git a/server-go/redis_state_test.go b/server-go/redis_state_test.go index ce0cf11..bb5a001 100644 --- a/server-go/redis_state_test.go +++ b/server-go/redis_state_test.go @@ -37,3 +37,45 @@ func TestRedisPoWFailureIsClosed(t *testing.T) { t.Fatal("challenge generation must fail closed when shared state is unavailable") } } + +func TestRedisTokenReplayProtectionCrossesInstances(t *testing.T) { + redisServer := miniredis.RunT(t) + redisURL := "redis://" + redisServer.Addr() + issuer := NewScoringEngineWithRedis("shared-test-secret", redisURL) + verifier := NewScoringEngineWithRedis("shared-test-secret", redisURL) + token := issuer.generateToken("203.0.113.5", "site-1", 0.1, TokenBinding{}) + + if valid, _ := verifier.VerifyToken(token)["valid"].(bool); !valid { + t.Fatal("another instance could not validate the token") + } + replayed := issuer.VerifyToken(token) + if valid, _ := replayed["valid"].(bool); valid || replayed["reason"] != "token_already_used" { + t.Fatalf("spent token was reusable: %#v", replayed) + } +} + +func TestRedisTokenClaimFailsClosed(t *testing.T) { + redisServer := miniredis.RunT(t) + engine := NewScoringEngineWithRedis("shared-test-secret", "redis://"+redisServer.Addr()) + token := engine.generateToken("203.0.113.5", "site-1", 0.1, TokenBinding{}) + redisServer.Close() + + result := engine.VerifyToken(token) + if valid, _ := result["valid"].(bool); valid || result["reason"] != "state_unavailable" { + t.Fatalf("verification did not fail closed: %#v", result) + } +} + +func TestRedisIdempotencyResultsCrossInstances(t *testing.T) { + redisServer := miniredis.RunT(t) + client := NewScoringEngineWithRedis("shared-test-secret", "redis://"+redisServer.Addr()).redisClient + first := NewRedisIdempotencyStore(client) + second := NewRedisIdempotencyStore(client) + want := siteverifySuccess("2026-08-23T00:00:00.000Z", "example.com", "login", "request-1", 0.1) + + first.Set("retry-key", "token-value", want) + got, ok := second.Get("retry-key", "token-value") + if !ok || got["hostname"] != want["hostname"] || got["action"] != want["action"] { + t.Fatalf("another instance did not read the idempotency result: %#v", got) + } +} diff --git a/server-go/scoring.go b/server-go/scoring.go index 07f44dd..463b35e 100644 --- a/server-go/scoring.go +++ b/server-go/scoring.go @@ -294,6 +294,7 @@ type ScoringEngine struct { // Which page origins may mint tokens. Unrestricted unless the operator sets // FCAPTCHA_ALLOWED_HOSTNAMES; see siteverify.go. allowedHostnames *HostnameAllowlist + redisClient *redis.Client } // webBotAuthTimeout bounds the whole Web Bot Auth verification, including the @@ -328,6 +329,7 @@ type FingerprintData struct { type TokenStore struct { mu sync.Mutex cache *expirable.LRU[string, struct{}] + redis *redis.Client } const ( @@ -341,18 +343,36 @@ func newTokenStore() *TokenStore { } } +func newRedisTokenStore(client *redis.Client) *TokenStore { + return &TokenStore{redis: client} +} + func (t *TokenStore) IsUsed(sig string) bool { + if t.redis != nil { + exists, err := t.redis.Exists(context.Background(), redisStatePrefix+"token:spent:"+sig).Result() + return err == nil && exists > 0 + } return t.cache.Contains(sig) } func (t *TokenStore) MarkUsed(sig string) bool { + claimed, err := t.Claim(sig) + return err == nil && claimed +} + +// Claim atomically marks a token signature as spent. Redis SET NX makes this +// safe when two backend replicas validate the same token concurrently. +func (t *TokenStore) Claim(sig string) (bool, error) { + if t.redis != nil { + return t.redis.SetNX(context.Background(), redisStatePrefix+"token:spent:"+sig, "1", usedTokensTTL).Result() + } t.mu.Lock() defer t.mu.Unlock() if t.cache.Contains(sig) { - return false + return false, nil } t.cache.Add(sig, struct{}{}) - return true + return true, nil } // NewScoringEngine creates a new engine @@ -403,6 +423,8 @@ func NewScoringEngineWithRedis(secretKey, redisURL string) *ScoringEngine { } engine := NewScoringEngine(secretKey) engine.powStore = newRedisPoWChallengeStore(client) + engine.tokenStore = newRedisTokenStore(client) + engine.redisClient = client return engine } @@ -922,13 +944,6 @@ func (e *ScoringEngine) VerifyTokenWithIP(token, ip string) map[string]interface return result } - // Check for token replay (single-use tokens) - if e.tokenStore.IsUsed(sig) { - result["valid"] = false - result["reason"] = "token_already_used" - return result - } - // Verify IP matches (if provided) if ip != "" { ipHash, _ := data["ip_hash"].(string) @@ -941,8 +956,20 @@ func (e *ScoringEngine) VerifyTokenWithIP(token, ip string) map[string]interface } } - // Mark token as used (prevents replay) - e.tokenStore.MarkUsed(sig) + // Atomically claim the token only after all non-mutating validation passes. + // This is authoritative; a separate IsUsed/MarkUsed pair races across both + // goroutines and replicas. + claimed, err := e.tokenStore.Claim(sig) + if err != nil { + result["valid"] = false + result["reason"] = "state_unavailable" + return result + } + if !claimed { + result["valid"] = false + result["reason"] = "token_already_used" + return result + } result["valid"] = true result["site_key"] = data["site_key"] diff --git a/server-go/siteverify.go b/server-go/siteverify.go index 3ad0bbf..0057fdc 100644 --- a/server-go/siteverify.go +++ b/server-go/siteverify.go @@ -35,6 +35,7 @@ package main // Mirrors server-node/siteverify.js and server-python/siteverify.py. import ( + "context" "crypto/sha256" "crypto/subtle" "encoding/hex" @@ -47,6 +48,7 @@ import ( "time" "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/redis/go-redis/v9" ) const ( @@ -74,13 +76,13 @@ const ( // The error-code vocabulary. These strings are the contract — integrators // branch on them — so they match Cloudflare's spelling exactly, hyphens and all. const ( - errMissingSecret = "missing-input-secret" - errInvalidSecret = "invalid-input-secret" - errMissingResponse = "missing-input-response" - errInvalidResponse = "invalid-input-response" - errBadRequest = "bad-request" + errMissingSecret = "missing-input-secret" + errInvalidSecret = "invalid-input-secret" + errMissingResponse = "missing-input-response" + errInvalidResponse = "invalid-input-response" + errBadRequest = "bad-request" errTimeoutOrDuplicate = "timeout-or-duplicate" - errInternalError = "internal-error" + errInternalError = "internal-error" ) // reasonToErrorCode maps VerifyTokenWithIP's internal reason onto that @@ -271,6 +273,14 @@ func (a *HostnameAllowlist) Describe() string { // degrades that case to an ordinary fresh verification. type IdempotencyStore struct { entries *expirable.LRU[string, SiteverifyResponse] + redis *redis.Client +} + +// NewRedisIdempotencyStore shares retry responses between replicas. Keys are +// hashed before leaving the process so caller-provided idempotency values and +// tokens never appear in Redis key listings. +func NewRedisIdempotencyStore(client *redis.Client) *IdempotencyStore { + return &IdempotencyStore{redis: client} } // NewIdempotencyStore builds a bounded, self-expiring result cache. @@ -285,11 +295,27 @@ func (s *IdempotencyStore) key(idempotencyKey, token string) string { return idempotencyKey + ":" + hex.EncodeToString(sum[:])[:32] } +func (s *IdempotencyStore) redisKey(idempotencyKey, token string) string { + sum := sha256.Sum256([]byte(s.key(idempotencyKey, token))) + return redisStatePrefix + "siteverify:idempotency:" + hex.EncodeToString(sum[:]) +} + // Get returns a cached response, if one is still live. func (s *IdempotencyStore) Get(idempotencyKey, token string) (SiteverifyResponse, bool) { if idempotencyKey == "" { return SiteverifyResponse{}, false } + if s.redis != nil { + payload, err := s.redis.Get(context.Background(), s.redisKey(idempotencyKey, token)).Bytes() + if err != nil { + return SiteverifyResponse{}, false + } + var response SiteverifyResponse + if json.Unmarshal(payload, &response) != nil { + return SiteverifyResponse{}, false + } + return response, true + } return s.entries.Get(s.key(idempotencyKey, token)) } @@ -298,6 +324,13 @@ func (s *IdempotencyStore) Set(idempotencyKey, token string, resp SiteverifyResp if idempotencyKey == "" { return } + if s.redis != nil { + payload, err := json.Marshal(resp) + if err == nil { + _ = s.redis.Set(context.Background(), s.redisKey(idempotencyKey, token), payload, idempotencyTTL).Err() + } + return + } s.entries.Add(s.key(idempotencyKey, token), resp) }