@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.
- package/engine.js +97 -2
- package/index.js +14 -2
- package/package.json +2 -2
- 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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|