@webdecoy/fcaptcha 1.1.0 → 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 +376 -0
- package/package.json +1 -1
- package/server.js +374 -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,6 +790,12 @@ 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
|
|
|
@@ -748,7 +1119,12 @@ module.exports = {
|
|
|
748
1119
|
analyzeHeaders,
|
|
749
1120
|
parseUserAgent,
|
|
750
1121
|
checkBrowserConsistency,
|
|
1122
|
+
checkDeclaredAIAgent,
|
|
751
1123
|
checkJA3Fingerprint,
|
|
1124
|
+
checkJA4Fingerprint,
|
|
1125
|
+
getTrustedJA4HeaderNames,
|
|
1126
|
+
readJA4FromHeaders,
|
|
1127
|
+
KNOWN_BOT_JA4_HASHES,
|
|
752
1128
|
analyzeFormInteraction,
|
|
753
1129
|
// Advanced fingerprint detection functions
|
|
754
1130
|
analyzeWebRTC,
|
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) {
|
|
@@ -812,7 +1148,7 @@ app.get('/health', (req, res) => {
|
|
|
812
1148
|
});
|
|
813
1149
|
|
|
814
1150
|
app.post('/api/verify', (req, res) => {
|
|
815
|
-
const { siteKey, signals, powSolution } = req.body;
|
|
1151
|
+
const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
816
1152
|
let ip = req.headers['x-real-ip'] || '';
|
|
817
1153
|
if (!ip) {
|
|
818
1154
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -831,12 +1167,12 @@ app.post('/api/verify', (req, res) => {
|
|
|
831
1167
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
832
1168
|
}
|
|
833
1169
|
|
|
834
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1170
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
835
1171
|
res.json(result);
|
|
836
1172
|
});
|
|
837
1173
|
|
|
838
1174
|
app.post('/api/score', (req, res) => {
|
|
839
|
-
const { siteKey, signals, action, powSolution } = req.body;
|
|
1175
|
+
const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
840
1176
|
let ip = req.headers['x-real-ip'] || '';
|
|
841
1177
|
if (!ip) {
|
|
842
1178
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -854,7 +1190,7 @@ app.post('/api/score', (req, res) => {
|
|
|
854
1190
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
855
1191
|
}
|
|
856
1192
|
|
|
857
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1193
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
858
1194
|
res.json({
|
|
859
1195
|
success: result.success,
|
|
860
1196
|
score: result.score,
|
|
@@ -920,6 +1256,7 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
920
1256
|
prefix: challenge.prefix,
|
|
921
1257
|
difficulty: challenge.difficulty,
|
|
922
1258
|
expiresAt: challenge.expiresAt,
|
|
1259
|
+
nonce: challenge.nonce,
|
|
923
1260
|
sig: challenge.sig
|
|
924
1261
|
});
|
|
925
1262
|
});
|