Skip to content

Commit eeae057

Browse files
Chris Portschellercport1
authored andcommitted
feat(go): share token verification state through Redis
1 parent 0430520 commit eeae057

8 files changed

Lines changed: 152 additions & 43 deletions

File tree

‎INSTALLATION.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,11 @@ services:
271271
```
272272
273273
FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now
274-
shares PoW challenges and their atomic single-use claim across instances. Other
275-
state remains local, so continue to run one instance until the remaining stores
276-
and the Node/Python implementations gain shared-state support.
274+
shares PoW challenges, token replay protection, and Siteverify idempotency
275+
results across instances. Challenge and token claims are atomic. Rate limits,
276+
suspicion, and fingerprints remain local, so continue to run one instance until
277+
the remaining stores and the Node/Python implementations gain shared-state
278+
support.
277279

278280
Run:
279281

@@ -422,10 +424,10 @@ server {
422424
}
423425
```
424426

425-
**Important:** Do not run multiple instances yet. The Go server can share PoW
426-
challenge state through Redis, but token replay protection, rate limiting,
427-
suspicion, fingerprints, and idempotency are still process-local. Node and
428-
Python remain entirely process-local.
427+
**Important:** Do not run multiple instances yet. The Go server shares PoW,
428+
token replay, and Siteverify idempotency state through Redis, but rate limiting,
429+
suspicion, and fingerprints are still process-local. Node and Python remain
430+
entirely process-local.
429431

430432
---
431433

@@ -437,7 +439,7 @@ Python remain entirely process-local.
437439
|----------|----------|---------|-------------|
438440
| `FCAPTCHA_SECRET` | Yes | - | Secret key for signing tokens (min 16 chars) |
439441
| `FCAPTCHA_INSECURE_DEV_MODE` | No | off | Explicitly use the public development signing key for local-only development. Never expose a server with this enabled |
440-
| `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 |
442+
| `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 |
441443
| `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 |
442444
| `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) |
443445
| `FCAPTCHA_ALLOWED_HOSTNAMES` | No | (any) | Comma-separated hostnames permitted to mint tokens, matched against the request `Origin` (then `Referer`) |

