@webdecoy/fcaptcha 1.0.4 → 1.12.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 +697 -1
- package/package.json +1 -1
- package/server.js +378 -37
package/detection.js
CHANGED
|
@@ -107,6 +107,69 @@ const EXPECTED_BROWSER_HEADERS = new Set([
|
|
|
107
107
|
'accept', 'accept-language', 'accept-encoding', 'user-agent'
|
|
108
108
|
]);
|
|
109
109
|
|
|
110
|
+
// Self-identifying AI agents and crawlers. These agents are honest about who they
|
|
111
|
+
// are, so matching is high-confidence but intentionally LOW-severity: operators
|
|
112
|
+
// decide via policy whether to allow polite agents rather than hard-blocking.
|
|
113
|
+
// Keep in sync with server-go and server-python.
|
|
114
|
+
const DECLARED_AI_AGENT_PATTERNS = [
|
|
115
|
+
/\bClaudeBot\b/i,
|
|
116
|
+
/\bClaude-User\b/i,
|
|
117
|
+
/\bClaude-SearchBot\b/i,
|
|
118
|
+
/\banthropic-ai\b/i,
|
|
119
|
+
/\bGPTBot\b/i,
|
|
120
|
+
/\bChatGPT-User\b/i,
|
|
121
|
+
/\bOAI-SearchBot\b/i,
|
|
122
|
+
/\bPerplexityBot\b/i,
|
|
123
|
+
/\bPerplexity-User\b/i,
|
|
124
|
+
/\bGoogle-Extended\b/i,
|
|
125
|
+
/\bCCBot\b/i,
|
|
126
|
+
/\bBytespider\b/i,
|
|
127
|
+
/\bmeta-externalagent\b/i,
|
|
128
|
+
/\bAmazonbot\b/i,
|
|
129
|
+
/\bcohere-ai\b/i,
|
|
130
|
+
/\bDiffbot\b/i,
|
|
131
|
+
/\bYouBot\b/i
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent and
|
|
135
|
+
// detects Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures). Both
|
|
136
|
+
// are high-confidence identifications surfaced as the declared_ai category so
|
|
137
|
+
// callers can apply an allow/block policy instead of relying on a hard score.
|
|
138
|
+
function checkDeclaredAIAgent(userAgent, headers = {}) {
|
|
139
|
+
const detections = [];
|
|
140
|
+
const ua = userAgent || '';
|
|
141
|
+
|
|
142
|
+
for (const re of DECLARED_AI_AGENT_PATTERNS) {
|
|
143
|
+
const match = ua.match(re);
|
|
144
|
+
if (match) {
|
|
145
|
+
detections.push({
|
|
146
|
+
category: 'declared_ai',
|
|
147
|
+
score: 0.5,
|
|
148
|
+
confidence: 0.99,
|
|
149
|
+
reason: `Declared AI agent user-agent: ${match[0]}`,
|
|
150
|
+
details: { matched: match[0] }
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Web Bot Auth: presence of HTTP Message Signature headers identifies a
|
|
157
|
+
// (claimed) verified agent. v1 detects/identifies only; signature verification
|
|
158
|
+
// against the agent's published JWKS is a follow-up before trusting it.
|
|
159
|
+
const h = headers || {};
|
|
160
|
+
if (h['signature'] && h['signature-input'] && h['signature-agent']) {
|
|
161
|
+
detections.push({
|
|
162
|
+
category: 'declared_ai',
|
|
163
|
+
score: 0.4,
|
|
164
|
+
confidence: 0.95,
|
|
165
|
+
reason: `Signed agent request (Web Bot Auth): ${h['signature-agent']}`,
|
|
166
|
+
details: { signatureAgent: h['signature-agent'], verified: false }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return detections;
|
|
171
|
+
}
|
|
172
|
+
|
|
110
173
|
function analyzeHeaders(headers) {
|
|
111
174
|
const detections = [];
|
|
112
175
|
const headersLower = {};
|
|
@@ -311,6 +374,296 @@ function checkJA3Fingerprint(ja3Hash) {
|
|
|
311
374
|
return [];
|
|
312
375
|
}
|
|
313
376
|
|
|
377
|
+
// =============================================================================
|
|
378
|
+
// TLS Fingerprinting (JA4) — read from trusted reverse-proxy headers
|
|
379
|
+
// =============================================================================
|
|
380
|
+
//
|
|
381
|
+
// Unlike the client-supplied X-JA3-Hash (which attackers can spoof), JA4
|
|
382
|
+
// fingerprints are computed from the TLS ClientHello by the reverse proxy
|
|
383
|
+
// and passed via a trusted header the server is configured to accept.
|
|
384
|
+
// Configure with: TRUSTED_JA4_HEADERS=cf-ja4,x-tls-ja4 (comma-separated).
|
|
385
|
+
|
|
386
|
+
const KNOWN_BOT_JA4_HASHES = {
|
|
387
|
+
// Populate with observed automation fingerprints in production.
|
|
388
|
+
// JA4 format: t##d####_####..._####
|
|
389
|
+
// Placeholders — administrators should add real fingerprints as they
|
|
390
|
+
// are collected from deployed environments.
|
|
391
|
+
// Example once identified:
|
|
392
|
+
// 't13d1516h2_8daaf6152771_02713d6af862': 'Go default stdlib TLS'
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
function getTrustedJA4HeaderNames() {
|
|
396
|
+
const env = process.env.TRUSTED_JA4_HEADERS;
|
|
397
|
+
if (!env) return [];
|
|
398
|
+
return env.split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readJA4FromHeaders(headers, trustedHeaderNames) {
|
|
402
|
+
if (!trustedHeaderNames || trustedHeaderNames.length === 0) return null;
|
|
403
|
+
for (const name of trustedHeaderNames) {
|
|
404
|
+
const v = headers[name];
|
|
405
|
+
if (v && typeof v === 'string' && v.trim()) return v.trim();
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function checkJA4Fingerprint(ja4) {
|
|
411
|
+
if (!ja4) return [];
|
|
412
|
+
const match = KNOWN_BOT_JA4_HASHES[ja4];
|
|
413
|
+
if (match) {
|
|
414
|
+
return [{
|
|
415
|
+
category: 'fingerprint',
|
|
416
|
+
score: 0.8,
|
|
417
|
+
confidence: 0.9,
|
|
418
|
+
reason: `TLS JA4 fingerprint matches: ${match}`
|
|
419
|
+
}];
|
|
420
|
+
}
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// =============================================================================
|
|
425
|
+
// Statistical Utility Functions (Keystroke Cadence Analysis)
|
|
426
|
+
// =============================================================================
|
|
427
|
+
|
|
428
|
+
function mean(arr) {
|
|
429
|
+
if (arr.length === 0) return 0;
|
|
430
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function stddev(arr) {
|
|
434
|
+
if (arr.length < 2) return 0;
|
|
435
|
+
const m = mean(arr);
|
|
436
|
+
const variance = arr.reduce((acc, v) => acc + (v - m) * (v - m), 0) / arr.length;
|
|
437
|
+
return Math.sqrt(variance);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function erf(x) {
|
|
441
|
+
// Abramowitz & Stegun approximation (max error ~1.5e-7)
|
|
442
|
+
const sign = x >= 0 ? 1 : -1;
|
|
443
|
+
x = Math.abs(x);
|
|
444
|
+
const t = 1.0 / (1.0 + 0.3275911 * x);
|
|
445
|
+
const y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
|
|
446
|
+
return sign * y;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function normalCDF(x, mu, sigma) {
|
|
450
|
+
if (sigma <= 0) return x >= mu ? 1.0 : 0.0;
|
|
451
|
+
return 0.5 * (1.0 + erf((x - mu) / (sigma * Math.SQRT2)));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function ksTestStatistic(samples, cdfFn) {
|
|
455
|
+
const n = samples.length;
|
|
456
|
+
if (n === 0) return 0;
|
|
457
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
458
|
+
let maxD = 0;
|
|
459
|
+
for (let i = 0; i < n; i++) {
|
|
460
|
+
const empirical = (i + 1) / n;
|
|
461
|
+
const theoretical = cdfFn(sorted[i]);
|
|
462
|
+
const d1 = Math.abs(empirical - theoretical);
|
|
463
|
+
const d2 = Math.abs((i / n) - theoretical);
|
|
464
|
+
maxD = Math.max(maxD, d1, d2);
|
|
465
|
+
}
|
|
466
|
+
return maxD;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function shannonEntropy(arr, bins) {
|
|
470
|
+
if (arr.length === 0) return 0;
|
|
471
|
+
const min = Math.min(...arr);
|
|
472
|
+
const max = Math.max(...arr);
|
|
473
|
+
if (max === min) return 0;
|
|
474
|
+
const binWidth = (max - min) / bins;
|
|
475
|
+
const counts = new Array(bins).fill(0);
|
|
476
|
+
for (const v of arr) {
|
|
477
|
+
let idx = Math.floor((v - min) / binWidth);
|
|
478
|
+
if (idx >= bins) idx = bins - 1;
|
|
479
|
+
counts[idx]++;
|
|
480
|
+
}
|
|
481
|
+
const n = arr.length;
|
|
482
|
+
let entropy = 0;
|
|
483
|
+
for (const c of counts) {
|
|
484
|
+
if (c > 0) {
|
|
485
|
+
const p = c / n;
|
|
486
|
+
entropy -= p * Math.log2(p);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return entropy;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function lag1Autocorrelation(arr) {
|
|
493
|
+
if (arr.length < 3) return 0;
|
|
494
|
+
const n = arr.length - 1;
|
|
495
|
+
const x = arr.slice(0, n);
|
|
496
|
+
const y = arr.slice(1);
|
|
497
|
+
const mx = mean(x);
|
|
498
|
+
const my = mean(y);
|
|
499
|
+
let num = 0, dx2 = 0, dy2 = 0;
|
|
500
|
+
for (let i = 0; i < n; i++) {
|
|
501
|
+
const dx = x[i] - mx;
|
|
502
|
+
const dy = y[i] - my;
|
|
503
|
+
num += dx * dy;
|
|
504
|
+
dx2 += dx * dx;
|
|
505
|
+
dy2 += dy * dy;
|
|
506
|
+
}
|
|
507
|
+
const denom = Math.sqrt(dx2 * dy2);
|
|
508
|
+
if (denom === 0) return 0;
|
|
509
|
+
return num / denom;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// =============================================================================
|
|
513
|
+
// Keystroke Cadence Analysis
|
|
514
|
+
// =============================================================================
|
|
515
|
+
|
|
516
|
+
function analyzeKeystrokeCadence(stats) {
|
|
517
|
+
const keyCount = stats.keyCount || 0;
|
|
518
|
+
const intervals = stats.intervals || [];
|
|
519
|
+
const dwellTimes = stats.dwellTimes || [];
|
|
520
|
+
const rollovers = stats.rollovers || 0;
|
|
521
|
+
|
|
522
|
+
// Gate on minimum data
|
|
523
|
+
if (keyCount < 20 || intervals.length < 15) return null;
|
|
524
|
+
|
|
525
|
+
const metrics = {};
|
|
526
|
+
let totalWeight = 0;
|
|
527
|
+
let weightedSum = 0;
|
|
528
|
+
let activeMetricCount = 0;
|
|
529
|
+
|
|
530
|
+
// 1. Dwell Variance (weight 0.15)
|
|
531
|
+
if (dwellTimes.length >= 10) {
|
|
532
|
+
const dSd = stddev(dwellTimes);
|
|
533
|
+
let score;
|
|
534
|
+
if (dSd < 3) score = 0;
|
|
535
|
+
else if (dSd < 10) score = 0.35 * (dSd - 3) / 7;
|
|
536
|
+
else if (dSd < 20) score = 0.35 + 0.65 * (dSd - 10) / 10;
|
|
537
|
+
else score = 1.0;
|
|
538
|
+
metrics.dwellVariance = score;
|
|
539
|
+
totalWeight += 0.15;
|
|
540
|
+
weightedSum += score * 0.15;
|
|
541
|
+
activeMetricCount++;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 2. Log-Normal Fit (weight 0.20)
|
|
545
|
+
if (intervals.length >= 15) {
|
|
546
|
+
const logIntervals = intervals.filter(v => v > 0).map(v => Math.log(v));
|
|
547
|
+
if (logIntervals.length >= 10) {
|
|
548
|
+
const mu = mean(logIntervals);
|
|
549
|
+
const sigma = stddev(logIntervals);
|
|
550
|
+
const D = ksTestStatistic(logIntervals, (x) => normalCDF(x, mu, sigma));
|
|
551
|
+
// Critical value at α=0.10: 1.22 / sqrt(n)
|
|
552
|
+
const critical = 1.22 / Math.sqrt(logIntervals.length);
|
|
553
|
+
let score;
|
|
554
|
+
if (D <= critical * 0.8) score = 1.0;
|
|
555
|
+
else if (D <= critical) score = 0.7;
|
|
556
|
+
else if (D <= critical * 1.5) score = 0.3;
|
|
557
|
+
else score = 0;
|
|
558
|
+
metrics.logNormalFit = score;
|
|
559
|
+
totalWeight += 0.20;
|
|
560
|
+
weightedSum += score * 0.20;
|
|
561
|
+
activeMetricCount++;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 3. Uniformity Detection (weight 0.15)
|
|
566
|
+
if (intervals.length >= 15) {
|
|
567
|
+
const minI = Math.min(...intervals);
|
|
568
|
+
const maxI = Math.max(...intervals);
|
|
569
|
+
if (maxI > minI) {
|
|
570
|
+
const D = ksTestStatistic(intervals, (x) => (x - minI) / (maxI - minI));
|
|
571
|
+
// Low D = looks uniform = bot-like
|
|
572
|
+
let score;
|
|
573
|
+
if (D < 0.05) score = 0;
|
|
574
|
+
else if (D < 0.1) score = 0.3;
|
|
575
|
+
else if (D < 0.15) score = 0.6;
|
|
576
|
+
else score = 1.0;
|
|
577
|
+
metrics.uniformity = score;
|
|
578
|
+
totalWeight += 0.15;
|
|
579
|
+
weightedSum += score * 0.15;
|
|
580
|
+
activeMetricCount++;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 4. Lag-1 Autocorrelation (weight 0.15)
|
|
585
|
+
if (intervals.length >= 15) {
|
|
586
|
+
const r = Math.abs(lag1Autocorrelation(intervals));
|
|
587
|
+
let score;
|
|
588
|
+
if (r < 0.02) score = 0.1;
|
|
589
|
+
else if (r < 0.1) score = 0.1 + 0.4 * (r - 0.02) / 0.08;
|
|
590
|
+
else if (r < 0.2) score = 0.5 + 0.4 * (r - 0.1) / 0.1;
|
|
591
|
+
else if (r <= 0.4) score = 0.9;
|
|
592
|
+
else if (r <= 0.6) score = 0.9 - 0.4 * (r - 0.4) / 0.2;
|
|
593
|
+
else score = 0.5;
|
|
594
|
+
metrics.autocorrelation = score;
|
|
595
|
+
totalWeight += 0.15;
|
|
596
|
+
weightedSum += score * 0.15;
|
|
597
|
+
activeMetricCount++;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 5. Burst Regularity (weight 0.10)
|
|
601
|
+
const burstGaps = [];
|
|
602
|
+
for (let i = 0; i < intervals.length; i++) {
|
|
603
|
+
if (intervals[i] > 300) burstGaps.push(intervals[i]);
|
|
604
|
+
}
|
|
605
|
+
if (burstGaps.length >= 3) {
|
|
606
|
+
const burstMean = mean(burstGaps);
|
|
607
|
+
const burstSd = stddev(burstGaps);
|
|
608
|
+
const cv = burstMean > 0 ? burstSd / burstMean : 0;
|
|
609
|
+
let score;
|
|
610
|
+
if (cv < 0.1) score = 0.1;
|
|
611
|
+
else if (cv < 0.3) score = 0.1 + 0.9 * (cv - 0.1) / 0.2;
|
|
612
|
+
else score = 1.0;
|
|
613
|
+
metrics.burstRegularity = score;
|
|
614
|
+
totalWeight += 0.10;
|
|
615
|
+
weightedSum += score * 0.10;
|
|
616
|
+
activeMetricCount++;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 6. Shannon Entropy (weight 0.15)
|
|
620
|
+
if (intervals.length >= 15) {
|
|
621
|
+
const H = shannonEntropy(intervals, 10);
|
|
622
|
+
// Bell curve scoring: peak around H≈2.5
|
|
623
|
+
let score;
|
|
624
|
+
if (H < 0.5) score = 0.1;
|
|
625
|
+
else if (H < 1.5) score = 0.1 + 0.5 * (H - 0.5);
|
|
626
|
+
else if (H < 2.0) score = 0.6 + 0.4 * (H - 1.5) / 0.5;
|
|
627
|
+
else if (H <= 3.0) score = 1.0;
|
|
628
|
+
else if (H <= 3.3) score = 0.7;
|
|
629
|
+
else score = 0.4;
|
|
630
|
+
metrics.entropy = score;
|
|
631
|
+
totalWeight += 0.15;
|
|
632
|
+
weightedSum += score * 0.15;
|
|
633
|
+
activeMetricCount++;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 7. Rollover Rate (weight 0.10)
|
|
637
|
+
if (keyCount >= 30) {
|
|
638
|
+
const rate = rollovers / keyCount;
|
|
639
|
+
let score;
|
|
640
|
+
if (rate === 0) score = 0.5;
|
|
641
|
+
else if (rate < 0.05) score = 0.5 + 0.3 * (rate / 0.05);
|
|
642
|
+
else if (rate < 0.15) score = 0.8 + 0.2 * ((rate - 0.05) / 0.1);
|
|
643
|
+
else score = 1.0;
|
|
644
|
+
metrics.rolloverRate = score;
|
|
645
|
+
totalWeight += 0.10;
|
|
646
|
+
weightedSum += score * 0.10;
|
|
647
|
+
activeMetricCount++;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (totalWeight === 0) return null;
|
|
651
|
+
|
|
652
|
+
const humanScore = weightedSum / totalWeight;
|
|
653
|
+
const botScore = 1.0 - humanScore;
|
|
654
|
+
|
|
655
|
+
// Only emit detection when botScore > 0.55
|
|
656
|
+
if (botScore <= 0.55) return null;
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
category: 'bot',
|
|
660
|
+
score: botScore * 0.7,
|
|
661
|
+
confidence: Math.min(0.7, activeMetricCount / 7),
|
|
662
|
+
reason: 'Keystroke cadence analysis indicates non-human typing pattern',
|
|
663
|
+
details: { metrics, cadenceHumanScore: humanScore }
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
314
667
|
// =============================================================================
|
|
315
668
|
// Form Interaction Analysis (Credential Stuffing & Spam Detection)
|
|
316
669
|
// =============================================================================
|
|
@@ -376,6 +729,18 @@ function analyzeFormInteraction(formAnalysis) {
|
|
|
376
729
|
const textareaData = formAnalysis.textareaKeyboard;
|
|
377
730
|
if (textareaData) {
|
|
378
731
|
for (const [fieldId, stats] of Object.entries(textareaData)) {
|
|
732
|
+
// Programmatic fill: meaningful content appeared with zero keystrokes and
|
|
733
|
+
// zero pastes (e.g. Playwright fill() / element.value=). The length gate
|
|
734
|
+
// keeps this off browser autofill of short fields.
|
|
735
|
+
if ((stats.contentLength || 0) > 8 && (stats.keyCount || 0) === 0 && (stats.pasteCount || 0) === 0) {
|
|
736
|
+
detections.push({
|
|
737
|
+
category: 'automation',
|
|
738
|
+
score: 0.8,
|
|
739
|
+
confidence: 0.75,
|
|
740
|
+
reason: `Textarea "${fieldId}" filled programmatically (${stats.contentLength} chars, no keystrokes or paste)`
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
379
744
|
// Check for paste-heavy input (spam bots often paste content)
|
|
380
745
|
if (stats.pasteCount > 0 && stats.keyCount < 5) {
|
|
381
746
|
detections.push({
|
|
@@ -425,18 +790,349 @@ function analyzeFormInteraction(formAnalysis) {
|
|
|
425
790
|
reason: `Textarea "${fieldId}" has ${stats.contentLength} chars but no keyboard events (DOM manipulation)`
|
|
426
791
|
});
|
|
427
792
|
}
|
|
793
|
+
|
|
794
|
+
// Keystroke cadence analysis
|
|
795
|
+
const cadenceResult = analyzeKeystrokeCadence(stats);
|
|
796
|
+
if (cadenceResult) {
|
|
797
|
+
detections.push(cadenceResult);
|
|
798
|
+
}
|
|
428
799
|
}
|
|
429
800
|
}
|
|
430
801
|
|
|
431
802
|
return detections;
|
|
432
803
|
}
|
|
433
804
|
|
|
805
|
+
// =============================================================================
|
|
806
|
+
// Advanced Fingerprint Detection Functions
|
|
807
|
+
// =============================================================================
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Analyze WebRTC signals for headless browser and VM detection
|
|
811
|
+
*/
|
|
812
|
+
function analyzeWebRTC(webrtcInfo) {
|
|
813
|
+
if (!webrtcInfo || !webrtcInfo.supported) return [];
|
|
814
|
+
|
|
815
|
+
const detections = [];
|
|
816
|
+
|
|
817
|
+
// Check media devices - headless browsers typically have 0 devices
|
|
818
|
+
const mediaDevices = webrtcInfo.mediaDevices || {};
|
|
819
|
+
if (mediaDevices.supported && mediaDevices.totalDevices === 0) {
|
|
820
|
+
detections.push({
|
|
821
|
+
category: 'headless',
|
|
822
|
+
score: 0.7,
|
|
823
|
+
confidence: 0.75,
|
|
824
|
+
reason: 'No media devices detected (typical of headless browsers)'
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Suspicious: has video inputs but no audio (unusual)
|
|
829
|
+
if (mediaDevices.supported && mediaDevices.videoInputs > 0 && mediaDevices.audioInputs === 0) {
|
|
830
|
+
detections.push({
|
|
831
|
+
category: 'bot',
|
|
832
|
+
score: 0.4,
|
|
833
|
+
confidence: 0.5,
|
|
834
|
+
reason: 'Has video devices but no audio devices (unusual configuration)'
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Check local IP detection - VMs and some headless setups may not expose local IPs
|
|
839
|
+
if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
|
|
840
|
+
detections.push({
|
|
841
|
+
category: 'headless',
|
|
842
|
+
score: 0.4,
|
|
843
|
+
confidence: 0.5,
|
|
844
|
+
reason: 'No local IP addresses detected via WebRTC'
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return detections;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Analyze Speech API signals - voices are OS/browser specific and hard to spoof
|
|
853
|
+
*/
|
|
854
|
+
function analyzeSpeechAPI(speechInfo) {
|
|
855
|
+
if (!speechInfo || !speechInfo.supported) return [];
|
|
856
|
+
|
|
857
|
+
const detections = [];
|
|
858
|
+
|
|
859
|
+
// No voices at all - suspicious
|
|
860
|
+
if (speechInfo.totalVoices === 0) {
|
|
861
|
+
detections.push({
|
|
862
|
+
category: 'headless',
|
|
863
|
+
score: 0.6,
|
|
864
|
+
confidence: 0.7,
|
|
865
|
+
reason: 'No speech synthesis voices available'
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Very few voices (less than 5) - could be headless or minimal VM
|
|
870
|
+
if (speechInfo.totalVoices > 0 && speechInfo.totalVoices < 5) {
|
|
871
|
+
detections.push({
|
|
872
|
+
category: 'headless',
|
|
873
|
+
score: 0.3,
|
|
874
|
+
confidence: 0.4,
|
|
875
|
+
reason: `Very few speech voices available (${speechInfo.totalVoices})`
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// No local voices - all remote/network voices suggests unusual setup
|
|
880
|
+
if (speechInfo.localVoices === 0 && speechInfo.totalVoices > 0) {
|
|
881
|
+
detections.push({
|
|
882
|
+
category: 'bot',
|
|
883
|
+
score: 0.3,
|
|
884
|
+
confidence: 0.4,
|
|
885
|
+
reason: 'No local speech synthesis voices'
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return detections;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Analyze Worker consistency - spoofed values often don't match between contexts
|
|
894
|
+
*/
|
|
895
|
+
function analyzeWorkerConsistency(workerConsistency) {
|
|
896
|
+
if (!workerConsistency || !workerConsistency.supported) return [];
|
|
897
|
+
|
|
898
|
+
const detections = [];
|
|
899
|
+
|
|
900
|
+
// Mismatches indicate fingerprint spoofing
|
|
901
|
+
if (!workerConsistency.consistent && workerConsistency.mismatchCount > 0) {
|
|
902
|
+
const score = Math.min(0.9, 0.3 + (workerConsistency.mismatchCount * 0.15));
|
|
903
|
+
detections.push({
|
|
904
|
+
category: 'bot',
|
|
905
|
+
score: score,
|
|
906
|
+
confidence: 0.85,
|
|
907
|
+
reason: `Worker/main thread mismatch detected: ${workerConsistency.mismatches.join(', ')}`
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return detections;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Analyze CSS Media Queries for environment consistency
|
|
916
|
+
*/
|
|
917
|
+
function analyzeCSSMediaQueries(cssMedia, signals) {
|
|
918
|
+
if (!cssMedia || !cssMedia.supported) return [];
|
|
919
|
+
|
|
920
|
+
const detections = [];
|
|
921
|
+
const nav = (signals.environmental || {}).navigator || {};
|
|
922
|
+
|
|
923
|
+
// Check pointer consistency with touch capability
|
|
924
|
+
const maxTouch = nav.maxTouchPoints || 0;
|
|
925
|
+
if (cssMedia.pointer === 'coarse' && maxTouch === 0) {
|
|
926
|
+
detections.push({
|
|
927
|
+
category: 'bot',
|
|
928
|
+
score: 0.5,
|
|
929
|
+
confidence: 0.6,
|
|
930
|
+
reason: 'CSS reports coarse pointer but no touch support'
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Check hover capability - headless browsers may have unusual values
|
|
935
|
+
if (cssMedia.hover === false && cssMedia.pointer === 'fine') {
|
|
936
|
+
detections.push({
|
|
937
|
+
category: 'bot',
|
|
938
|
+
score: 0.3,
|
|
939
|
+
confidence: 0.4,
|
|
940
|
+
reason: 'Fine pointer reported but no hover capability'
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Forced colors mode with reduced motion - accessibility features
|
|
945
|
+
// Note: These are NOT suspicious on their own, just for fingerprinting
|
|
946
|
+
// Don't penalize accessibility users
|
|
947
|
+
|
|
948
|
+
return detections;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Analyze font detection results for consistency
|
|
953
|
+
*/
|
|
954
|
+
function analyzeFonts(fontsInfo, userAgent) {
|
|
955
|
+
if (!fontsInfo || !fontsInfo.supported) return [];
|
|
956
|
+
|
|
957
|
+
const detections = [];
|
|
958
|
+
|
|
959
|
+
// Very few fonts detected could indicate headless browser
|
|
960
|
+
if (fontsInfo.count < 3) {
|
|
961
|
+
detections.push({
|
|
962
|
+
category: 'headless',
|
|
963
|
+
score: 0.5,
|
|
964
|
+
confidence: 0.5,
|
|
965
|
+
reason: `Very few fonts detected (${fontsInfo.count})`
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Check OS-specific fonts against UA
|
|
970
|
+
const ua = (userAgent || '').toLowerCase();
|
|
971
|
+
|
|
972
|
+
// Windows UA but no Segoe UI
|
|
973
|
+
if (ua.includes('windows') && fontsInfo.hasSegoeUI === false && fontsInfo.count > 5) {
|
|
974
|
+
detections.push({
|
|
975
|
+
category: 'bot',
|
|
976
|
+
score: 0.5,
|
|
977
|
+
confidence: 0.6,
|
|
978
|
+
reason: 'Windows UA but Segoe UI font not detected'
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Mac UA but no SF Pro (modern macOS)
|
|
983
|
+
if ((ua.includes('mac os x') || ua.includes('macintosh')) && fontsInfo.hasSFPro === false &&
|
|
984
|
+
ua.includes('10_15') === false && ua.includes('10_14') === false && fontsInfo.count > 5) {
|
|
985
|
+
// SF Pro is on macOS 10.15+ (Catalina and later)
|
|
986
|
+
detections.push({
|
|
987
|
+
category: 'bot',
|
|
988
|
+
score: 0.3,
|
|
989
|
+
confidence: 0.4,
|
|
990
|
+
reason: 'Modern macOS UA but SF Pro font not detected'
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Linux UA but no DejaVu Sans (very common on Linux)
|
|
995
|
+
if (ua.includes('linux') && !ua.includes('android') &&
|
|
996
|
+
fontsInfo.hasDejaVuSans === false && fontsInfo.count > 5) {
|
|
997
|
+
detections.push({
|
|
998
|
+
category: 'bot',
|
|
999
|
+
score: 0.4,
|
|
1000
|
+
confidence: 0.5,
|
|
1001
|
+
reason: 'Linux UA but DejaVu Sans font not detected'
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return detections;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Analyze permissions/API availability for headless detection
|
|
1010
|
+
*/
|
|
1011
|
+
function analyzePermissions(permissionsInfo) {
|
|
1012
|
+
if (!permissionsInfo || !permissionsInfo.supported) return [];
|
|
1013
|
+
|
|
1014
|
+
const detections = [];
|
|
1015
|
+
|
|
1016
|
+
// Count available APIs
|
|
1017
|
+
const apiKeys = [
|
|
1018
|
+
'hasPermissionsAPI', 'hasClipboard', 'hasShare', 'hasCredentials',
|
|
1019
|
+
'hasBluetooth', 'hasUsb', 'hasSerial', 'hasHid', 'hasXR',
|
|
1020
|
+
'hasGeolocation', 'hasMIDI'
|
|
1021
|
+
];
|
|
1022
|
+
|
|
1023
|
+
const availableApis = apiKeys.filter(key => permissionsInfo[key] === true).length;
|
|
1024
|
+
|
|
1025
|
+
// Very few APIs available could indicate minimal/headless browser
|
|
1026
|
+
if (availableApis < 3) {
|
|
1027
|
+
detections.push({
|
|
1028
|
+
category: 'headless',
|
|
1029
|
+
score: 0.4,
|
|
1030
|
+
confidence: 0.5,
|
|
1031
|
+
reason: `Very few navigator APIs available (${availableApis})`
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return detections;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Analyze DOMRect/geometry fingerprint for rendering anomalies
|
|
1040
|
+
*/
|
|
1041
|
+
function analyzeDOMRect(domRectInfo) {
|
|
1042
|
+
if (!domRectInfo || !domRectInfo.supported) return [];
|
|
1043
|
+
|
|
1044
|
+
const detections = [];
|
|
1045
|
+
|
|
1046
|
+
// Check for zero or very small dimensions (rendering issues)
|
|
1047
|
+
if (domRectInfo.rectAWidth === 0 || domRectInfo.rectBWidth === 0) {
|
|
1048
|
+
detections.push({
|
|
1049
|
+
category: 'headless',
|
|
1050
|
+
score: 0.6,
|
|
1051
|
+
confidence: 0.7,
|
|
1052
|
+
reason: 'DOMRect rendering returned zero-width elements'
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Check for exact integer values (unusual in real browsers)
|
|
1057
|
+
if (domRectInfo.rectAWidth === Math.floor(domRectInfo.rectAWidth) &&
|
|
1058
|
+
domRectInfo.rectBWidth === Math.floor(domRectInfo.rectBWidth) &&
|
|
1059
|
+
domRectInfo.rangeWidth === Math.floor(domRectInfo.rangeWidth)) {
|
|
1060
|
+
detections.push({
|
|
1061
|
+
category: 'bot',
|
|
1062
|
+
score: 0.3,
|
|
1063
|
+
confidence: 0.4,
|
|
1064
|
+
reason: 'DOMRect measurements are all exact integers (unusual)'
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return detections;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Master function to analyze all advanced fingerprint signals
|
|
1073
|
+
*/
|
|
1074
|
+
function analyzeAdvancedSignals(signals, userAgent) {
|
|
1075
|
+
const detections = [];
|
|
1076
|
+
const env = signals.environmental || {};
|
|
1077
|
+
|
|
1078
|
+
// WebRTC analysis
|
|
1079
|
+
if (env.webrtcInfo) {
|
|
1080
|
+
detections.push(...analyzeWebRTC(env.webrtcInfo));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Speech API analysis
|
|
1084
|
+
if (env.speechInfo) {
|
|
1085
|
+
detections.push(...analyzeSpeechAPI(env.speechInfo));
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Worker consistency analysis
|
|
1089
|
+
if (env.workerConsistency) {
|
|
1090
|
+
detections.push(...analyzeWorkerConsistency(env.workerConsistency));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// CSS Media Queries analysis
|
|
1094
|
+
if (env.cssMediaQueries) {
|
|
1095
|
+
detections.push(...analyzeCSSMediaQueries(env.cssMediaQueries, signals));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Font analysis
|
|
1099
|
+
if (env.fontsInfo) {
|
|
1100
|
+
detections.push(...analyzeFonts(env.fontsInfo, userAgent));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Permissions analysis
|
|
1104
|
+
if (env.permissionsInfo) {
|
|
1105
|
+
detections.push(...analyzePermissions(env.permissionsInfo));
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// DOMRect analysis
|
|
1109
|
+
if (env.domRectFingerprint) {
|
|
1110
|
+
detections.push(...analyzeDOMRect(env.domRectFingerprint));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return detections;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
434
1116
|
module.exports = {
|
|
435
1117
|
isDatacenterIP,
|
|
436
1118
|
checkIPReputation,
|
|
437
1119
|
analyzeHeaders,
|
|
438
1120
|
parseUserAgent,
|
|
439
1121
|
checkBrowserConsistency,
|
|
1122
|
+
checkDeclaredAIAgent,
|
|
440
1123
|
checkJA3Fingerprint,
|
|
441
|
-
|
|
1124
|
+
checkJA4Fingerprint,
|
|
1125
|
+
getTrustedJA4HeaderNames,
|
|
1126
|
+
readJA4FromHeaders,
|
|
1127
|
+
KNOWN_BOT_JA4_HASHES,
|
|
1128
|
+
analyzeFormInteraction,
|
|
1129
|
+
// Advanced fingerprint detection functions
|
|
1130
|
+
analyzeWebRTC,
|
|
1131
|
+
analyzeSpeechAPI,
|
|
1132
|
+
analyzeWorkerConsistency,
|
|
1133
|
+
analyzeCSSMediaQueries,
|
|
1134
|
+
analyzeFonts,
|
|
1135
|
+
analyzePermissions,
|
|
1136
|
+
analyzeDOMRect,
|
|
1137
|
+
analyzeAdvancedSignals
|
|
442
1138
|
};
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const express = require('express');
|
|
8
8
|
const cors = require('cors');
|
|
9
9
|
const crypto = require('crypto');
|
|
10
|
+
const path = require('path');
|
|
10
11
|
const detection = require('./detection');
|
|
11
12
|
|
|
12
13
|
const app = express();
|
|
@@ -15,6 +16,21 @@ app.use(express.json());
|
|
|
15
16
|
|
|
16
17
|
const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
|
|
17
18
|
const PORT = process.env.PORT || 3000;
|
|
19
|
+
const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
|
|
20
|
+
|
|
21
|
+
// Serve the browser widget alongside the API so deployments expose a single
|
|
22
|
+
// origin to integrators (the implicit contract behind <serverUrl>/fcaptcha.js).
|
|
23
|
+
// Set FCAPTCHA_SERVE_CLIENT=false to opt out — useful when hosting the widget
|
|
24
|
+
// on a separate CDN / edge cache. Set FCAPTCHA_CLIENT_PATH=/abs/path/fcaptcha.js
|
|
25
|
+
// to override the default lookup when server-node is deployed standalone.
|
|
26
|
+
const CLIENT_PATH = process.env.FCAPTCHA_CLIENT_PATH
|
|
27
|
+
|| path.join(__dirname, '..', 'client', 'fcaptcha.js');
|
|
28
|
+
|
|
29
|
+
if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
|
|
30
|
+
app.get('/fcaptcha.js', (req, res) => {
|
|
31
|
+
res.sendFile(CLIENT_PATH);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
// =============================================================================
|
|
20
36
|
// In-Memory Storage (Use Redis in production)
|
|
@@ -28,6 +44,7 @@ const powChallengeStore = {
|
|
|
28
44
|
// Generate a new challenge
|
|
29
45
|
generate(siteKey, ip, difficulty = 4) {
|
|
30
46
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
47
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
31
48
|
const timestamp = Date.now();
|
|
32
49
|
const expiresAt = timestamp + (5 * 60 * 1000); // 5 minutes
|
|
33
50
|
|
|
@@ -38,14 +55,14 @@ const powChallengeStore = {
|
|
|
38
55
|
timestamp,
|
|
39
56
|
expiresAt,
|
|
40
57
|
difficulty,
|
|
58
|
+
nonce,
|
|
41
59
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
42
60
|
};
|
|
43
61
|
|
|
44
62
|
// Sign the challenge
|
|
45
63
|
challengeData.sig = crypto.createHmac('sha256', SECRET_KEY)
|
|
46
64
|
.update(JSON.stringify(challengeData))
|
|
47
|
-
.digest('hex')
|
|
48
|
-
.slice(0, 16);
|
|
65
|
+
.digest('hex');
|
|
49
66
|
|
|
50
67
|
// Store challenge
|
|
51
68
|
this.challenges.set(challengeId, {
|
|
@@ -60,8 +77,8 @@ const powChallengeStore = {
|
|
|
60
77
|
return challengeData;
|
|
61
78
|
},
|
|
62
79
|
|
|
63
|
-
// Verify a PoW solution
|
|
64
|
-
verify(challengeId, nonce, hash, siteKey) {
|
|
80
|
+
// Verify a PoW solution (signalsHash is optional for backward compat)
|
|
81
|
+
verify(challengeId, nonce, hash, siteKey, signalsHash = null) {
|
|
65
82
|
const challenge = this.challenges.get(challengeId);
|
|
66
83
|
|
|
67
84
|
if (!challenge) {
|
|
@@ -83,8 +100,10 @@ const powChallengeStore = {
|
|
|
83
100
|
return { valid: false, reason: 'solution_already_used' };
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
// Verify the hash
|
|
87
|
-
const input =
|
|
103
|
+
// Verify the hash (with optional signalsHash binding)
|
|
104
|
+
const input = signalsHash
|
|
105
|
+
? `${challenge.prefix}:${signalsHash}:${nonce}`
|
|
106
|
+
: `${challenge.prefix}:${nonce}`;
|
|
88
107
|
const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
|
|
89
108
|
|
|
90
109
|
if (hash !== expectedHash) {
|
|
@@ -103,7 +122,10 @@ const powChallengeStore = {
|
|
|
103
122
|
// Delete challenge (one-time use)
|
|
104
123
|
this.challenges.delete(challengeId);
|
|
105
124
|
|
|
106
|
-
|
|
125
|
+
// Calculate server-side elapsed time (un-spoofable)
|
|
126
|
+
const serverElapsed = Date.now() - challenge.createdAt;
|
|
127
|
+
|
|
128
|
+
return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
|
|
107
129
|
},
|
|
108
130
|
|
|
109
131
|
_cleanup() {
|
|
@@ -213,14 +235,15 @@ const AUTOMATION_UA_PATTERNS = [
|
|
|
213
235
|
const WEIGHTS = {
|
|
214
236
|
vision_ai: 0.15,
|
|
215
237
|
headless: 0.15,
|
|
216
|
-
automation: 0.
|
|
238
|
+
automation: 0.08,
|
|
217
239
|
cdp: 0.12,
|
|
218
240
|
behavioral: 0.18,
|
|
219
|
-
fingerprint: 0.
|
|
220
|
-
rate_limit: 0.
|
|
221
|
-
datacenter: 0.
|
|
222
|
-
tor_vpn: 0.
|
|
223
|
-
bot: 0.
|
|
241
|
+
fingerprint: 0.08,
|
|
242
|
+
rate_limit: 0.01,
|
|
243
|
+
datacenter: 0.07,
|
|
244
|
+
tor_vpn: 0.01,
|
|
245
|
+
bot: 0.13,
|
|
246
|
+
declared_ai: 0.02
|
|
224
247
|
};
|
|
225
248
|
|
|
226
249
|
// =============================================================================
|
|
@@ -236,6 +259,30 @@ function detectVisionAI(signals) {
|
|
|
236
259
|
const b = signals.behavioral || {};
|
|
237
260
|
const t = signals.temporal || {};
|
|
238
261
|
|
|
262
|
+
// Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
|
|
263
|
+
// Exempt: touch users (mobile) and keyboard-only users (accessibility)
|
|
264
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
265
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
266
|
+
const approachPts = b.approachPoints ?? 0;
|
|
267
|
+
const touchEvents = b.touchEvents ?? 0;
|
|
268
|
+
const keyEvents = b.keyEvents ?? 0;
|
|
269
|
+
const isTouchUser = touchEvents >= 3;
|
|
270
|
+
const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
|
|
271
|
+
|
|
272
|
+
if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
|
|
273
|
+
detections.push({
|
|
274
|
+
category: 'vision_ai', score: 0.9, confidence: 0.85,
|
|
275
|
+
reason: 'No mouse movement detected before click (AI agent pattern)'
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (approachPts === 0 && !isTouchUser && !isKeyboardUser) {
|
|
280
|
+
detections.push({
|
|
281
|
+
category: 'vision_ai', score: 0.7, confidence: 0.8,
|
|
282
|
+
reason: 'No approach trajectory to target'
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
239
286
|
// PoW timing
|
|
240
287
|
const pow = t.pow || {};
|
|
241
288
|
if (pow.duration && pow.iterations) {
|
|
@@ -283,7 +330,6 @@ function detectVisionAI(signals) {
|
|
|
283
330
|
|
|
284
331
|
// Exploration
|
|
285
332
|
const exploration = b.explorationRatio ?? 0.3;
|
|
286
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
287
333
|
if (exploration < 0.05 && trajectory > 50) {
|
|
288
334
|
detections.push({
|
|
289
335
|
category: 'vision_ai', score: 0.4, confidence: 0.4,
|
|
@@ -291,6 +337,31 @@ function detectVisionAI(signals) {
|
|
|
291
337
|
});
|
|
292
338
|
}
|
|
293
339
|
|
|
340
|
+
// Input-event forensics: teleport clicks and agent think-time cadence.
|
|
341
|
+
const fcs = b.inputForensics;
|
|
342
|
+
if (fcs) {
|
|
343
|
+
const teleports = fcs.teleportClicks ?? 0;
|
|
344
|
+
if (teleports >= 1 && !isTouchUser) {
|
|
345
|
+
detections.push({
|
|
346
|
+
category: 'vision_ai', score: 0.7, confidence: 0.7,
|
|
347
|
+
reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// Bursts of activity separated by multi-second perfect silence — the agent
|
|
351
|
+
// act -> screenshot -> inference loop. Low confidence (slow humans idle too);
|
|
352
|
+
// requires silence to dominate. Keyboard-only users are exempt.
|
|
353
|
+
if (!isKeyboardUser &&
|
|
354
|
+
(fcs.cadenceEvents ?? 0) >= 12 &&
|
|
355
|
+
(fcs.cadenceSilentGaps ?? 0) >= 3 &&
|
|
356
|
+
(fcs.cadenceGapCV ?? 0) > 2.5 &&
|
|
357
|
+
(fcs.cadenceSilentRatio ?? 0) > 0.6) {
|
|
358
|
+
detections.push({
|
|
359
|
+
category: 'vision_ai', score: 0.6, confidence: 0.5,
|
|
360
|
+
reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
294
365
|
return detections;
|
|
295
366
|
}
|
|
296
367
|
|
|
@@ -365,6 +436,25 @@ function detectHeadless(signals, userAgent) {
|
|
|
365
436
|
});
|
|
366
437
|
}
|
|
367
438
|
|
|
439
|
+
// Playwright-specific detection
|
|
440
|
+
const playwright = env.playwright || {};
|
|
441
|
+
if (playwright.detected) {
|
|
442
|
+
const playwrightSignals = playwright.signals || [];
|
|
443
|
+
const scoreMap = {
|
|
444
|
+
playwright_globals: 0.95,
|
|
445
|
+
webdriver_deleted: 0.8,
|
|
446
|
+
webdriver_configurable: 0.7,
|
|
447
|
+
chrome_runtime_missing: 0.6,
|
|
448
|
+
};
|
|
449
|
+
for (const sig of playwrightSignals) {
|
|
450
|
+
const sigScore = scoreMap[sig] || 0.7;
|
|
451
|
+
detections.push({
|
|
452
|
+
category: 'headless', score: sigScore, confidence: 0.8,
|
|
453
|
+
reason: `Playwright artifact detected: ${sig}`
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
368
458
|
return detections;
|
|
369
459
|
}
|
|
370
460
|
|
|
@@ -416,6 +506,38 @@ function detectCDP(signals) {
|
|
|
416
506
|
const env = signals.environmental || {};
|
|
417
507
|
const cdp = env.cdp || {};
|
|
418
508
|
|
|
509
|
+
// Input-event forensics: catch CDP-injected input that reports isTrusted:true
|
|
510
|
+
// and so evades the global-based checks below. Touch users are exempt.
|
|
511
|
+
const b = signals.behavioral || {};
|
|
512
|
+
const isTouchUser = (b.touchEvents ?? 0) >= 3;
|
|
513
|
+
const fcs = b.inputForensics;
|
|
514
|
+
if (fcs && !isTouchUser) {
|
|
515
|
+
// Real mice coalesce several hardware samples per frame; a stream of
|
|
516
|
+
// pointermoves that NEVER coalesced is synthetic injection.
|
|
517
|
+
if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
|
|
518
|
+
detections.push({
|
|
519
|
+
category: 'cdp', score: 0.8, confidence: 0.6,
|
|
520
|
+
reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// movementX/Y incoherent with actual position deltas across most moves.
|
|
524
|
+
if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
|
|
525
|
+
detections.push({
|
|
526
|
+
category: 'cdp', score: 0.6, confidence: 0.5,
|
|
527
|
+
reason: 'Pointer movement deltas incoherent with position (synthetic input)'
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// CDP Runtime/DevTools console consumer attached. Low confidence: a developer
|
|
533
|
+
// with DevTools open also trips this, so it contributes rather than blocks.
|
|
534
|
+
if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
|
|
535
|
+
detections.push({
|
|
536
|
+
category: 'cdp', score: 0.6, confidence: 0.5,
|
|
537
|
+
reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
419
541
|
if (cdp.detected) {
|
|
420
542
|
const signalList = cdp.signals || [];
|
|
421
543
|
const signalCount = signalList.length;
|
|
@@ -456,9 +578,29 @@ function detectBehavioral(signals) {
|
|
|
456
578
|
const b = signals.behavioral || {};
|
|
457
579
|
const t = signals.temporal || {};
|
|
458
580
|
|
|
581
|
+
// Insufficient mouse data - critical check for zero-click bots
|
|
582
|
+
// Exempt: touch users (mobile) and keyboard-only users (accessibility)
|
|
583
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
584
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
585
|
+
const touchEvts = b.touchEvents ?? 0;
|
|
586
|
+
const keyEvts = b.keyEvents ?? 0;
|
|
587
|
+
const isTouchUsr = touchEvts >= 3;
|
|
588
|
+
const isKbdUser = keyEvts >= 2 && totalPoints === 0;
|
|
589
|
+
|
|
590
|
+
if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
|
|
591
|
+
detections.push({
|
|
592
|
+
category: 'behavioral', score: 0.8, confidence: 0.9,
|
|
593
|
+
reason: 'Zero mouse, touch, or keyboard events recorded'
|
|
594
|
+
});
|
|
595
|
+
} else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
|
|
596
|
+
detections.push({
|
|
597
|
+
category: 'behavioral', score: 0.6, confidence: 0.7,
|
|
598
|
+
reason: 'Insufficient mouse movement before interaction'
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
459
602
|
// Velocity variance
|
|
460
603
|
const velVar = b.velocityVariance ?? 1;
|
|
461
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
462
604
|
if (velVar < 0.02 && trajectory > 50) {
|
|
463
605
|
detections.push({
|
|
464
606
|
category: 'behavioral', score: 0.6, confidence: 0.6,
|
|
@@ -523,7 +665,6 @@ function detectBehavioral(signals) {
|
|
|
523
665
|
|
|
524
666
|
// Direction changes
|
|
525
667
|
const dirChanges = b.directionChanges ?? 10;
|
|
526
|
-
const totalPoints = b.totalPoints ?? 0;
|
|
527
668
|
if (totalPoints > 50 && dirChanges < 3) {
|
|
528
669
|
detections.push({
|
|
529
670
|
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
@@ -534,6 +675,134 @@ function detectBehavioral(signals) {
|
|
|
534
675
|
return detections;
|
|
535
676
|
}
|
|
536
677
|
|
|
678
|
+
// =============================================================================
|
|
679
|
+
// Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
|
|
680
|
+
// UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
|
|
681
|
+
// Safari without permission (absence of motion events treated as neutral).
|
|
682
|
+
// =============================================================================
|
|
683
|
+
|
|
684
|
+
function _isMobileUA(userAgent) {
|
|
685
|
+
const ua = (userAgent || '').toLowerCase();
|
|
686
|
+
return /mobile|android|iphone|ipad|ipod/.test(ua);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function detectTouchAuthenticity(signals, userAgent) {
|
|
690
|
+
const detections = [];
|
|
691
|
+
if (!_isMobileUA(userAgent)) return detections;
|
|
692
|
+
|
|
693
|
+
const b = signals.behavioral || {};
|
|
694
|
+
const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
|
|
695
|
+
if (touchPoints < 3) return detections;
|
|
696
|
+
|
|
697
|
+
const forceVariance = b.touchForceVariance ?? 0;
|
|
698
|
+
const radiusVariance = b.touchRadiusVariance ?? 0;
|
|
699
|
+
const forceAllOne = b.touchForceAllOne === true;
|
|
700
|
+
const uniqueIds = b.touchUniqueIdentifiers ?? 0;
|
|
701
|
+
const forceMax = b.touchForceMax ?? 0;
|
|
702
|
+
const radiusMax = b.touchRadiusMax ?? 0;
|
|
703
|
+
|
|
704
|
+
// Uniform non-zero force across all events → synthetic injection.
|
|
705
|
+
// Older Android returning all-zero is legitimate — only penalize uniformity
|
|
706
|
+
// when max > 0.
|
|
707
|
+
if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
|
|
708
|
+
detections.push({
|
|
709
|
+
category: 'behavioral', score: 0.75, confidence: 0.85,
|
|
710
|
+
reason: 'Touch force is identical across all events (synthetic touch)'
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// All force=1 exactly is a common synthetic default in automation frameworks.
|
|
715
|
+
if (forceAllOne && touchPoints >= 5) {
|
|
716
|
+
detections.push({
|
|
717
|
+
category: 'behavioral', score: 0.8, confidence: 0.9,
|
|
718
|
+
reason: 'All touches report force=1.0 exactly (synthetic pattern)'
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Uniform contact radius across many events is unusual on real phones.
|
|
723
|
+
if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
|
|
724
|
+
detections.push({
|
|
725
|
+
category: 'behavioral', score: 0.7, confidence: 0.8,
|
|
726
|
+
reason: 'Touch contact radius identical across all events'
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Mobile UA with real touches but zero unique identifiers — framework default.
|
|
731
|
+
if (touchPoints >= 5 && uniqueIds === 0) {
|
|
732
|
+
detections.push({
|
|
733
|
+
category: 'behavioral', score: 0.6, confidence: 0.7,
|
|
734
|
+
reason: 'Mobile touches lack identifier tracking (synthetic injection)'
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return detections;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function detectSensorEntropy(signals, userAgent) {
|
|
742
|
+
const detections = [];
|
|
743
|
+
if (!_isMobileUA(userAgent)) return detections;
|
|
744
|
+
|
|
745
|
+
const env = signals.environmental || {};
|
|
746
|
+
const sensor = env.sensor || {};
|
|
747
|
+
const motionCount = sensor.motionEventCount ?? 0;
|
|
748
|
+
const motionVariance = sensor.motionAccelVariance ?? 0;
|
|
749
|
+
const orientationCount = sensor.orientationEventCount ?? 0;
|
|
750
|
+
const orientationVariance = sensor.orientationVariance ?? 0;
|
|
751
|
+
|
|
752
|
+
// Sensor events fired but completely flat → emulator / headless mobile.
|
|
753
|
+
if (motionCount >= 10 && motionVariance < 0.01) {
|
|
754
|
+
detections.push({
|
|
755
|
+
category: 'headless', score: 0.7, confidence: 0.8,
|
|
756
|
+
reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (orientationCount >= 10 && orientationVariance < 0.01) {
|
|
761
|
+
detections.push({
|
|
762
|
+
category: 'headless', score: 0.6, confidence: 0.7,
|
|
763
|
+
reason: 'Orientation sensor active but completely flat — likely emulator'
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
|
|
768
|
+
|
|
769
|
+
return detections;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function detectTouchKinematics(signals) {
|
|
773
|
+
const detections = [];
|
|
774
|
+
const b = signals.behavioral || {};
|
|
775
|
+
const touchPoints = b.touchTotalPoints ?? 0;
|
|
776
|
+
if (touchPoints < 10) return detections;
|
|
777
|
+
|
|
778
|
+
const straightLine = b.touchStraightLineRatio ?? 0;
|
|
779
|
+
const tremor = b.touchMicroTremorScore ?? 0;
|
|
780
|
+
const dirChanges = b.touchDirectionChanges ?? 0;
|
|
781
|
+
|
|
782
|
+
if (straightLine > 0.85 && touchPoints >= 20) {
|
|
783
|
+
detections.push({
|
|
784
|
+
category: 'behavioral', score: 0.65, confidence: 0.75,
|
|
785
|
+
reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (tremor < 0.05 && touchPoints >= 30) {
|
|
790
|
+
detections.push({
|
|
791
|
+
category: 'behavioral', score: 0.55, confidence: 0.65,
|
|
792
|
+
reason: 'Touch path has no micro-tremor (unnaturally smooth)'
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (dirChanges === 0 && touchPoints >= 30) {
|
|
797
|
+
detections.push({
|
|
798
|
+
category: 'behavioral', score: 0.5, confidence: 0.6,
|
|
799
|
+
reason: 'Touch path has zero direction changes over long trajectory'
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return detections;
|
|
804
|
+
}
|
|
805
|
+
|
|
537
806
|
function detectFingerprint(signals, ip, siteKey) {
|
|
538
807
|
const detections = [];
|
|
539
808
|
const env = signals.environmental || {};
|
|
@@ -653,7 +922,7 @@ function generateToken(ip, siteKey, score) {
|
|
|
653
922
|
};
|
|
654
923
|
|
|
655
924
|
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
656
|
-
data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex')
|
|
925
|
+
data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
657
926
|
|
|
658
927
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
659
928
|
}
|
|
@@ -671,7 +940,7 @@ function verifyToken(token, ip = null) {
|
|
|
671
940
|
delete decoded.sig;
|
|
672
941
|
|
|
673
942
|
const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
|
|
674
|
-
const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex')
|
|
943
|
+
const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
675
944
|
|
|
676
945
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
677
946
|
return { valid: false, reason: 'invalid_signature' };
|
|
@@ -705,16 +974,34 @@ function verifyToken(token, ip = null) {
|
|
|
705
974
|
}
|
|
706
975
|
}
|
|
707
976
|
|
|
708
|
-
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null) {
|
|
709
|
-
const detections = [
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
977
|
+
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
|
|
978
|
+
const detections = [];
|
|
979
|
+
|
|
980
|
+
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
981
|
+
const clientSignalsHash = powSolution?.signalsHash || null;
|
|
982
|
+
if (signalsJson && clientSignalsHash) {
|
|
983
|
+
const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
|
|
984
|
+
if (computedHash !== clientSignalsHash) {
|
|
985
|
+
detections.push({
|
|
986
|
+
category: 'bot',
|
|
987
|
+
score: 0.95,
|
|
988
|
+
confidence: 0.95,
|
|
989
|
+
reason: 'Signals tampered after PoW (signalsHash mismatch)'
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
// Use signalsJson as the canonical signals source
|
|
993
|
+
try {
|
|
994
|
+
signals = JSON.parse(signalsJson);
|
|
995
|
+
} catch (e) {
|
|
996
|
+
// Fall back to parsed signals if signalsJson is invalid
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Inject powTiming into signals.temporal.pow for detection functions
|
|
1001
|
+
if (powTiming) {
|
|
1002
|
+
if (!signals.temporal) signals.temporal = {};
|
|
1003
|
+
signals.temporal.pow = powTiming;
|
|
1004
|
+
}
|
|
718
1005
|
|
|
719
1006
|
// Verify PoW if provided
|
|
720
1007
|
let powValid = false;
|
|
@@ -724,7 +1011,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
724
1011
|
powSolution.challengeId,
|
|
725
1012
|
powSolution.nonce,
|
|
726
1013
|
powSolution.hash,
|
|
727
|
-
siteKey
|
|
1014
|
+
siteKey,
|
|
1015
|
+
clientSignalsHash
|
|
728
1016
|
);
|
|
729
1017
|
powValid = powVerification.valid;
|
|
730
1018
|
|
|
@@ -736,16 +1024,53 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
736
1024
|
reason: `PoW verification failed: ${powVerification.reason}`
|
|
737
1025
|
});
|
|
738
1026
|
}
|
|
1027
|
+
|
|
1028
|
+
// Verify challenge nonce binding
|
|
1029
|
+
if (powValid && powVerification.nonce) {
|
|
1030
|
+
const clientNonce = signals.meta?.challengeNonce;
|
|
1031
|
+
if (!clientNonce || clientNonce !== powVerification.nonce) {
|
|
1032
|
+
detections.push({
|
|
1033
|
+
category: 'bot',
|
|
1034
|
+
score: 0.9,
|
|
1035
|
+
confidence: 0.9,
|
|
1036
|
+
reason: 'Challenge nonce mismatch (signals not bound to challenge)'
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (powValid && powVerification.serverElapsed < 1500) {
|
|
1042
|
+
// Server-side timing: challenge was solved too fast (un-spoofable)
|
|
1043
|
+
detections.push({
|
|
1044
|
+
category: 'bot',
|
|
1045
|
+
score: 0.8,
|
|
1046
|
+
confidence: 0.85,
|
|
1047
|
+
reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
739
1050
|
} else {
|
|
740
|
-
// No PoW solution provided -
|
|
1051
|
+
// No PoW solution provided - hard fail
|
|
741
1052
|
detections.push({
|
|
742
1053
|
category: 'bot',
|
|
743
|
-
score: 0.
|
|
744
|
-
confidence: 0.
|
|
1054
|
+
score: 0.9,
|
|
1055
|
+
confidence: 0.95,
|
|
745
1056
|
reason: 'No PoW solution provided'
|
|
746
1057
|
});
|
|
747
1058
|
}
|
|
748
1059
|
|
|
1060
|
+
// Run behavioral detectors
|
|
1061
|
+
detections.push(
|
|
1062
|
+
...detectVisionAI(signals),
|
|
1063
|
+
...detectHeadless(signals, userAgent),
|
|
1064
|
+
...detectAutomation(signals),
|
|
1065
|
+
...detectCDP(signals),
|
|
1066
|
+
...detectBehavioral(signals),
|
|
1067
|
+
...detectTouchAuthenticity(signals, userAgent),
|
|
1068
|
+
...detectSensorEntropy(signals, userAgent),
|
|
1069
|
+
...detectTouchKinematics(signals),
|
|
1070
|
+
...detectFingerprint(signals, ip, siteKey),
|
|
1071
|
+
...detectRateAbuse(ip, siteKey)
|
|
1072
|
+
);
|
|
1073
|
+
|
|
749
1074
|
// Add IP reputation check (async but we'll use sync version for simplicity)
|
|
750
1075
|
if (detection.isDatacenterIP(ip)) {
|
|
751
1076
|
detections.push({
|
|
@@ -764,12 +1089,23 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
764
1089
|
const consistencyDetections = detection.checkBrowserConsistency(userAgent, signals);
|
|
765
1090
|
detections.push(...consistencyDetections);
|
|
766
1091
|
|
|
767
|
-
//
|
|
1092
|
+
// Flag declared/verified AI agents (self-identifying UA or Web Bot Auth signature)
|
|
1093
|
+
detections.push(...detection.checkDeclaredAIAgent(userAgent, headers));
|
|
1094
|
+
|
|
1095
|
+
// Add JA3 fingerprint check (client-supplied — spoofable)
|
|
768
1096
|
if (ja3Hash) {
|
|
769
1097
|
const ja3Detections = detection.checkJA3Fingerprint(ja3Hash);
|
|
770
1098
|
detections.push(...ja3Detections);
|
|
771
1099
|
}
|
|
772
1100
|
|
|
1101
|
+
// Add JA4 fingerprint check from trusted proxy headers (un-spoofable by client)
|
|
1102
|
+
if (TRUSTED_JA4_HEADERS.length > 0) {
|
|
1103
|
+
const ja4 = detection.readJA4FromHeaders(headers, TRUSTED_JA4_HEADERS);
|
|
1104
|
+
if (ja4) {
|
|
1105
|
+
detections.push(...detection.checkJA4Fingerprint(ja4));
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
773
1109
|
// Add form interaction analysis (credential stuffing & spam detection)
|
|
774
1110
|
const formAnalysis = signals.formAnalysis;
|
|
775
1111
|
if (formAnalysis) {
|
|
@@ -777,6 +1113,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
777
1113
|
detections.push(...formDetections);
|
|
778
1114
|
}
|
|
779
1115
|
|
|
1116
|
+
// Add advanced fingerprint detection analysis
|
|
1117
|
+
const advancedDetections = detection.analyzeAdvancedSignals(signals, userAgent);
|
|
1118
|
+
detections.push(...advancedDetections);
|
|
1119
|
+
|
|
780
1120
|
const categoryScores = calculateCategoryScores(detections);
|
|
781
1121
|
const finalScore = calculateFinalScore(categoryScores);
|
|
782
1122
|
|
|
@@ -808,7 +1148,7 @@ app.get('/health', (req, res) => {
|
|
|
808
1148
|
});
|
|
809
1149
|
|
|
810
1150
|
app.post('/api/verify', (req, res) => {
|
|
811
|
-
const { siteKey, signals, powSolution } = req.body;
|
|
1151
|
+
const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
812
1152
|
let ip = req.headers['x-real-ip'] || '';
|
|
813
1153
|
if (!ip) {
|
|
814
1154
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -827,12 +1167,12 @@ app.post('/api/verify', (req, res) => {
|
|
|
827
1167
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
828
1168
|
}
|
|
829
1169
|
|
|
830
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1170
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
831
1171
|
res.json(result);
|
|
832
1172
|
});
|
|
833
1173
|
|
|
834
1174
|
app.post('/api/score', (req, res) => {
|
|
835
|
-
const { siteKey, signals, action, powSolution } = req.body;
|
|
1175
|
+
const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
836
1176
|
let ip = req.headers['x-real-ip'] || '';
|
|
837
1177
|
if (!ip) {
|
|
838
1178
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -850,7 +1190,7 @@ app.post('/api/score', (req, res) => {
|
|
|
850
1190
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
851
1191
|
}
|
|
852
1192
|
|
|
853
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1193
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
854
1194
|
res.json({
|
|
855
1195
|
success: result.success,
|
|
856
1196
|
score: result.score,
|
|
@@ -916,6 +1256,7 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
916
1256
|
prefix: challenge.prefix,
|
|
917
1257
|
difficulty: challenge.difficulty,
|
|
918
1258
|
expiresAt: challenge.expiresAt,
|
|
1259
|
+
nonce: challenge.nonce,
|
|
919
1260
|
sig: challenge.sig
|
|
920
1261
|
});
|
|
921
1262
|
});
|