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
54 changes: 54 additions & 0 deletions .github/workflows/redis-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Redis multi-replica conformance

on:
pull_request:
paths: ['server-go/**', 'server-node/**', 'server-python/**', 'test/redis-conformance.js', 'bench/lib/pow.js', '.github/workflows/redis-conformance.yml']
push:
branches: [main]

jobs:
replicas:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- implementation: go
dockerfile: docker/Dockerfile
- implementation: node
dockerfile: server-node/Dockerfile
- implementation: python
dockerfile: server-python/Dockerfile
services:
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build production image
run: docker build -f ${{ matrix.dockerfile }} -t fcaptcha-redis-test .
- name: Start two replicas
env:
FCAPTCHA_SECRET: redis-conformance-secret
run: |
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
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
for port in 3101 3102; do
for attempt in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$port/health" && break
if [ "$attempt" = 30 ]; then docker logs fcaptcha-a; docker logs fcaptcha-b; exit 1; fi
sleep 1
done
done
- name: Verify cross-instance security state
env:
FCAPTCHA_SECRET: redis-conformance-secret
run: node test/redis-conformance.js http://127.0.0.1:3101 http://127.0.0.1:3102
10 changes: 4 additions & 6 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,8 @@ FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now
shares PoW challenges, token replay protection, Siteverify idempotency, rate
limits, suspicion, fingerprint cardinality, and site-key rotation guards.
Challenge and token claims are atomic. Go may run multiple replicas when Redis
is configured. Node now shares the same security-state classes and may also run
multiple replicas with Redis. Python does not yet use Redis and must remain
single-instance.
is configured. Node and Python share the same security-state classes and may
also run multiple replicas with Redis.

Run:

Expand Down Expand Up @@ -426,8 +425,7 @@ server {
```

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

---

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

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

Kubernetes:

Expand Down Expand Up @@ -781,7 +780,7 @@ Set `action` (and optionally `cdata`) when you request the token —
| `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 |
| `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) |
| `PORT` | Server port | 3000 |
| `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) |
| `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) |
| `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 |
| `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) |
| `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 |
Expand Down
4 changes: 2 additions & 2 deletions charts/fcaptcha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ range.
The default Go image may run multiple replicas when `redis.url` is configured.
PoW, token replay, Siteverify idempotency, rate limits, suspicion, fingerprint
cardinality, and site-key rotation guards are shared; one-time claims are
atomic. Without Redis, run one replica. Node and Python images do not yet use
Redis and must remain single-instance.
atomic. Without Redis, run one replica. Go, Node, and Python images support the
same shared-state contract.

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 Down
5 changes: 2 additions & 3 deletions charts/fcaptcha/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ extraEnv: []
## managed instance or a purpose-built operator. Point this at one.
##
## The Go server uses this for shared security state and may run multiple
## replicas when it is configured. Node/Python do not use it yet.
## replicas when it is configured. Go, Node, and Python use the same contract.
redis:
url: ""
existingSecret: ""
Expand Down Expand Up @@ -147,8 +147,7 @@ affinity: {}
topologySpreadConstraints: []

