@webdecoy/fcaptcha 1.34.1 → 1.35.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.
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { BoundedMap } = require('./limits');
4
+
5
+ const WINDOW_MS = 15 * 60 * 1000;
6
+ // Detectors only distinguish >5 and >10. Saturating at 16 preserves those
7
+ // decisions without retaining every attacker-selected value.
8
+ const MAX_MEMBERS = 16;
9
+
10
+ class FingerprintStore {
11
+ constructor({ maxEntries = 100000, ttlMs = WINDOW_MS, now = Date.now } = {}) {
12
+ this.fingerprints = new BoundedMap(maxEntries);
13
+ this.ipFingerprints = new BoundedMap(maxEntries);
14
+ this.ttlMs = ttlMs;
15
+ this.now = now;
16
+ }
17
+
18
+ _get(store, key) {
19
+ const entry = store.get(key);
20
+ if (entry && entry.expiresAt <= this.now()) {
21
+ store.delete(key);
22
+ return undefined;
23
+ }
24
+ return entry;
25
+ }
26
+
27
+ _add(store, key, value) {
28
+ let entry = this._get(store, key);
29
+ if (!entry) {
30
+ entry = { values: new Set(), expiresAt: this.now() + this.ttlMs };
31
+ store.set(key, entry);
32
+ }
33
+ if (entry.values.size < MAX_MEMBERS) entry.values.add(value);
34
+ }
35
+
36
+ record(fp, ip, siteKey) {
37
+ const entry = this._get(this.ipFingerprints, ip);
38
+ // Do not allocate more fingerprint keys once this source has saturated.
39
+ if (entry && entry.values.size >= MAX_MEMBERS && !entry.values.has(fp)) return;
40
+ this._add(this.ipFingerprints, ip, fp);
41
+ this._add(this.fingerprints, `${siteKey}:${fp}`, ip);
42
+ }
43
+
44
+ getIpFpCount(ip) {
45
+ return this._get(this.ipFingerprints, ip)?.values.size || 0;
46
+ }
47
+
48
+ getFpIpCount(fp, siteKey) {
49
+ return this._get(this.fingerprints, `${siteKey}:${fp}`)?.values.size || 0;
50
+ }
51
+ }
52
+
53
+ module.exports = { FingerprintStore, WINDOW_MS, MAX_MEMBERS };
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Usage:
7
7
  * const fcaptcha = require('@webdecoy/fcaptcha');
8
8
  * const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
9
- * const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
9
+ * const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
10
10
  */
11
11
 
12
12
  const crypto = require('crypto');
