@webdecoy/fcaptcha 1.30.0 → 1.31.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/clientip.js +14 -1
- package/engine.js +980 -0
- package/index.js +62 -309
- package/package.json +2 -2
- package/server.js +37 -883
package/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const crypto = require('crypto');
|
|
13
13
|
const detection = require('./detection');
|
|
14
|
-
const { ProxyTrust } = require('./clientip');
|
|
14
|
+
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
15
15
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
16
16
|
const { signingSecret } = require('./config');
|
|
17
17
|
|
|
@@ -64,7 +64,7 @@ class PoWChallengeStore {
|
|
|
64
64
|
return challengeData;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
verify(challengeId, nonce, hash, siteKey) {
|
|
67
|
+
verify(challengeId, nonce, hash, siteKey, ip) {
|
|
68
68
|
const challenge = this.challenges.get(challengeId);
|
|
69
69
|
|
|
70
70
|
if (!challenge) {
|
|
@@ -80,6 +80,10 @@ class PoWChallengeStore {
|
|
|
80
80
|
return { valid: false, reason: 'site_key_mismatch' };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
if (networkIdentity(challenge.ip) !== networkIdentity(ip)) {
|
|
84
|
+
return { valid: false, reason: 'challenge_network_mismatch' };
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
// Check for replay
|
|
84
88
|
const solutionKey = `${challengeId}:${nonce}`;
|
|
85
89
|
if (this.usedSolutions.has(solutionKey)) {
|
|
@@ -193,23 +197,31 @@ class FingerprintStore {
|
|
|
193
197
|
// Scoring Engine
|
|
194
198
|
// =============================================================================
|
|
195
199
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
200
|
+
// The detection and scoring core is shared with server.js. These used to be two
|
|
201
|
+
// separate implementations, and the library was the one that fell behind: it
|
|
202
|
+
// was missing seventeen detectors, had no `cdp` or `declared_ai` weight
|
|
203
|
+
// category, and still aggregated with the pre-v1.18.0 confidence-weighted mean
|
|
204
|
+
// (where corroborating evidence lowers the verdict) and no dispositive floor.
|
|
205
|
+
// Both engines now run the same code. See engine.js.
|
|
206
|
+
const {
|
|
207
|
+
AUTOMATION_UA_PATTERNS,
|
|
208
|
+
WEIGHTS,
|
|
209
|
+
setInteractionMode,
|
|
210
|
+
detectVisionAI,
|
|
211
|
+
detectHeadless,
|
|
212
|
+
detectStealthArtifacts,
|
|
213
|
+
detectAutomation,
|
|
214
|
+
detectCDP,
|
|
215
|
+
detectBehavioral,
|
|
216
|
+
detectTouchAuthenticity,
|
|
217
|
+
detectSensorEntropy,
|
|
218
|
+
detectTouchKinematics,
|
|
219
|
+
calculateCategoryScores,
|
|
220
|
+
calculateFinalScore,
|
|
221
|
+
applyDispositiveFloor,
|
|
222
|
+
applyCorroborationFloor,
|
|
223
|
+
} = require('./engine');
|
|
224
|
+
const { detectInputForensics } = require('./inputforensics');
|
|
213
225
|
|
|
214
226
|
class ScoringEngine {
|
|
215
227
|
constructor(options = {}) {
|
|
@@ -250,12 +262,24 @@ class ScoringEngine {
|
|
|
250
262
|
let powSatisfied = false;
|
|
251
263
|
|
|
252
264
|
// Run all detection modules
|
|
253
|
-
|
|
254
|
-
detections.push(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
265
|
+
// Run the shared detection core — the same detectors server.js runs.
|
|
266
|
+
detections.push(
|
|
267
|
+
...detectVisionAI(signals),
|
|
268
|
+
...detectHeadless(signals, userAgent),
|
|
269
|
+
...detectStealthArtifacts(signals),
|
|
270
|
+
...detectAutomation(signals),
|
|
271
|
+
...detectCDP(signals),
|
|
272
|
+
...detectBehavioral(signals),
|
|
273
|
+
// Input forensics v2: typing cadence and modality, the paste-shortcut /
|
|
274
|
+
// platform contradiction, scroll morphology, font coherence.
|
|
275
|
+
...detectInputForensics(signals),
|
|
276
|
+
...detectTouchAuthenticity(signals, userAgent),
|
|
277
|
+
...detectSensorEntropy(signals, userAgent),
|
|
278
|
+
...detectTouchKinematics(signals),
|
|
279
|
+
// Stateful detectors stay on the engine, which owns the stores.
|
|
280
|
+
...this._detectFingerprint(signals, ip, siteKey),
|
|
281
|
+
...this._detectRateAbuse(ip, siteKey)
|
|
282
|
+
);
|
|
259
283
|
|
|
260
284
|
// Verify PoW
|
|
261
285
|
if (powSolution && powSolution.challengeId) {
|
|
@@ -263,7 +287,8 @@ class ScoringEngine {
|
|
|
263
287
|
powSolution.challengeId,
|
|
264
288
|
powSolution.nonce,
|
|
265
289
|
powSolution.hash,
|
|
266
|
-
siteKey
|
|
290
|
+
siteKey,
|
|
291
|
+
ip
|
|
267
292
|
);
|
|
268
293
|
|
|
269
294
|
if (!powResult.valid) {
|
|
@@ -334,8 +359,13 @@ class ScoringEngine {
|
|
|
334
359
|
}
|
|
335
360
|
|
|
336
361
|
// Calculate scores
|
|
337
|
-
|
|
338
|
-
|
|
362
|
+
// Same aggregation as server.js: noisy-OR within a category, weighted sum
|
|
363
|
+
// across them, then the dispositive and corroboration floors.
|
|
364
|
+
const categoryScores = calculateCategoryScores(detections, this.weights);
|
|
365
|
+
const finalScore = applyCorroborationFloor(
|
|
366
|
+
applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
|
|
367
|
+
categoryScores
|
|
368
|
+
);
|
|
339
369
|
|
|
340
370
|
let recommendation;
|
|
341
371
|
if (finalScore < 0.3) recommendation = 'allow';
|
|
@@ -401,249 +431,6 @@ class ScoringEngine {
|
|
|
401
431
|
return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
|
|
402
432
|
}
|
|
403
433
|
|
|
404
|
-
_detectVisionAI(signals) {
|
|
405
|
-
const detections = [];
|
|
406
|
-
const b = signals.behavioral || {};
|
|
407
|
-
const t = signals.temporal || {};
|
|
408
|
-
|
|
409
|
-
const pow = t.pow || {};
|
|
410
|
-
if (pow.duration && pow.iterations) {
|
|
411
|
-
const expectedMin = (pow.iterations / 500000) * 1000;
|
|
412
|
-
const expectedMax = (pow.iterations / 50000) * 1000;
|
|
413
|
-
|
|
414
|
-
if (pow.duration < expectedMin * 0.5) {
|
|
415
|
-
detections.push({
|
|
416
|
-
category: 'vision_ai', score: 0.8, confidence: 0.7,
|
|
417
|
-
reason: 'PoW completed impossibly fast'
|
|
418
|
-
});
|
|
419
|
-
} else if (pow.duration > expectedMax * 3) {
|
|
420
|
-
detections.push({
|
|
421
|
-
category: 'vision_ai', score: 0.6, confidence: 0.5,
|
|
422
|
-
reason: 'PoW timing suggests external processing'
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const microTremor = b.microTremorScore ?? 0.5;
|
|
428
|
-
if (microTremor < 0.15) {
|
|
429
|
-
detections.push({
|
|
430
|
-
category: 'vision_ai', score: 0.7, confidence: 0.6,
|
|
431
|
-
reason: 'Mouse movement lacks natural micro-tremor'
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if ((b.approachDirectness ?? 0) > 0.95) {
|
|
436
|
-
detections.push({
|
|
437
|
-
category: 'vision_ai', score: 0.5, confidence: 0.5,
|
|
438
|
-
reason: 'Mouse path to target is unnaturally direct'
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const precision = b.clickPrecision ?? 10;
|
|
443
|
-
if (precision > 0 && precision < 2) {
|
|
444
|
-
detections.push({
|
|
445
|
-
category: 'vision_ai', score: 0.4, confidence: 0.5,
|
|
446
|
-
reason: 'Click precision is unnaturally accurate'
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const exploration = b.explorationRatio ?? 0.3;
|
|
451
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
452
|
-
if (exploration < 0.05 && trajectory > 50) {
|
|
453
|
-
detections.push({
|
|
454
|
-
category: 'vision_ai', score: 0.4, confidence: 0.4,
|
|
455
|
-
reason: 'No exploratory mouse movement before click'
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return detections;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
_detectHeadless(signals, userAgent) {
|
|
463
|
-
const detections = [];
|
|
464
|
-
const env = signals.environmental || {};
|
|
465
|
-
const headless = env.headlessIndicators || {};
|
|
466
|
-
const automation = env.automationFlags || {};
|
|
467
|
-
|
|
468
|
-
if (env.webdriver) {
|
|
469
|
-
detections.push({
|
|
470
|
-
category: 'headless', score: 0.95, confidence: 0.95,
|
|
471
|
-
reason: 'WebDriver detected'
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (automation.plugins === 0) {
|
|
476
|
-
detections.push({
|
|
477
|
-
category: 'headless', score: 0.6, confidence: 0.6,
|
|
478
|
-
reason: 'No browser plugins detected'
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (automation.languages === false) {
|
|
483
|
-
detections.push({
|
|
484
|
-
category: 'headless', score: 0.5, confidence: 0.5,
|
|
485
|
-
reason: 'No navigator.languages'
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (headless.hasOuterDimensions === false) {
|
|
490
|
-
detections.push({
|
|
491
|
-
category: 'headless', score: 0.7, confidence: 0.7,
|
|
492
|
-
reason: 'Window lacks outer dimensions'
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (headless.innerEqualsOuter === true) {
|
|
497
|
-
detections.push({
|
|
498
|
-
category: 'headless', score: 0.4, confidence: 0.5,
|
|
499
|
-
reason: 'Viewport equals window size'
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (headless.notificationPermission === 'denied') {
|
|
504
|
-
detections.push({
|
|
505
|
-
category: 'headless', score: 0.3, confidence: 0.4,
|
|
506
|
-
reason: 'Notifications pre-denied'
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
for (const pattern of AUTOMATION_UA_PATTERNS) {
|
|
511
|
-
if (pattern.test(userAgent)) {
|
|
512
|
-
detections.push({
|
|
513
|
-
category: 'headless', score: 0.9, confidence: 0.9,
|
|
514
|
-
reason: 'Automation pattern in User-Agent'
|
|
515
|
-
});
|
|
516
|
-
break;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const renderer = (this._getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
|
|
521
|
-
if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
|
|
522
|
-
detections.push({
|
|
523
|
-
category: 'headless', score: 0.8, confidence: 0.8,
|
|
524
|
-
reason: 'Software WebGL renderer detected'
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
return detections;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
_detectAutomation(signals) {
|
|
532
|
-
const detections = [];
|
|
533
|
-
const env = signals.environmental || {};
|
|
534
|
-
const b = signals.behavioral || {};
|
|
535
|
-
|
|
536
|
-
const jsTime = this._getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
|
|
537
|
-
if (jsTime > 0) {
|
|
538
|
-
if (jsTime < 0.1) {
|
|
539
|
-
detections.push({
|
|
540
|
-
category: 'automation', score: 0.4, confidence: 0.3,
|
|
541
|
-
reason: 'JS execution unusually fast'
|
|
542
|
-
});
|
|
543
|
-
} else if (jsTime > 50) {
|
|
544
|
-
detections.push({
|
|
545
|
-
category: 'automation', score: 0.3, confidence: 0.3,
|
|
546
|
-
reason: 'JS execution unusually slow'
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const raf = env.rafConsistency || {};
|
|
552
|
-
if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
|
|
553
|
-
detections.push({
|
|
554
|
-
category: 'automation', score: 0.5, confidence: 0.4,
|
|
555
|
-
reason: 'RequestAnimationFrame timing too consistent'
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const eventVar = b.eventDeltaVariance ?? 10;
|
|
560
|
-
const totalPoints = b.totalPoints ?? 0;
|
|
561
|
-
if (eventVar < 2 && totalPoints > 10) {
|
|
562
|
-
detections.push({
|
|
563
|
-
category: 'automation', score: 0.6, confidence: 0.6,
|
|
564
|
-
reason: 'Mouse event timing unnaturally consistent'
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
return detections;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
_detectBehavioral(signals) {
|
|
572
|
-
const detections = [];
|
|
573
|
-
const b = signals.behavioral || {};
|
|
574
|
-
const t = signals.temporal || {};
|
|
575
|
-
|
|
576
|
-
const velVar = b.velocityVariance ?? 1;
|
|
577
|
-
const trajectory = b.trajectoryLength ?? 0;
|
|
578
|
-
if (velVar < 0.02 && trajectory > 50) {
|
|
579
|
-
detections.push({
|
|
580
|
-
category: 'behavioral', score: 0.6, confidence: 0.6,
|
|
581
|
-
reason: 'Mouse velocity too consistent'
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const overshoots = b.overshootCorrections ?? 0;
|
|
586
|
-
if (overshoots === 0 && trajectory > 200) {
|
|
587
|
-
detections.push({
|
|
588
|
-
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
589
|
-
reason: 'No overshoot corrections on long trajectory'
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const interactionTime = b.interactionDuration ?? 1000;
|
|
594
|
-
if (interactionTime > 0 && interactionTime < 200) {
|
|
595
|
-
detections.push({
|
|
596
|
-
category: 'behavioral', score: 0.7, confidence: 0.7,
|
|
597
|
-
reason: 'Interaction completed too quickly'
|
|
598
|
-
});
|
|
599
|
-
} else if (interactionTime > 60000) {
|
|
600
|
-
detections.push({
|
|
601
|
-
category: 'captcha_farm', score: 0.3, confidence: 0.3,
|
|
602
|
-
reason: 'Unusually long interaction time'
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const firstInt = t.pageLoadToFirstInteraction;
|
|
607
|
-
if (firstInt !== null && firstInt > 0 && firstInt < 100) {
|
|
608
|
-
detections.push({
|
|
609
|
-
category: 'behavioral', score: 0.5, confidence: 0.5,
|
|
610
|
-
reason: 'First interaction too soon after page load'
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const eventRate = b.mouseEventRate ?? 60;
|
|
615
|
-
if (eventRate > 200) {
|
|
616
|
-
detections.push({
|
|
617
|
-
category: 'behavioral', score: 0.6, confidence: 0.5,
|
|
618
|
-
reason: 'Mouse event rate abnormally high'
|
|
619
|
-
});
|
|
620
|
-
} else if (eventRate > 0 && eventRate < 10) {
|
|
621
|
-
detections.push({
|
|
622
|
-
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
623
|
-
reason: 'Mouse event rate abnormally low'
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const straight = b.straightLineRatio ?? 0;
|
|
628
|
-
if (straight > 0.8 && trajectory > 100) {
|
|
629
|
-
detections.push({
|
|
630
|
-
category: 'behavioral', score: 0.5, confidence: 0.5,
|
|
631
|
-
reason: 'Mouse movements too straight'
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const dirChanges = b.directionChanges ?? 10;
|
|
636
|
-
const totalPoints = b.totalPoints ?? 0;
|
|
637
|
-
if (totalPoints > 50 && dirChanges < 3) {
|
|
638
|
-
detections.push({
|
|
639
|
-
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
640
|
-
reason: 'Too few direction changes'
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
return detections;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
434
|
_detectFingerprint(signals, ip, siteKey) {
|
|
648
435
|
const detections = [];
|
|
649
436
|
const env = signals.environmental || {};
|
|
@@ -706,44 +493,6 @@ class ScoringEngine {
|
|
|
706
493
|
return detections;
|
|
707
494
|
}
|
|
708
495
|
|
|
709
|
-
_calculateCategoryScores(detections) {
|
|
710
|
-
const categoryData = {};
|
|
711
|
-
|
|
712
|
-
for (const d of detections) {
|
|
713
|
-
if (!categoryData[d.category]) {
|
|
714
|
-
categoryData[d.category] = [];
|
|
715
|
-
}
|
|
716
|
-
categoryData[d.category].push([d.score, d.confidence]);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const result = {};
|
|
720
|
-
for (const [cat, scores] of Object.entries(categoryData)) {
|
|
721
|
-
if (scores.length > 0) {
|
|
722
|
-
const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
|
|
723
|
-
if (totalWeight > 0) {
|
|
724
|
-
const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
|
|
725
|
-
result[cat] = Math.min(1.0, weightedSum / totalWeight);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
for (const cat of Object.keys(this.weights)) {
|
|
731
|
-
if (!(cat in result)) {
|
|
732
|
-
result[cat] = 0.0;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
return result;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
_calculateFinalScore(categoryScores) {
|
|
740
|
-
let total = 0;
|
|
741
|
-
for (const [cat, weight] of Object.entries(this.weights)) {
|
|
742
|
-
total += (categoryScores[cat] || 0) * weight;
|
|
743
|
-
}
|
|
744
|
-
return Math.min(1.0, total);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
496
|
_generateToken(ip, siteKey, score) {
|
|
748
497
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
749
498
|
const data = {
|
|
@@ -838,6 +587,10 @@ module.exports = {
|
|
|
838
587
|
createScoringEngine: (options) => new ScoringEngine(options),
|
|
839
588
|
createMiddleware,
|
|
840
589
|
|
|
590
|
+
// Call before verify() when scoring a session that had no widget to click,
|
|
591
|
+
// so click-derived checks are not read off absent fields.
|
|
592
|
+
setInteractionMode,
|
|
593
|
+
|
|
841
594
|
// Constants
|
|
842
595
|
WEIGHTS,
|
|
843
596
|
AUTOMATION_UA_PATTERNS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test:clientip": "node clientip.test.js",
|
|
16
16
|
"test:limits": "node limits.test.js",
|
|
17
17
|
"test:detection": "node detection.test.js",
|
|
18
|
-
"test": "node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node siteverify.test.js && node index.test.js && node --test suspicion.test.js",
|
|
18
|
+
"test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node index.test.js && node --test suspicion.test.js",
|
|
19
19
|
"test:siteverify": "node siteverify.test.js",
|
|
20
20
|
"test:forensics": "node inputforensics.test.js",
|
|
21
21
|
"test:suspicion": "node --test suspicion.test.js"
|