## Off by default because Redis is optional. Safe for the default Go image when
## redis.url or redis.existingSecret is configured; Node/Python remain
## single-instance.
## redis.url or redis.existingSecret is configured.
autoscaling:
enabled: false
minReplicas: 2
Expand Down
129 changes: 129 additions & 0 deletions server-python/redis_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Redis-backed security state shared with the Go and Node servers."""

import hashlib
import json
import secrets
import time

import redis

PREFIX = "fcaptcha:v1:"
POW_TTL_MS = 300_000
SPENT_TTL_MS = 600_000
IDEMPOTENCY_TTL_MS = 300_000
DETECTION_TTL_MS = 900_000

CLAIM = """
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) == false then return -1 end
redis.call('DEL', KEYS[1])
return 1
"""
RATE = """
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local count = redis.call('ZCARD', KEYS[1]); local added = 0
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4]); count=count+1; added=1
end
redis.call('PEXPIRE', KEYS[1], ARGV[5]); return {count, added}
"""
SITEKEY = """
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[3]); return 1
end
if redis.call('SCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
redis.call('SADD', KEYS[1], ARGV[1]); redis.call('PEXPIRE', KEYS[1], ARGV[3]); return 1
"""


class RedisState:
def __init__(self, url: str, client=None):
self.client = client or redis.Redis.from_url(url, decode_responses=True)
self.client.ping()

@staticmethod
def opaque(kind: str, value: str) -> str:
return f"{PREFIX}{kind}:{hashlib.sha256(value.encode()).hexdigest()}"

@staticmethod
def challenge_key(challenge_id: str) -> str:
return f"{PREFIX}pow:challenge:{challenge_id}"

def put_challenge(self, challenge: dict) -> None:
ttl = challenge["expiresAt"] - int(time.time() * 1000)
if ttl <= 0:
raise RuntimeError("challenge already expired")
stored = {**challenge, "challengeId": challenge["id"]}
stored.pop("id", None)
self.client.set(self.challenge_key(challenge["id"]), json.dumps(stored), px=ttl)

def get_challenge(self, challenge_id: str):
payload = self.client.get(self.challenge_key(challenge_id))
if not payload:
return None
challenge = json.loads(payload)
challenge["id"] = challenge.get("challengeId", challenge_id)
return challenge

def claim_challenge(self, challenge_id: str, solution_key: str):
result = int(self.client.eval(
CLAIM, 2, self.challenge_key(challenge_id),
f"{PREFIX}pow:spent:{solution_key}", SPENT_TTL_MS,
))
return result == 1, "solution_already_used" if result == -1 else "challenge_not_found"

def claim_token(self, signature: str) -> bool:
return bool(self.client.set(f"{PREFIX}token:spent:{signature}", "1", nx=True, px=SPENT_TTL_MS))

def idempotency_key(self, key: str, token: str) -> str:
token_hash = hashlib.sha256(token.encode()).hexdigest()[:32]
return self.opaque("siteverify:idempotency", f"{key}:{token_hash}")

def get_idempotency(self, key: str, token: str):
if not key:
return None
payload = self.client.get(self.idempotency_key(key, token))
return json.loads(payload) if payload else None

def set_idempotency(self, key: str, token: str, response: dict) -> None:
if key:
self.client.set(self.idempotency_key(key, token), json.dumps(response), px=IDEMPOTENCY_TTL_MS)

def rate_check(self, key: str, window: int, maximum: int):
now = int(time.time() * 1000)
count, added = self.client.eval(
RATE, 1, self.opaque("rate", key), now-window*1000, now, maximum,
f"{now}:{secrets.token_hex(8)}", window*1000+1000,
)
return int(added) == 0, int(count)

def record_suspicion(self, site_key: str, ip: str) -> None:
now = int(time.time() * 1000); key = self.opaque("suspicion", f"{site_key}|{ip}")
with self.client.pipeline(transaction=True) as p:
p.zremrangebyscore(key, "-inf", now-DETECTION_TTL_MS)
p.zadd(key, {f"{now}:{secrets.token_hex(8)}": now})
p.zremrangebyrank(key, 0, -17).pexpire(key, DETECTION_TTL_MS).execute()

def suspicion_count(self, site_key: str, ip: str) -> int:
key = self.opaque("suspicion", f"{site_key}|{ip}")
self.client.zremrangebyscore(key, "-inf", int(time.time()*1000)-DETECTION_TTL_MS)
return int(self.client.zcard(key))

def record_fingerprint(self, fp: str, ip: str, site_key: str) -> None:
fp_key = self.opaque("fingerprint:ips", f"{site_key}|{fp}")
ip_key = self.opaque("fingerprint:fps", ip)
with self.client.pipeline(transaction=True) as p:
p.sadd(fp_key, self.opaque("value:ip", ip)).pexpire(fp_key, DETECTION_TTL_MS)
p.sadd(ip_key, self.opaque("value:fp", fp)).pexpire(ip_key, DETECTION_TTL_MS).execute()

def ip_fingerprint_count(self, ip: str) -> int:
return int(self.client.scard(self.opaque("fingerprint:fps", ip)))

def fingerprint_ip_count(self, fp: str, site_key: str) -> int:
return int(self.client.scard(self.opaque("fingerprint:ips", f"{site_key}|{fp}")))

def claim_site_key(self, site_key: str, ip: str, maximum: int) -> bool:
return int(self.client.eval(
SITEKEY, 1, self.opaque("sitekeys", ip), self.opaque("value:sitekey", site_key),
maximum, 3_600_000,
)) == 1
Loading
Loading