@webdecoy/fcaptcha 1.35.0 → 1.37.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 (4) hide show
  1. package/engine.js +124 -12
  2. package/index.js +15 -3
  3. package/package.json +2 -2
  4. package/server.js +18 -3
package/engine.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
3
5
  /**
4
6
  * Shared detection and scoring core.
5
7
  *
@@ -130,6 +132,90 @@ function setInteractionMode(signals, widget) {
130
132
  * Whether click-derived behavioural fields carry meaning for this request.
131
133
  * Absent context means widget mode — the long-standing behaviour.
132
134
  */
135
+ // Caps how many tokens one page instance on one device at one address can be
136
+ // issued per minute. A person verifies a form once and retries a handful of
137
+ // times; the automated client observed on the public demo (2026-09-09) verified
138
+ // fourteen times in fifty-two seconds. Same limit as the per-address detection
139
+ // so the two read consistently in logs.
140
+ const DEVICE_VERIFICATIONS_PER_MINUTE = 10;
141
+
142
+ const nested = (o, ...keys) => keys.reduce((v, k) => (v && typeof v === 'object') ? v[k] : undefined, o);
143
+
144
+ // Coarse device identity used for cardinality checks and the per-device rate
145
+ // gate: canvas hash, WebGL renderer, platform and core count. Identical
146
+ // hardware produces identical values, which is why the rate gate also keys on
147
+ // the widget instance.
148
+ function deviceFingerprint(signals) {
149
+ const env = (signals && signals.environmental) || {};
150
+ const automation = env.automationFlags || {};
151
+ const components = [
152
+ String(nested(env, 'canvasHash', 'hash') || ''),
153
+ String(nested(env, 'webglInfo', 'renderer') || ''),
154
+ String(automation.platform || ''),
155
+ String(automation.hardwareConcurrency || '')
156
+ ];
157
+ return crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
158
+ }
159
+
160
+ // The per-page-load id the widget or invisible session reports about itself.
161
+ // Bounded, because it is client-supplied and becomes part of a rate-limiter key.
162
+ function widgetInstance(signals) {
163
+ const meta = (signals && signals.meta) || {};
164
+ const id = typeof meta.sessionId === 'string' && meta.sessionId ? meta.sessionId
165
+ : typeof meta.widgetId === 'string' ? meta.widgetId : '';
166
+ return id.slice(0, 64);
167
+ }
168
+
169
+ // The rate gate as one function so server.js and index.js cannot drift.
170
+ // Returns the detection to record when the budget is exhausted, else null.
171
+ function deviceRateDetection(exceeded, count) {
172
+ if (!exceeded) return null;
173
+ return { category: 'rate_limit', score: 0.8, confidence: 0.9,
174
+ reason: 'Verification rate exceeded for this device (per-minute)', details: { count, window: 60 } };
175
+ }
176
+ const deviceRateKey = (siteKey, ip, signals, instance) => `device:${siteKey}:${ip}:${deviceFingerprint(signals)}:${instance}`;
177
+
178
+ /**
179
+ * Keyboard-only accessibility exemption.
180
+ *
181
+ * A visitor who works the page from the keyboard — a screen-reader user, a
182
+ * switch-access user, anyone who never touches a pointer — produces key events
183
+ * and no pointer samples, and must not be scored as "no mouse movement". An
184
+ * agent typing through an automation protocol produces exactly the same counts,
185
+ * which is how a keyboard-driven form filler claimed the exemption on the public
186
+ * demo (2026-09-09) and was scored as a person.
187
+ *
188
+ * The counts cannot tell them apart; the key holds can. A finger on a key holds
189
+ * it for tens of milliseconds (the bench's keyboard personas never drop below
190
+ * 41ms); an automation protocol releases it in one or two. So the exemption
191
+ * asks, when hold data exists, that it look like fingers. With no hold data at
192
+ * all — a widget older than this check, or a visitor who only tabbed to the
193
+ * checkbox and never typed into a field — the exemption stands, because refusing
194
+ * it would score exactly the people it protects.
195
+ */
196
+ // Average keydown→keyup hold below which the keys were not pressed by a hand:
197
+ // well under the 41ms bench floor, well over the 1–8ms an automation protocol
198
+ // produces. A real-human capture should confirm it before it is tightened.
199
+ const MACHINE_KEY_HOLD_MS = 20;
200
+ // How many holds the average has to rest on.
201
+ const MIN_KEY_HOLD_SAMPLES = 3;
202
+
203
+ function mechanicalKeyHold(signals, b) {
204
+ let n = Number(b.keyHoldSamples) || 0;
205
+ let sum = n * (Number(b.keyHoldAvg) || 0);
206
+ const fields = nested(signals, 'formAnalysis', 'textareaKeyboard') || {};
207
+ for (const stats of Object.values(fields)) {
208
+ const dwells = stats && Array.isArray(stats.dwellTimes) ? stats.dwellTimes : [];
209
+ for (const d of dwells) { if (typeof d === 'number') { n++; sum += d; } }
210
+ }
211
+ return n >= MIN_KEY_HOLD_SAMPLES && sum / n < MACHINE_KEY_HOLD_MS;
212
+ }
213
+
214
+ function keyboardOnlyUser(signals, b) {
215
+ if ((b.keyEvents ?? 0) < 2 || (b.totalPoints ?? 0) !== 0) return false;
216
+ return !mechanicalKeyHold(signals, b);
217
+ }
218
+
133
219
  function hasWidgetInteraction(signals) {
134
220
  const ctx = signals && signals[SERVER_CONTEXT_KEY];
135
221
  if (!ctx || typeof ctx.widgetInteraction !== 'boolean') return true;
@@ -149,7 +235,7 @@ function detectVisionAI(signals) {
149
235
  const touchEvents = b.touchEvents ?? 0;
150
236
  const keyEvents = b.keyEvents ?? 0;
151
237
  const isTouchUser = isTouchModality(b);
152
- const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
238
+ const isKeyboardUser = keyboardOnlyUser(signals, b);
153
239
  // Click-derived fields only exist when a widget was there to click.
154
240
  const isWidget = hasWidgetInteraction(signals);
155
241
 
@@ -495,7 +581,10 @@ function detectCDP(signals) {
495
581
  if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
496
582
  detections.push({
497
583
  category: 'cdp', score: 0.6, confidence: 0.5,
498
- reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
584
+ reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)',
585
+ // A developer with DevTools open produces exactly this signal, so it
586
+ // cannot be one of the independent views the corroboration floor counts.
587
+ nonCorroborating: true
499
588
  });
500
589
  }
501
590
 
@@ -547,7 +636,7 @@ function detectBehavioral(signals) {
547
636
  const touchEvts = b.touchEvents ?? 0;
548
637
  const keyEvts = b.keyEvents ?? 0;
549
638
  const isTouchUsr = isTouchModality(b);
550
- const isKbdUser = keyEvts >= 2 && totalPoints === 0;
639
+ const isKbdUser = keyboardOnlyUser(signals, b);
551
640
 
552
641
  if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
553
642
  detections.push({
@@ -912,38 +1001,61 @@ function applyDispositiveFloor(score, detections) {
912
1001
  * product. It treats absence of environmental evidence as evidence of absence — but
913
1002
  * on this adversary a clean environment is the attack working, not innocence.
914
1003
  *
915
- * The rule: when two or more behavioural categories independently reach 0.5, floor
916
- * the score at 0.6.
1004
+ * The rule: when two or more behavioural categories independently reach 0.4, floor
1005
+ * the score at 0.6. A signal a developer's own tooling produces is not an
1006
+ * independent view: the DevTools console-attach probe is marked nonCorroborating,
1007
+ * so it still scores but cannot be one of the two that agree.
917
1008
  *
918
1009
  * Constants swept over a 40-point grid against the labelled corpus
919
1010
  * (bench/tools/sweep-corroboration.js), not reasoned about. No human in the
920
1011
  * 126-sample panel reaches two agreeing behavioural categories at any threshold
921
- * tested, while 66 of 75 agents do at 0.5. Requiring three fails outright — all
922
- * sixteen such combinations leave the adversary allowed. The floor value does not
923
- * affect separation, so 0.6 is a policy choice: the block boundary, kept distinct
924
- * from the 0.9 reserved for a browser that declares its own automation.
1012
+ * tested, and 0.3, 0.4 and 0.5 catch the same 66 of 75 agents. The bar moved from
1013
+ * 0.5 to 0.4 on a live measurement the corpus cannot make (2026-09-09): an
1014
+ * extension-driven click in a real Chrome on the public demo scored vision_ai 0.40
1015
+ * and behavioral 0.44 with every environmental category clean — weighted sum
1016
+ * 0.189, allowed at 0.5, floored at 0.4. The exclusion is what keeps the lower
1017
+ * bar honest: the human-looking sessions beside it sat at behavioral 0.37 with cdp
1018
+ * 0.30 from the console probe alone. Requiring three fails outright — all sixteen
1019
+ * such combinations leave the adversary allowed. The floor value does not affect
1020
+ * separation, so 0.6 is a policy choice: the block boundary, kept distinct from
1021
+ * the 0.9 reserved for a browser that declares its own automation.
925
1022
  *
926
1023
  * Caveat: the adversary is one synthetic, hand-authored sample. This shows the
927
1024
  * arithmetic works on the shape the corpus describes, not that it works on a real
928
1025
  * source-patched browser.
929
1026
  */
930
- const CORROBORATION_AGREE_AT = 0.5;
1027
+ const CORROBORATION_AGREE_AT = 0.4;
931
1028
  const CORROBORATION_MIN_AGREE = 2;
932
1029
  const CORROBORATION_FLOOR = 0.6;
1030
+ // Absorbs the few ulps a noisy-OR product lands from a round number (0.75×0.8 is
1031
+ // 0.3999999999999999, not 0.4), so agreement does not depend on arithmetic order.
1032
+ const CORROBORATION_EPSILON = 1e-9;
933
1033
 
934
1034
  // Categories a browser trips by how it moves rather than by what it is. A
935
1035
  // patched binary can hide what it is; it cannot hide that nothing is moving the
936
1036
  // pointer like a hand.
937
1037
  const BEHAVIOURAL_CATEGORIES = ['vision_ai', 'behavioral', 'automation', 'cdp'];
938
1038
 
939
- function applyCorroborationFloor(score, categoryScores) {
1039
+ function applyCorroborationFloor(score, detections) {
1040
+ // Only independent views count. A signal that a developer's own tooling
1041
+ // produces (nonCorroborating) still scores, but cannot be one of the two.
1042
+ const categoryScores = calculateCategoryScores(detections.filter((d) => !d.nonCorroborating));
940
1043
  const agreeing = BEHAVIOURAL_CATEGORIES
941
- .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT).length;
1044
+ .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT - CORROBORATION_EPSILON).length;
942
1045
  return agreeing >= CORROBORATION_MIN_AGREE ? Math.max(score, CORROBORATION_FLOOR) : score;
943
1046
  }
944
1047
 
945
1048
  module.exports = {
946
1049
  // Constants
1050
+ DEVICE_VERIFICATIONS_PER_MINUTE,
1051
+ MACHINE_KEY_HOLD_MS,
1052
+ MIN_KEY_HOLD_SAMPLES,
1053
+ deviceFingerprint,
1054
+ widgetInstance,
1055
+ deviceRateKey,
1056
+ deviceRateDetection,
1057
+ keyboardOnlyUser,
1058
+ mechanicalKeyHold,
947
1059
  AUTOMATION_UA_PATTERNS,
948
1060
  WEIGHTS,
949
1061
  DISPOSITIVE_FLOOR,
package/index.js CHANGED
@@ -187,6 +187,7 @@ const {
187
187
  calculateFinalScore,
188
188
  applyDispositiveFloor,
189
189
  applyCorroborationFloor,
190
+ widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
190
191
  } = require('./engine');
191
192
  const { detectInputForensics } = require('./inputforensics');
192
193
 
@@ -342,10 +343,21 @@ class ScoringEngine {
342
343
  // Calculate scores
343
344
  // Same aggregation as server.js: noisy-OR within a category, weighted sum
344
345
  // across them, then the dispositive and corroboration floors.
346
+ // Per-device verification rate — a precondition, not evidence. Keyed on the
347
+ // widget instance too, so identical machines behind one NAT do not share a
348
+ // budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
349
+ let deviceRateExceeded = false;
350
+ const instance = widgetInstance(signals);
351
+ if (instance) {
352
+ const [exceeded, count] = this.rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
353
+ const hit = deviceRateDetection(exceeded, count);
354
+ if (hit) { deviceRateExceeded = true; detections.push(hit); }
355
+ }
356
+
345
357
  const categoryScores = calculateCategoryScores(detections, this.weights);
346
358
  const finalScore = applyCorroborationFloor(
347
359
  applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
348
- categoryScores
360
+ detections
349
361
  );
350
362
 
351
363
  let recommendation;
@@ -358,7 +370,7 @@ class ScoringEngine {
358
370
  // dilute it below the allow threshold and used to mint a token for an empty
359
371
  // request. Keep this gate independent of scoring so weight changes cannot
360
372
  // reopen the bypass.
361
- const success = finalScore < 0.5 && powSatisfied;
373
+ const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded;
362
374
  const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
363
375
 
364
376
  // Feed the ledger so the next challenge this source asks for is priced on
@@ -373,7 +385,7 @@ class ScoringEngine {
373
385
  recommendation,
374
386
  categoryScores,
375
387
  detections,
376
- ...(!powSatisfied ? { reason: 'pow_not_satisfied' } : {})
388
+ ...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' } : {})
377
389
  };
378
390
  }
379
391
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.35.0",
3
+ "version": "1.37.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 suspicion.test.js security.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 suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.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
@@ -369,6 +369,7 @@ const {
369
369
  calculateFinalScore,
370
370
  applyDispositiveFloor,
371
371
  applyCorroborationFloor,
372
+ widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
372
373
  } = require('./engine');
373
374
 
374
375
  // Stateful detectors stay here: they read the module-level stores below.
@@ -675,6 +676,18 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
675
676
  detections.push(...await detectFingerprint(signals, ip, siteKey));
676
677
  detections.push(...await detectRateAbuse(ip, siteKey));
677
678
 
679
+ // Per-device verification rate — a precondition, not evidence: rate_limit
680
+ // weighs 0.01, so the per-address detection above can never move a verdict.
681
+ // Keyed on the widget instance too, so identical machines behind one NAT do
682
+ // not share a budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
683
+ let deviceRateExceeded = false;
684
+ const instance = widgetInstance(signals);
685
+ if (instance) {
686
+ const [exceeded, count] = await rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
687
+ const hit = deviceRateDetection(exceeded, count);
688
+ if (hit) { deviceRateExceeded = true; detections.push(hit); }
689
+ }
690
+
678
691
  // Add IP reputation check (async but we'll use sync version for simplicity)
679
692
  if (detection.isDatacenterIP(ip)) {
680
693
  detections.push({
@@ -728,7 +741,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
728
741
  const categoryScores = calculateCategoryScores(detections);
729
742
  const finalScore = applyCorroborationFloor(
730
743
  applyDispositiveFloor(calculateFinalScore(categoryScores), detections),
731
- categoryScores
744
+ detections
732
745
  );
733
746
 
734
747
  let recommendation;
@@ -754,7 +767,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
754
767
  // who never completed the challenge. Gating here means no future reweighting
755
768
  // can reopen the bypass, and it holds even if the dispositive floor is
756
769
  // lowered or removed.
757
- const success = finalScore < 0.5 && hostnameAllowed && powSatisfied;
770
+ const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded;
758
771
 
759
772
  // Name the failed precondition whenever one fails, not only when the score
760
773
  // would otherwise have allowed. Gating it on the score made the PoW case
@@ -762,7 +775,9 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
762
775
  // and the branch never fired — which is exactly the case a caller most needs
763
776
  // explained.
764
777
  let withheldReason = '';
765
- if (!powSatisfied) withheldReason = 'pow_not_satisfied';
778
+ // Rate first because it is the one the caller can act on: back off.
779
+ if (deviceRateExceeded) withheldReason = 'rate_limited';
780
+ else if (!powSatisfied) withheldReason = 'pow_not_satisfied';
766
781
  else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
767
782
 
768
783
  const token = success