@webdecoy/fcaptcha 1.46.2 → 1.47.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/README.md CHANGED
@@ -15,14 +15,21 @@ const { createScoringEngine } = require('@webdecoy/fcaptcha');
15
15
 
16
16
  // Create a scoring engine
17
17
  const engine = createScoringEngine({
18
- secret: process.env.FCAPTCHA_SECRET
18
+ secret: process.env.FCAPTCHA_SECRET,
19
+ // Required for multiple processes or replicas: provide one implementation
20
+ // whose claim(signature) operation is atomic and shared (for example Redis).
21
+ tokenStore: sharedTokenStore
19
22
  });
20
23
 
21
24
  // Generate a PoW challenge
22
25
  const challenge = engine.generateChallenge('my-site', clientIP);
23
26
 
24
27
  // Verify a submission
25
- const result = engine.verify(signals, clientIP, siteKey, userAgent, headers, powSolution);
28
+ const result = engine.verify(
29
+ signals, clientIP, siteKey, userAgent, headers, powSolution,
30
+ signalsJson, powTiming,
31
+ { hostname: 'app.example.com', action: 'login', cdata: sessionId }
32
+ );
26
33
 
27
34
  if (result.success) {
28
35
  // User passed verification
@@ -31,6 +38,17 @@ if (result.success) {
31
38
  }
32
39
  ```
33
40
 
41
+ Engines in one Node process share the default replay store. Multiple processes
42
+ or replicas must inject one shared atomic `tokenStore`; otherwise no in-memory
43
+ implementation can enforce one-time redemption across them.
44
+
45
+ `createMiddleware()` binds tokens to the request `Origin`/`Referer` and the
46
+ submitted `action`/`cdata`. Its `tokenVerifyHandler` is server-to-server and
47
+ requires `secret` in the request body. Set `verifySecret` in middleware options
48
+ to split that credential from the signing secret. A relying backend must compare
49
+ the returned `hostname` and `action` with the values expected for the protected
50
+ operation before granting it.
51
+
34
52
  ## Features
35
53
 
36
54
  - **Proof of Work** - SHA-256 challenges that force compute cost on attackers
@@ -47,6 +65,7 @@ const {
47
65
  PoWChallengeStore, // Challenge storage
48
66
  RateLimiter, // Rate limiting
49
67
  FingerprintStore, // Fingerprint tracking
68
+ TokenStore, // Process-local replay store / custom-store interface
50
69
  detection, // Detection utilities
51
70
  createScoringEngine, // Factory function
52
71
  createMiddleware, // Express middleware factory
package/clientip.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * credible only when the peer that sent them is a proxy we put there.
10
10
  *
11
11
  * TRUSTED_PROXIES holds the peers allowed to speak for someone else, as a
12
- * comma-separated list of CIDRs and bare IPs. Unset means the private and
13
- * loopback defaults below. '*' trusts every peer (only correct when an edge you
12
+ * comma-separated list of CIDRs and bare IPs. Unset trusts loopback only.
13
+ * '*' trusts every peer (only correct when an edge you
14
14
  * control always overwrites the headers). 'none' trusts nobody, so the socket
15
15
  * address is always used.
16
16
  */
@@ -18,21 +18,13 @@
18
18
  const ipaddr = require('ipaddr.js');
19
19
 
20
20
  /**
21
- * Fallback trust set: loopback plus the private and link-local ranges. A server
22
- * exposed directly to the internet sees public peer addresses, which are not in
23
- * this set, so forged headers are ignored. A server behind a sidecar or
24
- * in-cluster reverse proxy (nginx, Railway, Fly, Kubernetes ingress) sees a
25
- * loopback or private peer and behaves as before.
21
+ * Fallback trust is deliberately loopback-only. Private address space is shared
22
+ * by unrelated workloads in common clusters and must be explicitly narrowed to
23
+ * the actual ingress or sidecar ranges.
26
24
  */
27
25
  const DEFAULT_TRUSTED_PROXY_CIDRS = [
28
26
  '127.0.0.0/8',
29
- '::1/128',
30
- '10.0.0.0/8',
31
- '172.16.0.0/12',
32
- '192.168.0.0/16',
33
- '169.254.0.0/16',
34
- 'fe80::/10',
35
- 'fc00::/7'
27
+ '::1/128'
36
28
  ];
37
29
 
38
30
  // hostOnly strips a trailing :port or wrapping brackets. Node's
package/index.js CHANGED
@@ -6,7 +6,8 @@
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, signalsJson, powTiming);
9
+ * const result = engine.verify(signals, ip, siteKey, userAgent, headers,
10
+ * powSolution, signalsJson, powTiming, { hostname, action, cdata });
10
11
  */
11
12
 
12
13
  const crypto = require('crypto');
@@ -20,6 +21,13 @@ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
20
21
  const { FingerprintStore } = require('./fingerprint-store');
21
22
  const { TokenStore } = require('./token-store');
22
23
  const { resolveSignals } = require('./protocol');
24
+ const { requestHostname, sanitizeAction, sanitizeCdata, secretMatches } = require('./siteverify');
25
+ const { canonicalTokenPayload } = require('./token-canonical');
26
+
27
+ // Independent engines in one process are equivalent redemption authorities.
28
+ // Sharing the default store prevents a token from being spent once per engine.
29
+ // Multi-process/replica deployments must still inject one atomic shared store.
30
+ const DEFAULT_TOKEN_STORE = new TokenStore();
23
31
 
24
32
  // =============================================================================
25
33
  // PoW Challenge Store (can be extended with Redis)
@@ -205,7 +213,10 @@ class ScoringEngine {
205
213
  this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
206
214
  this.suspicion = options.suspicion || new SuspicionLedger();
207
215
  this.weights = options.weights || WEIGHTS;
208
- this.tokenStore = options.tokenStore || new TokenStore();
216
+ this.tokenStore = options.tokenStore || DEFAULT_TOKEN_STORE;
217
+ this.verifySecret = options.verifySecret === undefined
218
+ ? this.secret
219
+ : signingSecret(options.verifySecret);
209
220
  }
210
221
 
211
222
  // Generate a PoW challenge
@@ -232,7 +243,7 @@ class ScoringEngine {
232
243
  }
233
244
 
234
245
  // Verify signals and return score
235
- verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null) {
246
+ verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null, binding = {}) {
236
247
  const resolved = resolveSignals(signals, signalsJson, powSolution?.signalsHash);
237
248
  signals = resolved.signals;
238
249
  if (powTiming) {
@@ -380,7 +391,7 @@ class ScoringEngine {
380
391
  // request. Keep this gate independent of scoring so weight changes cannot
381
392
  // reopen the bypass.
382
393
  const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
383
- const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
394
+ const token = success ? this._generateToken(ip, siteKey, finalScore, binding) : null;
384
395
 
385
396
  // Feed the ledger so the next challenge this source asks for is priced on
386
397
  // what it just did.
@@ -412,7 +423,7 @@ class ScoringEngine {
412
423
  const sig = decoded.sig;
413
424
  delete decoded.sig;
414
425
 
415
- const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
426
+ const payload = canonicalTokenPayload(decoded);
416
427
  const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
417
428
 
418
429
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
@@ -505,17 +516,21 @@ class ScoringEngine {
505
516
  return detections;
506
517
  }
507
518
 
508
- _generateToken(ip, siteKey, score) {
519
+ _generateToken(ip, siteKey, score, binding = {}) {
509
520
  const ipHash = ipBinding(this.secret, ip);
510
521
  const data = {
522
+ v: 2,
511
523
  site_key: siteKey,
512
524
  jti: crypto.randomBytes(16).toString('hex'),
513
525
  timestamp: Math.floor(Date.now() / 1000),
514
526
  score: Math.round(score * 1000) / 1000,
515
- ip_hash: ipHash
527
+ ip_hash: ipHash,
528
+ hostname: typeof binding.hostname === 'string' ? binding.hostname : '',
529
+ action: sanitizeAction(binding.action),
530
+ cdata: sanitizeCdata(binding.cdata)
516
531
  };
517
532
 
518
- const payload = JSON.stringify(data, Object.keys(data).sort());
533
+ const payload = canonicalTokenPayload(data);
519
534
  const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
520
535
  data.sig = sig;
521
536
 
@@ -540,7 +555,7 @@ function createMiddleware(options = {}) {
540
555
 
541
556
  // Middleware to extract IP from request. Forwarding headers are honoured
542
557
  // only from a peer in the trusted-proxy set (TRUSTED_PROXIES, or the
543
- // private/loopback defaults) — otherwise any caller could claim a clean
558
+ // loopback-only default) — otherwise any caller could claim a clean
544
559
  // residential IP and skip the datacenter, Tor/VPN and rate-limit checks.
545
560
  // Pass options.trustedProxies to override the env for this instance.
546
561
  getIP: (req) => proxyTrust.clientIP(req),
@@ -565,7 +580,7 @@ function createMiddleware(options = {}) {
565
580
 
566
581
  // Verify route handler
567
582
  verifyHandler: (req, res) => {
568
- const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
583
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
569
584
  const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
570
585
  const siteKey = siteKeys.normalize(rawSiteKey || 'default', ip);
571
586
  const userAgent = req.headers['user-agent'] || '';
@@ -575,13 +590,21 @@ function createMiddleware(options = {}) {
575
590
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
576
591
  }
577
592
 
578
- const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
593
+ const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming, {
594
+ hostname: requestHostname(headers),
595
+ action: sanitizeAction(action),
596
+ cdata: sanitizeCdata(cdata)
597
+ });
579
598
  res.json(result);
580
599
  },
581
600
 
582
601
  // Token verify route handler
583
602
  tokenVerifyHandler: (req, res) => {
584
- const { token, remoteip } = req.body;
603
+ const { token, secret, remoteip } = req.body || {};
604
+ if (!secret) return res.status(401).json({ valid: false, reason: 'missing_secret' });
605
+ if (!secretMatches(secret, engine.verifySecret)) {
606
+ return res.status(401).json({ valid: false, reason: 'invalid_secret' });
607
+ }
585
608
  res.json(engine.verifyToken(token, typeof remoteip === 'string' ? remoteip : null));
586
609
  }
587
610
  };
@@ -597,6 +620,7 @@ module.exports = {
597
620
  PoWChallengeStore,
598
621
  RateLimiter,
599
622
  FingerprintStore,
623
+ TokenStore,
600
624
 
601
625
  // Detection module (re-export)
602
626
  detection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.46.2",
3
+ "version": "1.47.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
package/server.js CHANGED
@@ -20,6 +20,7 @@ const { detectInputForensics } = require('./inputforensics');
20
20
  const { RedisState } = require('./redis-state');
21
21
  const { FingerprintStore } = require('./fingerprint-store');
22
22
  const { TokenStore } = require('./token-store');
23
+ const { canonicalTokenPayload } = require('./token-canonical');
23
24
  const { resolveSignals, isObject, invalidRequest } = require('./protocol');
24
25
  const {
25
26
  HostnameAllowlist,
@@ -457,13 +458,12 @@ async function detectRateAbuse(ip, siteKey) {
457
458
  // endpoint or presented from a site that lifted the key — the backend compares
458
459
  // what siteverify reports against what it expected.
459
460
  //
460
- // Empty strings rather than omitted keys: the signing payload is a sorted-key
461
- // JSON serialisation, so a token whose key set varies with what the browser
462
- // happened to send would be a second payload shape to keep in sync across three
463
- // languages. Fixed shape, empty when unknown.
461
+ // Empty strings rather than omitted keys keep one fixed signed claim set across
462
+ // all three runtimes.
464
463
  function generateToken(ip, siteKey, score, binding = {}) {
465
464
  const ipHash = ipBinding(SECRET_KEY, ip);
466
465
  const data = {
466
+ v: 2,
467
467
  site_key: siteKey,
468
468
  jti: crypto.randomBytes(16).toString('hex'),
469
469
  timestamp: Math.floor(Date.now() / 1000),
@@ -474,7 +474,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
474
474
  cdata: binding.cdata || ''
475
475
  };
476
476
 
477
- const payload = JSON.stringify(data, Object.keys(data).sort());
477
+ const payload = canonicalTokenPayload(data);
478
478
  data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
479
479
 
480
480
  return Buffer.from(JSON.stringify(data)).toString('base64url');
@@ -492,7 +492,7 @@ async function verifyToken(token, ip = null) {
492
492
  const sig = decoded.sig;
493
493
  delete decoded.sig;
494
494
 
495
- const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
495
+ const payload = canonicalTokenPayload(decoded);
496
496
  const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
497
497
 
498
498
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ function encodedString(value, field) {
4
+ if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
5
+ return Buffer.from(value, 'utf8').toString('base64url');
6
+ }
7
+
8
+ /**
9
+ * Version 2 signs a language-neutral, length-safe representation instead of
10
+ * reserializing JSON. Native JSON encoders disagree on integral floats and on
11
+ * escaping Unicode/HTML-sensitive strings, which broke mixed-runtime pools.
12
+ * Tokens without v=2 retain the legacy canonicalization for a rolling upgrade.
13
+ */
14
+ function canonicalTokenPayload(data) {
15
+ if (data.v !== 2) return JSON.stringify(data, Object.keys(data).sort());
16
+ if (!Number.isSafeInteger(data.timestamp)) throw new TypeError('timestamp must be an integer');
17
+ if (typeof data.score !== 'number' || !Number.isFinite(data.score)) throw new TypeError('score must be finite');
18
+
19
+ return [
20
+ 'fcaptcha-token-v2',
21
+ encodedString(data.site_key, 'site_key'),
22
+ encodedString(data.jti, 'jti'),
23
+ String(data.timestamp),
24
+ data.score.toFixed(3),
25
+ encodedString(data.ip_hash, 'ip_hash'),
26
+ encodedString(data.hostname, 'hostname'),
27
+ encodedString(data.action, 'action'),
28
+ encodedString(data.cdata, 'cdata')
29
+ ].join('\n');
30
+ }
31
+
32
+ module.exports = { canonicalTokenPayload };