@webdecoy/fcaptcha 1.0.1 → 1.0.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +106 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
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
@@ -42,13 +42,11 @@ const powChallengeStore = {
42
42
  };
43
43
 
44
44
  // Sign the challenge
45
- const sig = crypto.createHmac('sha256', SECRET_KEY)
45
+ challengeData.sig = crypto.createHmac('sha256', SECRET_KEY)
46
46
  .update(JSON.stringify(challengeData))
47
47
  .digest('hex')
48
48
  .slice(0, 16);
49
49
 
50
- challengeData.sig = sig;
51
-
52
50
  // Store challenge
53
51
  this.challenges.set(challengeId, {
54
52
  ...challengeData,
@@ -173,6 +171,35 @@ const fingerprintStore = {
173
171
  }
174
172
  };
175
173
 
174
+ // Token Store - prevents token replay attacks
175
+ const tokenStore = {
176
+ usedTokens: new Set(),
177
+
178
+ // Mark a token as used (returns false if already used)
179
+ markUsed(tokenSig) {
180
+ if (this.usedTokens.has(tokenSig)) {
181
+ return false; // Already used
182
+ }
183
+ this.usedTokens.add(tokenSig);
184
+
185
+ // Cleanup old tokens periodically (tokens expire after 5 min anyway)
186
+ if (Math.random() < 0.1) this._cleanup();
187
+ return true;
188
+ },
189
+
190
+ isUsed(tokenSig) {
191
+ return this.usedTokens.has(tokenSig);
192
+ },
193
+
194
+ _cleanup() {
195
+ // In production with Redis, use TTL instead
196
+ // For in-memory, just clear if too large (tokens expire in 5 min)
197
+ if (this.usedTokens.size > 50000) {
198
+ this.usedTokens.clear();
199
+ }
200
+ }
201
+ };
202
+
176
203
  // =============================================================================
177
204
  // Detection Patterns
178
205
  // =============================================================================
@@ -187,7 +214,8 @@ const WEIGHTS = {
187
214
  vision_ai: 0.15,
188
215
  headless: 0.15,
189
216
  automation: 0.10,
190
- behavioral: 0.20,
217
+ cdp: 0.12,
218
+ behavioral: 0.18,
191
219
  fingerprint: 0.10,
192
220
  rate_limit: 0.05,
193
221
  datacenter: 0.10,
@@ -383,6 +411,46 @@ function detectAutomation(signals) {
383
411
  return detections;
384
412
  }
385
413
 
414
+ function detectCDP(signals) {
415
+ const detections = [];
416
+ const env = signals.environmental || {};
417
+ const cdp = env.cdp || {};
418
+
419
+ if (cdp.detected) {
420
+ const signalList = cdp.signals || [];
421
+ const signalCount = signalList.length;
422
+
423
+ // High-confidence signals
424
+ const highConfSignals = ['chromedriver_cdc', 'puppeteer_eval', 'cdp_script_injection'];
425
+ const hasHighConf = signalList.some(s => highConfSignals.includes(s));
426
+
427
+ if (hasHighConf) {
428
+ detections.push({
429
+ category: 'cdp',
430
+ score: 0.9,
431
+ confidence: 0.95,
432
+ reason: `CDP automation detected: ${signalList.join(', ')}`
433
+ });
434
+ } else if (signalCount >= 2) {
435
+ detections.push({
436
+ category: 'cdp',
437
+ score: 0.8,
438
+ confidence: 0.85,
439
+ reason: `Multiple CDP indicators: ${signalList.join(', ')}`
440
+ });
441
+ } else if (signalCount === 1) {
442
+ detections.push({
443
+ category: 'cdp',
444
+ score: 0.6,
445
+ confidence: 0.7,
446
+ reason: `CDP indicator: ${signalList.join(', ')}`
447
+ });
448
+ }
449
+ }
450
+
451
+ return detections;
452
+ }
453
+
386
454
  function detectBehavioral(signals) {
387
455
  const detections = [];
388
456
  const b = signals.behavioral || {};
@@ -585,13 +653,12 @@ function generateToken(ip, siteKey, score) {
585
653
  };
586
654
 
587
655
  const payload = JSON.stringify(data, Object.keys(data).sort());
588
- const sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
589
- data.sig = sig;
656
+ data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
590
657
 
591
658
  return Buffer.from(JSON.stringify(data)).toString('base64url');
592
659
  }
593
660
 
594
- function verifyToken(token) {
661
+ function verifyToken(token, ip = null) {
595
662
  try {
596
663
  const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
597
664
 
@@ -610,11 +677,28 @@ function verifyToken(token) {
610
677
  return { valid: false, reason: 'invalid_signature' };
611
678
  }
612
679
 
680
+ // Check for token replay (single-use tokens)
681
+ if (tokenStore.isUsed(sig)) {
682
+ return { valid: false, reason: 'token_already_used' };
683
+ }
684
+
685
+ // Verify IP matches (if provided)
686
+ if (ip) {
687
+ const expectedIpHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
688
+ if (decoded.ip_hash !== expectedIpHash) {
689
+ return { valid: false, reason: 'ip_mismatch' };
690
+ }
691
+ }
692
+
693
+ // Mark token as used (prevents replay)
694
+ tokenStore.markUsed(sig);
695
+
613
696
  return {
614
697
  valid: true,
615
698
  site_key: decoded.site_key,
616
699
  timestamp: decoded.timestamp,
617
- score: decoded.score
700
+ score: decoded.score,
701
+ ip_hash: decoded.ip_hash
618
702
  };
619
703
  } catch (e) {
620
704
  return { valid: false, reason: e.message };
@@ -626,6 +710,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
626
710
  ...detectVisionAI(signals),
627
711
  ...detectHeadless(signals, userAgent),
628
712
  ...detectAutomation(signals),
713
+ ...detectCDP(signals),
629
714
  ...detectBehavioral(signals),
630
715
  ...detectFingerprint(signals, ip, siteKey),
631
716
  ...detectRateAbuse(ip, siteKey)
@@ -777,7 +862,19 @@ app.post('/api/score', (req, res) => {
777
862
 
778
863
  app.post('/api/token/verify', (req, res) => {
779
864
  const { token } = req.body;
780
- res.json(verifyToken(token));
865
+
866
+ // Extract client IP for verification
867
+ let ip = req.headers['x-real-ip'] || '';
868
+ if (!ip) {
869
+ const forwarded = req.headers['x-forwarded-for'];
870
+ if (forwarded) {
871
+ ip = forwarded.split(',')[0].trim();
872
+ } else {
873
+ ip = req.socket.remoteAddress;
874
+ }
875
+ }
876
+
877
+ res.json(verifyToken(token, ip));
781
878
  });
782
879
 
783
880
  // PoW Challenge endpoint - client fetches this on page load