Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

---

Expand All @@ -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`) |
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 6 additions & 5 deletions charts/fcaptcha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 |
Expand Down
12 changes: 6 additions & 6 deletions charts/fcaptcha/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion server-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions server-go/redis_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
49 changes: 38 additions & 11 deletions server-go/scoring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -328,6 +329,7 @@ type FingerprintData struct {
type TokenStore struct {
mu sync.Mutex
cache *expirable.LRU[string, struct{}]
redis *redis.Client
}

const (
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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"]
Expand Down
45 changes: 39 additions & 6 deletions server-go/siteverify.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ package main
// Mirrors server-node/siteverify.js and server-python/siteverify.py.

import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
Expand All @@ -47,6 +48,7 @@ import (
"time"

"github.com/hashicorp/golang-lru/v2/expirable"
"github.com/redis/go-redis/v9"
)

const (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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))
}

Expand All @@ -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)
}

Expand Down
Loading