@webdecoy/fcaptcha 1.39.1 → 1.41.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/config.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { EXPERIMENTAL_POLICY } = require('./experimental');
4
+
3
5
  const INSECURE_DEFAULT_SECRET = 'dev-secret-change-in-production';
4
6
 
5
7
  // The library constructs the secret more than once per process (the challenge
@@ -28,4 +30,12 @@ function signingSecret(explicit, env = process.env) {
28
30
  return signingSecretFromEnv(env);
29
31
  }
30
32
 
31
- module.exports = { INSECURE_DEFAULT_SECRET, signingSecret, signingSecretFromEnv };
33
+ function experimentalBlockingEnabled(explicit, env = process.env) {
34
+ if (explicit !== undefined && explicit !== false && typeof explicit !== 'string') {
35
+ throw new TypeError('experimentalBlocking must be a policy name or false');
36
+ }
37
+ const policy = explicit === undefined ? env.FCAPTCHA_EXPERIMENTAL_BLOCKING : explicit;
38
+ return typeof policy === 'string' && policy.trim() === EXPERIMENTAL_POLICY;
39
+ }
40
+
41
+ module.exports = { INSECURE_DEFAULT_SECRET, signingSecret, signingSecretFromEnv, experimentalBlockingEnabled };
package/detection.js CHANGED
@@ -894,16 +894,6 @@ function analyzeWebRTC(webrtcInfo) {
894
894
  });
895
895
  }
896
896
 
897
- // Check local IP detection - VMs and some headless setups may not expose local IPs
898
- if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
899
- detections.push({
900
- category: 'headless',
901
- score: 0.4,
902
- confidence: 0.5,
903
- reason: 'No local IP addresses detected via WebRTC'
904
- });
905
- }
906
-
907
897
  return detections;
908
898
  }
909
899
 
@@ -956,14 +946,17 @@ function analyzeWorkerConsistency(workerConsistency) {
956
946
 
957
947
  const detections = [];
958
948
 
959
- // Mismatches indicate fingerprint spoofing
960
- if (!workerConsistency.consistent && workerConsistency.mismatchCount > 0) {
961
- const score = Math.min(0.9, 0.3 + (workerConsistency.mismatchCount * 0.15));
949
+ // Mismatches indicate fingerprint spoofing. hardwareConcurrency is scored
950
+ // separately (engine.js detectStealthArtifacts); counting it here as well
951
+ // would count the same mismatch twice.
952
+ const mismatches = (workerConsistency.mismatches || []).filter((m) => m !== 'hardwareConcurrency');
953
+ if (!workerConsistency.consistent && mismatches.length > 0) {
954
+ const score = Math.min(0.9, 0.3 + (mismatches.length * 0.15));
962
955
  detections.push({
963
956
  category: 'bot',
964
957
  score: score,
965
958
  confidence: 0.85,
966
- reason: `Worker/main thread mismatch detected: ${workerConsistency.mismatches.join(', ')}`
959
+ reason: `Worker/main thread mismatch detected: ${mismatches.join(', ')}`
967
960
  });
968
961
  }
969
962
 
package/engine.js CHANGED
@@ -502,6 +502,19 @@ function detectStealthArtifacts(signals) {
502
502
  });
503
503
  }
504
504
 
505
+ // The page and a Worker disagree on hardwareConcurrency. Stealth tooling
506
+ // patches the page's value and not the worker's. Contributory only: Chrome's
507
+ // DevTools hardware-concurrency override produces the same disagreement with
508
+ // native getters and an attached console, so a developer testing that
509
+ // setting is indistinguishable from the patch.
510
+ const worker = env.workerConsistency;
511
+ if (worker && Array.isArray(worker.mismatches) && worker.mismatches.includes('hardwareConcurrency')) {
512
+ detections.push({
513
+ category: 'bot', score: 0.45, confidence: 0.85,
514
+ reason: 'Page and Worker disagree on hardwareConcurrency'
515
+ });
516
+ }
517
+
505
518
  return detections;
506
519
  }
