From 039c9675fb5f06a165ec53aeebd380981010ecae Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 22:50:05 -0500 Subject: [PATCH 1/2] feat(python): share security state through Redis --- .github/workflows/redis-conformance.yml | 54 ++++++++++ INSTALLATION.md | 10 +- README.md | 5 +- charts/fcaptcha/README.md | 4 +- charts/fcaptcha/values.yaml | 5 +- server-python/redis_state.py | 129 ++++++++++++++++++++++ server-python/server.py | 138 +++++++++++++++++++----- test/CONFORMANCE.md | 6 ++ test/redis-conformance.js | 47 ++++++++ 9 files changed, 360 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/redis-conformance.yml create mode 100644 server-python/redis_state.py create mode 100644 test/redis-conformance.js diff --git a/.github/workflows/redis-conformance.yml b/.github/workflows/redis-conformance.yml new file mode 100644 index 0000000..49aa978 --- /dev/null +++ b/.github/workflows/redis-conformance.yml @@ -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 diff --git a/INSTALLATION.md b/INSTALLATION.md index 4facea4..5a817f8 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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: @@ -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. --- @@ -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`) | diff --git a/README.md b/README.md index c3cab3c..e79d7fa 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 | diff --git a/charts/fcaptcha/README.md b/charts/fcaptcha/README.md index dcf917c..a2989bd 100644 --- a/charts/fcaptcha/README.md +++ b/charts/fcaptcha/README.md @@ -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). diff --git a/charts/fcaptcha/values.yaml b/charts/fcaptcha/values.yaml index e8798d7..b446f5c 100644 --- a/charts/fcaptcha/values.yaml +++ b/charts/fcaptcha/values.yaml @@ -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: "" @@ -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 diff --git a/server-python/redis_state.py b/server-python/redis_state.py new file mode 100644 index 0000000..406c2af --- /dev/null +++ b/server-python/redis_state.py @@ -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 diff --git a/server-python/server.py b/server-python/server.py index ffeaff6..512cc59 100644 --- a/server-python/server.py +++ b/server-python/server.py @@ -22,7 +22,8 @@ from pydantic import BaseModel from clientip import ProxyTrust, network_identity -from sitekeys import SiteKeyGuard +from sitekeys import SiteKeyGuard, OVERFLOW_SITE_KEY +from redis_state import RedisState from siteverify import ( HostnameAllowlist, IdempotencyStore, @@ -166,7 +167,31 @@ def _env_flag(name: str) -> bool: ALLOWED_HOSTNAMES = HostnameAllowlist.from_env() # Lets a caller retry a validation that timed out without burning the token. -IDEMPOTENCY = IdempotencyStore() +REDIS_URL = os.getenv("REDIS_URL", "") +SHARED_STATE = RedisState(REDIS_URL) if REDIS_URL else None + + +class RedisIdempotencyStore: + def get(self, key, token): + return SHARED_STATE.get_idempotency(key, token) + + def set(self, key, token, response): + SHARED_STATE.set_idempotency(key, token, response) + + +IDEMPOTENCY = RedisIdempotencyStore() if SHARED_STATE else IdempotencyStore() + + +def normalize_site_key(site_key, ip): + key = site_key if isinstance(site_key, str) and site_key else "default" + if SITE_KEYS.allowlist is not None and key not in SITE_KEYS.allowlist: + return OVERFLOW_SITE_KEY + if not SHARED_STATE or not ip: + return SITE_KEYS.normalize(key, ip) + try: + return key if SHARED_STATE.claim_site_key(key, ip, SITE_KEYS.max_per_ip) else OVERFLOW_SITE_KEY + except Exception: + return OVERFLOW_SITE_KEY if VERDICT_LOGGING_ENABLED and VERDICT_LOG_INCLUDE_RAW: print( "WARNING: FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW enabled — verdict logs include " @@ -308,6 +333,11 @@ def __init__(self): self.requests: Dict[str, List[float]] = defaultdict(list) def check(self, key: str, window: int = 60, max_requests: int = 10) -> tuple[bool, int]: + if SHARED_STATE: + try: + return SHARED_STATE.rate_check(key, window, max_requests) + except Exception: + return True, max_requests now = time.time() cutoff = now - window @@ -327,6 +357,12 @@ def __init__(self): self.ip_fingerprints: Dict[str, set] = defaultdict(set) def record(self, fp: str, ip: str, site_key: str): + if SHARED_STATE: + try: + SHARED_STATE.record_fingerprint(fp, ip, site_key) + except Exception: + pass + return key = f"{site_key}:{fp}" if key not in self.fingerprints: self.fingerprints[key] = {"count": 0, "ips": set()} @@ -335,9 +371,19 @@ def record(self, fp: str, ip: str, site_key: str): self.ip_fingerprints[ip].add(fp) def get_ip_fp_count(self, ip: str) -> int: + if SHARED_STATE: + try: + return SHARED_STATE.ip_fingerprint_count(ip) + except Exception: + return 100 return len(self.ip_fingerprints.get(ip, set())) def get_fp_ip_count(self, fp: str, site_key: str) -> int: + if SHARED_STATE: + try: + return SHARED_STATE.fingerprint_ip_count(fp, site_key) + except Exception: + return 100 key = f"{site_key}:{fp}" return len(self.fingerprints.get(key, {}).get("ips", set())) @@ -392,8 +438,10 @@ def generate(self, site_key: str, ip: str, is_datacenter: bool = False) -> Dict: sig = hmac.new(SECRET_KEY.encode(), sig_data.encode(), hashlib.sha256).hexdigest() challenge["sig"] = sig - # Store challenge - self.challenges[challenge_id] = challenge + if SHARED_STATE: + SHARED_STATE.put_challenge(challenge) + else: + self.challenges[challenge_id] = challenge # Cleanup old challenges periodically if len(self.challenges) % 10 == 0: @@ -416,13 +464,17 @@ def verify(self, solution: PoWSolution, site_key: str, ip: str, signals_hash: st if not solution or not solution.challengeId: return {"valid": False, "reason": "no_solution"} - challenge = self.challenges.get(solution.challengeId) + try: + challenge = SHARED_STATE.get_challenge(solution.challengeId) if SHARED_STATE else self.challenges.get(solution.challengeId) + except Exception: + return {"valid": False, "reason": "state_unavailable"} if not challenge: return {"valid": False, "reason": "challenge_not_found"} now = int(time.time() * 1000) if now > challenge["expiresAt"]: - del self.challenges[solution.challengeId] + if not SHARED_STATE: + del self.challenges[solution.challengeId] return {"valid": False, "reason": "challenge_expired"} if challenge["siteKey"] != site_key: @@ -451,14 +503,24 @@ def verify(self, solution: PoWSolution, site_key: str, ip: str, signals_hash: st if not solution.hash.startswith(target): return {"valid": False, "reason": "insufficient_difficulty"} - # Mark solution as used - self.used_solutions.add(solution_key) + if SHARED_STATE: + try: + claimed, reason = SHARED_STATE.claim_challenge(solution.challengeId, solution_key) + except Exception: + return {"valid": False, "reason": "state_unavailable"} + if not claimed: + return {"valid": False, "reason": reason} + else: + if solution_key in self.used_solutions: + return {"valid": False, "reason": "solution_already_used"} + self.used_solutions.add(solution_key) # Calculate server-side elapsed time (un-spoofable) server_elapsed = now - challenge["timestamp"] # Delete challenge (one-time use) - del self.challenges[solution.challengeId] + if not SHARED_STATE: + del self.challenges[solution.challengeId] return { "valid": True, @@ -489,6 +551,8 @@ def is_used(self, sig: str) -> bool: return sig in self.used_tokens def mark_used(self, sig: str) -> bool: + if SHARED_STATE: + return SHARED_STATE.claim_token(sig) if sig in self.used_tokens: return False # Already used self.used_tokens[sig] = time.time() @@ -503,9 +567,33 @@ def mark_used(self, sig: str) -> bool: rate_limiter = RateLimiter() -# Recent strong verdicts per source, used to price the next challenge that -# source asks for. Bounded and short-lived; see suspicion.py. -suspicion_ledger = SuspicionLedger() + +class SharedSuspicionLedger: + def __init__(self): + self.local = SuspicionLedger() + + def record(self, site_key, ip, score): + if not SHARED_STATE: + return self.local.record(site_key, ip, score) + if score < 0.8 or not ip: + return + try: + SHARED_STATE.record_suspicion(site_key, ip) + except Exception: + pass + + def count(self, site_key, ip): + if not SHARED_STATE: + return self.local.count(site_key, ip) + if not ip: + return 0 + try: + return SHARED_STATE.suspicion_count(site_key, ip) + except Exception: + return 16 + + +suspicion_ledger = SharedSuspicionLedger() fingerprint_store = FingerprintStore() pow_store = PoWChallengeStore() token_store = TokenStore() @@ -1563,18 +1651,17 @@ def verify_token(token: str, ip: str = None) -> Dict: ): return {"valid": False, "reason": "invalid_signature"} - # Check for token replay (single-use tokens) - if token_store.is_used(sig): - return {"valid": False, "reason": "token_already_used"} - # Verify IP matches (if provided) if ip: expected_ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:8] if ip_hash != expected_ip_hash: return {"valid": False, "reason": "ip_mismatch"} - # Mark token as used (prevents replay) - token_store.mark_used(sig) + try: + if not token_store.mark_used(sig): + return {"valid": False, "reason": "token_already_used"} + except Exception: + return {"valid": False, "reason": "state_unavailable"} # hostname/action/cdata default to "" so a token minted before they # existed still verifies and reports the same shape. The signature covers @@ -1590,8 +1677,8 @@ def verify_token(token: str, ip: str = None) -> Dict: "action": decoded.get("action", ""), "cdata": decoded.get("cdata", "") } - except Exception as e: - return {"valid": False, "reason": str(e)} + except Exception: + return {"valid": False, "reason": "invalid_token"} def run_verification( @@ -1905,7 +1992,7 @@ def collect_headers(request: Request) -> Dict[str, str]: async def verify(req: VerifyRequest, request: Request): ip = PROXY_TRUST.client_ip(request) # Bound the state an unvalidated site_key can allocate (sitekeys.py). - req.siteKey = SITE_KEYS.normalize(req.siteKey, ip) + req.siteKey = normalize_site_key(req.siteKey, ip) user_agent = request.headers.get("User-Agent", "") ja3_hash = PROXY_TRUST.trusted_header(request, "X-JA3-Hash") @@ -1923,7 +2010,7 @@ async def verify(req: VerifyRequest, request: Request): async def score(req: ScoreRequest, request: Request): ip = PROXY_TRUST.client_ip(request) # Bound the state an unvalidated site_key can allocate (sitekeys.py). - req.siteKey = SITE_KEYS.normalize(req.siteKey, ip) + req.siteKey = normalize_site_key(req.siteKey, ip) user_agent = request.headers.get("User-Agent", "") ja3_hash = PROXY_TRUST.trusted_header(request, "X-JA3-Hash") headers = collect_headers(request) @@ -2012,10 +2099,13 @@ async def pow_challenge(request: Request, siteKey: str = "default"): from detection import is_datacenter_ip ip = PROXY_TRUST.client_ip(request) - siteKey = SITE_KEYS.normalize(siteKey, ip) + siteKey = normalize_site_key(siteKey, ip) is_datacenter = is_datacenter_ip(ip) - challenge = pow_store.generate(siteKey, ip, is_datacenter) + try: + challenge = pow_store.generate(siteKey, ip, is_datacenter) + except Exception: + return JSONResponse(status_code=503, content={"error": "state_unavailable"}) return challenge diff --git a/test/CONFORMANCE.md b/test/CONFORMANCE.md index 6438206..8146ab4 100644 --- a/test/CONFORMANCE.md +++ b/test/CONFORMANCE.md @@ -20,3 +20,9 @@ FCAPTCHA_SECRET=conformance-test-secret node test/conformance.js http://localhos ``` CI builds each production container and runs this exact file against it. + +`redis-conformance.js` is the multi-replica companion. CI starts two production +containers against one Redis service and proves that a challenge issued by one +replica verifies on the other, token replay is rejected across replicas, and a +Siteverify idempotency response created on one replica is returned by the other. +The same test runs unchanged against Go, Node, and Python. diff --git a/test/redis-conformance.js b/test/redis-conformance.js new file mode 100644 index 0000000..74afadc --- /dev/null +++ b/test/redis-conformance.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const { buildVerifyBody } = require('../bench/lib/pow'); + +const A = process.argv[2] || 'http://localhost:3101'; +const B = process.argv[3] || 'http://localhost:3102'; +const SECRET = process.env.FCAPTCHA_SECRET; +if (!SECRET) throw new Error('FCAPTCHA_SECRET is required'); +const VISITOR = '203.0.113.25'; +const HEADERS = { 'content-type': 'application/json', 'x-real-ip': VISITOR, origin: 'https://example.com' }; + +async function post(server, path, body, headers = HEADERS) { + const response = await fetch(`${server}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }); + return { status: response.status, body: await response.json() }; +} + +async function mintAcrossInstances(siteKey) { + const { body } = await buildVerifyBody(A, siteKey, {}, { 'X-Real-IP': VISITOR }); + const result = await post(B, '/api/verify', body); + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.success, true, JSON.stringify(result.body)); + assert.ok(result.body.token); + return result.body.token; +} + +(async () => { + const token = await mintAcrossInstances('redis-cross-instance-token'); + const first = await post(A, '/api/token/verify', { token, secret: SECRET }); + assert.strictEqual(first.body.valid, true, JSON.stringify(first.body)); + const replay = await post(B, '/api/token/verify', { token, secret: SECRET }); + assert.strictEqual(replay.body.valid, false); + assert.strictEqual(replay.body.reason, 'token_already_used'); + + const retryToken = await mintAcrossInstances('redis-cross-instance-idempotency'); + const request = { secret: SECRET, response: retryToken, idempotency_key: 'cross-instance-retry' }; + const original = await post(A, '/siteverify', request); + assert.strictEqual(original.body.success, true, JSON.stringify(original.body)); + const retry = await post(B, '/siteverify', request); + assert.deepStrictEqual(retry.body, original.body); + + console.log('cross-instance Redis conformance passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); From 50dc0ab9df8e3377fd7c2ee4181ee65ccd90af50 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 22:51:30 -0500 Subject: [PATCH 2/2] test: use clean browser signals for Redis conformance --- test/redis-conformance.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/redis-conformance.js b/test/redis-conformance.js index 74afadc..830ca9d 100644 --- a/test/redis-conformance.js +++ b/test/redis-conformance.js @@ -9,7 +9,18 @@ const B = process.argv[3] || 'http://localhost:3102'; const SECRET = process.env.FCAPTCHA_SECRET; if (!SECRET) throw new Error('FCAPTCHA_SECRET is required'); const VISITOR = '203.0.113.25'; -const HEADERS = { 'content-type': 'application/json', 'x-real-ip': VISITOR, origin: 'https://example.com' }; +const HEADERS = { + 'content-type': 'application/json', 'x-real-ip': VISITOR, + origin: 'https://example.com', 'user-agent': 'Mozilla/5.0 Chrome/120.0.0.0', + 'accept-language': 'en-US,en;q=0.9', 'accept-encoding': 'gzip, deflate, br' +}; +const HUMAN_SIGNALS = { + behavioral: { + totalPoints: 80, trajectoryLength: 350, interactionDuration: 2000, + velocityVariance: 0.8, microTremorScore: 0.6, directionChanges: 15, + mouseEventRate: 60, approachPoints: 12 + } +}; async function post(server, path, body, headers = HEADERS) { const response = await fetch(`${server}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }); @@ -17,7 +28,7 @@ async function post(server, path, body, headers = HEADERS) { } async function mintAcrossInstances(siteKey) { - const { body } = await buildVerifyBody(A, siteKey, {}, { 'X-Real-IP': VISITOR }); + const { body } = await buildVerifyBody(A, siteKey, HUMAN_SIGNALS, HEADERS); const result = await post(B, '/api/verify', body); assert.strictEqual(result.status, 200); assert.strictEqual(result.body.success, true, JSON.stringify(result.body));