@webdecoy/fcaptcha 1.1.0 → 1.13.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 +413 -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,58 @@ 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
|
+
// Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
|
|
22
|
+
// logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
|
|
23
|
+
// When on, each /api/verify and /api/score logs one privacy-safe JSON line
|
|
24
|
+
// (score, recommendation, category scores, and per-hit category/score/confidence)
|
|
25
|
+
// for observability and tuning. It deliberately omits IP, user agent, raw
|
|
26
|
+
// signals, and the free-text detection `reason`, which can interpolate
|
|
27
|
+
// visitor-derived data (reverse-DNS hostname, UA/header fragments, field ids).
|
|
28
|
+
//
|
|
29
|
+
// FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW additionally includes that free-text reason.
|
|
30
|
+
// Separate and off by default because reasons can carry visitor-derived data —
|
|
31
|
+
// only enable in trusted debugging contexts with no privacy obligations.
|
|
32
|
+
const envFlag = (name) => ['1', 'true', 'yes', 'on']
|
|
33
|
+
.includes(String(process.env[name] || '').trim().toLowerCase());
|
|
34
|
+
const VERDICT_LOGGING_ENABLED = envFlag('FCAPTCHA_LOG_VERDICTS');
|
|
35
|
+
const VERDICT_LOG_INCLUDE_RAW = envFlag('FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW');
|
|
36
|
+
if (VERDICT_LOGGING_ENABLED && VERDICT_LOG_INCLUDE_RAW) {
|
|
37
|
+
console.warn('WARNING: FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW enabled — verdict logs include free-text detection reasons that may contain visitor-derived data (hostnames, UA/header fragments, field ids). Do not enable where you have privacy obligations.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function logVerdict(endpoint, siteKey, result) {
|
|
41
|
+
if (!VERDICT_LOGGING_ENABLED || !result) return;
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
event: 'verdict',
|
|
44
|
+
endpoint,
|
|
45
|
+
siteKey,
|
|
46
|
+
success: result.success,
|
|
47
|
+
score: result.score,
|
|
48
|
+
recommendation: result.recommendation,
|
|
49
|
+
categoryScores: result.categoryScores,
|
|
50
|
+
detections: (result.detections || []).map((d) => {
|
|
51
|
+
const det = { category: d.category, score: d.score, confidence: d.confidence };
|
|
52
|
+
if (VERDICT_LOG_INCLUDE_RAW) det.reason = d.reason;
|
|
53
|
+
return det;
|
|
54
|
+
})
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Serve the browser widget alongside the API so deployments expose a single
|
|
59
|
+
// origin to integrators (the implicit contract behind <serverUrl>/fcaptcha.js).
|
|
60
|
+
// Set FCAPTCHA_SERVE_CLIENT=false to opt out — useful when hosting the widget
|
|
61
|
+
// on a separate CDN / edge cache. Set FCAPTCHA_CLIENT_PATH=/abs/path/fcaptcha.js
|
|
62
|
+
// to override the default lookup when server-node is deployed standalone.
|
|
63
|
+
const CLIENT_PATH = process.env.FCAPTCHA_CLIENT_PATH
|
|
64
|
+
|| path.join(__dirname, '..', 'client', 'fcaptcha.js');
|
|
65
|
+
|
|
66
|
+
if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
|
|
67
|
+
app.get('/fcaptcha.js', (req, res) => {
|
|
68
|
+
res.sendFile(CLIENT_PATH);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
18
71
|
|
|
19
72
|
// =============================================================================
|
|
20
73
|
// In-Memory Storage (Use Redis in production)
|
|
@@ -28,6 +81,7 @@ const powChallengeStore = {
|
|
|
28
81
|
// Generate a new challenge
|
|
29
82
|
generate(siteKey, ip, difficulty = 4) {
|
|
30
83
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
84
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
31
85
|
const timestamp = Date.now();
|
|
32
86
|
const expiresAt = timestamp + (5 * 60 * 1000); // 5 minutes
|
|
33
87
|
|
|
@@ -38,14 +92,14 @@ const powChallengeStore = {
|
|
|
38
92
|
timestamp,
|
|
39
93
|
expiresAt,
|
|
40
94
|
difficulty,
|
|
95
|
+
nonce,
|
|
41
96
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
42
97
|
};
|
|
43
98
|
|
|
44
99
|
// Sign the challenge
|
|
45
100
|
challengeData.sig = crypto.createHmac('sha256', SECRET_KEY)
|
|
46
101
|
.update(JSON.stringify(challengeData))
|
|
47
|
-
.digest('hex')
|
|
48
|
-
.slice(0, 16);
|
|
102
|
+
.digest('hex');
|
|
49
103
|
|
|
50
104
|
// Store challenge
|
|
51
105
|
this.challenges.set(challengeId, {
|
|
@@ -60,8 +114,8 @@ const powChallengeStore = {
|
|
|
60
114
|
return challengeData;
|
|
61
115
|
},
|
|
62
116
|
|
|
63
|
-
// Verify a PoW solution
|
|
64
|
-
verify(challengeId, nonce, hash, siteKey) {
|
|
117
|
+
// Verify a PoW solution (signalsHash is optional for backward compat)
|
|
118
|
+
verify(challengeId, nonce, hash, siteKey, signalsHash = null) {
|
|
65
119
|
const challenge = this.challenges.get(challengeId);
|
|
66
120
|
|
|
67
121
|
if (!challenge) {
|
|
@@ -83,8 +137,10 @@ const powChallengeStore = {
|
|
|
83
137
|
return { valid: false, reason: 'solution_already_used' };
|
|
84
138
|
}
|
|
85
139
|
|
|
86
|
-
// Verify the hash
|
|
87
|
-
const input =
|
|
140
|
+
// Verify the hash (with optional signalsHash binding)
|
|
141
|
+
const input = signalsHash
|
|
142
|
+
? `${challenge.prefix}:${signalsHash}:${nonce}`
|
|
143
|
+
: `${challenge.prefix}:${nonce}`;
|
|
88
144
|
const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
|
|
89
145
|
|
|
90
146
|
if (hash !== expectedHash) {
|
|
@@ -103,7 +159,10 @@ const powChallengeStore = {
|
|
|
103
159
|
// Delete challenge (one-time use)
|
|
104
160
|
this.challenges.delete(challengeId);
|
|
105
161
|
|
|
106
|
-
|
|
162
|
+
// Calculate server-side elapsed time (un-spoofable)
|
|
163
|
+
const serverElapsed = Date.now() - challenge.createdAt;
|
|
164
|
+
|
|
165
|
+
return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
|
|
107
166
|
},
|
|
108
167
|
|
|
109
168
|
_cleanup() {
|
|
@@ -213,14 +272,15 @@ const AUTOMATION_UA_PATTERNS = [
|
|
|
213
272
|
const WEIGHTS = {
|
|
214
273
|
vision_ai: 0.15,
|
|
215
274
|
headless: 0.15,
|
|
216
|
-
automation: 0.
|
|
275
|
+
automation: 0.08,
|
|
217
276
|
cdp: 0.12,
|
|
218
277
|
behavioral: 0.18,
|
|
219
|
-
fingerprint: 0.
|
|
220
|
-
rate_limit: 0.
|
|
221
|
-
datacenter: 0.
|
|
222
|
-
tor_vpn: 0.
|
|
223
|
-
bot: 0.
|
|
278
|
+
fingerprint: 0.08,
|
|
279
|
+
rate_limit: 0.01,
|
|
280
|
+
datacenter: 0.07,
|
|
281
|
+
tor_vpn: 0.01,
|
|
282
|
+
bot: 0.13,
|
|
283
|
+
declared_ai: 0.02
|
|
224
284
|
};
|
|
225
285
|
|
|
226
286
|
// =============================================================================
|
|
@@ -236,6 +296,30 @@ function detectVisionAI(signals) {
|
|
|
236
296
|
const b = signals.behavioral || {};
|
|
237
297
|
const t = signals.temporal || {};
|
|
238
298
|
|
|
299
|
+
// Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
|
|
300
|
+
// Exempt: touch users (mobile) and keyboard-only users (accessibility)
|
|
301
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
302
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
303
|
+
const approachPts = b.approachPoints ?? 0;
|
|
304
|
+
const touchEvents = b.touchEvents ?? 0;
|
|
305
|
+
const keyEvents = b.keyEvents ?? 0;
|
|
306
|
+
const isTouchUser = touchEvents >= 3;
|
|
307
|
+
const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
|
|
308
|
+
|
|
309
|
+
if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
|
|
310
|
+
detections.push({
|
|
311
|
+
category: 'vision_ai', score: 0.9, confidence: 0.85,
|
|
312
|
+
reason: 'No mouse movement detected before click (AI agent pattern)'
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (approachPts === 0 && !isTouchUser && !isKeyboardUser) {
|
|
317
|
+
detections.push({
|
|
318
|
+
category: 'vision_ai', score: 0.7, confidence: 0.8,
|
|
319
|
+
reason: 'No approach trajectory to target'
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
239
323
|
// PoW timing
|
|
240
324
|
const pow = t.pow || {};
|
|
241
325
|
if (pow.duration && pow.iterations) {
|
|
@@ -283,7 +367,6 @@ function detectVisionAI(signals) {
|
|
|
283
367
|
|
|
284
368
|
// Exploration
|
|
285
369
|
const exploration = b.explorationRatio ?? 0.3;
|
|
286
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
287
370
|
if (exploration < 0.05 && trajectory > 50) {
|
|
288
371
|
detections.push({
|
|
289
372
|
category: 'vision_ai', score: 0.4, confidence: 0.4,
|
|
@@ -291,6 +374,31 @@ function detectVisionAI(signals) {
|
|
|
291
374
|
});
|
|
292
375
|
}
|
|
293
376
|
|
|
377
|
+
// Input-event forensics: teleport clicks and agent think-time cadence.
|
|
378
|
+
const fcs = b.inputForensics;
|
|
379
|
+
if (fcs) {
|
|
380
|
+
const teleports = fcs.teleportClicks ?? 0;
|
|
381
|
+
if (teleports >= 1 && !isTouchUser) {
|
|
382
|
+
detections.push({
|
|
383
|
+
category: 'vision_ai', score: 0.7, confidence: 0.7,
|
|
384
|
+
reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
// Bursts of activity separated by multi-second perfect silence — the agent
|
|
388
|
+
// act -> screenshot -> inference loop. Low confidence (slow humans idle too);
|
|
389
|
+
// requires silence to dominate. Keyboard-only users are exempt.
|
|
390
|
+
if (!isKeyboardUser &&
|
|
391
|
+
(fcs.cadenceEvents ?? 0) >= 12 &&
|
|
392
|
+
(fcs.cadenceSilentGaps ?? 0) >= 3 &&
|
|
393
|
+
(fcs.cadenceGapCV ?? 0) > 2.5 &&
|
|
394
|
+
(fcs.cadenceSilentRatio ?? 0) > 0.6) {
|
|
395
|
+
detections.push({
|
|
396
|
+
category: 'vision_ai', score: 0.6, confidence: 0.5,
|
|
397
|
+
reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
294
402
|
return detections;
|
|
295
403
|
}
|
|
296
404
|
|
|
@@ -365,6 +473,25 @@ function detectHeadless(signals, userAgent) {
|
|
|
365
473
|
});
|
|
366
474
|
}
|
|
367
475
|
|
|
476
|
+
// Playwright-specific detection
|
|
477
|
+
const playwright = env.playwright || {};
|
|
478
|
+
if (playwright.detected) {
|
|
479
|
+
const playwrightSignals = playwright.signals || [];
|
|
480
|
+
const scoreMap = {
|
|
481
|
+
playwright_globals: 0.95,
|
|
482
|
+
webdriver_deleted: 0.8,
|
|
483
|
+
webdriver_configurable: 0.7,
|
|
484
|
+
chrome_runtime_missing: 0.6,
|
|
485
|
+
};
|
|
486
|
+
for (const sig of playwrightSignals) {
|
|
487
|
+
const sigScore = scoreMap[sig] || 0.7;
|
|
488
|
+
detections.push({
|
|
489
|
+
category: 'headless', score: sigScore, confidence: 0.8,
|
|
490
|
+
reason: `Playwright artifact detected: ${sig}`
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
368
495
|
return detections;
|
|
369
496
|
}
|
|
370
497
|
|
|
@@ -416,6 +543,38 @@ function detectCDP(signals) {
|
|
|
416
543
|
const env = signals.environmental || {};
|
|
417
544
|
const cdp = env.cdp || {};
|
|
418
545
|
|
|
546
|
+
// Input-event forensics: catch CDP-injected input that reports isTrusted:true
|
|
547
|
+
// and so evades the global-based checks below. Touch users are exempt.
|
|
548
|
+
const b = signals.behavioral || {};
|
|
549
|
+
const isTouchUser = (b.touchEvents ?? 0) >= 3;
|
|
550
|
+
const fcs = b.inputForensics;
|
|
551
|
+
if (fcs && !isTouchUser) {
|
|
552
|
+
// Real mice coalesce several hardware samples per frame; a stream of
|
|
553
|
+
// pointermoves that NEVER coalesced is synthetic injection.
|
|
554
|
+
if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
|
|
555
|
+
detections.push({
|
|
556
|
+
category: 'cdp', score: 0.8, confidence: 0.6,
|
|
557
|
+
reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
// movementX/Y incoherent with actual position deltas across most moves.
|
|
561
|
+
if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
|
|
562
|
+
detections.push({
|
|
563
|
+
category: 'cdp', score: 0.6, confidence: 0.5,
|
|
564
|
+
reason: 'Pointer movement deltas incoherent with position (synthetic input)'
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// CDP Runtime/DevTools console consumer attached. Low confidence: a developer
|
|
570
|
+
// with DevTools open also trips this, so it contributes rather than blocks.
|
|
571
|
+
if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
|
|
572
|
+
detections.push({
|
|
573
|
+
category: 'cdp', score: 0.6, confidence: 0.5,
|
|
574
|
+
reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
419
578
|
if (cdp.detected) {
|
|
420
579
|
const signalList = cdp.signals || [];
|
|
421
580
|
const signalCount = signalList.length;
|
|
@@ -456,9 +615,29 @@ function detectBehavioral(signals) {
|
|
|
456
615
|
const b = signals.behavioral || {};
|
|
457
616
|
const t = signals.temporal || {};
|
|
458
617
|
|
|
618
|
+
// Insufficient mouse data - critical check for zero-click bots
|
|
619
|
+
// Exempt: touch users (mobile) and keyboard-only users (accessibility)
|
|
620
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
621
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
622
|
+
const touchEvts = b.touchEvents ?? 0;
|
|
623
|
+
const keyEvts = b.keyEvents ?? 0;
|
|
624
|
+
const isTouchUsr = touchEvts >= 3;
|
|
625
|
+
const isKbdUser = keyEvts >= 2 && totalPoints === 0;
|
|
626
|
+
|
|
627
|
+
if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
|
|
628
|
+
detections.push({
|
|
629
|
+
category: 'behavioral', score: 0.8, confidence: 0.9,
|
|
630
|
+
reason: 'Zero mouse, touch, or keyboard events recorded'
|
|
631
|
+
});
|
|
632
|
+
} else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
|
|
633
|
+
detections.push({
|
|
634
|
+
category: 'behavioral', score: 0.6, confidence: 0.7,
|
|
635
|
+
reason: 'Insufficient mouse movement before interaction'
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
459
639
|
// Velocity variance
|
|
460
640
|
const velVar = b.velocityVariance ?? 1;
|
|
461
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
462
641
|
if (velVar < 0.02 && trajectory > 50) {
|
|
463
642
|
detections.push({
|
|
464
643
|
category: 'behavioral', score: 0.6, confidence: 0.6,
|
|
@@ -523,7 +702,6 @@ function detectBehavioral(signals) {
|
|
|
523
702
|
|
|
524
703
|
// Direction changes
|
|
525
704
|
const dirChanges = b.directionChanges ?? 10;
|
|
526
|
-
const totalPoints = b.totalPoints ?? 0;
|
|
527
705
|
if (totalPoints > 50 && dirChanges < 3) {
|
|
528
706
|
detections.push({
|
|
529
707
|
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
@@ -534,6 +712,134 @@ function detectBehavioral(signals) {
|
|
|
534
712
|
return detections;
|
|
535
713
|
}
|
|
536
714
|
|
|
715
|
+
// =============================================================================
|
|
716
|
+
// Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
|
|
717
|
+
// UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
|
|
718
|
+
// Safari without permission (absence of motion events treated as neutral).
|
|
719
|
+
// =============================================================================
|
|
720
|
+
|
|
721
|
+
function _isMobileUA(userAgent) {
|
|
722
|
+
const ua = (userAgent || '').toLowerCase();
|
|
723
|
+
return /mobile|android|iphone|ipad|ipod/.test(ua);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function detectTouchAuthenticity(signals, userAgent) {
|
|
727
|
+
const detections = [];
|
|
728
|
+
if (!_isMobileUA(userAgent)) return detections;
|
|
729
|
+
|
|
730
|
+
const b = signals.behavioral || {};
|
|
731
|
+
const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
|
|
732
|
+
if (touchPoints < 3) return detections;
|
|
733
|
+
|
|
734
|
+
const forceVariance = b.touchForceVariance ?? 0;
|
|
735
|
+
const radiusVariance = b.touchRadiusVariance ?? 0;
|
|
736
|
+
const forceAllOne = b.touchForceAllOne === true;
|
|
737
|
+
const uniqueIds = b.touchUniqueIdentifiers ?? 0;
|
|
738
|
+
const forceMax = b.touchForceMax ?? 0;
|
|
739
|
+
const radiusMax = b.touchRadiusMax ?? 0;
|
|
740
|
+
|
|
741
|
+
// Uniform non-zero force across all events → synthetic injection.
|
|
742
|
+
// Older Android returning all-zero is legitimate — only penalize uniformity
|
|
743
|
+
// when max > 0.
|
|
744
|
+
if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
|
|
745
|
+
detections.push({
|
|
746
|
+
category: 'behavioral', score: 0.75, confidence: 0.85,
|
|
747
|
+
reason: 'Touch force is identical across all events (synthetic touch)'
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// All force=1 exactly is a common synthetic default in automation frameworks.
|
|
752
|
+
if (forceAllOne && touchPoints >= 5) {
|
|
753
|
+
detections.push({
|
|
754
|
+
category: 'behavioral', score: 0.8, confidence: 0.9,
|
|
755
|
+
reason: 'All touches report force=1.0 exactly (synthetic pattern)'
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Uniform contact radius across many events is unusual on real phones.
|
|
760
|
+
if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
|
|
761
|
+
detections.push({
|
|
762
|
+
category: 'behavioral', score: 0.7, confidence: 0.8,
|
|
763
|
+
reason: 'Touch contact radius identical across all events'
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Mobile UA with real touches but zero unique identifiers — framework default.
|
|
768
|
+
if (touchPoints >= 5 && uniqueIds === 0) {
|
|
769
|
+
detections.push({
|
|
770
|
+
category: 'behavioral', score: 0.6, confidence: 0.7,
|
|
771
|
+
reason: 'Mobile touches lack identifier tracking (synthetic injection)'
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return detections;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function detectSensorEntropy(signals, userAgent) {
|
|
779
|
+
const detections = [];
|
|
780
|
+
if (!_isMobileUA(userAgent)) return detections;
|
|
781
|
+
|
|
782
|
+
const env = signals.environmental || {};
|
|
783
|
+
const sensor = env.sensor || {};
|
|
784
|
+
const motionCount = sensor.motionEventCount ?? 0;
|
|
785
|
+
const motionVariance = sensor.motionAccelVariance ?? 0;
|
|
786
|
+
const orientationCount = sensor.orientationEventCount ?? 0;
|
|
787
|
+
const orientationVariance = sensor.orientationVariance ?? 0;
|
|
788
|
+
|
|
789
|
+
// Sensor events fired but completely flat → emulator / headless mobile.
|
|
790
|
+
if (motionCount >= 10 && motionVariance < 0.01) {
|
|
791
|
+
detections.push({
|
|
792
|
+
category: 'headless', score: 0.7, confidence: 0.8,
|
|
793
|
+
reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (orientationCount >= 10 && orientationVariance < 0.01) {
|
|
798
|
+
detections.push({
|
|
799
|
+
category: 'headless', score: 0.6, confidence: 0.7,
|
|
800
|
+
reason: 'Orientation sensor active but completely flat — likely emulator'
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
|
|
805
|
+
|
|
806
|
+
return detections;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function detectTouchKinematics(signals) {
|
|
810
|
+
const detections = [];
|
|
811
|
+
const b = signals.behavioral || {};
|
|
812
|
+
const touchPoints = b.touchTotalPoints ?? 0;
|
|
813
|
+
if (touchPoints < 10) return detections;
|
|
814
|
+
|
|
815
|
+
const straightLine = b.touchStraightLineRatio ?? 0;
|
|
816
|
+
const tremor = b.touchMicroTremorScore ?? 0;
|
|
817
|
+
const dirChanges = b.touchDirectionChanges ?? 0;
|
|
818
|
+
|
|
819
|
+
if (straightLine > 0.85 && touchPoints >= 20) {
|
|
820
|
+
detections.push({
|
|
821
|
+
category: 'behavioral', score: 0.65, confidence: 0.75,
|
|
822
|
+
reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (tremor < 0.05 && touchPoints >= 30) {
|
|
827
|
+
detections.push({
|
|
828
|
+
category: 'behavioral', score: 0.55, confidence: 0.65,
|
|
829
|
+
reason: 'Touch path has no micro-tremor (unnaturally smooth)'
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (dirChanges === 0 && touchPoints >= 30) {
|
|
834
|
+
detections.push({
|
|
835
|
+
category: 'behavioral', score: 0.5, confidence: 0.6,
|
|
836
|
+
reason: 'Touch path has zero direction changes over long trajectory'
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return detections;
|
|
841
|
+
}
|
|
842
|
+
|
|
537
843
|
function detectFingerprint(signals, ip, siteKey) {
|
|
538
844
|
const detections = [];
|
|
539
845
|
const env = signals.environmental || {};
|
|
@@ -653,7 +959,7 @@ function generateToken(ip, siteKey, score) {
|
|
|
653
959
|
};
|
|
654
960
|
|
|
655
961
|
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
656
|
-
data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex')
|
|
962
|
+
data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
657
963
|
|
|
658
964
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
659
965
|
}
|
|
@@ -671,7 +977,7 @@ function verifyToken(token, ip = null) {
|
|
|
671
977
|
delete decoded.sig;
|
|
672
978
|
|
|
673
979
|
const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
|
|
674
|
-
const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex')
|
|
980
|
+
const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
675
981
|
|
|
676
982
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
677
983
|
return { valid: false, reason: 'invalid_signature' };
|
|
@@ -705,16 +1011,34 @@ function verifyToken(token, ip = null) {
|
|
|
705
1011
|
}
|
|
706
1012
|
}
|
|
707
1013
|
|
|
708
|
-
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null) {
|
|
709
|
-
const detections = [
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1014
|
+
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
|
|
1015
|
+
const detections = [];
|
|
1016
|
+
|
|
1017
|
+
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
1018
|
+
const clientSignalsHash = powSolution?.signalsHash || null;
|
|
1019
|
+
if (signalsJson && clientSignalsHash) {
|
|
1020
|
+
const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
|
|
1021
|
+
if (computedHash !== clientSignalsHash) {
|
|
1022
|
+
detections.push({
|
|
1023
|
+
category: 'bot',
|
|
1024
|
+
score: 0.95,
|
|
1025
|
+
confidence: 0.95,
|
|
1026
|
+
reason: 'Signals tampered after PoW (signalsHash mismatch)'
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
// Use signalsJson as the canonical signals source
|
|
1030
|
+
try {
|
|
1031
|
+
signals = JSON.parse(signalsJson);
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
// Fall back to parsed signals if signalsJson is invalid
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Inject powTiming into signals.temporal.pow for detection functions
|
|
1038
|
+
if (powTiming) {
|
|
1039
|
+
if (!signals.temporal) signals.temporal = {};
|
|
1040
|
+
signals.temporal.pow = powTiming;
|
|
1041
|
+
}
|
|
718
1042
|
|
|
719
1043
|
// Verify PoW if provided
|
|
720
1044
|
let powValid = false;
|
|
@@ -724,7 +1048,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
724
1048
|
powSolution.challengeId,
|
|
725
1049
|
powSolution.nonce,
|
|
726
1050
|
powSolution.hash,
|
|
727
|
-
siteKey
|
|
1051
|
+
siteKey,
|
|
1052
|
+
clientSignalsHash
|
|
728
1053
|
);
|
|
729
1054
|
powValid = powVerification.valid;
|
|
730
1055
|
|
|
@@ -736,16 +1061,53 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
736
1061
|
reason: `PoW verification failed: ${powVerification.reason}`
|
|
737
1062
|
});
|
|
738
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// Verify challenge nonce binding
|
|
1066
|
+
if (powValid && powVerification.nonce) {
|
|
1067
|
+
const clientNonce = signals.meta?.challengeNonce;
|
|
1068
|
+
if (!clientNonce || clientNonce !== powVerification.nonce) {
|
|
1069
|
+
detections.push({
|
|
1070
|
+
category: 'bot',
|
|
1071
|
+
score: 0.9,
|
|
1072
|
+
confidence: 0.9,
|
|
1073
|
+
reason: 'Challenge nonce mismatch (signals not bound to challenge)'
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (powValid && powVerification.serverElapsed < 1500) {
|
|
1079
|
+
// Server-side timing: challenge was solved too fast (un-spoofable)
|
|
1080
|
+
detections.push({
|
|
1081
|
+
category: 'bot',
|
|
1082
|
+
score: 0.8,
|
|
1083
|
+
confidence: 0.85,
|
|
1084
|
+
reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
739
1087
|
} else {
|
|
740
|
-
// No PoW solution provided -
|
|
1088
|
+
// No PoW solution provided - hard fail
|
|
741
1089
|
detections.push({
|
|
742
1090
|
category: 'bot',
|
|
743
|
-
score: 0.
|
|
744
|
-
confidence: 0.
|
|
1091
|
+
score: 0.9,
|
|
1092
|
+
confidence: 0.95,
|
|
745
1093
|
reason: 'No PoW solution provided'
|
|
746
1094
|
});
|
|
747
1095
|
}
|
|
748
1096
|
|
|
1097
|
+
// Run behavioral detectors
|
|
1098
|
+
detections.push(
|
|
1099
|
+
...detectVisionAI(signals),
|
|
1100
|
+
...detectHeadless(signals, userAgent),
|
|
1101
|
+
...detectAutomation(signals),
|
|
1102
|
+
...detectCDP(signals),
|
|
1103
|
+
...detectBehavioral(signals),
|
|
1104
|
+
...detectTouchAuthenticity(signals, userAgent),
|
|
1105
|
+
...detectSensorEntropy(signals, userAgent),
|
|
1106
|
+
...detectTouchKinematics(signals),
|
|
1107
|
+
...detectFingerprint(signals, ip, siteKey),
|
|
1108
|
+
...detectRateAbuse(ip, siteKey)
|
|
1109
|
+
);
|
|
1110
|
+
|
|
749
1111
|
// Add IP reputation check (async but we'll use sync version for simplicity)
|
|
750
1112
|
if (detection.isDatacenterIP(ip)) {
|
|
751
1113
|
detections.push({
|
|
@@ -764,12 +1126,23 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
764
1126
|
const consistencyDetections = detection.checkBrowserConsistency(userAgent, signals);
|
|
765
1127
|
detections.push(...consistencyDetections);
|
|
766
1128
|
|
|
767
|
-
//
|
|
1129
|
+
// Flag declared/verified AI agents (self-identifying UA or Web Bot Auth signature)
|
|
1130
|
+
detections.push(...detection.checkDeclaredAIAgent(userAgent, headers));
|
|
1131
|
+
|
|
1132
|
+
// Add JA3 fingerprint check (client-supplied — spoofable)
|
|
768
1133
|
if (ja3Hash) {
|
|
769
1134
|
const ja3Detections = detection.checkJA3Fingerprint(ja3Hash);
|
|
770
1135
|
detections.push(...ja3Detections);
|
|
771
1136
|
}
|
|
772
1137
|
|
|
1138
|
+
// Add JA4 fingerprint check from trusted proxy headers (un-spoofable by client)
|
|
1139
|
+
if (TRUSTED_JA4_HEADERS.length > 0) {
|
|
1140
|
+
const ja4 = detection.readJA4FromHeaders(headers, TRUSTED_JA4_HEADERS);
|
|
1141
|
+
if (ja4) {
|
|
1142
|
+
detections.push(...detection.checkJA4Fingerprint(ja4));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
773
1146
|
// Add form interaction analysis (credential stuffing & spam detection)
|
|
774
1147
|
const formAnalysis = signals.formAnalysis;
|
|
775
1148
|
if (formAnalysis) {
|
|
@@ -812,7 +1185,7 @@ app.get('/health', (req, res) => {
|
|
|
812
1185
|
});
|
|
813
1186
|
|
|
814
1187
|
app.post('/api/verify', (req, res) => {
|
|
815
|
-
const { siteKey, signals, powSolution } = req.body;
|
|
1188
|
+
const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
816
1189
|
let ip = req.headers['x-real-ip'] || '';
|
|
817
1190
|
if (!ip) {
|
|
818
1191
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -831,12 +1204,13 @@ app.post('/api/verify', (req, res) => {
|
|
|
831
1204
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
832
1205
|
}
|
|
833
1206
|
|
|
834
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1207
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
1208
|
+
logVerdict('verify', siteKey, result);
|
|
835
1209
|
res.json(result);
|
|
836
1210
|
});
|
|
837
1211
|
|
|
838
1212
|
app.post('/api/score', (req, res) => {
|
|
839
|
-
const { siteKey, signals, action, powSolution } = req.body;
|
|
1213
|
+
const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
840
1214
|
let ip = req.headers['x-real-ip'] || '';
|
|
841
1215
|
if (!ip) {
|
|
842
1216
|
const forwarded = req.headers['x-forwarded-for'];
|
|
@@ -854,7 +1228,8 @@ app.post('/api/score', (req, res) => {
|
|
|
854
1228
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
855
1229
|
}
|
|
856
1230
|
|
|
857
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
|
|
1231
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
|
|
1232
|
+
logVerdict('score', siteKey, result);
|
|
858
1233
|
res.json({
|
|
859
1234
|
success: result.success,
|
|
860
1235
|
score: result.score,
|
|
@@ -920,6 +1295,7 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
920
1295
|
prefix: challenge.prefix,
|
|
921
1296
|
difficulty: challenge.difficulty,
|
|
922
1297
|
expiresAt: challenge.expiresAt,
|
|
1298
|
+
nonce: challenge.nonce,
|
|
923
1299
|
sig: challenge.sig
|
|
924
1300
|
});
|
|
925
1301
|
});
|