Skip to content

Commit 0efd96e

Browse files
cport1Chris Portscheller
andauthored
feat(go): share detection state through Redis (#43)
* feat(go): share detection state through Redis * fix(helm): close validateSecret before the new shared-state define The shared-state guard was added between validateSecret's inner {{ end }} and the one closing the define, so the new define nested inside it and the chart stopped parsing: 'unexpected <define> in command'. Neither the guard nor any other template rendered. Closes validateSecret first and drops the now-stray trailing end. Checked with helm lint plus template renders at one replica, at three without Redis (guard fires), and at three with Redis. * ci(helm): give the multi-replica render combinations a Redis URL autoscaling.enabled implies more than one pod, which the new shared-state guard now refuses without Redis, so the scaling and full combinations stopped rendering. Both get a Redis URL, which is what the chart is telling operators to do. Adds a guard test alongside the signing-key one: three replicas without Redis, and autoscaling without Redis, must both refuse to render. --------- Co-authored-by: Chris Portscheller <home@mac-mini.lan>
1 parent ece7108 commit 0efd96e

12 files changed

Lines changed: 252 additions & 36 deletions

File tree

‎.github/workflows/helm.yml‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ jobs:
4848
fi
4949
echo "guard holds"
5050
51+
# Multiple replicas without shared state give each pod its own challenge
52+
# and replay tables, so a challenge issued by one is unknown to the next.
53+
# Same treatment as the signing-key guard: prove it actually fires.
54+
- name: Refuses to render multiple replicas without shared state
55+
run: |
56+
if helm template t charts/fcaptcha --set secret=ci --set replicaCount=3 >/dev/null 2>&1; then
57+
echo "chart rendered 3 replicas with no Redis — the guard is broken"
58+
exit 1
59+
fi
60+
if helm template t charts/fcaptcha --set secret=ci --set autoscaling.enabled=true >/dev/null 2>&1; then
61+
echo "chart rendered with autoscaling and no Redis — the guard is broken"
62+
exit 1
63+
fi
64+
echo "guard holds"
65+
5166
- name: Install kubeconform
5267
run: |
5368
curl -sSL https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz \
@@ -64,11 +79,13 @@ jobs:
6479
render minimal --set secret=ci
6580
render existing --set existingSecret=mine
6681
render ingress --set secret=ci --set ingress.enabled=true
67-
render scaling --set secret=ci --set autoscaling.enabled=true --set podDisruptionBudget.enabled=true
82+
render scaling --set secret=ci --set autoscaling.enabled=true --set podDisruptionBudget.enabled=true \
83+
--set redis.url=redis://r:6379
6884
render redis --set secret=ci --set redis.url=redis://r:6379
6985
render full --set secret=ci --set ingress.enabled=true --set autoscaling.enabled=true \
7086
--set podDisruptionBudget.enabled=true --set config.logVerdicts=true \
71-
--set config.allowedHostnames=a.example --set verifySecret=v
87+
--set config.allowedHostnames=a.example --set verifySecret=v \
88+
--set redis.url=redis://r:6379
7289
7390
install:
7491
name: Install into a cluster

‎INSTALLATION.md‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,10 @@ services:
271271
```
272272
273273
FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now
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.
274+
shares PoW challenges, token replay protection, Siteverify idempotency, rate
275+
limits, suspicion, fingerprint cardinality, and site-key rotation guards.
276+
Challenge and token claims are atomic. Go may run multiple replicas when Redis
277+
is configured; Node and Python must remain single-instance.
279278

280279
Run:
281280

@@ -424,10 +423,9 @@ server {
424423
}
425424
```
426425

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.
426+
**Important:** Multiple Go instances require `REDIS_URL`; without it, all state
427+
is process-local. Node and Python remain entirely process-local and must run as
428+
one instance.
431429

432430
---
433431

@@ -439,7 +437,7 @@ entirely process-local.
439437
|----------|----------|---------|-------------|
440438
| `FCAPTCHA_SECRET` | Yes | - | Secret key for signing tokens (min 16 chars) |
441439
| `FCAPTCHA_INSECURE_DEV_MODE` | No | off | Explicitly use the public development signing key for local-only development. Never expose a server with this enabled |
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 |
440+
| `REDIS_URL` | No | - | Go only: Redis URL for shared security state. Required for multiple replicas; configuration and runtime failures are fail-closed |
443441
| `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 |
444442
| `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) |
445443
| `FCAPTCHA_ALLOWED_HOSTNAMES` | No | (any) | Comma-separated hostnames permitted to mint tokens, matched against the request `Origin` (then `Referer`) |

