@webdecoy/fcaptcha 1.17.0 → 1.18.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 CHANGED
@@ -97,12 +97,28 @@ async function checkIPReputation(ip) {
97
97
  // HTTP Header Analysis
98
98
  // =============================================================================
99
99
 
100
- const SUSPICIOUS_HEADERS = new Set([
101
- 'x-requested-with', 'x-forwarded-for', 'x-real-ip', 'via',
102
- 'forwarded', 'x-originating-ip', 'cf-connecting-ip',
103
- 'true-client-ip', 'x-cluster-client-ip'
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
- function analyzeHeaders(headers) {
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
- if (SUSPICIOUS_HEADERS.has(header)) {
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,
@@ -660,9 +688,17 @@ function analyzeKeystrokeCadence(stats) {
660
688
  // Form Interaction Analysis (Credential Stuffing & Spam Detection)
661
689
  // =============================================================================
662
690
 
663
- function analyzeFormInteraction(formAnalysis) {
691
+ /**
692
+ * @param {object} formAnalysis
693
+ * @param {object} [opts]
694
+ * @param {boolean} [opts.humanPresent] whether the visitor independently showed
695
+ * they were there — real pointer or touch movement. Defaults to false so a
696
+ * caller that does not know keeps the stricter behaviour.
697
+ */
698
+ function analyzeFormInteraction(formAnalysis, opts = {}) {
664
699
  if (!formAnalysis) return [];
665
700
 
701
+ const humanPresent = opts.humanPresent === true;
666
702
  const detections = [];
667
703
  const submit = formAnalysis.submit || {};
668
704
 
@@ -733,8 +769,18 @@ function analyzeFormInteraction(formAnalysis) {
733
769
  });
734
770
  }
735
771
 
736
- // Check for paste-heavy input (spam bots often paste content)
737
- if (stats.pasteCount > 0 && stats.keyCount < 5) {
772
+ // Check for paste-heavy input (spam bots often paste content).
773
+ //
774
+ // Standing down when the visitor independently demonstrated they were
775
+ // present. Pasting is not a bot behaviour — people paste addresses, error
776
+ // messages, snippets and one-time codes constantly, and a form filled by
777
+ // paste alone is an ordinary afternoon. The bench human panel caught this
778
+ // firing on 100% of the paste-by-human persona.
779
+ //
780
+ // What actually distinguishes the spam bot is that pasting is ALL it did:
781
+ // no pointer trajectory, no touch, nothing but the content appearing. So
782
+ // the check now needs the paste AND the absence of a person.
783
+ if (stats.pasteCount > 0 && stats.keyCount < 5 && !humanPresent) {
738
784
  detections.push({
739
785
  category: 'bot',
740
786
  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.17.0",
3
+ "version": "1.18.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 = touchEvents >= 3;
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
- if (microTremor < 0.15) {
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
- if ((b.approachDirectness ?? 0) > 0.95) {
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 = touchEvts >= 3;
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
- const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
970
- if (totalWeight > 0) {
971
- const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
972
- result[cat] = Math.min(1.0, weightedSum / totalWeight);
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
- const formDetections = detection.analyzeFormInteraction(formAnalysis);
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
- const headers = {};
1291
- for (const [key, value] of Object.entries(req.headers)) {
1292
- headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
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,