@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.
- package/engine.js +124 -12
- package/index.js +15 -3
- package/package.json +2 -2
- 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 =
|
|
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 =
|
|
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.
|
|
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,
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|