‎README.md‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,14 @@ 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, token replay protection, and Siteverify
61-
idempotency results across replicas; challenge and token consumption are atomic,
62-
it refuses to start if the configured Redis service is unavailable and returns
63-
503 rather than issuing an unpersisted challenge if Redis fails later.
64-
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.
60+
`REDIS_URL` to share PoW challenges, token replay protection, Siteverify
61+
idempotency, rate limits, suspicion, fingerprint cardinality, and site-key
62+
rotation guards across replicas. Challenge and token consumption are atomic.
63+
It refuses to start if configured Redis is unavailable and fails closed if it
64+
becomes unavailable later.
65+
66+
With `REDIS_URL`, the Go server can run multiple replicas. Node and Python do
67+
not yet use Redis and must remain single-instance.
6968

7069
Kubernetes:
7170

‎charts/fcaptcha/README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +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-
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.
41+
The default Go image may run multiple replicas when `redis.url` is configured.
42+
PoW, token replay, Siteverify idempotency, rate limits, suspicion, fingerprint
43+
cardinality, and site-key rotation guards are shared; one-time claims are
44+
atomic. Without Redis, run one replica. Node and Python images do not yet use
45+
Redis and must remain single-instance.
4646

4747
The full list of deployment settings with security consequences is in
4848
[SECURITY.md](https://github.com/WebDecoy/FCaptcha/blob/main/SECURITY.md#deployment-notes-that-are-security-relevant).
@@ -62,7 +62,7 @@ The full list of deployment settings with security consequences is in
6262
| `config.logVerdictsIncludeRaw` | Also log free-text detection reasons. **Can contain visitor-derived data** | `false` |
6363
| `image.repository` / `image.tag` | Container image; tag defaults to the chart's `appVersion` | `ghcr.io/webdecoy/fcaptcha` |
6464
| `replicaCount` | See the note above before raising this | `1` |
65-
| `redis.url` / `redis.existingSecret` | Go only: shared PoW, token replay, and Siteverify idempotency state; remaining stores are still local | `""` |
65+
| `redis.url` / `redis.existingSecret` | Go only: shared security state; required for multiple replicas | `""` |
6666
| `service.type` / `service.port` | | `ClusterIP` / `80` |
6767
| `ingress.enabled` | | `false` |
6868
| `resources` | | 100m CPU / 128Mi requested |

‎charts/fcaptcha/templates/_helpers.tpl‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,12 @@ to do by accident, and it costs one line of setup to satisfy.
6161
{{- fail "\n\nfcaptcha: a token signing key is required.\n\n --set secret=$(openssl rand -hex 32)\n\nor point at one you already manage:\n\n --set existingSecret=my-fcaptcha-secret\n\nWithout it the server falls back to a key published in its own source, and\nanyone can mint tokens your backend will accept.\n" -}}
6262
{{- end -}}
6363
{{- end -}}
64+
65+
{{/* Multiple Go pods require shared security state. */}}
66+
{{- define "fcaptcha.validateSharedState" -}}
67+
{{- $multiple := or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled -}}
68+
{{- $redisConfigured := or .Values.redis.url .Values.redis.existingSecret -}}
69+
{{- if and $multiple (not $redisConfigured) -}}
70+
{{- fail "\n\nfcaptcha: multiple replicas require Redis-backed shared state.\n\nSet redis.url or redis.existingSecret, or keep replicaCount=1 and autoscaling.enabled=false.\n" -}}
71+
{{- end -}}
72+
{{- end -}}

‎charts/fcaptcha/templates/deployment.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{{- include "fcaptcha.validateSecret" . -}}
2+
{{- include "fcaptcha.validateSharedState" . -}}
23
apiVersion: apps/v1
34
kind: Deployment
45
metadata:

‎charts/fcaptcha/values.yaml‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,8 @@ 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, 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.
71+
## The Go server uses this for shared security state and may run multiple
72+
## replicas when it is configured. Node/Python do not use it yet.
7473
redis:
7574
url: ""
7675
existingSecret: ""
@@ -147,9 +146,9 @@ tolerations: []
147146
affinity: {}
148147
topologySpreadConstraints: []
149148

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.
149+
## Off by default because Redis is optional. Safe for the default Go image when
150+
## redis.url or redis.existingSecret is configured; Node/Python remain
151+
## single-instance.
153152
autoscaling:
154153
enabled: false
155154
minReplicas: 2

‎server-go/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ func main() {
253253
// the first component of every rate-limit, fingerprint and challenge
254254
// partition key. See sitekeys.go.
255255
siteKeys := SiteKeyGuardFromEnv()
256+
if engine.redisClient != nil {
257+
siteKeys = SiteKeyGuardFromEnvWithRedis(engine.redisClient)
258+
}
256259
log.Printf("site keys: %s", siteKeys.Describe())
257260

258261
// Holds a JA4 fingerprint per live connection, populated during the TLS

‎server-go/redis_state_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,54 @@ func TestRedisIdempotencyResultsCrossInstances(t *testing.T) {
7979
t.Fatalf("another instance did not read the idempotency result: %#v", got)
8080
}
8181
}
82+
83+
func TestRedisDetectionStateCrossesInstances(t *testing.T) {
84+
redisServer := miniredis.RunT(t)
85+
redisURL := "redis://" + redisServer.Addr()
86+
first := NewScoringEngineWithRedis("shared-test-secret", redisURL)
87+
second := NewScoringEngineWithRedis("shared-test-secret", redisURL)
88+
89+
for i := 0; i < 3; i++ {
90+
exceeded, _ := first.rateLimiter.Check("site|203.0.113.5", 60, 3)
91+
if exceeded {
92+
t.Fatalf("request %d was limited before the configured ceiling", i+1)
93+
}
94+
}
95+
if exceeded, count := second.rateLimiter.Check("site|203.0.113.5", 60, 3); !exceeded || count != 3 {
96+
t.Fatalf("another instance did not enforce the shared rate limit: exceeded=%v count=%d", exceeded, count)
97+
}
98+
99+
first.suspicion.Record("site", "203.0.113.5", 0.95)
100+
if count := second.suspicion.Count("site", "203.0.113.5"); count != 1 {
101+
t.Fatalf("another instance saw %d shared suspicion hits, want 1", count)
102+
}
103+
104+
first.fingerprintStore.Record("fp-a", "203.0.113.5", "site")
105+
second.fingerprintStore.Record("fp-b", "203.0.113.5", "site")
106+
if count := first.fingerprintStore.GetIPFingerprintCount("203.0.113.5"); count != 2 {
107+
t.Fatalf("shared address fingerprint count=%d, want 2", count)
108+
}
109+
second.fingerprintStore.Record("fp-a", "198.51.100.8", "site")
110+
if count := first.fingerprintStore.GetFingerprintIPCount("fp-a", "site"); count != 2 {
111+
t.Fatalf("shared fingerprint address count=%d, want 2", count)
112+
}
113+
}
114+
115+
func TestRedisSiteKeyRotationLimitCrossesInstances(t *testing.T) {
116+
redisServer := miniredis.RunT(t)
117+
client := NewScoringEngineWithRedis("shared-test-secret", "redis://"+redisServer.Addr()).redisClient
118+
first := NewSiteKeyGuard(nil, 2)
119+
first.redis = client
120+
second := NewSiteKeyGuard(nil, 2)
121+
second.redis = client
122+
123+
if got := first.Normalize("site-a", "203.0.113.5"); got != "site-a" {
124+
t.Fatalf("first key normalized to %q", got)
125+
}
126+
if got := second.Normalize("site-b", "203.0.113.5"); got != "site-b" {
127+
t.Fatalf("second key normalized to %q", got)
128+
}
129+
if got := first.Normalize("site-c", "203.0.113.5"); got != overflowSiteKey {
130+
t.Fatalf("cross-instance rotation was not folded into overflow: %q", got)
131+
}
132+
}

‎server-go/scoring.go‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,15 @@ const webBotAuthTimeout = 3 * time.Second
307307
type RateLimiter struct {
308308
mu sync.RWMutex
309309
requests map[string][]int64
310+
redis *redis.Client
310311
}
311312

312313
// FingerprintStore tracks fingerprint patterns
313314
type FingerprintStore struct {
314315
mu sync.RWMutex
315316
fingerprints map[string]*FingerprintData
316317
ipFingerprints map[string]map[string]bool
318+
redis *redis.Client
317319
}
318320

319321
type FingerprintData struct {
@@ -424,6 +426,9 @@ func NewScoringEngineWithRedis(secretKey, redisURL string) *ScoringEngine {
424426
engine := NewScoringEngine(secretKey)
425427
engine.powStore = newRedisPoWChallengeStore(client)
426428
engine.tokenStore = newRedisTokenStore(client)
429+
engine.rateLimiter = newRedisRateLimiter(client)
430+
engine.fingerprintStore = newRedisFingerprintStore(client)
431+
engine.suspicion = NewRedisSuspicionLedger(client)
427432
engine.redisClient = client
428433
return engine
429434
}
@@ -434,13 +439,21 @@ func newRateLimiter() *RateLimiter {
434439
}
435440
}
436441

442+
func newRedisRateLimiter(client *redis.Client) *RateLimiter {
443+
return &RateLimiter{redis: client}
444+
}
445+
437446
func newFingerprintStore() *FingerprintStore {
438447
return &FingerprintStore{
439448
fingerprints: make(map[string]*FingerprintData),
440449
ipFingerprints: make(map[string]map[string]bool),
441450
}
442451
}
443452

453+
func newRedisFingerprintStore(client *redis.Client) *FingerprintStore {
454+
return &FingerprintStore{redis: client}
455+
}
456+
444457
func compileUAPatterns() []*regexp.Regexp {
445458
patterns := []string{
446459
`(?i)headless`,
@@ -2425,7 +2438,43 @@ func (e *ScoringEngine) computeSignature(payload []byte) string {
24252438
// Rate Limiter Methods
24262439
// ============================================================
24272440

2441+
var redisRateCheck = redis.NewScript(`
2442+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
2443+
local count = redis.call('ZCARD', KEYS[1])
2444+
local added = 0
2445+
if count < tonumber(ARGV[3]) then
2446+
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
2447+
count = count + 1
2448+
added = 1
2449+
end
2450+
redis.call('EXPIRE', KEYS[1], ARGV[5])
2451+
return {count, added}
2452+
`)
2453+
2454+
func redisOpaqueKey(kind, value string) string {
2455+
sum := sha256.Sum256([]byte(value))
2456+
return redisStatePrefix + kind + ":" + hex.EncodeToString(sum[:])
2457+
}
2458+
2459+
func randomHex(size int) string {
2460+
b := make([]byte, size)
2461+
if _, err := rand.Read(b); err != nil {
2462+
return fmt.Sprintf("%d", time.Now().UnixNano())
2463+
}
2464+
return hex.EncodeToString(b)
2465+
}
2466+
24282467
func (rl *RateLimiter) Check(key string, windowSeconds int64, maxRequests int) (bool, int) {
2468+
if rl.redis != nil {
2469+
now := time.Now().UnixMilli()
2470+
redisKey := redisOpaqueKey("rate", key)
2471+
member := fmt.Sprintf("%d:%s", now, randomHex(8))
2472+
values, err := redisRateCheck.Run(context.Background(), rl.redis, []string{redisKey}, now-windowSeconds*1000, now, maxRequests, member, windowSeconds+1).Int64Slice()
2473+
if err != nil {
2474+
return true, maxRequests
2475+
}
2476+
return values[1] == 0, int(values[0])
2477+
}
24292478
rl.mu.Lock()
24302479
defer rl.mu.Unlock()
24312480

@@ -2458,6 +2507,18 @@ func (rl *RateLimiter) Check(key string, windowSeconds int64, maxRequests int) (
24582507
// ============================================================
24592508

24602509
func (fs *FingerprintStore) Record(fingerprint, ip, siteKey string) {
2510+
if fs.redis != nil {
2511+
ctx := context.Background()
2512+
fpKey := redisOpaqueKey("fingerprint:ips", siteKey+"|"+fingerprint)
2513+
ipKey := redisOpaqueKey("fingerprint:fps", ip)
2514+
pipe := fs.redis.TxPipeline()
2515+
pipe.SAdd(ctx, fpKey, redisOpaqueKey("value:ip", ip))
2516+
pipe.Expire(ctx, fpKey, suspicionWindow)
2517+
pipe.SAdd(ctx, ipKey, redisOpaqueKey("value:fp", fingerprint))
2518+
pipe.Expire(ctx, ipKey, suspicionWindow)
2519+
_, _ = pipe.Exec(ctx)
2520+
return
2521+
}
24612522
fs.mu.Lock()
24622523
defer fs.mu.Unlock()
24632524

@@ -2481,6 +2542,13 @@ func (fs *FingerprintStore) Record(fingerprint, ip, siteKey string) {
24812542
}
24822543

24832544
func (fs *FingerprintStore) GetIPFingerprintCount(ip string) int {
2545+
if fs.redis != nil {
2546+
count, err := fs.redis.SCard(context.Background(), redisOpaqueKey("fingerprint:fps", ip)).Result()
2547+
if err != nil {
2548+
return 100
2549+
}
2550+
return int(count)
2551+
}
24842552
fs.mu.RLock()
24852553
defer fs.mu.RUnlock()
24862554

@@ -2491,6 +2559,13 @@ func (fs *FingerprintStore) GetIPFingerprintCount(ip string) int {
24912559
}
24922560

24932561
func (fs *FingerprintStore) GetFingerprintIPCount(fingerprint, siteKey string) int {
2562+
if fs.redis != nil {
2563+
count, err := fs.redis.SCard(context.Background(), redisOpaqueKey("fingerprint:ips", siteKey+"|"+fingerprint)).Result()
2564+
if err != nil {
2565+
return 100
2566+
}
2567+
return int(count)
2568+
}
24942569
fs.mu.RLock()
24952570
defer fs.mu.RUnlock()
24962571

0 commit comments

Comments
 (0)