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
12 changes: 6 additions & 6 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ 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 currently shares only PoW challenge/claim state, and Python
does not yet use Redis; both must remain single-instance.
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.

Run:

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

**Important:** Multiple Go instances require `REDIS_URL`; without it, all state
is process-local. Node shares only PoW state when Redis is configured; its other
stores remain local. Python remains entirely process-local. Both must run as one
instance.
is process-local. Node also supports multiple instances with Redis. Python
remains entirely process-local and must run as one instance.

---

Expand All @@ -439,7 +439,7 @@ 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 | - | Go only: Redis URL for shared security state. Required for multiple replicas; Node and Python ignore it and must stay single-instance. Configuration and runtime failures are fail-closed |
| `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 |
| `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
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,8 @@ 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 server can run multiple replicas. Node currently shares
PoW challenge/claim state only; its other stores remain local, so it must remain
single-instance. Python does not yet use Redis and must also remain
single-instance.
With `REDIS_URL`, the Go and Node servers can run multiple replicas. Python does
not yet use Redis and must remain single-instance.

Kubernetes:

Expand Down Expand Up @@ -783,7 +781,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 PoW challenges, token replay protection, Siteverify idempotency, site-key state and suspicion history across replicas. **Go server only** — Node and Python ignore it and must stay single-instance. The server refuses to start if a configured Redis is unreachable, and fails closed if it drops out | (unset, process-local state) |
| `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) |
| `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
115 changes: 114 additions & 1 deletion server-node/redis-state.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
'use strict';

const crypto = require('crypto');
const { createClient } = require('redis');

const PREFIX = 'fcaptcha:v1:';
const POW_TTL_MS = 5 * 60 * 1000;
const SPENT_TTL_MS = 10 * 60 * 1000;
const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
const DETECTION_TTL_MS = 15 * 60 * 1000;

const CLAIM_CHALLENGE = `
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
`;
const RATE_CHECK = `
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}
`;
const SITEKEY_CLAIM = `
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 {
constructor(url, client = null) {
Expand All @@ -28,6 +53,11 @@ class RedisState {
return `${PREFIX}pow:challenge:${id}`;
}

opaqueKey(kind, value) {
const digest = crypto.createHash('sha256').update(value).digest('hex');
return `${PREFIX}${kind}:${digest}`;
}

async putChallenge(challenge) {
const ttl = challenge.expiresAt - Date.now();
if (ttl <= 0) throw new Error('challenge already expired');
Expand Down Expand Up @@ -55,6 +85,89 @@ class RedisState {
reason: result === -1 ? 'solution_already_used' : 'challenge_not_found'
};
}

async claimToken(signature) {
return Boolean(await this.client.set(
`${PREFIX}token:spent:${signature}`,
'1',
{ NX: true, PX: SPENT_TTL_MS }
));
}

idempotencyKey(idempotencyKey, token) {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
const composite = `${idempotencyKey}:${tokenHash}`;
const opaque = crypto.createHash('sha256').update(composite).digest('hex');
return `${PREFIX}siteverify:idempotency:${opaque}`;
}

async getIdempotency(idempotencyKey, token) {
if (!idempotencyKey) return null;
const payload = await this.client.get(this.idempotencyKey(idempotencyKey, token));
return payload ? JSON.parse(payload) : null;
}

async setIdempotency(idempotencyKey, token, response) {
if (!idempotencyKey) return;
await this.client.set(
this.idempotencyKey(idempotencyKey, token),
JSON.stringify(response),
{ PX: IDEMPOTENCY_TTL_MS }
);
}

async rateCheck(key, windowSeconds, maxRequests) {
const now = Date.now();
const result = await this.client.eval(RATE_CHECK, {
keys: [this.opaqueKey('rate', key)],
arguments: [
String(now - windowSeconds * 1000), String(now), String(maxRequests),
`${now}:${crypto.randomBytes(8).toString('hex')}`, String(windowSeconds * 1000 + 1000)
]
});
return [Number(result[1]) === 0, Number(result[0])];
}

async recordSuspicion(siteKey, ip) {
const now = Date.now();
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
await this.client.multi()
.zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
.zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
.zRemRangeByRank(key, 0, -17)
.pExpire(key, DETECTION_TTL_MS)
.exec();
}

async suspicionCount(siteKey, ip) {
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
await this.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS);
return Number(await this.client.zCard(key));
}

async recordFingerprint(fingerprint, ip, siteKey) {
const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
const ipKey = this.opaqueKey('fingerprint:fps', ip);
await this.client.multi()
.sAdd(fpKey, this.opaqueKey('value:ip', ip)).pExpire(fpKey, DETECTION_TTL_MS)
.sAdd(ipKey, this.opaqueKey('value:fp', fingerprint)).pExpire(ipKey, DETECTION_TTL_MS)
.exec();
}

async ipFingerprintCount(ip) {
return Number(await this.client.sCard(this.opaqueKey('fingerprint:fps', ip)));
}

async fingerprintIpCount(fingerprint, siteKey) {
return Number(await this.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
}

async claimSiteKey(siteKey, ip, maxPerIp) {
return Number(await this.client.eval(SITEKEY_CLAIM, {
keys: [this.opaqueKey('sitekeys', ip)],
arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
})) === 1;
}
}

module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS };
module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS, IDEMPOTENCY_TTL_MS };
14 changes: 13 additions & 1 deletion server-node/redis-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ class FakeRedis {
}
on() {}
async ping() { return 'PONG'; }
async set(key, value) { this.values.set(key, value); return 'OK'; }
async set(key, value, options = {}) {
if (options.NX && this.values.has(key)) return null;
this.values.set(key, value);
return 'OK';
}
async get(key) { return this.values.get(key) || null; }
async eval(_script, { keys }) {
if (!this.values.has(keys[0])) return 0;
Expand Down Expand Up @@ -42,6 +46,14 @@ class FakeRedis {
await issuer.claimChallenge(challenge.id, 'challenge-1:7'),
{ claimed: false, reason: 'challenge_not_found' }
);

assert.strictEqual(await verifier.claimToken('token-signature'), true);
assert.strictEqual(await issuer.claimToken('token-signature'), false);

const response = { success: true, hostname: 'example.com', 'error-codes': [] };
await issuer.setIdempotency('retry-key', 'token', response);
assert.deepStrictEqual(await verifier.getIdempotency('retry-key', 'token'), response);
assert.strictEqual(await verifier.getIdempotency('retry-key', 'different-token'), null);
console.log('redis shared-state tests passed');
})().catch((err) => {
console.error(err);
Expand Down
Loading
Loading