‎README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ FCAPTCHA_SECRET=my-secret docker compose -f docker/docker-compose.yml up -d
5757
```
5858

5959
FCaptcha state is currently process-local by default. The Go server can use
60-
`REDIS_URL` to share PoW challenges and atomically consume them across replicas;
60+
`REDIS_URL` to share PoW challenges, token replay protection, and Siteverify
61+
idempotency results across replicas; challenge and token consumption are atomic,
6162
it refuses to start if the configured Redis service is unavailable and returns
6263
503 rather than issuing an unpersisted challenge if Redis fails later.
6364

64-
This is the first shared-state slice, not yet permission to scale the entire
65-
service horizontally: token replay protection, rate limits, suspicion history,
66-
fingerprint history, and idempotency results remain per-process, and the Node
67-
and Python servers do not yet use Redis. Run one replica until those stores are
68-
also shared.
65+
This is not yet permission to scale the entire service horizontally: rate
66+
limits, suspicion history, and fingerprint history remain per-process, and the
67+
Node and Python servers do not yet use Redis. Run one replica until those stores
68+
are also shared.
6969

7070
Kubernetes:
7171

‎charts/fcaptcha/README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ to the controller and rate limiting collapses onto one address — silently. The
3838
default covers the usual in-cluster pod CIDRs; narrow it to your controller's
3939
range.
4040

41-
**Run one replica for now.** With the Go image, `redis.url` shares PoW challenges
42-
and their atomic one-time claim. Token replay protection, rate limits,
43-
suspicion, fingerprints, and idempotency are still per-pod; Node and Python do
44-
not yet use Redis. `autoscaling` remains off until those remaining stores land.
41+
**Run one replica for now.** With the Go image, `redis.url` shares PoW challenges,
42+
token replay protection, and Siteverify idempotency results; challenge and token
43+
claims are atomic. Rate limits, suspicion, and fingerprints are still per-pod;
44+
Node and Python do not yet use Redis. `autoscaling` remains off until those
45+
remaining stores land.
4546

4647
The full list of deployment settings with security consequences is in
4748
[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
6162
| `config.logVerdictsIncludeRaw` | Also log free-text detection reasons. **Can contain visitor-derived data** | `false` |
6263
| `image.repository` / `image.tag` | Container image; tag defaults to the chart's `appVersion` | `ghcr.io/webdecoy/fcaptcha` |
6364
| `replicaCount` | See the note above before raising this | `1` |
64-
| `redis.url` / `redis.existingSecret` | Go only: shared PoW challenge and atomic claim state; remaining stores are still local | `""` |
65+
| `redis.url` / `redis.existingSecret` | Go only: shared PoW, token replay, and Siteverify idempotency state; remaining stores are still local | `""` |
6566
| `service.type` / `service.port` | | `ClusterIP` / `80` |
6667
| `ingress.enabled` | | `false` |
6768
| `resources` | | 100m CPU / 128Mi requested |

‎charts/fcaptcha/values.yaml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ extraEnv: []
6868
## captcha chart, and the honest answer for anything you care about keeping is a
6969
## managed instance or a purpose-built operator. Point this at one.
7070
##
71-
## The Go server uses this for shared PoW challenge and atomic claim state.
72-
## Other stores remain in memory, and Node/Python do not use it yet, so
73-
## replicaCount still defaults to 1.
71+
## The Go server uses this for shared PoW, token replay, and Siteverify
72+
## idempotency state. Other stores remain in memory, and Node/Python do not use
73+
## it yet, so replicaCount still defaults to 1.
7474
redis:
7575
url: ""
7676
existingSecret: ""
@@ -147,9 +147,9 @@ tolerations: []
147147
affinity: {}
148148
topologySpreadConstraints: []
149149

150-
## Off by default because token replay protection, rate limiting, suspicion,
151-
## fingerprints, and idempotency remain per-pod. Go can share PoW challenges,
152-
## but that alone does not make horizontal scaling safe.
150+
## Off by default because rate limiting, suspicion, and fingerprints remain
151+
## per-pod. Go shares PoW, token replay, and idempotency state, but that alone
152+
## does not make horizontal scaling safe.
153153
autoscaling:
154154
enabled: false
155155
minReplicas: 2

‎server-go/main.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,11 @@ func main() {
325325
// integration at this server should be a base-URL change and nothing else.
326326
// See siteverify.go for the adapter itself.
327327
log.Printf("allowed hostnames: %s", engine.allowedHostnames.Describe())
328-
compat := siteverifyHandler(engine, proxyTrust, NewIdempotencyStore(), verifySecret, requireVerifySecret)
328+
idempotencyStore := NewIdempotencyStore()
329+
if engine.redisClient != nil {
330+
idempotencyStore = NewRedisIdempotencyStore(engine.redisClient)
331+
}
332+
compat := siteverifyHandler(engine, proxyTrust, idempotencyStore, verifySecret, requireVerifySecret)
329333
r.Post("/turnstile/v0/siteverify", compat)
330334
r.Post("/recaptcha/api/siteverify", compat)
331335
r.Post("/siteverify", compat)

‎server-go/redis_state_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,45 @@ func TestRedisPoWFailureIsClosed(t *testing.T) {
3737
t.Fatal("challenge generation must fail closed when shared state is unavailable")
3838
}
3939
}
40+
41+
func TestRedisTokenReplayProtectionCrossesInstances(t *testing.T) {
42+
redisServer := miniredis.RunT(t)
43+
redisURL := "redis://" + redisServer.Addr()
44+
issuer := NewScoringEngineWithRedis("shared-test-secret", redisURL)
45+
verifier := NewScoringEngineWithRedis("shared-test-secret", redisURL)
46+
token := issuer.generateToken("203.0.113.5", "site-1", 0.1, TokenBinding{})
47+
48+
if valid, _ := verifier.VerifyToken(token)["valid"].(bool); !valid {
49+
t.Fatal("another instance could not validate the token")
50+
}
51+
replayed := issuer.VerifyToken(token)
52+
if valid, _ := replayed["valid"].(bool); valid || replayed["reason"] != "token_already_used" {
53+
t.Fatalf("spent token was reusable: %#v", replayed)
54+
}
55+
}
56+
57+
func TestRedisTokenClaimFailsClosed(t *testing.T) {
58+
redisServer := miniredis.RunT(t)
59+
engine := NewScoringEngineWithRedis("shared-test-secret", "redis://"+redisServer.Addr())
60+
token := engine.generateToken("203.0.113.5", "site-1", 0.1, TokenBinding{})
61+
redisServer.Close()
62+
63+
result := engine.VerifyToken(token)
64+
if valid, _ := result["valid"].(bool); valid || result["reason"] != "state_unavailable" {
65+
t.Fatalf("verification did not fail closed: %#v", result)
66+
}
67+
}
68+
69+
func TestRedisIdempotencyResultsCrossInstances(t *testing.T) {
70+
redisServer := miniredis.RunT(t)
71+
client := NewScoringEngineWithRedis("shared-test-secret", "redis://"+redisServer.Addr()).redisClient
72+
first := NewRedisIdempotencyStore(client)
73+
second := NewRedisIdempotencyStore(client)
74+
want := siteverifySuccess("2026-08-23T00:00:00.000Z", "example.com", "login", "request-1", 0.1)
75+
76+
first.Set("retry-key", "token-value", want)
77+
got, ok := second.Get("retry-key", "token-value")
78+
if !ok || got["hostname"] != want["hostname"] || got["action"] != want["action"] {
79+
t.Fatalf("another instance did not read the idempotency result: %#v", got)
80+
}
81+
}

‎server-go/scoring.go‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ type ScoringEngine struct {
294294
// Which page origins may mint tokens. Unrestricted unless the operator sets
295295
// FCAPTCHA_ALLOWED_HOSTNAMES; see siteverify.go.
296296
allowedHostnames *HostnameAllowlist
297+
redisClient *redis.Client
297298
}
298299

299300
// webBotAuthTimeout bounds the whole Web Bot Auth verification, including the
@@ -328,6 +329,7 @@ type FingerprintData struct {
328329
type TokenStore struct {
329330
mu sync.Mutex
330331
cache *expirable.LRU[string, struct{}]
332+
redis *redis.Client
331333
}
332334

333335
const (
@@ -341,18 +343,36 @@ func newTokenStore() *TokenStore {
341343
}
342344
}
343345

346+
func newRedisTokenStore(client *redis.Client) *TokenStore {
347+
return &TokenStore{redis: client}
348+
}
349+
344350
func (t *TokenStore) IsUsed(sig string) bool {
351+
if t.redis != nil {
352+
exists, err := t.redis.Exists(context.Background(), redisStatePrefix+"token:spent:"+sig).Result()
353+
return err == nil && exists > 0
354+
}
345355
return t.cache.Contains(sig)
346356
}
347357

348358
func (t *TokenStore) MarkUsed(sig string) bool {
359+
claimed, err := t.Claim(sig)
360+
return err == nil && claimed
361+
}
362+
363+
// Claim atomically marks a token signature as spent. Redis SET NX makes this
364+
// safe when two backend replicas validate the same token concurrently.
365+
func (t *TokenStore) Claim(sig string) (bool, error) {
366+
if t.redis != nil {
367+
return t.redis.SetNX(context.Background(), redisStatePrefix+"token:spent:"+sig, "1", usedTokensTTL).Result()
368+
}
349369
t.mu.Lock()
350370
defer t.mu.Unlock()
351371
if t.cache.Contains(sig) {
352-
return false
372+
return false, nil
353373
}
354374
t.cache.Add(sig, struct{}{})
355-
return true
375+
return true, nil
356376
}
357377

358378
// NewScoringEngine creates a new engine
@@ -403,6 +423,8 @@ func NewScoringEngineWithRedis(secretKey, redisURL string) *ScoringEngine {
403423
}
404424
engine := NewScoringEngine(secretKey)
405425
engine.powStore = newRedisPoWChallengeStore(client)
426+
engine.tokenStore = newRedisTokenStore(client)
427+
engine.redisClient = client
406428
return engine
407429
}
408430

@@ -922,13 +944,6 @@ func (e *ScoringEngine) VerifyTokenWithIP(token, ip string) map[string]interface
922944
return result
923945
}
924946

925-
// Check for token replay (single-use tokens)
926-
if e.tokenStore.IsUsed(sig) {
927-
result["valid"] = false
928-
result["reason"] = "token_already_used"
929-
return result
930-
}
931-
932947
// Verify IP matches (if provided)
933948
if ip != "" {
934949
ipHash, _ := data["ip_hash"].(string)
@@ -941,8 +956,20 @@ func (e *ScoringEngine) VerifyTokenWithIP(token, ip string) map[string]interface
941956
}
942957
}
943958

944-
// Mark token as used (prevents replay)
945-
e.tokenStore.MarkUsed(sig)
959+
// Atomically claim the token only after all non-mutating validation passes.
960+
// This is authoritative; a separate IsUsed/MarkUsed pair races across both
961+
// goroutines and replicas.
962+
claimed, err := e.tokenStore.Claim(sig)
963+
if err != nil {
964+
result["valid"] = false
965+
result["reason"] = "state_unavailable"
966+
return result
967+
}
968+
if !claimed {
969+
result["valid"] = false
970+
result["reason"] = "token_already_used"
971+
return result
972+
}
946973

947974
result["valid"] = true
948975
result["site_key"] = data["site_key"]

‎server-go/siteverify.go‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ package main
3535
// Mirrors server-node/siteverify.js and server-python/siteverify.py.
3636

3737
import (
38+
"context"
3839
"crypto/sha256"
3940
"crypto/subtle"
4041
"encoding/hex"
@@ -47,6 +48,7 @@ import (
4748
"time"
4849

4950
"github.com/hashicorp/golang-lru/v2/expirable"
51+
"github.com/redis/go-redis/v9"
5052
)
5153

5254
const (
@@ -74,13 +76,13 @@ const (
7476
// The error-code vocabulary. These strings are the contract — integrators
7577
// branch on them — so they match Cloudflare's spelling exactly, hyphens and all.
7678
const (
77-
errMissingSecret = "missing-input-secret"
78-
errInvalidSecret = "invalid-input-secret"
79-
errMissingResponse = "missing-input-response"
80-
errInvalidResponse = "invalid-input-response"
81-
errBadRequest = "bad-request"
79+
errMissingSecret = "missing-input-secret"
80+
errInvalidSecret = "invalid-input-secret"
81+
errMissingResponse = "missing-input-response"
82+
errInvalidResponse = "invalid-input-response"
83+
errBadRequest = "bad-request"
8284
errTimeoutOrDuplicate = "timeout-or-duplicate"
83-
errInternalError = "internal-error"
85+
errInternalError = "internal-error"
8486
)
8587

8688
// reasonToErrorCode maps VerifyTokenWithIP's internal reason onto that
@@ -271,6 +273,14 @@ func (a *HostnameAllowlist) Describe() string {
271273
// degrades that case to an ordinary fresh verification.
272274
type IdempotencyStore struct {
273275
entries *expirable.LRU[string, SiteverifyResponse]
276+
redis *redis.Client
277+
}
278+
279+
// NewRedisIdempotencyStore shares retry responses between replicas. Keys are
280+
// hashed before leaving the process so caller-provided idempotency values and
281+
// tokens never appear in Redis key listings.
282+
func NewRedisIdempotencyStore(client *redis.Client) *IdempotencyStore {
283+
return &IdempotencyStore{redis: client}
274284
}
275285

276286
// NewIdempotencyStore builds a bounded, self-expiring result cache.
@@ -285,11 +295,27 @@ func (s *IdempotencyStore) key(idempotencyKey, token string) string {
285295
return idempotencyKey + ":" + hex.EncodeToString(sum[:])[:32]
286296
}
287297

298+
func (s *IdempotencyStore) redisKey(idempotencyKey, token string) string {
299+
sum := sha256.Sum256([]byte(s.key(idempotencyKey, token)))
300+
return redisStatePrefix + "siteverify:idempotency:" + hex.EncodeToString(sum[:])
301+
}
302+
288303
// Get returns a cached response, if one is still live.
289304
func (s *IdempotencyStore) Get(idempotencyKey, token string) (SiteverifyResponse, bool) {
290305
if idempotencyKey == "" {
291306
return SiteverifyResponse{}, false
292307
}
308+
if s.redis != nil {
309+
payload, err := s.redis.Get(context.Background(), s.redisKey(idempotencyKey, token)).Bytes()
310+
if err != nil {
311+
return SiteverifyResponse{}, false
312+
}
313+
var response SiteverifyResponse
314+
if json.Unmarshal(payload, &response) != nil {
315+
return SiteverifyResponse{}, false
316+
}
317+
return response, true
318+
}
293319
return s.entries.Get(s.key(idempotencyKey, token))
294320
}
295321

@@ -298,6 +324,13 @@ func (s *IdempotencyStore) Set(idempotencyKey, token string, resp SiteverifyResp
298324
if idempotencyKey == "" {
299325
return
300326
}
327+
if s.redis != nil {
328+
payload, err := json.Marshal(resp)
329+
if err == nil {
330+
_ = s.redis.Set(context.Background(), s.redisKey(idempotencyKey, token), payload, idempotencyTTL).Err()
331+
}
332+
return
333+
}
301334
s.entries.Add(s.key(idempotencyKey, token), resp)
302335
}
303336

0 commit comments

Comments
 (0)