@webdecoy/fcaptcha 1.36.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 +97 -2
  2. package/index.js +14 -2
  3. package/package.json +2 -2
  4. package/server.js +17 -2
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
 
@@ -550,7 +636,7 @@ function detectBehavioral(signals) {
550
636
  const touchEvts = b.touchEvents ?? 0;
551
637
  const keyEvts = b.keyEvents ?? 0;
552
638
  const isTouchUsr = isTouchModality(b);
553
- const isKbdUser = keyEvts >= 2 && totalPoints === 0;
639
+ const isKbdUser = keyboardOnlyUser(signals, b);
554
640
 
555
641
  if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
556
642
  detections.push({
@@ -961,6 +1047,15 @@ function applyCorroborationFloor(score, detections) {
961
1047
 
962
1048
  module.exports = {
963
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,
964
1059
  AUTOMATION_UA_PATTERNS,
965
1060
  WEIGHTS,
966
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,6 +343,17 @@ 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),
@@ -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.36.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 corroboration.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({
@@ -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