@@ -14,6 +14,10 @@ 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 { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
18
+ const { FingerprintStore } = require('./fingerprint-store');
19
+ const { TokenStore } = require('./token-store');
20
+ const { resolveSignals } = require('./protocol');
17
21
 
18
22
  // =============================================================================
19
23
  // PoW Challenge Store (can be extended with Redis)
@@ -22,8 +26,8 @@ const { signingSecret } = require('./config');
22
26
  class PoWChallengeStore {
23
27
  constructor(options = {}) {
24
28
  this.secret = signingSecret(options.secret);
25
- this.challenges = new Map();
26
- this.usedSolutions = new Set();
29
+ this.challenges = new BoundedMap();
30
+ this.usedSolutions = new BoundedSet();
27
31
  this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
28
32
  }
29
33
 
@@ -41,6 +45,7 @@ class PoWChallengeStore {
41
45
  // How long the client must hold this challenge before submitting a
42
46
  // solution. Inside the signed payload so it cannot be talked down.
43
47
  minAgeMs,
48
+ nonce: crypto.randomBytes(16).toString('hex'),
44
49
  prefix: `${challengeId}:${timestamp}:${difficulty}`
45
50
  };
46
51
 
@@ -64,7 +69,7 @@ class PoWChallengeStore {
64
69
  return challengeData;
65
70
  }
66
71
 
67
- verify(challengeId, nonce, hash, siteKey, ip) {
72
+ verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
68
73
  const challenge = this.challenges.get(challengeId);
69
74
 
70
75
  if (!challenge) {
@@ -91,7 +96,7 @@ class PoWChallengeStore {
91
96
  }
92
97
 
93
98
  // Verify the hash
94
- const input = `${challenge.prefix}:${nonce}`;
99
+ const input = signalsHash ? `${challenge.prefix}:${signalsHash}:${nonce}` : `${challenge.prefix}:${nonce}`;
95
100
  const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
96
101
 
97
102
  if (hash !== expectedHash) {
@@ -111,6 +116,7 @@ class PoWChallengeStore {
111
116
  return {
112
117
  valid: true,
113
118
  difficulty: challenge.difficulty,
119
+ nonce: challenge.nonce,
114
120
  serverElapsed: Date.now() - challenge.timestamp,
115
121
  // Fall back for challenges issued before adaptive cost existed.
116
122
  minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
@@ -124,9 +130,6 @@ class PoWChallengeStore {
124
130
  this.challenges.delete(id);
125
131
  }
126
132
  }
127
- if (this.usedSolutions.size > 10000) {
128
- this.usedSolutions.clear();
129
- }
130
133
  }
131
134
  }
132
135
 
@@ -136,7 +139,7 @@ class PoWChallengeStore {
136
139
 
137
140
  class RateLimiter {
138
141
  constructor() {
139
- this.requests = new Map();
142
+ this.requests = new BoundedMap();
140
143
  }
141
144
 
142
145
  check(key, windowSeconds = 60, maxRequests = 10) {
@@ -157,42 +160,6 @@ class RateLimiter {
157
160
  }
158
161
  }
159
162
 
160
- // =============================================================================
161
- // Fingerprint Store
162
- // =============================================================================
163
-
164
- class FingerprintStore {
165
- constructor() {
166
- this.fingerprints = new Map();
167
- this.ipFingerprints = new Map();
168
- }
169
-
170
- record(fp, ip, siteKey) {
171
- const key = `${siteKey}:${fp}`;
172
-
173
- if (!this.fingerprints.has(key)) {
174
- this.fingerprints.set(key, { count: 0, ips: new Set() });
175
- }
176
- const data = this.fingerprints.get(key);
177
- data.count++;
178
- data.ips.add(ip);
179
-
180
- if (!this.ipFingerprints.has(ip)) {
181
- this.ipFingerprints.set(ip, new Set());
182
- }
183
- this.ipFingerprints.get(ip).add(fp);
184
- }
185
-
186
- getIpFpCount(ip) {
187
- return this.ipFingerprints.get(ip)?.size || 0;
188
- }
189
-
190
- getFpIpCount(fp, siteKey) {
191
- const key = `${siteKey}:${fp}`;
192
- return this.fingerprints.get(key)?.ips.size || 0;
193
- }
194
- }
195
-
196
163
  // =============================================================================
197
164
  // Scoring Engine
198
165
  // =============================================================================
@@ -231,6 +198,7 @@ class ScoringEngine {
231
198
  this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
232
199
  this.suspicion = options.suspicion || new SuspicionLedger();
233
200
  this.weights = options.weights || WEIGHTS;
201
+ this.tokenStore = options.tokenStore || new TokenStore();
234
202
  }
235
203
 
236
204
  // Generate a PoW challenge
@@ -257,8 +225,16 @@ class ScoringEngine {
257
225
  }
258
226
 
259
227
  // Verify signals and return score
260
- verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
228
+ verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null) {
229
+ const resolved = resolveSignals(signals, signalsJson, powSolution?.signalsHash);
230
+ signals = resolved.signals;
231
+ if (powTiming) {
232
+ signals.temporal = signals.temporal || {};
233
+ signals.temporal.pow = powTiming;
234
+ }
261
235
  const detections = [];
236
+ if (!resolved.commitmentValid) detections.push({ category: 'bot', score: 0.95,
237
+ confidence: 0.95, reason: 'Signals tampered after PoW (signalsHash mismatch)' });
262
238
  let powSatisfied = false;
263
239
 
264
240
  // Run all detection modules
@@ -288,7 +264,8 @@ class ScoringEngine {
288
264
  powSolution.nonce,
289
265
  powSolution.hash,
290
266
  siteKey,
291
- ip
267
+ ip,
268
+ powSolution.signalsHash
292
269
  );
293
270
 
294
271
  if (!powResult.valid) {
@@ -299,7 +276,7 @@ class ScoringEngine {
299
276
  reason: `PoW verification failed: ${powResult.reason}`
300
277
  });
301
278
  } else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
302
- powSatisfied = true;
279
+ powSatisfied = false;
303
280
  // Under the universal baseline nothing legitimate can have happened —
304
281
  // no human completes an interaction that fast.
305
282
  detections.push({
@@ -309,11 +286,9 @@ class ScoringEngine {
309
286
  reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
310
287
  });
311
288
  } else if (powResult.serverElapsed < powResult.minAgeMs) {
312
- powSatisfied = true;
313
- // Between the baseline and this source's own elevated floor is weaker
314
- // evidence: a client predating adaptive cost, or one served from a
315
- // stale cache, does not know to wait. It contributes rather than
316
- // deciding.
289
+ powSatisfied = false;
290
+ // Keep the diagnostic weaker than a baseline violation; token issuance
291
+ // independently requires the full advertised delay.
317
292
  detections.push({
318
293
  category: 'bot',
319
294
  score: 0.5,
@@ -321,7 +296,13 @@ class ScoringEngine {
321
296
  reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
322
297
  });
323
298
  } else {
324
- powSatisfied = true;
299
+ powSatisfied = resolved.commitmentValid;
300
+ }
301
+ // The server-issued nonce is required even for legacy, uncommitted PoW.
302
+ if (powResult.valid && powResult.nonce && signals.meta?.challengeNonce !== powResult.nonce) {
303
+ powSatisfied = false;
304
+ detections.push({ category: 'bot', score: 0.9, confidence: 0.9,
305
+ reason: 'Challenge nonce mismatch (signals not bound to challenge)' });
325
306
  }
326
307
  } else {
327
308
  detections.push({
@@ -397,7 +378,7 @@ class ScoringEngine {
397
378
  }
398
379
 
399
380
  // Verify a previously issued token
400
- verifyToken(token) {
381
+ verifyToken(token, ip = null) {
401
382
  try {
402
383
  const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
403
384
 
@@ -415,14 +396,22 @@ class ScoringEngine {
415
396
  return { valid: false, reason: 'invalid_signature' };
416
397
  }
417
398
 
399
+ if (ip && decoded.ip_hash !== crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8)) {
400
+ return { valid: false, reason: 'ip_mismatch' };
401
+ }
402
+ const claim = this.tokenStore.claim(sig);
403
+ if (!claim.claimed) return { valid: false, reason: claim.reason };
418
404
  return {
419
405
  valid: true,
420
406
  site_key: decoded.site_key,
421
407
  timestamp: decoded.timestamp,
422
- score: decoded.score
408
+ score: decoded.score,
409
+ hostname: decoded.hostname || '',
410
+ action: decoded.action || '',
411
+ cdata: decoded.cdata || ''
423
412
  };
424
413
  } catch (e) {
425
- return { valid: false, reason: e.message };
414
+ return { valid: false, reason: 'invalid_token' };
426
415
  }
427
416
  }
428
417
 
@@ -497,6 +486,7 @@ class ScoringEngine {
497
486
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
498
487
  const data = {
499
488
  site_key: siteKey,
489
+ jti: crypto.randomBytes(16).toString('hex'),
500
490
  timestamp: Math.floor(Date.now() / 1000),
501
491
  score: Math.round(score * 1000) / 1000,
502
492
  ip_hash: ipHash
@@ -516,6 +506,7 @@ class ScoringEngine {
516
506
 
517
507
  function createMiddleware(options = {}) {
518
508
  const engine = new ScoringEngine(options);
509
+ const siteKeys = SiteKeyGuard.fromEnv();
519
510
  const proxyTrust = options.trustedProxies !== undefined
520
511
  ? new ProxyTrust(options.trustedProxies)
521
512
  : ProxyTrust.fromEnv();
@@ -533,8 +524,9 @@ function createMiddleware(options = {}) {
533
524
 
534
525
  // Challenge route handler
535
526
  challengeHandler: (req, res) => {
536
- const siteKey = req.query.siteKey || 'default';
527
+ const rawSiteKey = req.query.siteKey || 'default';
537
528
  const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
529
+ const siteKey = siteKeys.normalize(rawSiteKey, ip);
538
530
  const challenge = engine.generateChallenge(siteKey, ip);
539
531
 
540
532
  res.json({
@@ -542,14 +534,17 @@ function createMiddleware(options = {}) {
542
534
  prefix: challenge.prefix,
543
535
  difficulty: challenge.difficulty,
544
536
  expiresAt: challenge.expiresAt,
545
- sig: challenge.sig
537
+ sig: challenge.sig,
538
+ nonce: challenge.nonce,
539
+ minAgeMs: challenge.minAgeMs
546
540
  });
547
541
  },
548
542
 
549
543
  // Verify route handler
550
544
  verifyHandler: (req, res) => {
551
- const { siteKey, signals, powSolution } = req.body;
545
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
552
546
  const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
547
+ const siteKey = siteKeys.normalize(rawSiteKey || 'default', ip);
553
548
  const userAgent = req.headers['user-agent'] || '';
554
549
 
555
550
  const headers = {};
@@ -557,14 +552,14 @@ function createMiddleware(options = {}) {
557
552
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
558
553
  }
559
554
 
560
- const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
555
+ const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
561
556
  res.json(result);
562
557
  },
563
558
 
564
559
  // Token verify route handler
565
560
  tokenVerifyHandler: (req, res) => {
566
- const { token } = req.body;
567
- res.json(engine.verifyToken(token));
561
+ const { token, remoteip } = req.body;
562
+ res.json(engine.verifyToken(token, typeof remoteip === 'string' ? remoteip : null));
568
563
  }
569
564
  };
570
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.34.1",
3
+ "version": "1.35.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@
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",
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",
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"
package/protocol.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+
7
+ function invalidRequest() {
8
+ return Object.assign(new Error('invalid_request'), { status: 400 });
9
+ }
10
+
11
+ // Resolve the exact bytes committed by the proof before any detector mutates
12
+ // signals. Legacy proofs without a commitment still use the signals object.
13
+ function resolveSignals(signals, signalsJson, signalsHash) {
14
+ let commitmentValid = true;
15
+ if (signalsHash) {
16
+ if (typeof signalsJson !== 'string' || typeof signalsHash !== 'string') {
17
+ commitmentValid = false;
18
+ } else {
19
+ commitmentValid = crypto.createHash('sha256').update(signalsJson).digest('hex') === signalsHash;
20
+ try { signals = JSON.parse(signalsJson); } catch { throw invalidRequest(); }
21
+ }
22
+ }
23
+ if (!isObject(signals)) throw invalidRequest();
24
+ for (const key of ['behavioral', 'environmental', 'temporal', 'meta', 'formAnalysis']) {
25
+ if (signals[key] != null && !isObject(signals[key])) throw invalidRequest();
26
+ }
27
+ return { signals, commitmentValid };
28
+ }
29
+
30
+ module.exports = { resolveSignals, isObject, invalidRequest };
package/redis-state.js CHANGED
@@ -38,6 +38,16 @@ redis.call('PEXPIRE', KEYS[1], ARGV[3])
38
38
  return 1
39
39
  `;
40
40
 
41
+ const RECORD_FINGERPRINT = `
42
+ if redis.call('SCARD', KEYS[2]) >= 16 and redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 0 then return 0 end
43
+ if redis.call('SCARD', KEYS[1]) < 16 then redis.call('SADD', KEYS[1], ARGV[1]) end
44
+ if redis.call('SCARD', KEYS[2]) < 16 then redis.call('SADD', KEYS[2], ARGV[2]) end
45
+ for i = 1, 2 do
46
+ if redis.call('PTTL', KEYS[i]) < 0 then redis.call('PEXPIRE', KEYS[i], ARGV[3]) end
47
+ end
48
+ return 1
49
+ `;
50
+
41
51
  class RedisState {
42
52
  constructor(url, client = null) {
43
53
  this.client = client || createClient({ url });
@@ -148,10 +158,10 @@ class RedisState {
148
158
  async recordFingerprint(fingerprint, ip, siteKey) {
149
159
  const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
150
160
  const ipKey = this.opaqueKey('fingerprint:fps', ip);
151
- await this.client.multi()
152
- .sAdd(fpKey, this.opaqueKey('value:ip', ip)).pExpire(fpKey, DETECTION_TTL_MS)
153
- .sAdd(ipKey, this.opaqueKey('value:fp', fingerprint)).pExpire(ipKey, DETECTION_TTL_MS)
154
- .exec();
161
+ await this.client.eval(RECORD_FINGERPRINT, {
162
+ keys: [fpKey, ipKey],
163
+ arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
164
+ });
155
165
  }
156
166
 
157
167
  async ipFingerprintCount(ip) {
package/server.js CHANGED
@@ -16,6 +16,9 @@ const { signingSecretFromEnv } = require('./config');
16
16
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
17
17
  const { detectInputForensics } = require('./inputforensics');
18
18
  const { RedisState } = require('./redis-state');
19
+ const { FingerprintStore } = require('./fingerprint-store');
20
+ const { TokenStore } = require('./token-store');
21
+ const { resolveSignals, isObject, invalidRequest } = require('./protocol');
19
22
  const {
20
23
  HostnameAllowlist,
21
24
  IdempotencyStore,
@@ -283,7 +286,8 @@ const powChallengeStore = {
283
286
  }
284
287
 
285
288
  // Calculate server-side elapsed time (un-spoofable)
286
- const serverElapsed = Date.now() - challenge.createdAt;
289
+ // timestamp is shared by all Redis writers; createdAt was Node-only.
290
+ const serverElapsed = Date.now() - challenge.timestamp;
287
291
 
288
292
  return {
289
293
  valid: true,
@@ -329,72 +333,22 @@ const rateLimiter = {
329
333
  }
330
334
  };
331
335
 
336
+ const localFingerprintStore = new FingerprintStore();
332
337
  const fingerprintStore = {
333
- fingerprints: new BoundedMap(),
334
- ipFingerprints: new BoundedMap(),
335
-
336
338
  async record(fp, ip, siteKey) {
337
- if (SHARED_STATE) {
338
- try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
339
- return;
340
- }
341
- const key = `${siteKey}:${fp}`;
342
-
343
- if (!this.fingerprints.has(key)) {
344
- this.fingerprints.set(key, { count: 0, ips: new Set() });
345
- }
346
- const data = this.fingerprints.get(key);
347
- data.count++;
348
- data.ips.add(ip);
349
-
350
- if (!this.ipFingerprints.has(ip)) {
351
- this.ipFingerprints.set(ip, new Set());
352
- }
353
- this.ipFingerprints.get(ip).add(fp);
339
+ if (!SHARED_STATE) return localFingerprintStore.record(fp, ip, siteKey);
340
+ try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
354
341
  },
355
-
356
342
  async getIpFpCount(ip) {
357
- if (SHARED_STATE) {
358
- try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
359
- }
360
- return this.ipFingerprints.get(ip)?.size || 0;
343
+ if (!SHARED_STATE) return localFingerprintStore.getIpFpCount(ip);
344
+ try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
361
345
  },
362
-
363
346
  async getFpIpCount(fp, siteKey) {
364
- if (SHARED_STATE) {
365
- try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
366
- }
367
- const key = `${siteKey}:${fp}`;
368
- return this.fingerprints.get(key)?.ips.size || 0;
369
- }
370
- };
371
-
372
- // Token Store - prevents token replay attacks
373
- const tokenStore = {
374
- usedTokens: new BoundedSet(),
375
-
376
- // Mark a token as used (returns false if already used)
377
- markUsed(tokenSig) {
378
- if (this.usedTokens.has(tokenSig)) {
379
- return false; // Already used
380
- }
381
- this.usedTokens.add(tokenSig);
382
-
383
- // Cleanup old tokens periodically (tokens expire after 5 min anyway)
384
- if (Math.random() < 0.1) this._cleanup();
385
- return true;
386
- },
387
-
388
- isUsed(tokenSig) {
389
- return this.usedTokens.has(tokenSig);
390
- },
391
-
392
- _cleanup() {
393
- // In production with Redis, use TTL instead. In memory usedTokens is a
394
- // BoundedSet, so it evicts its oldest entries rather than clearing wholesale
395
- // — clearing would let an attacker who forced the threshold replay a token.
347
+ if (!SHARED_STATE) return localFingerprintStore.getFpIpCount(fp, siteKey);
348
+ try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
396
349
  }
397
350
  };
351
+ const tokenStore = new TokenStore();
398
352
 
399
353
  // Detection patterns, the pure detectors and the scoring aggregation now
400
354
  // live in engine.js, shared with the npm library entry point (index.js).
@@ -499,6 +453,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
499
453
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
500
454
  const data = {
501
455
  site_key: siteKey,
456
+ jti: crypto.randomBytes(16).toString('hex'),
502
457
  timestamp: Math.floor(Date.now() / 1000),
503
458
  score: Math.round(score * 1000) / 1000,
504
459
  ip_hash: ipHash,
@@ -540,15 +495,15 @@ async function verifyToken(token, ip = null) {
540
495
  }
541
496
  }
542
497
 
543
- let claimed;
498
+ let claim;
544
499
  try {
545
- claimed = SHARED_STATE
546
- ? await SHARED_STATE.claimToken(sig)
547
- : tokenStore.markUsed(sig);
500
+ claim = SHARED_STATE
501
+ ? { claimed: await SHARED_STATE.claimToken(sig), reason: 'token_already_used' }
502
+ : tokenStore.claim(sig);
548
503
  } catch (_) {
549
504
  return { valid: false, reason: 'state_unavailable' };
550
505
  }
551
- if (!claimed) return { valid: false, reason: 'token_already_used' };
506
+ if (!claim.claimed) return { valid: false, reason: claim.reason };
552
507
 
553
508
  // hostname/action/cdata default to '' so a token minted before they existed
554
509
  // still verifies and reports the same shape. The signature covers whatever
@@ -588,24 +543,12 @@ async function verifyWebBotAuth(req) {
588
543
  async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
589
544
  const detections = Array.isArray(preDetections) ? [...preDetections] : [];
590
545
 
591
- // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
592
546
  const clientSignalsHash = powSolution?.signalsHash || null;
593
- if (signalsJson && clientSignalsHash) {
594
- const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
595
- if (computedHash !== clientSignalsHash) {
596
- detections.push({
597
- category: 'bot',
598
- score: 0.95,
599
- confidence: 0.95,
600
- reason: 'Signals tampered after PoW (signalsHash mismatch)'
601
- });
602
- }
603
- // Use signalsJson as the canonical signals source
604
- try {
605
- signals = JSON.parse(signalsJson);
606
- } catch (e) {
607
- // Fall back to parsed signals if signalsJson is invalid
608
- }
547
+ const resolved = resolveSignals(signals, signalsJson, clientSignalsHash);
548
+ signals = resolved.signals;
549
+ if (!resolved.commitmentValid) {
550
+ detections.push({ category: 'bot', score: 0.95, confidence: 0.95,
551
+ reason: 'Signals tampered after PoW (signalsHash mismatch)' });
609
552
  }
610
553
 
611
554
  // Inject powTiming into signals.temporal.pow for detection functions
@@ -660,7 +603,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
660
603
  // replays a solution. All ordinary things that happen to real people.
661
604
  });
662
605
  } else {
663
- powSatisfied = true;
606
+ powSatisfied = resolved.commitmentValid &&
607
+ powVerification.serverElapsed >= Math.max(BASE_MIN_AGE_MS, powVerification.minAgeMs || 0);
664
608
  }
665
609
 
666
610
  // Verify challenge nonce binding
@@ -692,9 +636,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
692
636
  reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
693
637
  });
694
638
  } else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
695
- // Between the baseline and this source's own elevated floor is weaker
696
- // evidence: a client predating adaptive cost, or one served from a stale
697
- // cache, does not know to wait. It contributes rather than deciding.
639
+ // Keep the diagnostic weaker than a baseline violation; token issuance
640
+ // independently requires the full advertised delay.
698
641
  detections.push({
699
642
  category: 'bot',
700
643
  score: 0.5,
@@ -870,7 +813,18 @@ function collectHeaders(req) {
870
813
  return headers;
871
814
  }
872
815
 
873
- app.post('/api/verify', async (req, res) => {
816
+ const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
817
+
818
+ function validateScoringRequest(req, res, next) {
819
+ const body = req.body;
820
+ if (!isObject(body) || !isObject(body.signals) ||
821
+ (body.siteKey != null && typeof body.siteKey !== 'string') ||
822
+ (body.powSolution != null && !isObject(body.powSolution)) ||
823
+ (body.powTiming != null && !isObject(body.powTiming))) return next(invalidRequest());
824
+ next();
825
+ }
826
+
827
+ app.post('/api/verify', validateScoringRequest, asyncRoute(async (req, res) => {
874
828
  const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
875
829
  const ip = PROXY_TRUST.clientIP(req);
876
830
  // Bound the state an unvalidated siteKey can allocate (limits.js).
@@ -890,9 +844,9 @@ app.post('/api/verify', async (req, res) => {
890
844
  const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
891
845
  logVerdict('verify', siteKey, result);
892
846
  res.json(result);
893
- });
847
+ }));
894
848
 
895
- app.post('/api/score', async (req, res) => {
849
+ app.post('/api/score', validateScoringRequest, asyncRoute(async (req, res) => {
896
850
  const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
897
851
  const ip = PROXY_TRUST.clientIP(req);
898
852
  const siteKey = await normalizeSiteKey(rawSiteKey, ip);
@@ -924,9 +878,9 @@ app.post('/api/score', async (req, res) => {
924
878
  // needs to know which precondition failed.
925
879
  ...(result.reason ? { reason: result.reason } : {})
926
880
  });
927
- });
881
+ }));
928
882
 
929
- app.post('/api/token/verify', async (req, res) => {
883
+ app.post('/api/token/verify', asyncRoute(async (req, res) => {
930
884
  const { token, secret, remoteip } = req.body;
931
885
 
932
886
  // The secret gate. This endpoint is the boundary between "a browser finished a
@@ -946,7 +900,7 @@ app.post('/api/token/verify', async (req, res) => {
946
900
  // received the token. Bind only when that trusted backend explicitly supplies
947
901
  // the visitor address; using the caller socket here compares unrelated hosts.
948
902
  res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
949
- });
903
+ }));
950
904
 
951
905
  // Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
952
906
  //
@@ -968,12 +922,13 @@ async function siteverifyHandler(req, res) {
968
922
  );
969
923
  }
970
924
 
971
- app.post('/turnstile/v0/siteverify', siteverifyHandler);
972
- app.post('/recaptcha/api/siteverify', siteverifyHandler);
973
- app.post('/siteverify', siteverifyHandler);
925
+ app.post('/turnstile/v0/siteverify', asyncRoute(siteverifyHandler));
926
+ app.post('/recaptcha/api/siteverify', asyncRoute(siteverifyHandler));
927
+ app.post('/siteverify', asyncRoute(siteverifyHandler));
974
928
 
975
929
  // PoW Challenge endpoint - client fetches this on page load
976
- app.get('/api/pow/challenge', async (req, res) => {
930
+ app.get('/api/pow/challenge', asyncRoute(async (req, res) => {
931
+ if (req.query.siteKey != null && typeof req.query.siteKey !== 'string') throw invalidRequest();
977
932
  const ip = PROXY_TRUST.clientIP(req);
978
933
  const siteKey = await normalizeSiteKey(req.query.siteKey, ip);
979
934
 
@@ -1007,7 +962,7 @@ app.get('/api/pow/challenge', async (req, res) => {
1007
962
  // wait instead of as a worse score.
1008
963
  minAgeMs: challenge.minAgeMs
1009
964
  });
1010
- });
965
+ }));
1011
966
 
1012
967
  // Legacy challenge endpoint for backwards compatibility
1013
968
  app.get('/api/challenge', (req, res) => {
@@ -1026,7 +981,8 @@ app.use((err, req, res, next) => {
1026
981
  if (err && (err.status === 413 || err.type === 'entity.too.large')) {
1027
982
  return res.status(413).json({ error: 'request_too_large' });
1028
983
  }
1029
- return next(err);
984
+ const status = err && err.status === 400 ? 400 : 500;
985
+ res.status(status).json({ error: status === 400 ? 'invalid_request' : 'internal_error' });
1030
986
  });
1031
987
 
1032
988
  // =============================================================================
@@ -1035,15 +991,19 @@ app.use((err, req, res, next) => {
1035
991
 
1036
992
  async function start() {
1037
993
  if (SHARED_STATE) await SHARED_STATE.connect();
1038
- app.listen(PORT, () => {
1039
- console.log(`FCaptcha server running on port ${PORT}`);
994
+ const server = app.listen(PORT, () => {
995
+ console.log(`FCaptcha server running on port ${server.address().port}`);
1040
996
  console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1041
997
  console.log(`Site keys: ${SITE_KEYS.describe()}`);
1042
998
  console.log(`Shared state: ${SHARED_STATE ? 'Redis (PoW)' : 'in-memory'}`);
1043
999
  });
1000
+ return server;
1044
1001
  }
1045
1002
 
1046
- start().catch((err) => {
1047
- console.error(`FCaptcha failed to start: ${err.message}`);
1048
- process.exit(1);
1049
- });
1003
+ if (require.main === module) {
1004
+ start().catch((err) => {
1005
+ console.error(`FCaptcha failed to start: ${err.message}`);
1006
+ process.exit(1);
1007
+ });
1008
+ }
1009
+ module.exports = { app, start };
package/siteverify.js CHANGED
@@ -79,6 +79,7 @@ const ERROR_CODES = {
79
79
  const REASON_TO_ERROR_CODE = {
80
80
  expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
81
81
  token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
82
+ token_store_full: ERROR_CODES.INTERNAL_ERROR,
82
83
  invalid_signature: ERROR_CODES.INVALID_RESPONSE,
83
84
  invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
84
85
  invalid_json: ERROR_CODES.INVALID_RESPONSE,
package/token-store.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // Spent tokens must not be evicted while they can still verify. If capacity is
4
+ // exhausted, refuse new claims rather than reopening replay of older tokens.
5
+ class TokenStore {
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
+ markUsed(signature) {
14
+ return this.claim(signature).claimed;
15
+ }
16
+
17
+ claim(signature) {
18
+ const now = this.now();
19
+ if (now >= this.nextCleanup) {
20
+ for (const [key, expiresAt] of this.entries) {
21
+ if (expiresAt <= now) this.entries.delete(key);
22
+ }
23
+ this.nextCleanup = now + 60000;
24
+ }
25
+ if (this.entries.has(signature)) return { claimed: false, reason: 'token_already_used' };
26
+ if (this.entries.size >= this.maxEntries) return { claimed: false, reason: 'token_store_full' };
27
+ this.entries.set(signature, now + 10 * 60 * 1000);
28
+ return { claimed: true };
29
+ }
30
+ }
31
+
32
+ module.exports = { TokenStore };