507
520
 
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const { calculateCategoryScores, BEHAVIOURAL_CATEGORIES, CORROBORATION_AGREE_AT,
4
+ CORROBORATION_FLOOR } = require('./engine');
5
+
6
+ // Change this ID whenever the policy's evidence, thresholds, or scoring semantics
7
+ // change. An old selector must never silently opt an operator into a new policy.
8
+ const EXPERIMENTAL_POLICY = 'stealth-corroboration-v1';
9
+ const ANIMATION_POLICY = 'animation-consistency-v1';
10
+
11
+ function animationObservation(signals) {
12
+ const probe = signals.environmental?.animationConsistency;
13
+ const realm = (value) => {
14
+ if (value?.status !== 'ok' || !Array.isArray(value.specified) || value.specified.length !== 3 ||
15
+ !value.specified.every((n) => n === 1000) || !Array.isArray(value.durations) || value.durations.length !== 3 ||
16
+ !value.durations.every((row) => Array.isArray(row) && row.length === 4 &&
17
+ row.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1e9))) return null;
18
+ return value.durations.every((row, i) => row.every((n) => n === (i === 2 ? 1000 : 0)));
19
+ };
20
+ const main = probe?.version === 1 ? realm(probe.main) : null;
21
+ const frame = probe?.version === 1 ? realm(probe.iframe) : null;
22
+ const status = main === null || frame === null ? 'unknown' : main && frame ? 'detected' : 'clear';
23
+ // Always observe: existing policy selectors must never enable new evidence.
24
+ return { mode: 'observe', status, detections: status === 'detected' ? [{
25
+ id: 'animation-timing-inconsistency',
26
+ reason: 'Browser API timing inconsistency; experimental observation, not proof of automation',
27
+ }] : [] };
28
+ }
29
+
30
+ // Observe by default. This intentionally measures a known-ambiguous hypothesis:
31
+ // #87's stealth session and a DevTools hardware override can look identical.
32
+ // Never merge these detections into production scoring or state updates. The
33
+ // server's explicit blocking option adds a separate token gate.
34
+ function evaluateExperimental(signals, productionScore, detections, blocking = false) {
35
+ const worker = signals.environmental?.workerConsistency;
36
+ const mismatch = worker?.supported === true && worker.consistent === false &&
37
+ Array.isArray(worker.mismatches) && worker.mismatches.includes('hardwareConcurrency');
38
+ const categoryScores = calculateCategoryScores(detections.filter((d) => !d.nonCorroborating));
39
+ const corroboratingCategories = BEHAVIOURAL_CATEGORIES.filter(
40
+ (c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT - 1e-9);
41
+ const score = mismatch && corroboratingCategories.length > 0
42
+ ? Math.max(productionScore, CORROBORATION_FLOOR) : productionScore;
43
+ return {
44
+ mode: blocking ? 'block' : 'observe',
45
+ policy: EXPERIMENTAL_POLICY,
46
+ score,
47
+ // Score threshold only; this is not a hypothetical PoW/hostname/rate verdict.
48
+ wouldBlock: score >= 0.5,
49
+ detections: mismatch ? [{
50
+ id: 'worker-hardware-concurrency-mismatch',
51
+ reason: 'Page and Worker disagree on hardwareConcurrency; also possible with DevTools or privacy tools',
52
+ }] : [],
53
+ corroboratingCategories,
54
+ observations: { [ANIMATION_POLICY]: animationObservation(signals) },
55
+ };
56
+ }
57
+
58
+ module.exports = { evaluateExperimental, EXPERIMENTAL_POLICY };
package/index.js CHANGED
@@ -13,7 +13,7 @@ const crypto = require('crypto');
13
13
  const detection = require('./detection');
14
14
  const { ProxyTrust, networkIdentity } = require('./clientip');
15
15
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
16
- const { signingSecret } = require('./config');
16
+ const { signingSecret, experimentalBlockingEnabled } = require('./config');
17
17
  const { AdmissionLimiter, ChallengeMap } = require('./admission');
18
18
  const { ipBinding } = require('./ip-binding');
19
19
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
@@ -194,9 +194,11 @@ const {
194
194
  widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
195
195
  } = require('./engine');
196
196
  const { detectInputForensics } = require('./inputforensics');
197
+ const { evaluateExperimental } = require('./experimental');
197
198
 
198
199
  class ScoringEngine {
199
200
  constructor(options = {}) {
201
+ this.experimentalBlocking = experimentalBlockingEnabled(options.experimentalBlocking);
200
202
  this.secret = signingSecret(options.secret);
201
203
  this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
202
204
  this.rateLimiter = options.rateLimiter || new RateLimiter();
@@ -364,8 +366,11 @@ class ScoringEngine {
364
366
  detections
365
367
  );
366
368
 
369
+ const experimental = evaluateExperimental(signals, finalScore, detections, this.experimentalBlocking);
370
+ const experimentalBlocked = this.experimentalBlocking && experimental.wouldBlock && finalScore < 0.5;
367
371
  let recommendation;
368
- if (finalScore < 0.3) recommendation = 'allow';
372
+ if (experimentalBlocked) recommendation = 'block';
373
+ else if (finalScore < 0.3) recommendation = 'allow';
369
374
  else if (finalScore < 0.6) recommendation = 'challenge';
370
375
  else recommendation = 'block';
371
376
 
@@ -374,7 +379,7 @@ class ScoringEngine {
374
379
  // dilute it below the allow threshold and used to mint a token for an empty
375
380
  // request. Keep this gate independent of scoring so weight changes cannot
376
381
  // reopen the bypass.
377
- const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded;
382
+ const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
378
383
  const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
379
384
 
380
385
  // Feed the ledger so the next challenge this source asks for is priced on
@@ -384,12 +389,14 @@ class ScoringEngine {
384
389
  return {
385
390
  success,
386
391
  score: finalScore,
392
+ experimental,
387
393
  token,
388
394
  timestamp: Math.floor(Date.now() / 1000),
389
395
  recommendation,
390
396
  categoryScores,
391
397
  detections,
392
- ...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' } : {})
398
+ ...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' }
399
+ : experimentalBlocked ? { reason: 'experimental_detection' } : {})
393
400
  };
394
401
  }
395
402
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.39.1",
3
+ "version": "1.41.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 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 redis-state.test.js && node index.test.js && node --test admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.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 redis-state.test.js && node index.test.js && node --test admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.test.js experimental.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"
package/server.js CHANGED
@@ -12,7 +12,7 @@ const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
13
  const { ProxyTrust, networkIdentity } = require('./clientip');
14
14
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
- const { signingSecretFromEnv } = require('./config');
15
+ const { signingSecretFromEnv, experimentalBlockingEnabled } = require('./config');
16
16
  const { AdmissionLimiter, ChallengeMap } = require('./admission');
17
17
  const { ipBinding } = require('./ip-binding');
18
18
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
@@ -43,6 +43,7 @@ app.use(express.json({ limit: MAX_REQUEST_BODY_BYTES }));
43
43
  app.use(express.urlencoded({ extended: false, limit: MAX_REQUEST_BODY_BYTES }));
44
44
 
45
45
  const SECRET_KEY = signingSecretFromEnv();
46
+ const EXPERIMENTAL_BLOCKING_ENABLED = experimentalBlockingEnabled();
46
47
  const REDIS_URL = process.env.REDIS_URL || '';
47
48
  const SHARED_STATE = REDIS_URL ? new RedisState(REDIS_URL) : null;
48
49
 
@@ -144,6 +145,7 @@ function logVerdict(endpoint, siteKey, result) {
144
145
  siteKey,
145
146
  success: result.success,
146
147
  score: result.score,
148
+ experimental: result.experimental,
147
149
  recommendation: result.recommendation,
148
150
  categoryScores: result.categoryScores,
149
151
  detections: (result.detections || []).map((d) => {
@@ -375,6 +377,7 @@ const {
375
377
  applyCorroborationFloor,
376
378
  widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
377
379
  } = require('./engine');
380
+ const { evaluateExperimental } = require('./experimental');
378
381
 
379
382
  // Stateful detectors stay here: they read the module-level stores below.
380
383
  async function detectFingerprint(signals, ip, siteKey) {
@@ -748,8 +751,11 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
748
751
  detections
749
752
  );
750
753
 
754
+ const experimental = evaluateExperimental(signals, finalScore, detections, EXPERIMENTAL_BLOCKING_ENABLED);
755
+ const experimentalBlocked = EXPERIMENTAL_BLOCKING_ENABLED && experimental.wouldBlock && finalScore < 0.5;
751
756
  let recommendation;
752
- if (finalScore < 0.3) recommendation = 'allow';
757
+ if (experimentalBlocked) recommendation = 'block';
758
+ else if (finalScore < 0.3) recommendation = 'allow';
753
759
  else if (finalScore < 0.6) recommendation = 'challenge';
754
760
  else recommendation = 'block';
755
761
 
@@ -764,14 +770,14 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
764
770
  // its own reason rather than smuggled in as a bot verdict.
765
771
  const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
766
772
 
767
- // Three independent conditions, deliberately not folded into the score.
773
+ // Independent gates, deliberately not folded into the baseline score.
768
774
  //
769
775
  // powSatisfied is the one that matters most: a score threshold answers "how
770
776
  // suspicious is this visitor", which is the wrong question to ask of someone
771
777
  // who never completed the challenge. Gating here means no future reweighting
772
778
  // can reopen the bypass, and it holds even if the dispositive floor is
773
779
  // lowered or removed.
774
- const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded;
780
+ const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
775
781
 
776
782
  // Name the failed precondition whenever one fails, not only when the score
777
783
  // would otherwise have allowed. Gating it on the score made the PoW case
@@ -783,6 +789,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
783
789
  if (deviceRateExceeded) withheldReason = 'rate_limited';
784
790
  else if (!powSatisfied) withheldReason = 'pow_not_satisfied';
785
791
  else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
792
+ else if (experimentalBlocked) withheldReason = 'experimental_detection';
786
793
 
787
794
  const token = success
788
795
  ? generateToken(ip, siteKey, finalScore, {
@@ -799,6 +806,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
799
806
  return {
800
807
  success,
801
808
  score: finalScore,
809
+ experimental,
802
810
  token,
803
811
  timestamp: Math.floor(Date.now() / 1000),
804
812
  recommendation,
@@ -907,6 +915,7 @@ app.post('/api/score', validateScoringRequest, asyncRoute(async (req, res) => {
907
915
  res.json({
908
916
  success: result.success,
909
917
  score: result.score,
918
+ experimental: result.experimental,
910
919
  token: result.token,
911
920
  // Echo the sanitized form, not the raw input: this is what got signed into
912
921
  // the token, so a caller comparing the two sees the same value.