@webdecoy/fcaptcha 1.17.0 → 1.19.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/detection.js +85 -18
- package/inputforensics.js +314 -0
- package/package.json +5 -2
- package/server.js +189 -21
package/detection.js
CHANGED
|
@@ -97,12 +97,28 @@ async function checkIPReputation(ip) {
|
|
|
97
97
|
// HTTP Header Analysis
|
|
98
98
|
// =============================================================================
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
// Headers a reverse proxy, CDN or load balancer adds on the way in.
|
|
101
|
+
//
|
|
102
|
+
// These are only anomalous when they arrive from a peer that is NOT a proxy we
|
|
103
|
+
// trust: an ordinary client has no business claiming to forward for someone
|
|
104
|
+
// else. Behind a proxy they are the opposite of suspicious — they are how the
|
|
105
|
+
// request is supposed to arrive, and Cloudflare alone contributes three of
|
|
106
|
+
// them.
|
|
107
|
+
//
|
|
108
|
+
// Scoring them unconditionally meant every visitor to every proxied deployment
|
|
109
|
+
// picked up a bot detection, permanently, including the project's own demo
|
|
110
|
+
// behind Railway. The bench measured it firing on 100% of the human panel *and*
|
|
111
|
+
// 100% of the agent corpus — a signal that fires on everyone distinguishes
|
|
112
|
+
// nobody, it just adds noise to both sides of the comparison.
|
|
113
|
+
const FORWARDING_HEADERS = new Set([
|
|
114
|
+
'x-forwarded-for', 'x-real-ip', 'via', 'forwarded', 'x-originating-ip',
|
|
115
|
+
'cf-connecting-ip', 'true-client-ip', 'x-cluster-client-ip'
|
|
104
116
|
]);
|
|
105
117
|
|
|
118
|
+
// Suspicious regardless of who the peer is. x-requested-with is set by XHR
|
|
119
|
+
// libraries, not by proxies, so trusting the peer says nothing about it.
|
|
120
|
+
const SUSPICIOUS_HEADERS = new Set(['x-requested-with']);
|
|
121
|
+
|
|
106
122
|
const EXPECTED_BROWSER_HEADERS = new Set([
|
|
107
123
|
'accept', 'accept-language', 'accept-encoding', 'user-agent'
|
|
108
124
|
]);
|
|
@@ -162,7 +178,16 @@ function checkDeclaredAIAgent(userAgent, headers = {}) {
|
|
|
162
178
|
return detections;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
|
|
181
|
+
/**
|
|
182
|
+
* @param {object} headers
|
|
183
|
+
* @param {object} [opts]
|
|
184
|
+
* @param {boolean} [opts.peerTrusted] whether the immediate peer is a proxy we
|
|
185
|
+
* trust. When it is, forwarding headers are expected infrastructure rather
|
|
186
|
+
* than an anomaly. Defaults to false, so a caller that does not know keeps
|
|
187
|
+
* the stricter behaviour.
|
|
188
|
+
*/
|
|
189
|
+
function analyzeHeaders(headers, opts = {}) {
|
|
190
|
+
const peerTrusted = opts.peerTrusted === true;
|
|
166
191
|
const detections = [];
|
|
167
192
|
const headersLower = {};
|
|
168
193
|
for (const [key, value] of Object.entries(headers)) {
|
|
@@ -185,9 +210,12 @@ function analyzeHeaders(headers) {
|
|
|
185
210
|
});
|
|
186
211
|
}
|
|
187
212
|
|
|
188
|
-
// Check for suspicious headers
|
|
213
|
+
// Check for suspicious headers. Forwarding headers only count when the peer
|
|
214
|
+
// is not a proxy we trust — see FORWARDING_HEADERS.
|
|
189
215
|
for (const header of Object.keys(headersLower)) {
|
|
190
|
-
|
|
216
|
+
const isSuspicious =
|
|
217
|
+
SUSPICIOUS_HEADERS.has(header) || (!peerTrusted && FORWARDING_HEADERS.has(header));
|
|
218
|
+
if (isSuspicious) {
|
|
191
219
|
detections.push({
|
|
192
220
|
category: 'bot',
|
|
193
221
|
score: 0.3,
|
|
@@ -375,14 +403,35 @@ function checkJA3Fingerprint(ja3Hash) {
|
|
|
375
403
|
// and passed via a trusted header the server is configured to accept.
|
|
376
404
|
// Configure with: TRUSTED_JA4_HEADERS=cf-ja4,x-tls-ja4 (comma-separated).
|
|
377
405
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
406
|
+
// Observed automation fingerprints. Ships EMPTY, deliberately.
|
|
407
|
+
//
|
|
408
|
+
// JA4 format: t##d####_<cipher hash>_<extension+sigalg hash>
|
|
409
|
+
//
|
|
410
|
+
// # The placeholder that used to live here was wrong
|
|
411
|
+
//
|
|
412
|
+
// It read:
|
|
413
|
+
//
|
|
414
|
+
// 't13d1516h2_8daaf6152771_02713d6af862': 'Go default stdlib TLS'
|
|
415
|
+
//
|
|
416
|
+
// That is the canonical example from the JA4 specification, and it is **Chrome**,
|
|
417
|
+
// not Go. Measured against a real TLS listener with server-go's ComputeJA4:
|
|
418
|
+
//
|
|
419
|
+
// Chromium 141 t13i1515h2_8daaf6152771_806a8c22fdea
|
|
420
|
+
// Go stdlib t13i131000_f57a46bbacb6_e5728521abd4
|
|
421
|
+
// curl t13i4906h2_0d8feac7bc37_7395dae3b2f3
|
|
422
|
+
// node https t13i521000_b262b3658495_8e6e362c5eac
|
|
423
|
+
// python urllib t13i171000_ab0a1bf427ad_8e6e362c5eac
|
|
424
|
+
//
|
|
425
|
+
// The middle section hashes the cipher list, i.e. the TLS stack itself.
|
|
426
|
+
// 8daaf6152771 is Chrome's; Go's is f57a46bbacb6. Had anyone uncommented that
|
|
427
|
+
// line, every Chrome visitor would have been scored as automation at 0.8/0.9.
|
|
428
|
+
//
|
|
429
|
+
// # Why it stays empty
|
|
430
|
+
//
|
|
431
|
+
// A static hash list is defeated by rotating a fingerprint, and these values
|
|
432
|
+
// drift with every browser release. Populate it only from abuse you actually saw
|
|
433
|
+
// on your own deployment, and record where each fingerprint came from.
|
|
434
|
+
const KNOWN_BOT_JA4_HASHES = {};
|
|
386
435
|
|
|
387
436
|
function getTrustedJA4HeaderNames() {
|
|
388
437
|
const env = process.env.TRUSTED_JA4_HEADERS;
|
|
@@ -660,9 +709,17 @@ function analyzeKeystrokeCadence(stats) {
|
|
|
660
709
|
// Form Interaction Analysis (Credential Stuffing & Spam Detection)
|
|
661
710
|
// =============================================================================
|
|
662
711
|
|
|
663
|
-
|
|
712
|
+
/**
|
|
713
|
+
* @param {object} formAnalysis
|
|
714
|
+
* @param {object} [opts]
|
|
715
|
+
* @param {boolean} [opts.humanPresent] whether the visitor independently showed
|
|
716
|
+
* they were there — real pointer or touch movement. Defaults to false so a
|
|
717
|
+
* caller that does not know keeps the stricter behaviour.
|
|
718
|
+
*/
|
|
719
|
+
function analyzeFormInteraction(formAnalysis, opts = {}) {
|
|
664
720
|
if (!formAnalysis) return [];
|
|
665
721
|
|
|
722
|
+
const humanPresent = opts.humanPresent === true;
|
|
666
723
|
const detections = [];
|
|
667
724
|
const submit = formAnalysis.submit || {};
|
|
668
725
|
|
|
@@ -733,8 +790,18 @@ function analyzeFormInteraction(formAnalysis) {
|
|
|
733
790
|
});
|
|
734
791
|
}
|
|
735
792
|
|
|
736
|
-
// Check for paste-heavy input (spam bots often paste content)
|
|
737
|
-
|
|
793
|
+
// Check for paste-heavy input (spam bots often paste content).
|
|
794
|
+
//
|
|
795
|
+
// Standing down when the visitor independently demonstrated they were
|
|
796
|
+
// present. Pasting is not a bot behaviour — people paste addresses, error
|
|
797
|
+
// messages, snippets and one-time codes constantly, and a form filled by
|
|
798
|
+
// paste alone is an ordinary afternoon. The bench human panel caught this
|
|
799
|
+
// firing on 100% of the paste-by-human persona.
|
|
800
|
+
//
|
|
801
|
+
// What actually distinguishes the spam bot is that pasting is ALL it did:
|
|
802
|
+
// no pointer trajectory, no touch, nothing but the content appearing. So
|
|
803
|
+
// the check now needs the paste AND the absence of a person.
|
|
804
|
+
if (stats.pasteCount > 0 && stats.keyCount < 5 && !humanPresent) {
|
|
738
805
|
detections.push({
|
|
739
806
|
category: 'bot',
|
|
740
807
|
score: 0.6,
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input forensics v2 — PRD workstream C.
|
|
5
|
+
*
|
|
6
|
+
* Four checks on *how* input arrived rather than how much of it there was:
|
|
7
|
+
* typing cadence, typing modality, the paste-shortcut/platform contradiction,
|
|
8
|
+
* and scroll morphology.
|
|
9
|
+
*
|
|
10
|
+
* ## Every threshold here was measured, not assumed
|
|
11
|
+
*
|
|
12
|
+
* The PRD quotes FP-Agent's observations (Manus 1.39ms inter-key, Browser Use
|
|
13
|
+
* 5.31ms, Skyvern 9.52ms) and then warns, correctly, that they are *not*
|
|
14
|
+
* constants: the paper found Browser Use programs an 18ms delay yet is observed
|
|
15
|
+
* at 5.31ms, because the renderer batches events. The numbers are a function of
|
|
16
|
+
* browser version and machine load. Hardcoding them would be copying someone
|
|
17
|
+
* else's laboratory conditions.
|
|
18
|
+
*
|
|
19
|
+
* So the values below come from the bench corpus (bench/README.md), captured on
|
|
20
|
+
* real hardware through a real browser:
|
|
21
|
+
*
|
|
22
|
+
* signal human scripted agent
|
|
23
|
+
* --------------------- ------------------------ --------------------
|
|
24
|
+
* inter-key interval min 114.6ms, med 226.9ms max 25.9ms, med 7.9ms
|
|
25
|
+
* interval variance 4549 8
|
|
26
|
+
* key hold (dwell) min 41.4ms, med 82.0ms max 25.5ms, med 7.8ms
|
|
27
|
+
* scroll max step 109px 704px
|
|
28
|
+
*
|
|
29
|
+
* The distributions do not overlap, so the thresholds sit in the empty space
|
|
30
|
+
* between them with a wide margin on both sides rather than hugging either.
|
|
31
|
+
* Re-derive them with `node bench/run-bench.js` on your own hardware if you
|
|
32
|
+
* fork this; that is what §7.4 of the PRD is asking for.
|
|
33
|
+
*
|
|
34
|
+
* ## What is deliberately NOT here
|
|
35
|
+
*
|
|
36
|
+
* Keystroke overlap — the claim that humans press the next key before releasing
|
|
37
|
+
* the current one and agents never do. An earlier PRD draft attributed it to
|
|
38
|
+
* FP-Agent; the paper does not say it. It defines a keystroke as "a keydown
|
|
39
|
+
* event followed by a keyup event" and its only overlap language is about
|
|
40
|
+
* modifier keys in shortcuts. Rollover typing is real in the keystroke-dynamics
|
|
41
|
+
* literature, so the hypothesis may well hold — but it has no source and no
|
|
42
|
+
* measurement here, so it earns no weight.
|
|
43
|
+
*
|
|
44
|
+
* ## Weighting
|
|
45
|
+
*
|
|
46
|
+
* All of these are contributory, not decisive, per §7.4. The coalesced-events
|
|
47
|
+
* signal FCaptcha already ships is under active attack from stealth tooling
|
|
48
|
+
* that synthesises it; behavioural signals have a shelf life, and any one of
|
|
49
|
+
* them carrying a verdict on its own is a liability. The one exception in
|
|
50
|
+
* spirit is the platform contradiction, which scores higher because it is not a
|
|
51
|
+
* threshold at all — it is the client disagreeing with itself.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// --- measured thresholds ----------------------------------------------------
|
|
55
|
+
|
|
56
|
+
// 2.9x below the slowest human keystroke observed, 1.5x above the fastest agent one.
|
|
57
|
+
const MAX_MACHINE_INTERKEY_MS = 40;
|
|
58
|
+
// Human variance was 4549; the scripted agent's was 8.
|
|
59
|
+
const MAX_MACHINE_INTERVAL_VARIANCE = 200;
|
|
60
|
+
// Human holds bottomed out at 41ms; the agent never exceeded 25.5ms.
|
|
61
|
+
const MAX_MACHINE_DWELL_MS = 35;
|
|
62
|
+
// Enough keystrokes to be a typing sample at all. A paste registers as one or
|
|
63
|
+
// two keydowns, and at n=2 any cadence statistic is noise.
|
|
64
|
+
const MIN_KEYS_FOR_CADENCE = 10;
|
|
65
|
+
// Human scrolling never stepped more than 109px between events; scrollIntoView
|
|
66
|
+
// covered 704px in one.
|
|
67
|
+
const MAX_HUMAN_SCROLL_STEP_PX = 400;
|
|
68
|
+
|
|
69
|
+
const MAC_PLATFORMS = /^(mac|iphone|ipad|ipod)/i;
|
|
70
|
+
const WINDOWS_PLATFORMS = /^win/i;
|
|
71
|
+
|
|
72
|
+
const num = (v, dflt = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : dflt);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Typing cadence: a hand cannot hold a key for 8ms, forty times, with no spread.
|
|
76
|
+
*
|
|
77
|
+
* Gated hard, because the failure mode is punishing people who paste. Humans
|
|
78
|
+
* paste constantly — password managers, copied addresses, one-time codes — and
|
|
79
|
+
* a paste arrives as one or two keydowns microseconds apart, which is
|
|
80
|
+
* arithmetically indistinguishable from very fast typing. The bench's
|
|
81
|
+
* paste-by-human persona is exactly that shape: 2 keys, 0.8ms apart, variance 0.
|
|
82
|
+
*
|
|
83
|
+
* Hence three preconditions before the cadence is even looked at: enough keys to
|
|
84
|
+
* be a sample, no paste anywhere on the page, and no paste into this field.
|
|
85
|
+
*/
|
|
86
|
+
function checkTypingCadence(fields, typing) {
|
|
87
|
+
const detections = [];
|
|
88
|
+
if (!fields) return detections;
|
|
89
|
+
|
|
90
|
+
const pastedAnywhere = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
|
|
91
|
+
if (pastedAnywhere) return detections;
|
|
92
|
+
|
|
93
|
+
for (const [, stats] of Object.entries(fields)) {
|
|
94
|
+
if (!stats || typeof stats !== 'object') continue;
|
|
95
|
+
if (num(stats.pasteCount) > 0) continue;
|
|
96
|
+
|
|
97
|
+
const keyCount = num(stats.keyCount);
|
|
98
|
+
if (keyCount < MIN_KEYS_FOR_CADENCE) continue;
|
|
99
|
+
|
|
100
|
+
const interval = num(stats.avgKeyInterval, Infinity);
|
|
101
|
+
const variance = num(stats.keyIntervalVariance, Infinity);
|
|
102
|
+
const dwells = Array.isArray(stats.dwellTimes) ? stats.dwellTimes : [];
|
|
103
|
+
const avgDwell = dwells.length
|
|
104
|
+
? dwells.reduce((a, b) => a + b, 0) / dwells.length
|
|
105
|
+
: Infinity;
|
|
106
|
+
|
|
107
|
+
// Both the rhythm and the hold have to be inhuman. Either alone is a
|
|
108
|
+
// machine that happens to be slow, or a fast typist on a bad keyboard.
|
|
109
|
+
const machineRhythm = interval < MAX_MACHINE_INTERKEY_MS && variance < MAX_MACHINE_INTERVAL_VARIANCE;
|
|
110
|
+
const machineHold = avgDwell < MAX_MACHINE_DWELL_MS;
|
|
111
|
+
|
|
112
|
+
if (machineRhythm && machineHold) {
|
|
113
|
+
detections.push({
|
|
114
|
+
category: 'behavioral',
|
|
115
|
+
score: 0.6,
|
|
116
|
+
confidence: 0.6,
|
|
117
|
+
reason: 'Typing cadence is mechanical (inter-key and hold both below human range)',
|
|
118
|
+
});
|
|
119
|
+
break; // one detection per request, however many fields were filled
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return detections;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The paste shortcut disagrees with the platform the client claims.
|
|
128
|
+
*
|
|
129
|
+
* macOS pastes with Meta+V; Windows and Linux paste with Control+V. These are
|
|
130
|
+
* not conventions a user can be halfway between — the other combination does
|
|
131
|
+
* nothing at all on that OS. So a client reporting `MacIntel` whose visitor
|
|
132
|
+
* pasted with Control+V has told us two incompatible things about itself.
|
|
133
|
+
*
|
|
134
|
+
* Requiring that the paste *landed* is what makes this safe. A Windows
|
|
135
|
+
* switcher on a new Mac really does fat-finger Ctrl+V — and on macOS nothing
|
|
136
|
+
* happens, so no paste event follows. Only a client whose real platform differs
|
|
137
|
+
* from its claimed one gets both the wrong shortcut and a completed paste.
|
|
138
|
+
*
|
|
139
|
+
* No threshold, no calibration, no hardware dependence. Scored higher than the
|
|
140
|
+
* cadence checks for exactly that reason.
|
|
141
|
+
*/
|
|
142
|
+
function checkPastePlatformCoherence(typing, platform) {
|
|
143
|
+
if (!platform) return [];
|
|
144
|
+
|
|
145
|
+
const pasted = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
|
|
146
|
+
if (!pasted) return [];
|
|
147
|
+
|
|
148
|
+
const ctrl = num(typing.pasteShortcutControl);
|
|
149
|
+
const meta = num(typing.pasteShortcutMeta);
|
|
150
|
+
|
|
151
|
+
const claimsMac = MAC_PLATFORMS.test(platform);
|
|
152
|
+
const claimsWindows = WINDOWS_PLATFORMS.test(platform);
|
|
153
|
+
|
|
154
|
+
if (claimsMac && ctrl > 0 && meta === 0) {
|
|
155
|
+
return [{
|
|
156
|
+
category: 'automation',
|
|
157
|
+
score: 0.7,
|
|
158
|
+
confidence: 0.8,
|
|
159
|
+
reason: `Paste shortcut contradicts platform (Control+V while claiming ${platform})`,
|
|
160
|
+
}];
|
|
161
|
+
}
|
|
162
|
+
if (claimsWindows && meta > 0 && ctrl === 0) {
|
|
163
|
+
return [{
|
|
164
|
+
category: 'automation',
|
|
165
|
+
score: 0.7,
|
|
166
|
+
confidence: 0.8,
|
|
167
|
+
reason: `Paste shortcut contradicts platform (Meta+V while claiming ${platform})`,
|
|
168
|
+
}];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Text that appeared without being typed or pasted.
|
|
175
|
+
*
|
|
176
|
+
* A genuine `input` event carries an `inputType` describing what the browser
|
|
177
|
+
* did; a synthetic one dispatched by script does not. Combined with no
|
|
178
|
+
* keystrokes and no paste, that is a value assigned straight into the DOM.
|
|
179
|
+
*
|
|
180
|
+
* Deliberately low confidence: browser autofill and some password managers also
|
|
181
|
+
* produce input events with no keystrokes behind them, and the whole point of
|
|
182
|
+
* §7.1's warning is that a check which punishes password-manager users is worse
|
|
183
|
+
* than no check. It contributes; it never decides.
|
|
184
|
+
*/
|
|
185
|
+
function checkProgrammaticFill(typing) {
|
|
186
|
+
const noType = num(typing.inputsWithoutInputType);
|
|
187
|
+
const noKeys = num(typing.inputsWithoutKeys);
|
|
188
|
+
const pasted = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
|
|
189
|
+
|
|
190
|
+
if (noType > 0 && noKeys > 0 && !pasted) {
|
|
191
|
+
return [{
|
|
192
|
+
category: 'automation',
|
|
193
|
+
score: 0.5,
|
|
194
|
+
confidence: 0.4,
|
|
195
|
+
reason: 'Field populated without keystrokes or paste (synthetic input event)',
|
|
196
|
+
}];
|
|
197
|
+
}
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Scroll morphology: the page moved further in one event than a hand can move it.
|
|
203
|
+
*
|
|
204
|
+
* A wheel notch or trackpad flick advances tens of pixels and emits an event
|
|
205
|
+
* each time, so covering a long page takes dozens of them. `scrollIntoView()`
|
|
206
|
+
* covers the same distance in one, because it is aiming at an element rather
|
|
207
|
+
* than turning a wheel. Measured: human scrolling never exceeded 109px between
|
|
208
|
+
* consecutive events; the agent's jump was 704px.
|
|
209
|
+
*
|
|
210
|
+
* This is an architecture tell as much as a bot tell — DOM-driven agents jump,
|
|
211
|
+
* vision-driven ones emit short bursts, people produce long variable gestures.
|
|
212
|
+
*
|
|
213
|
+
* Gated on there being no keyboard activity, because PageDown, End and Space
|
|
214
|
+
* are legitimate one-event jumps of roughly a viewport. A visitor using the
|
|
215
|
+
* keyboard to scroll is doing something a hand does; a DOM agent has no keys.
|
|
216
|
+
*/
|
|
217
|
+
function checkScrollMorphology(behavioral) {
|
|
218
|
+
const m = behavioral.scrollMorphology;
|
|
219
|
+
if (!m || typeof m !== 'object') return [];
|
|
220
|
+
|
|
221
|
+
if (num(behavioral.keyEvents) > 0) return [];
|
|
222
|
+
if (num(m.samples) < 2) return [];
|
|
223
|
+
|
|
224
|
+
if (num(m.maxStep) > MAX_HUMAN_SCROLL_STEP_PX) {
|
|
225
|
+
return [{
|
|
226
|
+
category: 'automation',
|
|
227
|
+
score: 0.5,
|
|
228
|
+
confidence: 0.5,
|
|
229
|
+
reason: 'Page scrolled by API rather than by gesture (single-event jump)',
|
|
230
|
+
}];
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Font list contradicts the claimed operating system.
|
|
237
|
+
*
|
|
238
|
+
* Scores the *contradiction*, never the enumeration. A short font list means a
|
|
239
|
+
* privacy extension, a minimal Linux install or a locked-down corporate image —
|
|
240
|
+
* all ordinary. A list full of Windows-only faces under a macOS claim is not
|
|
241
|
+
* ordinary, it is two different machines' worth of evidence in one payload.
|
|
242
|
+
*
|
|
243
|
+
* Validated in both directions by the bench corpus, though the positive case
|
|
244
|
+
* arrived by accident: the `incoherent-paste` persona spoofs `Win32` in order to
|
|
245
|
+
* test the paste-shortcut check, and while doing so it presents this machine's
|
|
246
|
+
* genuine macOS font set under a Windows claim. This check caught that
|
|
247
|
+
* independently. It stays silent on all 14 human personas.
|
|
248
|
+
*
|
|
249
|
+
* Still weighted as contributory. One machine's font set is thin evidence for a
|
|
250
|
+
* rule about every machine, and the specific shape the PRD describes — ChatGPT
|
|
251
|
+
* Agent reporting a Calibri-only list under both Linux and MacIntel claims — has
|
|
252
|
+
* not been captured here.
|
|
253
|
+
*/
|
|
254
|
+
function checkFontPlatformCoherence(fonts, platform) {
|
|
255
|
+
if (!fonts || fonts.supported === false || !platform) return [];
|
|
256
|
+
|
|
257
|
+
// An empty or near-empty list is a blocked enumeration, not a contradiction.
|
|
258
|
+
if (num(fonts.count) < 3) return [];
|
|
259
|
+
|
|
260
|
+
const claimsMac = MAC_PLATFORMS.test(platform);
|
|
261
|
+
const claimsWindows = WINDOWS_PLATFORMS.test(platform);
|
|
262
|
+
|
|
263
|
+
const macFonts = fonts.hasSFPro === true || fonts.hasMenlo === true;
|
|
264
|
+
const winFonts = fonts.hasSegoeUI === true || fonts.hasCalibri === true;
|
|
265
|
+
|
|
266
|
+
if (claimsMac && winFonts && !macFonts) {
|
|
267
|
+
return [{
|
|
268
|
+
category: 'automation',
|
|
269
|
+
score: 0.5,
|
|
270
|
+
confidence: 0.5,
|
|
271
|
+
reason: `Font set contradicts platform (Windows faces while claiming ${platform})`,
|
|
272
|
+
}];
|
|
273
|
+
}
|
|
274
|
+
if (claimsWindows && macFonts && !winFonts) {
|
|
275
|
+
return [{
|
|
276
|
+
category: 'automation',
|
|
277
|
+
score: 0.5,
|
|
278
|
+
confidence: 0.5,
|
|
279
|
+
reason: `Font set contradicts platform (macOS faces while claiming ${platform})`,
|
|
280
|
+
}];
|
|
281
|
+
}
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Runs every workstream-C check. */
|
|
286
|
+
function detectInputForensics(signals) {
|
|
287
|
+
const behavioral = signals.behavioral || {};
|
|
288
|
+
const form = signals.formAnalysis || {};
|
|
289
|
+
const env = signals.environmental || {};
|
|
290
|
+
const typing = form.typing || {};
|
|
291
|
+
const platform = (env.navigator && env.navigator.platform) || null;
|
|
292
|
+
|
|
293
|
+
return [
|
|
294
|
+
...checkTypingCadence(form.textareaKeyboard, typing),
|
|
295
|
+
...checkPastePlatformCoherence(typing, platform),
|
|
296
|
+
...checkProgrammaticFill(typing),
|
|
297
|
+
...checkScrollMorphology(behavioral),
|
|
298
|
+
...checkFontPlatformCoherence(env.fontsInfo, platform),
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = {
|
|
303
|
+
MAX_HUMAN_SCROLL_STEP_PX,
|
|
304
|
+
MAX_MACHINE_DWELL_MS,
|
|
305
|
+
MAX_MACHINE_INTERKEY_MS,
|
|
306
|
+
MAX_MACHINE_INTERVAL_VARIANCE,
|
|
307
|
+
MIN_KEYS_FOR_CADENCE,
|
|
308
|
+
checkFontPlatformCoherence,
|
|
309
|
+
checkPastePlatformCoherence,
|
|
310
|
+
checkProgrammaticFill,
|
|
311
|
+
checkScrollMorphology,
|
|
312
|
+
checkTypingCadence,
|
|
313
|
+
detectInputForensics,
|
|
314
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
"dev": "node --watch server.js",
|
|
14
14
|
"test:webbotauth": "node webbotauth.test.js",
|
|
15
15
|
"test:clientip": "node clientip.test.js",
|
|
16
|
-
"test:limits": "node limits.test.js"
|
|
16
|
+
"test:limits": "node limits.test.js",
|
|
17
|
+
"test:detection": "node detection.test.js",
|
|
18
|
+
"test": "node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js",
|
|
19
|
+
"test:forensics": "node inputforensics.test.js"
|
|
17
20
|
},
|
|
18
21
|
"dependencies": {
|
|
19
22
|
"cors": "^2.8.5",
|
package/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const detection = require('./detection');
|
|
|
12
12
|
const webbotauth = require('./webbotauth');
|
|
13
13
|
const { ProxyTrust } = require('./clientip');
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
|
+
const { detectInputForensics } = require('./inputforensics');
|
|
15
16
|
|
|
16
17
|
const app = express();
|
|
17
18
|
app.use(cors());
|
|
@@ -297,6 +298,57 @@ const WEIGHTS = {
|
|
|
297
298
|
// Detection Functions
|
|
298
299
|
// =============================================================================
|
|
299
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Whether this visitor is using a touch or pen device, and so should be exempt
|
|
303
|
+
* from the mouse-trajectory detections.
|
|
304
|
+
*
|
|
305
|
+
* The old rule was `touchEvents >= 3`, which a mobile user who simply taps the
|
|
306
|
+
* checkbox does not meet: the client records touchstart and touchmove, and a
|
|
307
|
+
* clean tap on a page short enough not to need scrolling produces exactly one
|
|
308
|
+
* event. The bench human panel captured precisely that — `touchEvents: 1` — and
|
|
309
|
+
* the visitor collected three agent detections for it, including
|
|
310
|
+
* "Zero mouse, touch, or keyboard events recorded" at confidence 0.9.
|
|
311
|
+
*
|
|
312
|
+
* One touch event is enough to establish modality. Corroborating it with the
|
|
313
|
+
* pointer type keeps a bare forged count from claiming the exemption on its
|
|
314
|
+
* own — though note this is a soft check either way, since every input here is
|
|
315
|
+
* client-supplied and an agent willing to claim `touchEvents: 1` was equally
|
|
316
|
+
* willing to claim 3.
|
|
317
|
+
*/
|
|
318
|
+
/**
|
|
319
|
+
* Movement that carries independent evidence of a human hand.
|
|
320
|
+
*
|
|
321
|
+
* A slow pointer is the thing two of these checks look for, and it is also
|
|
322
|
+
* exactly what an elderly or motor-impaired visitor produces. The bench human
|
|
323
|
+
* panel caught both firing on them: "Mouse event rate abnormally low" on the
|
|
324
|
+
* elderly and motor-slow personas, "Mouse velocity too consistent" on
|
|
325
|
+
* motor-slow.
|
|
326
|
+
*
|
|
327
|
+
* Slowness alone cannot separate those users from an agent, but it does not
|
|
328
|
+
* have to. The same captures carry markers no low-effort automation produces:
|
|
329
|
+
* saturated micro-tremor, dozens of direction changes, corrective overshoots.
|
|
330
|
+
* On the bench corpus the split is total - every human persona clears this bar
|
|
331
|
+
* (tremor 1.00, 22-49 direction changes, 1-4 corrections) and every agent
|
|
332
|
+
* misses it (tremor 0.04-0.16, 0-1 direction changes, 0 corrections).
|
|
333
|
+
*
|
|
334
|
+
* So: do not read slowness as automation when the movement independently looks
|
|
335
|
+
* like a hand. An agent can of course fake all three, but faking three
|
|
336
|
+
* correlated properties of human motion is a materially harder job than
|
|
337
|
+
* running slowly, which is the point.
|
|
338
|
+
*/
|
|
339
|
+
function hasHumanMovementMarkers(b) {
|
|
340
|
+
const tremor = b.microTremorScore ?? 0.5;
|
|
341
|
+
const corrections = b.overshootCorrections ?? 0;
|
|
342
|
+
const changes = b.directionChanges ?? 0;
|
|
343
|
+
return tremor >= 0.5 && (corrections >= 1 || changes >= 10);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isTouchModality(b) {
|
|
347
|
+
const touchEvents = b.touchEvents ?? 0;
|
|
348
|
+
const touchPoints = b.touchTotalPoints ?? 0;
|
|
349
|
+
return touchEvents >= 3 || ((touchEvents >= 1 || touchPoints >= 1) && b.pointerHasNonMouseType === true);
|
|
350
|
+
}
|
|
351
|
+
|
|
300
352
|
function getNestedValue(obj, ...keys) {
|
|
301
353
|
return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
|
|
302
354
|
}
|
|
@@ -313,7 +365,7 @@ function detectVisionAI(signals) {
|
|
|
313
365
|
const approachPts = b.approachPoints ?? 0;
|
|
314
366
|
const touchEvents = b.touchEvents ?? 0;
|
|
315
367
|
const keyEvents = b.keyEvents ?? 0;
|
|
316
|
-
const isTouchUser =
|
|
368
|
+
const isTouchUser = isTouchModality(b);
|
|
317
369
|
const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
|
|
318
370
|
|
|
319
371
|
if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
|
|
@@ -350,16 +402,39 @@ function detectVisionAI(signals) {
|
|
|
350
402
|
}
|
|
351
403
|
|
|
352
404
|
// Micro-tremor
|
|
405
|
+
// Micro-tremor.
|
|
406
|
+
//
|
|
407
|
+
// Two things were wrong here. Go defaulted a missing microTremorScore to 0
|
|
408
|
+
// (maximally suspicious) while Node and Python defaulted to 0.5, so a client
|
|
409
|
+
// that omitted the field was flagged by one server and not the others. And
|
|
410
|
+
// like the approach-directness check above, this fires on a measurement that
|
|
411
|
+
// does not exist for someone who never moved a mouse — the client itself
|
|
412
|
+
// reports 0.5 as its "no mouse data" sentinel.
|
|
413
|
+
//
|
|
414
|
+
// Require real mouse movement before judging its texture, and apply the same
|
|
415
|
+
// exemptions as the surrounding checks.
|
|
353
416
|
const microTremor = b.microTremorScore ?? 0.5;
|
|
354
|
-
|
|
417
|
+
const hasMouseMovement = totalPoints >= 5;
|
|
418
|
+
if (hasMouseMovement && !isTouchUser && !isKeyboardUser && microTremor < 0.15) {
|
|
355
419
|
detections.push({
|
|
356
420
|
category: 'vision_ai', score: 0.7, confidence: 0.6,
|
|
357
421
|
reason: 'Mouse movement lacks natural micro-tremor'
|
|
358
422
|
});
|
|
359
423
|
}
|
|
360
424
|
|
|
361
|
-
// Approach directness
|
|
362
|
-
|
|
425
|
+
// Approach directness.
|
|
426
|
+
//
|
|
427
|
+
// The client reports directness 1 (perfectly straight) when there is no
|
|
428
|
+
// approach path to measure at all, so this check used to fire on every
|
|
429
|
+
// keyboard-only, screen-reader and touch user — the populations the
|
|
430
|
+
// surrounding checks go out of their way to exempt. Found by the bench
|
|
431
|
+
// human panel: keyboard-only, screen-reader and touch all reported
|
|
432
|
+
// approachPoints 0 with approachDirectness 1.
|
|
433
|
+
//
|
|
434
|
+
// Require an actual path before judging its shape, and apply the same
|
|
435
|
+
// exemptions as its neighbours.
|
|
436
|
+
const hasApproachPath = approachPts >= 5;
|
|
437
|
+
if (hasApproachPath && !isTouchUser && !isKeyboardUser && (b.approachDirectness ?? 0) > 0.95) {
|
|
363
438
|
detections.push({
|
|
364
439
|
category: 'vision_ai', score: 0.5, confidence: 0.5,
|
|
365
440
|
reason: 'Mouse path to target is unnaturally direct'
|
|
@@ -422,6 +497,7 @@ function detectHeadless(signals, userAgent) {
|
|
|
422
497
|
if (env.webdriver) {
|
|
423
498
|
detections.push({
|
|
424
499
|
category: 'headless', score: 0.95, confidence: 0.95,
|
|
500
|
+
dispositive: true, // navigator.webdriver === true — see DISPOSITIVE_FLOOR
|
|
425
501
|
reason: 'WebDriver detected'
|
|
426
502
|
});
|
|
427
503
|
}
|
|
@@ -631,6 +707,7 @@ function detectCDP(signals) {
|
|
|
631
707
|
category: 'cdp',
|
|
632
708
|
score: 0.9,
|
|
633
709
|
confidence: 0.95,
|
|
710
|
+
dispositive: true, // driver-injected globals — see DISPOSITIVE_FLOOR
|
|
634
711
|
reason: `CDP automation detected: ${signalList.join(', ')}`
|
|
635
712
|
});
|
|
636
713
|
} else if (signalCount >= 2) {
|
|
@@ -664,7 +741,7 @@ function detectBehavioral(signals) {
|
|
|
664
741
|
const trajectory = b.trajectoryLength ?? 0;
|
|
665
742
|
const touchEvts = b.touchEvents ?? 0;
|
|
666
743
|
const keyEvts = b.keyEvents ?? 0;
|
|
667
|
-
const isTouchUsr =
|
|
744
|
+
const isTouchUsr = isTouchModality(b);
|
|
668
745
|
const isKbdUser = keyEvts >= 2 && totalPoints === 0;
|
|
669
746
|
|
|
670
747
|
if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
|
|
@@ -681,7 +758,7 @@ function detectBehavioral(signals) {
|
|
|
681
758
|
|
|
682
759
|
// Velocity variance
|
|
683
760
|
const velVar = b.velocityVariance ?? 1;
|
|
684
|
-
if (velVar < 0.02 && trajectory > 50) {
|
|
761
|
+
if (velVar < 0.02 && trajectory > 50 && !hasHumanMovementMarkers(b)) {
|
|
685
762
|
detections.push({
|
|
686
763
|
category: 'behavioral', score: 0.6, confidence: 0.6,
|
|
687
764
|
reason: 'Mouse velocity too consistent'
|
|
@@ -727,7 +804,7 @@ function detectBehavioral(signals) {
|
|
|
727
804
|
category: 'behavioral', score: 0.6, confidence: 0.5,
|
|
728
805
|
reason: 'Mouse event rate abnormally high'
|
|
729
806
|
});
|
|
730
|
-
} else if (eventRate > 0 && eventRate < 10) {
|
|
807
|
+
} else if (eventRate > 0 && eventRate < 10 && !hasHumanMovementMarkers(b)) {
|
|
731
808
|
detections.push({
|
|
732
809
|
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
733
810
|
reason: 'Mouse event rate abnormally low'
|
|
@@ -953,6 +1030,36 @@ function detectRateAbuse(ip, siteKey) {
|
|
|
953
1030
|
// Scoring
|
|
954
1031
|
// =============================================================================
|
|
955
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Combines the detections within each category into a category score.
|
|
1035
|
+
*
|
|
1036
|
+
* ## Why this is not a mean
|
|
1037
|
+
*
|
|
1038
|
+
* It used to be a confidence-weighted mean, which had a property nobody
|
|
1039
|
+
* intended: corroborating evidence *lowered* the verdict. A visitor whose
|
|
1040
|
+
* browser reported `navigator.webdriver === true` and nothing else scored 0.95
|
|
1041
|
+
* in the headless category. The same visitor, additionally caught with no
|
|
1042
|
+
* plugins, a software renderer, a viewport equal to its window and three more
|
|
1043
|
+
* automation tells, scored 0.686 — because each additional signal, being
|
|
1044
|
+
* individually weaker than the first, pulled the average down. Seven pieces of
|
|
1045
|
+
* corroboration made the case weaker than one.
|
|
1046
|
+
*
|
|
1047
|
+
* Noisy-OR fixes that. Each detection is treated as independent evidence of
|
|
1048
|
+
* strength `score × confidence`, and the category is the probability that at
|
|
1049
|
+
* least one of them is right:
|
|
1050
|
+
*
|
|
1051
|
+
* category = 1 - ∏(1 - scoreᵢ × confidenceᵢ)
|
|
1052
|
+
*
|
|
1053
|
+
* Evidence now accumulates: adding a signal can only raise a category, never
|
|
1054
|
+
* lower it. And it is *more* forgiving of isolated weak evidence than the mean
|
|
1055
|
+
* was — one detection at score 0.4, confidence 0.5 contributes 0.20 rather than
|
|
1056
|
+
* setting the whole category to 0.40 — which is the right treatment for a
|
|
1057
|
+
* lone low-confidence hit on a real user.
|
|
1058
|
+
*
|
|
1059
|
+
* Measured on the bench corpus (bench/tools/compare-aggregation.js): human
|
|
1060
|
+
* median 0.182 → 0.097 and human max 0.260 → 0.171, while agent median rose
|
|
1061
|
+
* 0.517 → 0.570. Both populations moved in the direction they should.
|
|
1062
|
+
*/
|
|
956
1063
|
function calculateCategoryScores(detections) {
|
|
957
1064
|
const categoryData = {};
|
|
958
1065
|
|
|
@@ -966,11 +1073,12 @@ function calculateCategoryScores(detections) {
|
|
|
966
1073
|
const result = {};
|
|
967
1074
|
for (const [cat, scores] of Object.entries(categoryData)) {
|
|
968
1075
|
if (scores.length > 0) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const
|
|
972
|
-
|
|
1076
|
+
let survives = 1;
|
|
1077
|
+
for (const [score, conf] of scores) {
|
|
1078
|
+
const strength = Math.max(0, Math.min(1, score * conf));
|
|
1079
|
+
survives *= 1 - strength;
|
|
973
1080
|
}
|
|
1081
|
+
result[cat] = Math.min(1.0, 1 - survives);
|
|
974
1082
|
}
|
|
975
1083
|
}
|
|
976
1084
|
|
|
@@ -992,6 +1100,57 @@ function calculateFinalScore(categoryScores) {
|
|
|
992
1100
|
return Math.min(1.0, total);
|
|
993
1101
|
}
|
|
994
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* Score below which a self-declared automated browser cannot fall.
|
|
1105
|
+
*
|
|
1106
|
+
* ## Why a floor exists at all
|
|
1107
|
+
*
|
|
1108
|
+
* The final score is a weighted sum across all eleven categories, so a category
|
|
1109
|
+
* can contribute at most its own weight no matter how certain it is. A local
|
|
1110
|
+
* automated browser trips at most the six categories reachable without a
|
|
1111
|
+
* datacenter IP, a reused fingerprint or a rate-limit hit — about 0.81 of the
|
|
1112
|
+
* weight — and in practice lands near 0.5. The bench measured exactly that: a
|
|
1113
|
+
* Playwright browser reporting `navigator.webdriver === true`, no plugins, a
|
|
1114
|
+
* software renderer and four more automation tells scored 0.549, i.e.
|
|
1115
|
+
* "challenge", not "block".
|
|
1116
|
+
*
|
|
1117
|
+
* That is the weighted sum working as designed. It expresses "what fraction of
|
|
1118
|
+
* the total suspicion budget did this visitor consume", and no single fact can
|
|
1119
|
+
* consume most of that budget. The trouble is that some facts are not
|
|
1120
|
+
* probabilistic evidence at all — they are the browser saying so.
|
|
1121
|
+
*
|
|
1122
|
+
* ## What qualifies
|
|
1123
|
+
*
|
|
1124
|
+
* Only detections marked `dispositive`, and the bar for that mark is that a
|
|
1125
|
+
* browser cannot produce the signal without being automated:
|
|
1126
|
+
*
|
|
1127
|
+
* - `navigator.webdriver === true` — a W3C-specified flag whose sole purpose
|
|
1128
|
+
* is to tell the page it is under automation.
|
|
1129
|
+
* - ChromeDriver / Puppeteer injected globals (`chromedriver_cdc`,
|
|
1130
|
+
* `puppeteer_eval`, `cdp_script_injection`), which exist in no ordinary
|
|
1131
|
+
* browsing session.
|
|
1132
|
+
*
|
|
1133
|
+
* Deliberately excluded, though both look tempting: the "console consumer
|
|
1134
|
+
* attached" CDP check, because the bench human panel proves it fires on a
|
|
1135
|
+
* developer with DevTools open; and the Playwright `webdriver_configurable`
|
|
1136
|
+
* artifact, which relies on a property-descriptor detail no specification
|
|
1137
|
+
* guarantees.
|
|
1138
|
+
*
|
|
1139
|
+
* ## What this does not do
|
|
1140
|
+
*
|
|
1141
|
+
* It does not catch a stealth agent, which patches `navigator.webdriver` before
|
|
1142
|
+
* the page ever sees it. That is not a regression — such an agent scores the
|
|
1143
|
+
* same as it did before — and it is the reason the behavioural workstreams
|
|
1144
|
+
* still matter. The floor closes the case where an agent is not even trying to
|
|
1145
|
+
* hide, which was previously being waved through with a "challenge".
|
|
1146
|
+
*/
|
|
1147
|
+
const DISPOSITIVE_FLOOR = 0.9;
|
|
1148
|
+
|
|
1149
|
+
function applyDispositiveFloor(score, detections) {
|
|
1150
|
+
const selfDeclared = detections.some((d) => d.dispositive === true);
|
|
1151
|
+
return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
995
1154
|
function generateToken(ip, siteKey, score) {
|
|
996
1155
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
997
1156
|
const data = {
|
|
@@ -1070,7 +1229,7 @@ async function verifyWebBotAuth(req) {
|
|
|
1070
1229
|
// require the raw request — currently Web Bot Auth signature verification, which
|
|
1071
1230
|
// needs the accurately-reconstructed signed request. They are seeded into the
|
|
1072
1231
|
// detection set so they participate in scoring like any other detection.
|
|
1073
|
-
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = []) {
|
|
1232
|
+
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
|
|
1074
1233
|
const detections = Array.isArray(preDetections) ? [...preDetections] : [];
|
|
1075
1234
|
|
|
1076
1235
|
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
@@ -1161,6 +1320,9 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1161
1320
|
...detectAutomation(signals),
|
|
1162
1321
|
...detectCDP(signals),
|
|
1163
1322
|
...detectBehavioral(signals),
|
|
1323
|
+
// Input forensics v2 (PRD workstream C): typing cadence and modality, the
|
|
1324
|
+
// paste-shortcut/platform contradiction, scroll morphology, font coherence.
|
|
1325
|
+
...detectInputForensics(signals),
|
|
1164
1326
|
...detectTouchAuthenticity(signals, userAgent),
|
|
1165
1327
|
...detectSensorEntropy(signals, userAgent),
|
|
1166
1328
|
...detectTouchKinematics(signals),
|
|
@@ -1179,7 +1341,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1179
1341
|
}
|
|
1180
1342
|
|
|
1181
1343
|
// Add header analysis
|
|
1182
|
-
const headerDetections = detection.analyzeHeaders(headers);
|
|
1344
|
+
const headerDetections = detection.analyzeHeaders(headers, { peerTrusted: opts.peerTrusted === true });
|
|
1183
1345
|
detections.push(...headerDetections);
|
|
1184
1346
|
|
|
1185
1347
|
// Add browser consistency checks
|
|
@@ -1206,7 +1368,11 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1206
1368
|
// Add form interaction analysis (credential stuffing & spam detection)
|
|
1207
1369
|
const formAnalysis = signals.formAnalysis;
|
|
1208
1370
|
if (formAnalysis) {
|
|
1209
|
-
|
|
1371
|
+
// A visitor who moved a pointer or touched the screen has shown they are
|
|
1372
|
+
// there; that changes how a paste-only form fill should be read.
|
|
1373
|
+
const beh = signals.behavioral || {};
|
|
1374
|
+
const humanPresent = (beh.totalPoints ?? 0) >= 5 || (beh.touchEvents ?? 0) >= 1;
|
|
1375
|
+
const formDetections = detection.analyzeFormInteraction(formAnalysis, { humanPresent });
|
|
1210
1376
|
detections.push(...formDetections);
|
|
1211
1377
|
}
|
|
1212
1378
|
|
|
@@ -1215,7 +1381,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1215
1381
|
detections.push(...advancedDetections);
|
|
1216
1382
|
|
|
1217
1383
|
const categoryScores = calculateCategoryScores(detections);
|
|
1218
|
-
const finalScore = calculateFinalScore(categoryScores);
|
|
1384
|
+
const finalScore = applyDispositiveFloor(calculateFinalScore(categoryScores), detections);
|
|
1219
1385
|
|
|
1220
1386
|
let recommendation;
|
|
1221
1387
|
if (finalScore < 0.3) recommendation = 'allow';
|
|
@@ -1271,11 +1437,12 @@ app.post('/api/verify', async (req, res) => {
|
|
|
1271
1437
|
|
|
1272
1438
|
// Collect headers for analysis
|
|
1273
1439
|
const headers = collectHeaders(req);
|
|
1440
|
+
const peerTrusted = PROXY_TRUST.peerTrusted(req);
|
|
1274
1441
|
|
|
1275
1442
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1276
1443
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1277
1444
|
|
|
1278
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
|
|
1445
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1279
1446
|
logVerdict('verify', siteKey, result);
|
|
1280
1447
|
res.json(result);
|
|
1281
1448
|
});
|
|
@@ -1287,15 +1454,16 @@ app.post('/api/score', async (req, res) => {
|
|
|
1287
1454
|
const userAgent = req.headers['user-agent'] || '';
|
|
1288
1455
|
const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
|
|
1289
1456
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1457
|
+
// Use collectHeaders, not a bare copy of req.headers: this endpoint used to
|
|
1458
|
+
// build its own map, which skipped the trust gate on the TLS-fingerprint
|
|
1459
|
+
// header names and let an untrusted client present its own JA4.
|
|
1460
|
+
const headers = collectHeaders(req);
|
|
1461
|
+
const peerTrusted = PROXY_TRUST.peerTrusted(req);
|
|
1294
1462
|
|
|
1295
1463
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1296
1464
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1297
1465
|
|
|
1298
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
|
|
1466
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1299
1467
|
logVerdict('score', siteKey, result);
|
|
1300
1468
|
res.json({
|
|
1301
1469
|
success: result.success,
|