Skip to content

Commit 5eb8903

Browse files
cport1Chris Portscheller
andauthored
feat(python): share security state through Redis (#47)
* feat(python): share security state through Redis * test: use clean browser signals for Redis conformance --------- Co-authored-by: Chris Portscheller <home@mac-mini.lan>
1 parent 2dae2c5 commit 5eb8903

9 files changed

Lines changed: 371 additions & 38 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Redis multi-replica conformance
2+
3+
on:
4+
pull_request:
5+
paths: ['server-go/**', 'server-node/**', 'server-python/**', 'test/redis-conformance.js', 'bench/lib/pow.js', '.github/workflows/redis-conformance.yml']
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
replicas:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
include:
16+
- implementation: go
17+
dockerfile: docker/Dockerfile
18+
- implementation: node
19+
dockerfile: server-node/Dockerfile
20+
- implementation: python
21+
dockerfile: server-python/Dockerfile
22+
services:
23+
redis:
24+
image: redis:7-alpine
25+
ports: ['6379:6379']
26+
options: >-
27+
--health-cmd "redis-cli ping"
28+
--health-interval 5s
29+
--health-timeout 3s
30+
--health-retries 10
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version: '20'
36+
- name: Build production image
37+
run: docker build -f ${{ matrix.dockerfile }} -t fcaptcha-redis-test .
38+
- name: Start two replicas
39+
env:
40+
FCAPTCHA_SECRET: redis-conformance-secret
41+
run: |
42+
docker run -d --name fcaptcha-a --network host -e PORT=3101 -e REDIS_URL=redis://127.0.0.1:6379 -e FCAPTCHA_SECRET="$FCAPTCHA_SECRET" fcaptcha-redis-test
43+
docker run -d --name fcaptcha-b --network host -e PORT=3102 -e REDIS_URL=redis://127.0.0.1:6379 -e FCAPTCHA_SECRET="$FCAPTCHA_SECRET" fcaptcha-redis-test
44+
for port in 3101 3102; do
45+
for attempt in $(seq 1 30); do
46+
curl -fsS "http://127.0.0.1:$port/health" && break
47+
if [ "$attempt" = 30 ]; then docker logs fcaptcha-a; docker logs fcaptcha-b; exit 1; fi
48+
sleep 1
49+
done
50+
done
51+
- name: Verify cross-instance security state
52+
env:
53+
FCAPTCHA_SECRET: redis-conformance-secret
54+
run: node test/redis-conformance.js http://127.0.0.1:3101 http://127.0.0.1:3102

INSTALLATION.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,8 @@ FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now
274274
shares PoW challenges, token replay protection, Siteverify idempotency, rate
275275
limits, suspicion, fingerprint cardinality, and site-key rotation guards.
276276
Challenge and token claims are atomic. Go may run multiple replicas when Redis
277-
is configured. Node now shares the same security-state classes and may also run
278-
multiple replicas with Redis. Python does not yet use Redis and must remain
279-
single-instance.
277+
is configured. Node and Python share the same security-state classes and may
278+
also run multiple replicas with Redis.
280279

281280
Run:
282281

@@ -426,8 +425,7 @@ server {
426425
```
427426

428427
**Important:** Multiple Go instances require `REDIS_URL`; without it, all state
429-
is process-local. Node also supports multiple instances with Redis. Python
430-
remains entirely process-local and must run as one instance.
428+
is process-local. Node and Python also support multiple instances with Redis.
431429

432430
---
433431

@@ -439,7 +437,7 @@ remains entirely process-local and must run as one instance.
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 | - | Redis URL for shared security state. Go and Node support multiple replicas; Python does not yet use it. Configuration and runtime failures are fail-closed |
440+
| `REDIS_URL` | No | - | Redis URL for shared security state. Go, Node, and Python support 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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ rotation guards across replicas. Challenge and token consumption are atomic.
6363
It refuses to start if configured Redis is unavailable and fails closed if it
6464
becomes unavailable later.
6565

66-
With `REDIS_URL`, the Go and Node servers can run multiple replicas. Python does
67-
not yet use Redis and must remain single-instance.
66+
With `REDIS_URL`, the Go, Node, and Python servers can run multiple replicas.
6867

6968
Kubernetes:
7069

@@ -781,7 +780,7 @@ Set `action` (and optionally `cdata`) when you request the token —
781780
| `FCAPTCHA_LEGACY_UNAUTH_VERIFY` | Restore the pre-1.22.0 behaviour where token verification accepted any caller. One release of migration cover; **do not leave it on** | off |
782781
| `FCAPTCHA_ALLOWED_HOSTNAMES` | Comma-separated hostnames permitted to mint tokens, matched against the request's `Origin` (then `Referer`). Unset accepts any origin. A request with no derivable origin (native app, server-side call) is always allowed — an attacker who can forge an `Origin` would just forge a listed one | (any) |
783782
| `PORT` | Server port | 3000 |
784-
| `REDIS_URL` | Share security state across replicas. Go and Node share all security stores and support multiple replicas. Python does not yet use Redis. Configured Redis failures are fail-closed | (unset, process-local state) |
783+
| `REDIS_URL` | Share security state across replicas. Go, Node, and Python share all security stores and support multiple replicas. Configured Redis failures are fail-closed | (unset, process-local state) |
785784
| `TRUSTED_PROXIES` | Comma-separated CIDRs/IPs of peers allowed to set `X-Forwarded-For`, `X-Real-IP` and the TLS-fingerprint headers. `*` trusts every peer, `none` trusts none. See [Trusted proxies](#trusted-proxies) | loopback + private ranges |
786785
| `FCAPTCHA_SITE_KEYS` | Comma-separated allowlist of accepted site keys. Unset accepts any key (zero-config self-hosting); unlisted keys are folded into a shared overflow bucket rather than allocating their own rate-limit/fingerprint state | (any) |
787786
| `FCAPTCHA_MAX_SITE_KEYS_PER_IP` | Distinct site keys one IP may allocate state for before the excess is folded into the overflow bucket. The cap itself is unconditional | 8 |

charts/fcaptcha/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ range.
4141
The default Go image may run multiple replicas when `redis.url` is configured.
4242
PoW, token replay, Siteverify idempotency, rate limits, suspicion, fingerprint
4343
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.
44+
atomic. Without Redis, run one replica. Go, Node, and Python images support the
45+
same shared-state contract.
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).

charts/fcaptcha/values.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ extraEnv: []
6969
## managed instance or a purpose-built operator. Point this at one.
7070
##
7171
## 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.
72+
## replicas when it is configured. Go, Node, and Python use the same contract.
7373
redis:
7474
url: ""
7575
existingSecret: ""
@@ -147,8 +147,7 @@ affinity: {}
147147
topologySpreadConstraints: []
148148

149149
## 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.
150+
## redis.url or redis.existingSecret is configured.
152151
autoscaling:
153152
enabled: false
154153
minReplicas: 2

server-python/redis_state.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Redis-backed security state shared with the Go and Node servers."""
2+
3+
import hashlib
4+
import json
5+
import secrets
6+
import time
7+
8+
import redis
9+
10+
PREFIX = "fcaptcha:v1:"
11+
POW_TTL_MS = 300_000
12+
SPENT_TTL_MS = 600_000
13+
IDEMPOTENCY_TTL_MS = 300_000
14+
DETECTION_TTL_MS = 900_000
15+
16+
CLAIM = """
17+
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
18+
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) == false then return -1 end
19+
redis.call('DEL', KEYS[1])
20+
return 1
21+
"""
22+
RATE = """
23+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
24+
local count = redis.call('ZCARD', KEYS[1]); local added = 0
25+
if count < tonumber(ARGV[3]) then
26+
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4]); count=count+1; added=1
27+
end
28+
redis.call('PEXPIRE', KEYS[1], ARGV[5]); return {count, added}
29+
"""
30+
SITEKEY = """
31+
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
32+
redis.call('PEXPIRE', KEYS[1], ARGV[3]); return 1
33+
end
34+
if redis.call('SCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
35+
redis.call('SADD', KEYS[1], ARGV[1]); redis.call('PEXPIRE', KEYS[1], ARGV[3]); return 1
36+
"""
37+
38+
39+
class RedisState:
40+
def __init__(self, url: str, client=None):
41+
self.client = client or redis.Redis.from_url(url, decode_responses=True)
42+
self.client.ping()
43+
44+
@staticmethod
45+
def opaque(kind: str, value: str) -> str:
46+
return f"{PREFIX}{kind}:{hashlib.sha256(value.encode()).hexdigest()}"
47+
48+
@staticmethod
49+
def challenge_key(challenge_id: str) -> str:
50+
return f"{PREFIX}pow:challenge:{challenge_id}"
51+
52+
def put_challenge(self, challenge: dict) -> None:
53+
ttl = challenge["expiresAt"] - int(time.time() * 1000)
54+
if ttl <= 0:
55+
raise RuntimeError("challenge already expired")
56+
stored = {**challenge, "challengeId": challenge["id"]}
57+
stored.pop("id", None)
58+
self.client.set(self.challenge_key(challenge["id"]), json.dumps(stored), px=ttl)
59+
60+
def get_challenge(self, challenge_id: str):
61+
payload = self.client.get(self.challenge_key(challenge_id))
62+
if not payload:
63+
return None
64+
challenge = json.loads(payload)
65+
challenge["id"] = challenge.get("challengeId", challenge_id)
66+
return challenge
67+
68+
def claim_challenge(self, challenge_id: str, solution_key: str):
69+
result = int(self.client.eval(
70+
CLAIM, 2, self.challenge_key(challenge_id),
71+
f"{PREFIX}pow:spent:{solution_key}", SPENT_TTL_MS,
72+
))
73+
return result == 1, "solution_already_used" if result == -1 else "challenge_not_found"
74+
75+
def claim_token(self, signature: str) -> bool:
76+
return bool(self.client.set(f"{PREFIX}token:spent:{signature}", "1", nx=True, px=SPENT_TTL_MS))
77+
78+
def idempotency_key(self, key: str, token: str) -> str:
79+
token_hash = hashlib.sha256(token.encode()).hexdigest()[:32]
80+
return self.opaque("siteverify:idempotency", f"{key}:{token_hash}")
81+
82+
def get_idempotency(self, key: str, token: str):
83+
if not key:
84+
return None
85+
payload = self.client.get(self.idempotency_key(key, token))
86+
return json.loads(payload) if payload else None
87+
88+
def set_idempotency(self, key: str, token: str, response: dict) -> None:
89+
if key:
90+
self.client.set(self.idempotency_key(key, token), json.dumps(response), px=IDEMPOTENCY_TTL_MS)
91+
92+
def rate_check(self, key: str, window: int, maximum: int):
93+
now = int(time.time() * 1000)
94+
count, added = self.client.eval(
95+
RATE, 1, self.opaque("rate", key), now-window*1000, now, maximum,
96+
f"{now}:{secrets.token_hex(8)}", window*1000+1000,
97+
)
98+
return int(added) == 0, int(count)
99+
100+
def record_suspicion(self, site_key: str, ip: str) -> None:
101+
now = int(time.time() * 1000); key = self.opaque("suspicion", f"{site_key}|{ip}")
102+
with self.client.pipeline(transaction=True) as p:
103+
p.zremrangebyscore(key, "-inf", now-DETECTION_TTL_MS)
104+
p.zadd(key, {f"{now}:{secrets.token_hex(8)}": now})
105+
p.zremrangebyrank(key, 0, -17).pexpire(key, DETECTION_TTL_MS).execute()
106+
107+
def suspicion_count(self, site_key: str, ip: str) -> int:
108+
key = self.opaque("suspicion", f"{site_key}|{ip}")
109+
self.client.zremrangebyscore(key, "-inf", int(time.time()*1000)-DETECTION_TTL_MS)
110+
return int(self.client.zcard(key))
111+
112+
def record_fingerprint(self, fp: str, ip: str, site_key: str) -> None:
113+
fp_key = self.opaque("fingerprint:ips", f"{site_key}|{fp}")
114+
ip_key = self.opaque("fingerprint:fps", ip)
115+
with self.client.pipeline(transaction=True) as p:
116+
p.sadd(fp_key, self.opaque("value:ip", ip)).pexpire(fp_key, DETECTION_TTL_MS)
117+
p.sadd(ip_key, self.opaque("value:fp", fp)).pexpire(ip_key, DETECTION_TTL_MS).execute()
118+
119+
def ip_fingerprint_count(self, ip: str) -> int:
120+
return int(self.client.scard(self.opaque("fingerprint:fps", ip)))
121+
122+
def fingerprint_ip_count(self, fp: str, site_key: str) -> int:
123+
return int(self.client.scard(self.opaque("fingerprint:ips", f"{site_key}|{fp}")))
124+
125+
def claim_site_key(self, site_key: str, ip: str, maximum: int) -> bool:
126+
return int(self.client.eval(
127+
SITEKEY, 1, self.opaque("sitekeys", ip), self.opaque("value:sitekey", site_key),
128+
maximum, 3_600_000,
129+
)) == 1

0 commit comments

Comments
 (0)