@webdecoy/fcaptcha 1.36.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/admission.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // Fixed windows bound both request work and limiter memory. A full table denies
4
+ // new sources; it never evicts a source that still has a live quota.
5
+ class AdmissionLimiter {
6
+ constructor({ maxEntries = 100000, now = Date.now } = {}) {
7
+ this.entries = new Map();
8
+ this.maxEntries = maxEntries;
9
+ this.now = now;
10
+ this.nextCleanup = 0;
11
+ }
12
+
13
+ allow(key, maximum, seconds = 60) {
14
+ const now = this.now();
15
+ if (now >= this.nextCleanup) {
16
+ for (const [k, entry] of this.entries) if (entry.expires <= now) this.entries.delete(k);
17
+ this.nextCleanup = now + 60000;
18
+ }
19
+ let entry = this.entries.get(key);
20
+ if (entry && entry.expires <= now) { this.entries.delete(key); entry = null; }
21
+ if (!entry) {
22
+ if (this.entries.size >= this.maxEntries) return false;
23
+ entry = { count: 0, expires: now + seconds * 1000 };
24
+ this.entries.set(key, entry);
25
+ }
26
+ if (entry.count >= maximum) return false;
27
+ entry.count++;
28
+ return true;
29
+ }
30
+ }
31
+
32
+ // Insertion order is expiry order (all challenges have the same lifetime).
33
+ // Cleanup only visits expired entries, rather than scanning the live map.
34
+ class ChallengeMap extends Map {
35
+ constructor(maxEntries = 100000) { super(); this.maxEntries = maxEntries; }
36
+ pruneExpired(now = Date.now()) {
37
+ for (const [id, challenge] of this) {
38
+ if (challenge.expiresAt > now) break;
39
+ this.delete(id);
40
+ }
41
+ }
42
+ set(id, challenge) {
43
+ this.pruneExpired();
44
+ if (!this.has(id) && this.size >= this.maxEntries) throw new Error('challenge_store_full');
45
+ return super.set(id, challenge);
46
+ }
47
+ prune() { this.pruneExpired(); }
48
+ }
49
+
50
+ module.exports = { AdmissionLimiter, ChallengeMap };
package/config.js CHANGED
@@ -10,7 +10,7 @@ let devModeWarned = false;
10
10
 
