@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.
- package/detection.js +321 -1
- package/package.json +1 -1
- 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
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
|
-
|
|
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
|