@webdecoy/fcaptcha 1.0.3 → 1.1.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.
Files changed (3) hide show
  1. package/detection.js +321 -1
  2. package/package.json +1 -1
  3. package/server.js +65 -3
package/detection.js CHANGED
@@ -431,6 +431,317 @@ function analyzeFormInteraction(formAnalysis) {
431
431
  return detections;
432
432
  }
433
433
 
434
+ // =============================================================================
435
+ // Advanced Fingerprint Detection Functions
436
+ // =============================================================================
437
+
438
+ /**
439
+ * Analyze WebRTC signals for headless browser and VM detection
440
+ */
441
+ function analyzeWebRTC(webrtcInfo) {
442
+ if (!webrtcInfo || !webrtcInfo.supported) return [];
443
+
444
+ const detections = [];
445
+
446
+ // Check media devices - headless browsers typically have 0 devices
447
+ const mediaDevices = webrtcInfo.mediaDevices || {};
448
+ if (mediaDevices.supported && mediaDevices.totalDevices === 0) {
449
+ detections.push({
450
+ category: 'headless',
451
+ score: 0.7,
452
+ confidence: 0.75,
453
+ reason: 'No media devices detected (typical of headless browsers)'
454
+ });
455
+ }
456
+
457
+ // Suspicious: has video inputs but no audio (unusual)
458
+ if (mediaDevices.supported && mediaDevices.videoInputs > 0 && mediaDevices.audioInputs === 0) {
459
+ detections.push({
460
+ category: 'bot',
461
+ score: 0.4,
462
+ confidence: 0.5,
463
+ reason: 'Has video devices but no audio devices (unusual configuration)'
464
+ });
465
+ }
466
+
467
+ // Check local IP detection - VMs and some headless setups may not expose local IPs
468
+ if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
469
+ detections.push({
470
+ category: 'headless',
471
+ score: 0.4,
472
+ confidence: 0.5,
473
+ reason: 'No local IP addresses detected via WebRTC'
474
+ });
475
+ }
476
+
477
+ return detections;
478
+ }
479
+
480
+ /**
481
+ * Analyze Speech API signals - voices are OS/browser specific and hard to spoof
482
+ */
483
+ function analyzeSpeechAPI(speechInfo) {
484
+ if (!speechInfo || !speechInfo.supported) return [];
485
+
486
+ const detections = [];
487
+
488
+ // No voices at all - suspicious
489
+ if (speechInfo.totalVoices === 0) {
490
+ detections.push({
491
+ category: 'headless',
492
+ score: 0.6,
493
+ confidence: 0.7,
494
+ reason: 'No speech synthesis voices available'
495
+ });
496
+ }
497
+
498
+ // Very few voices (less than 5) - could be headless or minimal VM
499
+ if (speechInfo.totalVoices > 0 && speechInfo.totalVoices < 5) {
500
+ detections.push({
501
+ category: 'headless',
502
+ score: 0.3,
503
+ confidence: 0.4,
504
+ reason: `Very few speech voices available (${speechInfo.totalVoices})`
505
+ });
506
+ }
507
+
508
+ // No local voices - all remote/network voices suggests unusual setup
509
+ if (speechInfo.localVoices === 0 && speechInfo.totalVoices > 0) {
510
+ detections.push({
511
+ category: 'bot',
512
+ score: 0.3,
513
+ confidence: 0.4,
514
+ reason: 'No local speech synthesis voices'
515
+ });
516
+ }
517
+
518
+ return detections;
519
+ }
520
+
521
+ /**
522
+ * Analyze Worker consistency - spoofed values often don't match between contexts
523
+ */
524
+ function analyzeWorkerConsistency(workerConsistency) {
525
+ if (!workerConsistency || !workerConsistency.supported) return [];
526
+
527
+ const detections = [];
528
+
529
+ // Mismatches indicate fingerprint spoofing
530
+ if (!workerConsistency.consistent && workerConsistency.mismatchCount > 0) {
531
+ const score = Math.min(0.9, 0.3 + (workerConsistency.mismatchCount * 0.15));
532
+ detections.push({
533
+ category: 'bot',
534
+ score: score,
535
+ confidence: 0.85,
536
+ reason: `Worker/main thread mismatch detected: ${workerConsistency.mismatches.join(', ')}`
537
+ });
538
+ }
539
+
540
+ return detections;
541
+ }
542
+
543
+ /**
544
+ * Analyze CSS Media Queries for environment consistency
545
+ */
546
+ function analyzeCSSMediaQueries(cssMedia, signals) {
547
+ if (!cssMedia || !cssMedia.supported) return [];
548
+
549
+ const detections = [];
550
+ const nav = (signals.environmental || {}).navigator || {};
551
+
552
+ // Check pointer consistency with touch capability
553
+ const maxTouch = nav.maxTouchPoints || 0;
554
+ if (cssMedia.pointer === 'coarse' && maxTouch === 0) {
555
+ detections.push({
556
+ category: 'bot',
557
+ score: 0.5,
558
+ confidence: 0.6,
559
+ reason: 'CSS reports coarse pointer but no touch support'
560
+ });
561
+ }
562
+
563
+ // Check hover capability - headless browsers may have unusual values
564
+ if (cssMedia.hover === false && cssMedia.pointer === 'fine') {
565
+ detections.push({
566
+ category: 'bot',
567
+ score: 0.3,
568
+ confidence: 0.4,
569
+ reason: 'Fine pointer reported but no hover capability'
570
+ });
571
+ }
572
+
573
+ // Forced colors mode with reduced motion - accessibility features
574
+ // Note: These are NOT suspicious on their own, just for fingerprinting
575
+ // Don't penalize accessibility users
576
+
577
+ return detections;
578
+ }
579
+
580
+ /**
581
+ * Analyze font detection results for consistency
582
+ */
583
+ function analyzeFonts(fontsInfo, userAgent) {
584
+ if (!fontsInfo || !fontsInfo.supported) return [];
585
+
586
+ const detections = [];
587
+
588
+ // Very few fonts detected could indicate headless browser
589
+ if (fontsInfo.count < 3) {
590
+ detections.push({
591
+ category: 'headless',
592
+ score: 0.5,
593
+ confidence: 0.5,
594
+ reason: `Very few fonts detected (${fontsInfo.count})`
595
+ });
596
+ }
597
+
598
+ // Check OS-specific fonts against UA
599
+ const ua = (userAgent || '').toLowerCase();
600
+
601
+ // Windows UA but no Segoe UI
602
+ if (ua.includes('windows') && fontsInfo.hasSegoeUI === false && fontsInfo.count > 5) {
603
+ detections.push({
604
+ category: 'bot',
605
+ score: 0.5,
606
+ confidence: 0.6,
607
+ reason: 'Windows UA but Segoe UI font not detected'
608
+ });
609
+ }
610
+
611
+ // Mac UA but no SF Pro (modern macOS)
612
+ if ((ua.includes('mac os x') || ua.includes('macintosh')) && fontsInfo.hasSFPro === false &&
613
+ ua.includes('10_15') === false && ua.includes('10_14') === false && fontsInfo.count > 5) {
614
+ // SF Pro is on macOS 10.15+ (Catalina and later)
615
+ detections.push({
616
+ category: 'bot',
617
+ score: 0.3,
618
+ confidence: 0.4,
619
+ reason: 'Modern macOS UA but SF Pro font not detected'
620
+ });
621
+ }
622
+
623
+ // Linux UA but no DejaVu Sans (very common on Linux)
624
+ if (ua.includes('linux') && !ua.includes('android') &&
625
+ fontsInfo.hasDejaVuSans === false && fontsInfo.count > 5) {
626
+ detections.push({
627
+ category: 'bot',
628
+ score: 0.4,
629
+ confidence: 0.5,
630
+ reason: 'Linux UA but DejaVu Sans font not detected'
631
+ });
632
+ }
633
+
634
+ return detections;
635
+ }
636
+
637
+ /**
638
+ * Analyze permissions/API availability for headless detection
639
+ */
640
+ function analyzePermissions(permissionsInfo) {
641
+ if (!permissionsInfo || !permissionsInfo.supported) return [];
642
+
643
+ const detections = [];
644
+
645
+ // Count available APIs
646
+ const apiKeys = [
647
+ 'hasPermissionsAPI', 'hasClipboard', 'hasShare', 'hasCredentials',
648
+ 'hasBluetooth', 'hasUsb', 'hasSerial', 'hasHid', 'hasXR',
649
+ 'hasGeolocation', 'hasMIDI'
650
+ ];
651
+
652
+ const availableApis = apiKeys.filter(key => permissionsInfo[key] === true).length;
653
+
654
+ // Very few APIs available could indicate minimal/headless browser
655
+ if (availableApis < 3) {
656
+ detections.push({
657
+ category: 'headless',
658
+ score: 0.4,
659
+ confidence: 0.5,
660
+ reason: `Very few navigator APIs available (${availableApis})`
661
+ });
662
+ }
663
+
664
+ return detections;
665
+ }
666
+
667
+ /**
668
+ * Analyze DOMRect/geometry fingerprint for rendering anomalies
669
+ */
670
+ function analyzeDOMRect(domRectInfo) {
671
+ if (!domRectInfo || !domRectInfo.supported) return [];
672
+
673
+ const detections = [];
674
+
675
+ // Check for zero or very small dimensions (rendering issues)
676
+ if (domRectInfo.rectAWidth === 0 || domRectInfo.rectBWidth === 0) {
677
+ detections.push({
678
+ category: 'headless',
679
+ score: 0.6,
680
+ confidence: 0.7,
681
+ reason: 'DOMRect rendering returned zero-width elements'
682
+ });
683
+ }
684
+
685
+ // Check for exact integer values (unusual in real browsers)
686
+ if (domRectInfo.rectAWidth === Math.floor(domRectInfo.rectAWidth) &&
687
+ domRectInfo.rectBWidth === Math.floor(domRectInfo.rectBWidth) &&
688
+ domRectInfo.rangeWidth === Math.floor(domRectInfo.rangeWidth)) {
689
+ detections.push({
690
+ category: 'bot',
691
+ score: 0.3,
692
+ confidence: 0.4,
693
+ reason: 'DOMRect measurements are all exact integers (unusual)'
694
+ });
695
+ }
696
+
697
+ return detections;
698
+ }
699
+
700
+ /**
701
+ * Master function to analyze all advanced fingerprint signals
702
+ */
703
+ function analyzeAdvancedSignals(signals, userAgent) {
704
+ const detections = [];
705
+ const env = signals.environmental || {};
706
+
707
+ // WebRTC analysis
708
+ if (env.webrtcInfo) {
709
+ detections.push(...analyzeWebRTC(env.webrtcInfo));
710
+ }
711
+
712
+ // Speech API analysis
713
+ if (env.speechInfo) {
714
+ detections.push(...analyzeSpeechAPI(env.speechInfo));
715
+ }
716
+
717
+ // Worker consistency analysis
718
+ if (env.workerConsistency) {
719
+ detections.push(...analyzeWorkerConsistency(env.workerConsistency));
720
+ }
721
+
722
+ // CSS Media Queries analysis
723
+ if (env.cssMediaQueries) {
724
+ detections.push(...analyzeCSSMediaQueries(env.cssMediaQueries, signals));
725
+ }
726
+
727
+ // Font analysis
728
+ if (env.fontsInfo) {
729
+ detections.push(...analyzeFonts(env.fontsInfo, userAgent));
730
+ }
731
+
732
+ // Permissions analysis
733
+ if (env.permissionsInfo) {
734
+ detections.push(...analyzePermissions(env.permissionsInfo));
735
+ }
736
+
737
+ // DOMRect analysis
738
+ if (env.domRectFingerprint) {
739
+ detections.push(...analyzeDOMRect(env.domRectFingerprint));
740
+ }
741
+
742
+ return detections;
743
+ }
744
+
434
745
  module.exports = {
435
746
  isDatacenterIP,
436
747
  checkIPReputation,
@@ -438,5 +749,14 @@ module.exports = {
438
749
  parseUserAgent,
439
750
  checkBrowserConsistency,
440
751
  checkJA3Fingerprint,
441
- analyzeFormInteraction
752
+ analyzeFormInteraction,
753
+ // Advanced fingerprint detection functions
754
+ analyzeWebRTC,
755
+ analyzeSpeechAPI,
756
+ analyzeWorkerConsistency,
757
+ analyzeCSSMediaQueries,
758
+ analyzeFonts,
759
+ analyzePermissions,
760
+ analyzeDOMRect,
761
+ analyzeAdvancedSignals
442
762
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.0.3",
3
+ "version": "1.1.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
@@ -171,6 +171,35 @@ const fingerprintStore = {
171
171
  }
172
172
  };
173
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
+
174
203
  // =============================================================================
175
204
  // Detection Patterns
176
205
  // =============================================================================
@@ -629,7 +658,7 @@ function generateToken(ip, siteKey, score) {
629
658
  return Buffer.from(JSON.stringify(data)).toString('base64url');
630
659
  }
631
660
 
632
- function verifyToken(token) {
661
+ function verifyToken(token, ip = null) {
633
662
  try {
634
663
  const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
635
664
 
@@ -648,11 +677,28 @@ function verifyToken(token) {
648
677
  return { valid: false, reason: 'invalid_signature' };
649
678
  }
650
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
+
651
696
  return {
652
697
  valid: true,
653
698
  site_key: decoded.site_key,
654
699
  timestamp: decoded.timestamp,
655
- score: decoded.score
700
+ score: decoded.score,
701
+ ip_hash: decoded.ip_hash
656
702
  };
657
703
  } catch (e) {
658
704
  return { valid: false, reason: e.message };
@@ -731,6 +777,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
731
777
  detections.push(...formDetections);
732
778
  }
733
779
 
780
+ // Add advanced fingerprint detection analysis
781
+ const advancedDetections = detection.analyzeAdvancedSignals(signals, userAgent);
782
+ detections.push(...advancedDetections);
783
+
734
784
  const categoryScores = calculateCategoryScores(detections);
735
785
  const finalScore = calculateFinalScore(categoryScores);
736
786
 
@@ -816,7 +866,19 @@ app.post('/api/score', (req, res) => {
816
866
 
817
867
  app.post('/api/token/verify', (req, res) => {
818
868
  const { token } = req.body;
819
- res.json(verifyToken(token));
869
+
870
+ // Extract client IP for verification
871
+ let ip = req.headers['x-real-ip'] || '';
872
+ if (!ip) {
873
+ const forwarded = req.headers['x-forwarded-for'];
874
+ if (forwarded) {
875
+ ip = forwarded.split(',')[0].trim();
876
+ } else {
877
+ ip = req.socket.remoteAddress;
878
+ }
879
+ }
880
+
881
+ res.json(verifyToken(token, ip));
820
882
  });
821
883
 
822
884
  // PoW Challenge endpoint - client fetches this on page load