11
11
  function signingSecretFromEnv(env = process.env) {
12
12
  const secret = String(env.FCAPTCHA_SECRET || '').trim();
13
- if (secret && secret !== INSECURE_DEFAULT_SECRET) return secret;
13
+ if (Buffer.byteLength(secret) >= 32 && new Set(secret).size >= 8 && secret !== INSECURE_DEFAULT_SECRET) return secret;
14
14
  if (/^(1|true|yes|on)$/i.test(String(env.FCAPTCHA_INSECURE_DEV_MODE || '').trim())) {
15
15
  if (!devModeWarned) {
16
16
  devModeWarned = true;
@@ -18,7 +18,7 @@ function signingSecretFromEnv(env = process.env) {
18
18
  }
19
19
  return INSECURE_DEFAULT_SECRET;
20
20
  }
21
- throw new Error('FCAPTCHA_SECRET is required and must not be the public development key. For local-only development, explicitly set FCAPTCHA_INSECURE_DEV_MODE=1.');
21
+ throw new Error('FCAPTCHA_SECRET is required: use at least 32 random bytes (openssl rand -hex 32), not a short or repetitive password. For local-only development, explicitly set FCAPTCHA_INSECURE_DEV_MODE=1.');
22
22
  }
23
23
 
24
24
  function signingSecret(explicit, env = process.env) {
package/engine.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
3
5
  /**
4
6
  * Shared detection and scoring core.
5
7
  *
@@ -130,6 +132,90 @@ function setInteractionMode(signals, widget) {
130
132
  * Whether click-derived behavioural fields carry meaning for this request.
131
133
  * Absent context means widget mode — the long-standing behaviour.
132
134
  */
135
+ // Caps how many tokens one page instance on one device at one address can be
136
+ // issued per minute. A person verifies a form once and retries a handful of
137
+ // times; the automated client observed on the public demo (2026-09-09) verified
138
+ // fourteen times in fifty-two seconds. Same limit as the per-address detection
139
+ // so the two read consistently in logs.
140
+ const DEVICE_VERIFICATIONS_PER_MINUTE = 10;
141
+
142
+ const nested = (o, ...keys) => keys.reduce((v, k) => (v && typeof v === 'object') ? v[k] : undefined, o);
143
+
144
+ // Coarse device identity used for cardinality checks and the per-device rate
145
+ // gate: canvas hash, WebGL renderer, platform and core count. Identical
146
+ // hardware produces identical values, which is why the rate gate also keys on
147
+ // the widget instance.
148
+ function deviceFingerprint(signals) {
149
+ const env = (signals && signals.environmental) || {};
150
+ const automation = env.automationFlags || {};
151
+ const components = [
152
+ String(nested(env, 'canvasHash', 'hash') || ''),
153
+ String(nested(env, 'webglInfo', 'renderer') || ''),
154
+ String(automation.platform || ''),
155
+ String(automation.hardwareConcurrency || '')
156
+ ];
157
+ return crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
158
+ }
159
+
160
+ // The per-page-load id the widget or invisible session reports about itself.
161
+ // Bounded, because it is client-supplied and becomes part of a rate-limiter key.
162
+ function widgetInstance(signals) {
163
+ const meta = (signals && signals.meta) || {};
164
+ const id = typeof meta.sessionId === 'string' && meta.sessionId ? meta.sessionId
165
+ : typeof meta.widgetId === 'string' ? meta.widgetId : '';
166
+ return id.slice(0, 64);
167
+ }
168
+
169
+ // The rate gate as one function so server.js and index.js cannot drift.
170
+ // Returns the detection to record when the budget is exhausted, else null.
171
+ function deviceRateDetection(exceeded, count) {
172
+ if (!exceeded) return null;
173
+ return { category: 'rate_limit', score: 0.8, confidence: 0.9,
174
+ reason: 'Verification rate exceeded for this device (per-minute)', details: { count, window: 60 } };
175
+ }
176
+ const deviceRateKey = (siteKey, ip, signals, instance) => `device:${siteKey}:${ip}:${deviceFingerprint(signals)}:${instance}`;
177
+
178
+ /**
179
+ * Keyboard-only accessibility exemption.
180
+ *
181
+ * A visitor who works the page from the keyboard — a screen-reader user, a
182
+ * switch-access user, anyone who never touches a pointer — produces key events
183
+ * and no pointer samples, and must not be scored as "no mouse movement". An
184
+ * agent typing through an automation protocol produces exactly the same counts,
185
+ * which is how a keyboard-driven form filler claimed the exemption on the public
186
+ * demo (2026-09-09) and was scored as a person.
187
+ *
188
+ * The counts cannot tell them apart; the key holds can. A finger on a key holds
189
+ * it for tens of milliseconds (the bench's keyboard personas never drop below
190
+ * 41ms); an automation protocol releases it in one or two. So the exemption
191
+ * asks, when hold data exists, that it look like fingers. With no hold data at
192
+ * all — a widget older than this check, or a visitor who only tabbed to the
193
+ * checkbox and never typed into a field — the exemption stands, because refusing
194
+ * it would score exactly the people it protects.
195
+ */
196
+ // Average keydown→keyup hold below which the keys were not pressed by a hand:
197
+ // well under the 41ms bench floor, well over the 1–8ms an automation protocol
198
+ // produces. A real-human capture should confirm it before it is tightened.
199
+ const MACHINE_KEY_HOLD_MS = 20;
200
+ // How many holds the average has to rest on.
201
+ const MIN_KEY_HOLD_SAMPLES = 3;
202
+
203
+ function mechanicalKeyHold(signals, b) {
204
+ let n = Number(b.keyHoldSamples) || 0;
205
+ let sum = n * (Number(b.keyHoldAvg) || 0);
206
+ const fields = nested(signals, 'formAnalysis', 'textareaKeyboard') || {};
207
+ for (const stats of Object.values(fields)) {
208
+ const dwells = stats && Array.isArray(stats.dwellTimes) ? stats.dwellTimes : [];
209
+ for (const d of dwells) { if (typeof d === 'number') { n++; sum += d; } }
210
+ }
211
+ return n >= MIN_KEY_HOLD_SAMPLES && sum / n < MACHINE_KEY_HOLD_MS;
212
+ }
213
+
214
+ function keyboardOnlyUser(signals, b) {
215
+ if ((b.keyEvents ?? 0) < 2 || (b.totalPoints ?? 0) !== 0) return false;
216
+ return !mechanicalKeyHold(signals, b);
217
+ }
218
+
133
219
  function hasWidgetInteraction(signals) {
134
220
  const ctx = signals && signals[SERVER_CONTEXT_KEY];
135
221
  if (!ctx || typeof ctx.widgetInteraction !== 'boolean') return true;
@@ -149,7 +235,7 @@ function detectVisionAI(signals) {
149
235
  const touchEvents = b.touchEvents ?? 0;
150
236
  const keyEvents = b.keyEvents ?? 0;
151
237
  const isTouchUser = isTouchModality(b);
152
- const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
238
+ const isKeyboardUser = keyboardOnlyUser(signals, b);
153
239
  // Click-derived fields only exist when a widget was there to click.
154
240
  const isWidget = hasWidgetInteraction(signals);
155
241
 
@@ -427,12 +513,8 @@ function detectAutomation(signals) {
427
513
  // JS execution timing
428
514
  const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
429
515
  if (jsTime > 0) {
430
- if (jsTime < 0.1) {
431
- detections.push({
432
- category: 'automation', score: 0.4, confidence: 0.3,
433
- reason: 'JS execution unusually fast'
434
- });
435
- } else if (jsTime > 50) {
516
+ // Fast execution is common on real hardware and is not evidence of automation.
517
+ if (jsTime > 50) {
436
518
  detections.push({
437
519
  category: 'automation', score: 0.3, confidence: 0.3,
438
520
  reason: 'JS execution unusually slow'
@@ -550,7 +632,7 @@ function detectBehavioral(signals) {
550
632
  const touchEvts = b.touchEvents ?? 0;
551
633
  const keyEvts = b.keyEvents ?? 0;
552
634
  const isTouchUsr = isTouchModality(b);
553
- const isKbdUser = keyEvts >= 2 && totalPoints === 0;
635
+ const isKbdUser = keyboardOnlyUser(signals, b);
554
636
 
555
637
  if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
556
638
  detections.push({
@@ -961,6 +1043,15 @@ function applyCorroborationFloor(score, detections) {
961
1043
 
962
1044
  module.exports = {
963
1045
  // Constants
1046
+ DEVICE_VERIFICATIONS_PER_MINUTE,
1047
+ MACHINE_KEY_HOLD_MS,
1048
+ MIN_KEY_HOLD_SAMPLES,
1049
+ deviceFingerprint,
1050
+ widgetInstance,
1051
+ deviceRateKey,
1052
+ deviceRateDetection,
1053
+ keyboardOnlyUser,
1054
+ mechanicalKeyHold,
964
1055
  AUTOMATION_UA_PATTERNS,
965
1056
  WEIGHTS,
966
1057
  DISPOSITIVE_FLOOR,
package/index.js CHANGED
@@ -14,6 +14,8 @@ const detection = require('./detection');
14
14
  const { ProxyTrust, networkIdentity } = require('./clientip');
15
15
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
16
16
  const { signingSecret } = require('./config');
17
+ const { AdmissionLimiter, ChallengeMap } = require('./admission');
18
+ const { ipBinding } = require('./ip-binding');
17
19
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
18
20
  const { FingerprintStore } = require('./fingerprint-store');
19
21
  const { TokenStore } = require('./token-store');
@@ -26,12 +28,14 @@ const { resolveSignals } = require('./protocol');
26
28
  class PoWChallengeStore {
27
29
  constructor(options = {}) {
28
30
  this.secret = signingSecret(options.secret);
29
- this.challenges = new BoundedMap();
31
+ this.challenges = new ChallengeMap();
32
+ this.issuance = new AdmissionLimiter();
30
33
  this.usedSolutions = new BoundedSet();
31
34
  this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
32
35
  }
33
36
 
34
37
  generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
38
+ if (!this.issuance.allow(ip, 128, Math.ceil(this.expirationMs / 1000))) throw new Error('challenge_quota_exceeded');
35
39
  const challengeId = crypto.randomBytes(16).toString('hex');
36
40
  const timestamp = Date.now();
37
41
  const expiresAt = timestamp + this.expirationMs;
@@ -187,6 +191,7 @@ const {
187
191
  calculateFinalScore,
188
192
  applyDispositiveFloor,
189
193
  applyCorroborationFloor,
194
+ widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
190
195
  } = require('./engine');
191
196
  const { detectInputForensics } = require('./inputforensics');
192
197
 
@@ -342,6 +347,17 @@ class ScoringEngine {
342
347
  // Calculate scores
343
348
  // Same aggregation as server.js: noisy-OR within a category, weighted sum
344
349
  // across them, then the dispositive and corroboration floors.
350
+ // Per-device verification rate — a precondition, not evidence. Keyed on the
351
+ // widget instance too, so identical machines behind one NAT do not share a
352
+ // budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
353
+ let deviceRateExceeded = false;
354
+ const instance = widgetInstance(signals);
355
+ if (instance) {
356
+ const [exceeded, count] = this.rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
357
+ const hit = deviceRateDetection(exceeded, count);
358
+ if (hit) { deviceRateExceeded = true; detections.push(hit); }
359
+ }
360
+
345
361
  const categoryScores = calculateCategoryScores(detections, this.weights);
346
362
  const finalScore = applyCorroborationFloor(
347
363
  applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
@@ -358,7 +374,7 @@ class ScoringEngine {
358
374
  // dilute it below the allow threshold and used to mint a token for an empty
359
375
  // request. Keep this gate independent of scoring so weight changes cannot
360
376
  // reopen the bypass.
361
- const success = finalScore < 0.5 && powSatisfied;
377
+ const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded;
362
378
  const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
363
379
 
364
380
  // Feed the ledger so the next challenge this source asks for is priced on
@@ -373,7 +389,7 @@ class ScoringEngine {
373
389
  recommendation,
374
390
  categoryScores,
375
391
  detections,
376
- ...(!powSatisfied ? { reason: 'pow_not_satisfied' } : {})
392
+ ...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' } : {})
377
393
  };
378
394
  }
379
395
 
@@ -396,7 +412,7 @@ class ScoringEngine {
396
412
  return { valid: false, reason: 'invalid_signature' };
397
413
  }
398
414
 
399
- if (ip && decoded.ip_hash !== crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8)) {
415
+ if (ip && decoded.ip_hash !== ipBinding(this.secret, ip)) {
400
416
  return { valid: false, reason: 'ip_mismatch' };
401
417
  }
402
418
  const claim = this.tokenStore.claim(sig);
@@ -483,7 +499,7 @@ class ScoringEngine {
483
499
  }
484
500
 
485
501
  _generateToken(ip, siteKey, score) {
486
- const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
502
+ const ipHash = ipBinding(this.secret, ip);
487
503
  const data = {
488
504
  site_key: siteKey,
489
505
  jti: crypto.randomBytes(16).toString('hex'),
package/ip-binding.js ADDED
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+ const crypto = require('crypto');
3
+ const ipaddr = require('ipaddr.js');
4
+
5
+ function ipBinding(secret, ip) {
6
+ let canonical = String(ip);
7
+ try { canonical = ipaddr.process(canonical).toString(); } catch (_) { /* non-IP test identities */ }
8
+ const key = crypto.createHmac('sha256', secret).update('fcaptcha:ip-binding:v2').digest();
9
+ return crypto.createHmac('sha256', key).update(canonical).digest('hex');
10
+ }
11
+ module.exports = { ipBinding };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,14 +15,14 @@
15
15
  "test:clientip": "node clientip.test.js",
16
16
  "test:limits": "node limits.test.js",
17
17
  "test:detection": "node detection.test.js",
18
- "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test suspicion.test.js security.test.js corroboration.test.js",
18
+ "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.test.js",
19
19
  "test:siteverify": "node siteverify.test.js",
20
20
  "test:forensics": "node inputforensics.test.js",
21
21
  "test:suspicion": "node --test suspicion.test.js"
22
22
  },
23
23
  "dependencies": {
24
24
  "cors": "^2.8.5",
25
- "express": "^4.18.0",
25
+ "express": "^4.22.3",
26
26
  "ipaddr.js": "^2.4.0",
27
27
  "redis": "^4.6.12",
28
28
  "web-bot-auth": "^0.1.3"
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "engines": {
39
- "node": ">=18.0.0"
39
+ "node": ">=22.0.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",
package/redis-state.js CHANGED
@@ -8,6 +8,23 @@ const POW_TTL_MS = 5 * 60 * 1000;
8
8
  const SPENT_TTL_MS = 10 * 60 * 1000;
9
9
  const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
10
10
  const DETECTION_TTL_MS = 15 * 60 * 1000;
11
+ const ADMIT = `
12
+ local count = tonumber(redis.call('GET', KEYS[1]) or '0')
13
+ if count >= tonumber(ARGV[1]) then return 0 end
14
+ redis.call('INCR', KEYS[1])
15
+ if count == 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
16
+ return 1
17
+ `;
18
+ const PUT_CHALLENGE = `
19
+ for i = 2, 3 do redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', ARGV[1]) end
20
+ if redis.call('ZCARD', KEYS[2]) >= 100000 or redis.call('ZCARD', KEYS[3]) >= 128 then return 0 end
21
+ if not redis.call('SET', KEYS[1], ARGV[4], 'NX', 'PX', ARGV[3]) then return 0 end
22
+ for i = 2, 3 do
23
+ redis.call('ZADD', KEYS[i], ARGV[2], KEYS[1])
24
+ redis.call('PEXPIRE', KEYS[i], ARGV[3])
25
+ end
26
+ return 1
27
+ `;
11
28
 
12
29
  const CLAIM_CHALLENGE = `
13
30
  if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
@@ -50,8 +67,22 @@ return 1
50
67
 
51
68
  class RedisState {
52
69
  constructor(url, client = null) {
53
- this.client = client || createClient({ url });
70
+ this.client = client || createClient({ url, disableOfflineQueue: true, commandsQueueMaxLength: 1024, socket: { connectTimeout: 2000 } });
54
71
  this.client.on?.('error', (err) => console.error('[redis]', err.message));
72
+ this.commandTimeoutMs = 2000;
73
+ }
74
+
75
+ async deadline(promise) {
76
+ let timer;
77
+ try {
78
+ return await Promise.race([promise, new Promise((_, reject) => {
79
+ timer = setTimeout(() => reject(new Error('state_unavailable')), this.commandTimeoutMs);
80
+ })]);
81
+ } finally { clearTimeout(timer); }
82
+ }
83
+
84
+ execute(method, ...args) {
85
+ return this.deadline(Promise.resolve().then(() => this.client[method](...args)));
55
86
  }
56
87
 
57
88
  async connect() {
@@ -59,6 +90,25 @@ class RedisState {
59
90
  await this.client.ping();
60
91
  }
61
92
 
93
+ async ready(timeoutMs = 2000) {
94
+ if (this.readinessPending) return this.readinessPending;
95
+ // node-redis 4's abort signal only handles queued commands and may outlive
96
+ // their queue nodes. Bound the probe externally instead. Retain a timed-out
97
+ // probe until it settles so repeated health checks cannot fill the queue.
98
+ let timer;
99
+ const ping = Promise.resolve().then(() => this.client.ping());
100
+ const pending = Promise.race([ping, new Promise((_, reject) => {
101
+ timer = setTimeout(() => reject(new Error('state_unavailable')), timeoutMs);
102
+ })]);
103
+ this.readinessPending = pending;
104
+ const settled = () => {
105
+ clearTimeout(timer);
106
+ if (this.readinessPending === pending) this.readinessPending = null;
107
+ };
108
+ ping.then(settled, settled);
109
+ return pending;
110
+ }
111
+
62
112
  challengeKey(id) {
63
113
  return `${PREFIX}pow:challenge:${id}`;
64
114
  }
@@ -74,18 +124,28 @@ class RedisState {
74
124
  // challengeId is the canonical cross-runtime field used by Go and Node.
75
125
  const stored = { ...challenge, challengeId: challenge.id };
76
126
  delete stored.id;
77
- await this.client.set(this.challengeKey(challenge.id), JSON.stringify(stored), { PX: ttl });
127
+ const added = await this.execute('eval', PUT_CHALLENGE, {
128
+ keys: [this.challengeKey(challenge.id), `${PREFIX}pow:quota:global`, this.opaqueKey('pow:quota:source', challenge.ip)],
129
+ arguments: [String(Date.now()), String(challenge.expiresAt), String(ttl), JSON.stringify(stored)]
130
+ });
131
+ if (Number(added) !== 1) throw new Error('challenge_quota_exceeded');
132
+ }
133
+
134
+ async admit(key, maximum, seconds = 60) {
135
+ return Number(await this.execute('eval', ADMIT, {
136
+ keys: [this.opaqueKey('admission', key)], arguments: [String(maximum), String(seconds * 1000)]
137
+ })) === 1;
78
138
  }
79
139
 
80
140
  async getChallenge(id) {
81
- const payload = await this.client.get(this.challengeKey(id));
141
+ const payload = await this.execute('get', this.challengeKey(id));
82
142
  if (!payload) return null;
83
143
  const challenge = JSON.parse(payload);
84
144
  return { ...challenge, id: challenge.challengeId };
85
145
  }
86
146
 
87
147
  async claimChallenge(challengeId, solutionKey) {
88
- const result = Number(await this.client.eval(CLAIM_CHALLENGE, {
148
+ const result = Number(await this.execute('eval', CLAIM_CHALLENGE, {
89
149
  keys: [this.challengeKey(challengeId), `${PREFIX}pow:spent:${solutionKey}`],
90
150
  arguments: [String(SPENT_TTL_MS)]
91
151
  }));
@@ -97,7 +157,7 @@ class RedisState {
97
157
  }
98
158
 
99
159
  async claimToken(signature) {
100
- return Boolean(await this.client.set(
160
+ return Boolean(await this.execute('set',
101
161
  `${PREFIX}token:spent:${signature}`,
102
162
  '1',
103
163
  { NX: true, PX: SPENT_TTL_MS }
@@ -113,13 +173,13 @@ class RedisState {
113
173
 
114
174
  async getIdempotency(idempotencyKey, token) {
115
175
  if (!idempotencyKey) return null;
116
- const payload = await this.client.get(this.idempotencyKey(idempotencyKey, token));
176
+ const payload = await this.execute('get', this.idempotencyKey(idempotencyKey, token));
117
177
  return payload ? JSON.parse(payload) : null;
118
178
  }
119
179
 
120
180
  async setIdempotency(idempotencyKey, token, response) {
121
181
  if (!idempotencyKey) return;
122
- await this.client.set(
182
+ await this.execute('set',
123
183
  this.idempotencyKey(idempotencyKey, token),
124
184
  JSON.stringify(response),
125
185
  { PX: IDEMPOTENCY_TTL_MS }
@@ -128,7 +188,7 @@ class RedisState {
128
188
 
129
189
  async rateCheck(key, windowSeconds, maxRequests) {
130
190
  const now = Date.now();
131
- const result = await this.client.eval(RATE_CHECK, {
191
+ const result = await this.execute('eval', RATE_CHECK, {
132
192
  keys: [this.opaqueKey('rate', key)],
133
193
  arguments: [
134
194
  String(now - windowSeconds * 1000), String(now), String(maxRequests),
@@ -141,39 +201,39 @@ class RedisState {
141
201
  async recordSuspicion(siteKey, ip) {
142
202
  const now = Date.now();
143
203
  const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
144
- await this.client.multi()
204
+ await this.deadline(this.client.multi()
145
205
  .zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
146
206
  .zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
147
207
  .zRemRangeByRank(key, 0, -17)
148
208
  .pExpire(key, DETECTION_TTL_MS)
149
- .exec();
209
+ .exec());
150
210
  }
151
211
 
152
212
  async suspicionCount(siteKey, ip) {
153
213
  const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
154
- await this.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS);
155
- return Number(await this.client.zCard(key));
214
+ await this.execute('zRemRangeByScore', key, '-inf', Date.now() - DETECTION_TTL_MS);
215
+ return Number(await this.execute('zCard', key));
156
216
  }
157
217
 
158
218
  async recordFingerprint(fingerprint, ip, siteKey) {
159
219
  const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
160
220
  const ipKey = this.opaqueKey('fingerprint:fps', ip);
161
- await this.client.eval(RECORD_FINGERPRINT, {
221
+ await this.execute('eval', RECORD_FINGERPRINT, {
162
222
  keys: [fpKey, ipKey],
163
223
  arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
164
224
  });
165
225
  }
166
226
 
167
227
  async ipFingerprintCount(ip) {
168
- return Number(await this.client.sCard(this.opaqueKey('fingerprint:fps', ip)));
228
+ return Number(await this.execute('sCard', this.opaqueKey('fingerprint:fps', ip)));
169
229
  }
170
230
 
171
231
  async fingerprintIpCount(fingerprint, siteKey) {
172
- return Number(await this.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
232
+ return Number(await this.execute('sCard', this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
173
233
  }
174
234
 
175
235
  async claimSiteKey(siteKey, ip, maxPerIp) {
176
- return Number(await this.client.eval(SITEKEY_CLAIM, {
236
+ return Number(await this.execute('eval', SITEKEY_CLAIM, {
177
237
  keys: [this.opaqueKey('sitekeys', ip)],
178
238
  arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
179
239
  })) === 1;
package/server.js CHANGED
@@ -13,6 +13,8 @@ const webbotauth = require('./webbotauth');
13
13
  const { ProxyTrust, networkIdentity } = require('./clientip');
14
14
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
15
  const { signingSecretFromEnv } = require('./config');
16
+ const { AdmissionLimiter, ChallengeMap } = require('./admission');
17
+ const { ipBinding } = require('./ip-binding');
16
18
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
17
19
  const { detectInputForensics } = require('./inputforensics');
18
20
  const { RedisState } = require('./redis-state');
@@ -172,11 +174,13 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
172
174
 
173
175
  // PoW Challenge Store
174
176
  const powChallengeStore = {
175
- challenges: new BoundedMap(),
177
+ challenges: new ChallengeMap(),
178
+ issuance: new AdmissionLimiter(),
176
179
  usedSolutions: new BoundedSet(),
177
180
 
178
181
  // Generate a new challenge
179
182
  async generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
183
+ if (!SHARED_STATE && !this.issuance.allow(ip, 128, 300)) throw new Error('challenge_quota_exceeded');
180
184
  const challengeId = crypto.randomBytes(16).toString('hex');
181
185
  const nonce = crypto.randomBytes(16).toString('hex');
182
186
  const timestamp = Date.now();
@@ -369,6 +373,7 @@ const {
369
373
  calculateFinalScore,
370
374
  applyDispositiveFloor,
371
375
  applyCorroborationFloor,
376
+ widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
372
377
  } = require('./engine');
373
378
 
374
379
  // Stateful detectors stay here: they read the module-level stores below.
@@ -450,7 +455,7 @@ async function detectRateAbuse(ip, siteKey) {
450
455
  // happened to send would be a second payload shape to keep in sync across three
451
456
  // languages. Fixed shape, empty when unknown.
452
457
  function generateToken(ip, siteKey, score, binding = {}) {
453
- const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
458
+ const ipHash = ipBinding(SECRET_KEY, ip);
454
459
  const data = {
455
460
  site_key: siteKey,
456
461
  jti: crypto.randomBytes(16).toString('hex'),
@@ -489,7 +494,7 @@ async function verifyToken(token, ip = null) {
489
494
 
490
495
  // Verify IP matches (if provided)
491
496
  if (ip) {
492
- const expectedIpHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
497
+ const expectedIpHash = ipBinding(SECRET_KEY, ip);
493
498
  if (decoded.ip_hash !== expectedIpHash) {
494
499
  return { valid: false, reason: 'ip_mismatch' };
495
500
  }
@@ -675,6 +680,18 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
675
680
  detections.push(...await detectFingerprint(signals, ip, siteKey));
676
681
  detections.push(...await detectRateAbuse(ip, siteKey));
677
682
 
683
+ // Per-device verification rate — a precondition, not evidence: rate_limit
684
+ // weighs 0.01, so the per-address detection above can never move a verdict.
685
+ // Keyed on the widget instance too, so identical machines behind one NAT do
686
+ // not share a budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
687
+ let deviceRateExceeded = false;
688
+ const instance = widgetInstance(signals);
689
+ if (instance) {
690
+ const [exceeded, count] = await rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
691
+ const hit = deviceRateDetection(exceeded, count);
692
+ if (hit) { deviceRateExceeded = true; detections.push(hit); }
693
+ }
694
+
678
695
  // Add IP reputation check (async but we'll use sync version for simplicity)
679
696
  if (detection.isDatacenterIP(ip)) {
680
697
  detections.push({
@@ -754,7 +771,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
754
771
  // who never completed the challenge. Gating here means no future reweighting
755
772
  // can reopen the bypass, and it holds even if the dispositive floor is
756
773
  // lowered or removed.
757
- const success = finalScore < 0.5 && hostnameAllowed && powSatisfied;
774
+ const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded;
758
775
 
759
776
  // Name the failed precondition whenever one fails, not only when the score
760
777
  // would otherwise have allowed. Gating it on the score made the PoW case
@@ -762,7 +779,9 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
762
779
  // and the branch never fired — which is exactly the case a caller most needs
763
780
  // explained.
764
781
  let withheldReason = '';
765
- if (!powSatisfied) withheldReason = 'pow_not_satisfied';
782
+ // Rate first because it is the one the caller can act on: back off.
783
+ if (deviceRateExceeded) withheldReason = 'rate_limited';
784
+ else if (!powSatisfied) withheldReason = 'pow_not_satisfied';
766
785
  else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
767
786
 
768
787
  const token = success
@@ -813,7 +832,27 @@ function collectHeaders(req) {
813
832
  return headers;
814
833
  }
815
834
 
816
- const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
835
+ const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res, next)).catch(next);
836
+
837
+ const admission = new AdmissionLimiter();
838
+ app.use(['/api', '/siteverify', '/turnstile', '/recaptcha'], asyncRoute(async (req, res, next) => {
839
+ const ip = PROXY_TRUST.clientIP(req);
840
+ const allow = (key, max) => SHARED_STATE ? SHARED_STATE.admit(key, max) : admission.allow(key, max);
841
+ try {
842
+ if (!await allow('global', 20000) || !await allow(`source:${ip}`, 600) ||
843
+ (req.originalUrl.split('?')[0].replace(/\/+$/, '').toLowerCase() === '/api/pow/challenge' && !await allow(`challenge:${ip}`, 60))) {
844
+ return res.status(429).set('Retry-After', '60').json({ error: 'rate_limited' });
845
+ }
846
+ } catch (_) { return res.status(503).json({ error: 'state_unavailable' }); }
847
+ next();
848
+ }));
849
+
850
+ app.get('/ready', asyncRoute(async (req, res) => {
851
+ try {
852
+ if (SHARED_STATE) await SHARED_STATE.ready();
853
+ res.json({ status: 'ok' });
854
+ } catch (_) { res.status(503).json({ status: 'unavailable' }); }
855
+ }));
817
856
 
818
857
  function validateScoringRequest(req, res, next) {
819
858
  const body = req.body;
@@ -991,7 +1030,8 @@ app.use((err, req, res, next) => {
991
1030
 
992
1031
  async function start() {
993
1032
  if (SHARED_STATE) await SHARED_STATE.connect();
994
- const server = app.listen(PORT, () => {
1033
+ const host = SECRET_KEY === require('./config').INSECURE_DEFAULT_SECRET ? '127.0.0.1' : '0.0.0.0';
1034
+ const server = app.listen(PORT, host, () => {
995
1035
  console.log(`FCaptcha server running on port ${server.address().port}`);
996
1036
  console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
997
1037
  console.log(`Site keys: ${SITE_KEYS.describe()}`);
package/webbotauth.js CHANGED
@@ -65,7 +65,8 @@ const {
65
65
  const { verifierFromJWK } = require('web-bot-auth/crypto');
66
66
 
67
67
  const VERIFY_TIMEOUT_MS = 3000;
68
- const MAX_DIRECTORY_BYTES = 1 << 20; // 1 MiB, matches the Go client's cap
68
+ const directoryAgent = new https.Agent({ keepAlive: true, maxTotalSockets: 16, maxSockets: 2, maxFreeSockets: 2, timeout: VERIFY_TIMEOUT_MS });
69
+ const MAX_DIRECTORY_BYTES = 256 * 1024;
69
70
  const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
70
71
 
71
72
  // The exact message web-bot-auth/crypto throws when crypto.subtle.verify()
@@ -91,7 +92,11 @@ const REQUIRED_TAG = 'web-bot-auth';
91
92
  const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
92
93
 
93
94
  // origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
94
- const directoryCache = new Map();
95
+ const { BoundedMap } = require('./limits');
96
+ // Each response is at most 256 KiB. Sixteen entries bound retained payload bytes;
97
+ // limiting parsed keys also bounds key-import work per verification.
98
+ const directoryCache = new BoundedMap(16);
99
+ const directoryInflight = new Map();
95
100
 
96
101
  // FetchFailure marks failures that are NOT cryptographic — the caller treats
97
102
  // them as "could not verify" (fail open), never as a forged-signature signal.
@@ -159,6 +164,7 @@ function fetchJSON(documentUrl) {
159
164
  documentUrl,
160
165
  {
161
166
  lookup: guardedLookup,
167
+ agent: directoryAgent,
162
168
  timeout: VERIFY_TIMEOUT_MS,
163
169
  headers: {
164
170
  accept: 'application/http-message-signatures-directory+json, application/json',
@@ -191,6 +197,8 @@ function fetchJSON(documentUrl) {
191
197
  },
192
198
  );
193
199
 
200
+ const deadline = setTimeout(() => req.destroy(new FetchFailure('directory fetch deadline exceeded')), VERIFY_TIMEOUT_MS);
201
+ req.on('close', () => clearTimeout(deadline));
194
202
  req.on('timeout', () => {
195
203
  req.destroy(new FetchFailure('directory fetch timed out'));
196
204
  });
@@ -236,11 +244,38 @@ async function getKeys(url, type) {
236
244
  const now = Date.now();
237
245
  const cached = directoryCache.get(url);
238
246
  if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
247
+ if (cached.error) {
248
+ if (now - cached.fetchedAt < 30000) throw new FetchFailure('directory temporarily unavailable');
249
+ } else {
239
250
  return cached.keys;
251
+ }
240
252
  }
241
- const keys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
242
- directoryCache.set(url, { keys, fetchedAt: now });
243
- return keys;
253
+ if (directoryInflight.has(url)) return directoryInflight.get(url);
254
+ if (directoryInflight.size >= 8) throw new FetchFailure('directory fetch capacity reached');
255
+ const pending = (async () => {
256
+ try {
257
+ const rawKeys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
258
+ if (rawKeys.length > 64) throw new FetchFailure('too many directory keys');
259
+ // Cache only public JWK fields, not arbitrary nested directory metadata.
260
+ const keys = rawKeys.map(raw => {
261
+ if (!raw || typeof raw !== 'object') throw new FetchFailure('invalid directory key');
262
+ const key = {};
263
+ for (const field of ['kty', 'kid', 'alg', 'crv', 'x', 'y', 'n', 'e', 'use']) {
264
+ if (raw[field] == null) continue;
265
+ if (typeof raw[field] !== 'string' || raw[field].length > 4096) throw new FetchFailure('invalid key field');
266
+ key[field] = raw[field];
267
+ }
268
+ return key;
269
+ });
270
+ directoryCache.set(url, { keys, fetchedAt: Date.now() });
271
+ return keys;
272
+ } catch (error) {
273
+ directoryCache.set(url, { error: true, fetchedAt: Date.now() });
274
+ throw error;
275
+ } finally { directoryInflight.delete(url); }
276
+ })();
277
+ directoryInflight.set(url, pending);
278
+ return pending;
244
279
  }
245
280
 
246
281
  // resolveKey fetches the directory named by the Signature-Agent header and