@webdecoy/fcaptcha 1.29.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.
Files changed (5) hide show
  1. package/clientip.js +14 -1
  2. package/engine.js +980 -0
  3. package/index.js +62 -309
  4. package/package.json +2 -2
  5. package/server.js +42 -888
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
- const WEIGHTS = {
197
- vision_ai: 0.15,
198
- headless: 0.15,
199
- automation: 0.10,
200
- behavioral: 0.20,
201
- fingerprint: 0.10,
202
- rate_limit: 0.05,
203
- datacenter: 0.10,
204
- tor_vpn: 0.05,
205
- bot: 0.10
206
- };
207
-
208
- const AUTOMATION_UA_PATTERNS = [
209
- /headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
210
- /puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
211
- /zombie/i, /electron/i, /chromium.*headless/i
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
- detections.push(...this._detectVisionAI(signals));
254
- detections.push(...this._detectHeadless(signals, userAgent));
255
- detections.push(...this._detectAutomation(signals));
256
- detections.push(...this._detectBehavioral(signals));
257
- detections.push(...this._detectFingerprint(signals, ip, siteKey));
258
- detections.push(...this._detectRateAbuse(ip, siteKey));
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
- const categoryScores = this._calculateCategoryScores(detections);
338
- const finalScore = this._calculateFinalScore(categoryScores);
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.29.0",
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"