@webdecoy/fcaptcha 1.0.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 +442 -0
- package/index.js +799 -0
- package/package.json +51 -0
- package/server.js +842 -0
package/index.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @webdecoy/fcaptcha - Open source CAPTCHA with PoW, bot detection, and Vision AI protection
|
|
3
|
+
*
|
|
4
|
+
* Main entry point for library usage.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const fcaptcha = require('@webdecoy/fcaptcha');
|
|
8
|
+
* const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
|
|
9
|
+
* const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const detection = require('./detection');
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// PoW Challenge Store (can be extended with Redis)
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
class PoWChallengeStore {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.secret = options.secret || 'dev-secret-change-in-production';
|
|
22
|
+
this.challenges = new Map();
|
|
23
|
+
this.usedSolutions = new Set();
|
|
24
|
+
this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
generate(siteKey, ip, difficulty = 4) {
|
|
28
|
+
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
29
|
+
const timestamp = Date.now();
|
|
30
|
+
const expiresAt = timestamp + this.expirationMs;
|
|
31
|
+
|
|
32
|
+
const challengeData = {
|
|
33
|
+
id: challengeId,
|
|
34
|
+
siteKey,
|
|
35
|
+
timestamp,
|
|
36
|
+
expiresAt,
|
|
37
|
+
difficulty,
|
|
38
|
+
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Sign the challenge
|
|
42
|
+
const sig = crypto.createHmac('sha256', this.secret)
|
|
43
|
+
.update(JSON.stringify(challengeData))
|
|
44
|
+
.digest('hex')
|
|
45
|
+
.slice(0, 16);
|
|
46
|
+
|
|
47
|
+
challengeData.sig = sig;
|
|
48
|
+
|
|
49
|
+
// Store challenge
|
|
50
|
+
this.challenges.set(challengeId, {
|
|
51
|
+
...challengeData,
|
|
52
|
+
ip,
|
|
53
|
+
createdAt: timestamp
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Periodic cleanup
|
|
57
|
+
if (Math.random() < 0.1) this._cleanup();
|
|
58
|
+
|
|
59
|
+
return challengeData;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
verify(challengeId, nonce, hash, siteKey) {
|
|
63
|
+
const challenge = this.challenges.get(challengeId);
|
|
64
|
+
|
|
65
|
+
if (!challenge) {
|
|
66
|
+
return { valid: false, reason: 'challenge_not_found' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Date.now() > challenge.expiresAt) {
|
|
70
|
+
this.challenges.delete(challengeId);
|
|
71
|
+
return { valid: false, reason: 'challenge_expired' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (challenge.siteKey !== siteKey) {
|
|
75
|
+
return { valid: false, reason: 'site_key_mismatch' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check for replay
|
|
79
|
+
const solutionKey = `${challengeId}:${nonce}`;
|
|
80
|
+
if (this.usedSolutions.has(solutionKey)) {
|
|
81
|
+
return { valid: false, reason: 'solution_already_used' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Verify the hash
|
|
85
|
+
const input = `${challenge.prefix}:${nonce}`;
|
|
86
|
+
const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
|
|
87
|
+
|
|
88
|
+
if (hash !== expectedHash) {
|
|
89
|
+
return { valid: false, reason: 'invalid_hash' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check difficulty
|
|
93
|
+
const target = '0'.repeat(challenge.difficulty);
|
|
94
|
+
if (!hash.startsWith(target)) {
|
|
95
|
+
return { valid: false, reason: 'insufficient_difficulty' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Mark as used
|
|
99
|
+
this.usedSolutions.add(solutionKey);
|
|
100
|
+
this.challenges.delete(challengeId);
|
|
101
|
+
|
|
102
|
+
return { valid: true, difficulty: challenge.difficulty };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_cleanup() {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
for (const [id, challenge] of this.challenges) {
|
|
108
|
+
if (now > challenge.expiresAt) {
|
|
109
|
+
this.challenges.delete(id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (this.usedSolutions.size > 10000) {
|
|
113
|
+
this.usedSolutions.clear();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// Rate Limiter
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
class RateLimiter {
|
|
123
|
+
constructor() {
|
|
124
|
+
this.requests = new Map();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
check(key, windowSeconds = 60, maxRequests = 10) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const cutoff = now - (windowSeconds * 1000);
|
|
130
|
+
|
|
131
|
+
let timestamps = this.requests.get(key) || [];
|
|
132
|
+
timestamps = timestamps.filter(t => t > cutoff);
|
|
133
|
+
|
|
134
|
+
const count = timestamps.length;
|
|
135
|
+
if (count >= maxRequests) {
|
|
136
|
+
return [true, count];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
timestamps.push(now);
|
|
140
|
+
this.requests.set(key, timestamps);
|
|
141
|
+
return [false, count + 1];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Fingerprint Store
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
class FingerprintStore {
|
|
150
|
+
constructor() {
|
|
151
|
+
this.fingerprints = new Map();
|
|
152
|
+
this.ipFingerprints = new Map();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
record(fp, ip, siteKey) {
|
|
156
|
+
const key = `${siteKey}:${fp}`;
|
|
157
|
+
|
|
158
|
+
if (!this.fingerprints.has(key)) {
|
|
159
|
+
this.fingerprints.set(key, { count: 0, ips: new Set() });
|
|
160
|
+
}
|
|
161
|
+
const data = this.fingerprints.get(key);
|
|
162
|
+
data.count++;
|
|
163
|
+
data.ips.add(ip);
|
|
164
|
+
|
|
165
|
+
if (!this.ipFingerprints.has(ip)) {
|
|
166
|
+
this.ipFingerprints.set(ip, new Set());
|
|
167
|
+
}
|
|
168
|
+
this.ipFingerprints.get(ip).add(fp);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getIpFpCount(ip) {
|
|
172
|
+
return this.ipFingerprints.get(ip)?.size || 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getFpIpCount(fp, siteKey) {
|
|
176
|
+
const key = `${siteKey}:${fp}`;
|
|
177
|
+
return this.fingerprints.get(key)?.ips.size || 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// Scoring Engine
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
const WEIGHTS = {
|
|
186
|
+
vision_ai: 0.15,
|
|
187
|
+
headless: 0.15,
|
|
188
|
+
automation: 0.10,
|
|
189
|
+
behavioral: 0.20,
|
|
190
|
+
fingerprint: 0.10,
|
|
191
|
+
rate_limit: 0.05,
|
|
192
|
+
datacenter: 0.10,
|
|
193
|
+
tor_vpn: 0.05,
|
|
194
|
+
bot: 0.10
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const AUTOMATION_UA_PATTERNS = [
|
|
198
|
+
/headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
|
|
199
|
+
/puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
|
|
200
|
+
/zombie/i, /electron/i, /chromium.*headless/i
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
class ScoringEngine {
|
|
204
|
+
constructor(options = {}) {
|
|
205
|
+
this.secret = options.secret || 'dev-secret-change-in-production';
|
|
206
|
+
this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
|
|
207
|
+
this.rateLimiter = options.rateLimiter || new RateLimiter();
|
|
208
|
+
this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
|
|
209
|
+
this.weights = options.weights || WEIGHTS;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Generate a PoW challenge
|
|
213
|
+
generateChallenge(siteKey, ip, options = {}) {
|
|
214
|
+
let difficulty = options.difficulty || 4;
|
|
215
|
+
|
|
216
|
+
if (options.scaleByReputation !== false) {
|
|
217
|
+
if (detection.isDatacenterIP(ip)) {
|
|
218
|
+
difficulty = Math.max(difficulty, 5);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const rateKey = `pow:${siteKey}:${ip}`;
|
|
222
|
+
const [exceeded, count] = this.rateLimiter.check(rateKey, 60, 20);
|
|
223
|
+
if (count > 10) {
|
|
224
|
+
difficulty = Math.min(6, difficulty + 1);
|
|
225
|
+
}
|
|
226
|
+
if (exceeded) {
|
|
227
|
+
difficulty = 6;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return this.powStore.generate(siteKey, ip, difficulty);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Verify signals and return score
|
|
235
|
+
verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
|
|
236
|
+
const detections = [];
|
|
237
|
+
|
|
238
|
+
// Run all detection modules
|
|
239
|
+
detections.push(...this._detectVisionAI(signals));
|
|
240
|
+
detections.push(...this._detectHeadless(signals, userAgent));
|
|
241
|
+
detections.push(...this._detectAutomation(signals));
|
|
242
|
+
detections.push(...this._detectBehavioral(signals));
|
|
243
|
+
detections.push(...this._detectFingerprint(signals, ip, siteKey));
|
|
244
|
+
detections.push(...this._detectRateAbuse(ip, siteKey));
|
|
245
|
+
|
|
246
|
+
// Verify PoW
|
|
247
|
+
if (powSolution && powSolution.challengeId) {
|
|
248
|
+
const powResult = this.powStore.verify(
|
|
249
|
+
powSolution.challengeId,
|
|
250
|
+
powSolution.nonce,
|
|
251
|
+
powSolution.hash,
|
|
252
|
+
siteKey
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!powResult.valid) {
|
|
256
|
+
detections.push({
|
|
257
|
+
category: 'bot',
|
|
258
|
+
score: 0.7,
|
|
259
|
+
confidence: 0.8,
|
|
260
|
+
reason: `PoW verification failed: ${powResult.reason}`
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
detections.push({
|
|
265
|
+
category: 'bot',
|
|
266
|
+
score: 0.5,
|
|
267
|
+
confidence: 0.6,
|
|
268
|
+
reason: 'No PoW solution provided'
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// IP reputation
|
|
273
|
+
if (detection.isDatacenterIP(ip)) {
|
|
274
|
+
detections.push({
|
|
275
|
+
category: 'datacenter',
|
|
276
|
+
score: 0.6,
|
|
277
|
+
confidence: 0.8,
|
|
278
|
+
reason: 'Request from known datacenter IP range'
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Header analysis
|
|
283
|
+
detections.push(...detection.analyzeHeaders(headers));
|
|
284
|
+
|
|
285
|
+
// Browser consistency
|
|
286
|
+
detections.push(...detection.checkBrowserConsistency(userAgent, signals));
|
|
287
|
+
|
|
288
|
+
// JA3 fingerprint
|
|
289
|
+
if (headers['x-ja3-hash']) {
|
|
290
|
+
detections.push(...detection.checkJA3Fingerprint(headers['x-ja3-hash']));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Form interaction
|
|
294
|
+
if (signals.formAnalysis) {
|
|
295
|
+
detections.push(...detection.analyzeFormInteraction(signals.formAnalysis));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Calculate scores
|
|
299
|
+
const categoryScores = this._calculateCategoryScores(detections);
|
|
300
|
+
const finalScore = this._calculateFinalScore(categoryScores);
|
|
301
|
+
|
|
302
|
+
let recommendation;
|
|
303
|
+
if (finalScore < 0.3) recommendation = 'allow';
|
|
304
|
+
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
305
|
+
else recommendation = 'block';
|
|
306
|
+
|
|
307
|
+
const success = finalScore < 0.5;
|
|
308
|
+
const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
success,
|
|
312
|
+
score: finalScore,
|
|
313
|
+
token,
|
|
314
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
315
|
+
recommendation,
|
|
316
|
+
categoryScores,
|
|
317
|
+
detections
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Verify a previously issued token
|
|
322
|
+
verifyToken(token) {
|
|
323
|
+
try {
|
|
324
|
+
const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
|
|
325
|
+
|
|
326
|
+
if (Date.now() / 1000 - decoded.timestamp > 300) {
|
|
327
|
+
return { valid: false, reason: 'expired' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const sig = decoded.sig;
|
|
331
|
+
delete decoded.sig;
|
|
332
|
+
|
|
333
|
+
const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
|
|
334
|
+
const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
|
|
335
|
+
|
|
336
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
337
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
valid: true,
|
|
342
|
+
site_key: decoded.site_key,
|
|
343
|
+
timestamp: decoded.timestamp,
|
|
344
|
+
score: decoded.score
|
|
345
|
+
};
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return { valid: false, reason: e.message };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Internal detection methods
|
|
352
|
+
_getNestedValue(obj, ...keys) {
|
|
353
|
+
return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_detectVisionAI(signals) {
|
|
357
|
+
const detections = [];
|
|
358
|
+
const b = signals.behavioral || {};
|
|
359
|
+
const t = signals.temporal || {};
|
|
360
|
+
|
|
361
|
+
const pow = t.pow || {};
|
|
362
|
+
if (pow.duration && pow.iterations) {
|
|
363
|
+
const expectedMin = (pow.iterations / 500000) * 1000;
|
|
364
|
+
const expectedMax = (pow.iterations / 50000) * 1000;
|
|
365
|
+
|
|
366
|
+
if (pow.duration < expectedMin * 0.5) {
|
|
367
|
+
detections.push({
|
|
368
|
+
category: 'vision_ai', score: 0.8, confidence: 0.7,
|
|
369
|
+
reason: 'PoW completed impossibly fast'
|
|
370
|
+
});
|
|
371
|
+
} else if (pow.duration > expectedMax * 3) {
|
|
372
|
+
detections.push({
|
|
373
|
+
category: 'vision_ai', score: 0.6, confidence: 0.5,
|
|
374
|
+
reason: 'PoW timing suggests external processing'
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const microTremor = b.microTremorScore ?? 0.5;
|
|
380
|
+
if (microTremor < 0.15) {
|
|
381
|
+
detections.push({
|
|
382
|
+
category: 'vision_ai', score: 0.7, confidence: 0.6,
|
|
383
|
+
reason: 'Mouse movement lacks natural micro-tremor'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if ((b.approachDirectness ?? 0) > 0.95) {
|
|
388
|
+
detections.push({
|
|
389
|
+
category: 'vision_ai', score: 0.5, confidence: 0.5,
|
|
390
|
+
reason: 'Mouse path to target is unnaturally direct'
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const precision = b.clickPrecision ?? 10;
|
|
395
|
+
if (precision > 0 && precision < 2) {
|
|
396
|
+
detections.push({
|
|
397
|
+
category: 'vision_ai', score: 0.4, confidence: 0.5,
|
|
398
|
+
reason: 'Click precision is unnaturally accurate'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const exploration = b.explorationRatio ?? 0.3;
|
|
403
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
404
|
+
if (exploration < 0.05 && trajectory > 50) {
|
|
405
|
+
detections.push({
|
|
406
|
+
category: 'vision_ai', score: 0.4, confidence: 0.4,
|
|
407
|
+
reason: 'No exploratory mouse movement before click'
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return detections;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_detectHeadless(signals, userAgent) {
|
|
415
|
+
const detections = [];
|
|
416
|
+
const env = signals.environmental || {};
|
|
417
|
+
const headless = env.headlessIndicators || {};
|
|
418
|
+
const automation = env.automationFlags || {};
|
|
419
|
+
|
|
420
|
+
if (env.webdriver) {
|
|
421
|
+
detections.push({
|
|
422
|
+
category: 'headless', score: 0.95, confidence: 0.95,
|
|
423
|
+
reason: 'WebDriver detected'
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (automation.plugins === 0) {
|
|
428
|
+
detections.push({
|
|
429
|
+
category: 'headless', score: 0.6, confidence: 0.6,
|
|
430
|
+
reason: 'No browser plugins detected'
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (automation.languages === false) {
|
|
435
|
+
detections.push({
|
|
436
|
+
category: 'headless', score: 0.5, confidence: 0.5,
|
|
437
|
+
reason: 'No navigator.languages'
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (headless.hasOuterDimensions === false) {
|
|
442
|
+
detections.push({
|
|
443
|
+
category: 'headless', score: 0.7, confidence: 0.7,
|
|
444
|
+
reason: 'Window lacks outer dimensions'
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (headless.innerEqualsOuter === true) {
|
|
449
|
+
detections.push({
|
|
450
|
+
category: 'headless', score: 0.4, confidence: 0.5,
|
|
451
|
+
reason: 'Viewport equals window size'
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (headless.notificationPermission === 'denied') {
|
|
456
|
+
detections.push({
|
|
457
|
+
category: 'headless', score: 0.3, confidence: 0.4,
|
|
458
|
+
reason: 'Notifications pre-denied'
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const pattern of AUTOMATION_UA_PATTERNS) {
|
|
463
|
+
if (pattern.test(userAgent)) {
|
|
464
|
+
detections.push({
|
|
465
|
+
category: 'headless', score: 0.9, confidence: 0.9,
|
|
466
|
+
reason: 'Automation pattern in User-Agent'
|
|
467
|
+
});
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const renderer = (this._getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
|
|
473
|
+
if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
|
|
474
|
+
detections.push({
|
|
475
|
+
category: 'headless', score: 0.8, confidence: 0.8,
|
|
476
|
+
reason: 'Software WebGL renderer detected'
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return detections;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_detectAutomation(signals) {
|
|
484
|
+
const detections = [];
|
|
485
|
+
const env = signals.environmental || {};
|
|
486
|
+
const b = signals.behavioral || {};
|
|
487
|
+
|
|
488
|
+
const jsTime = this._getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
|
|
489
|
+
if (jsTime > 0) {
|
|
490
|
+
if (jsTime < 0.1) {
|
|
491
|
+
detections.push({
|
|
492
|
+
category: 'automation', score: 0.4, confidence: 0.3,
|
|
493
|
+
reason: 'JS execution unusually fast'
|
|
494
|
+
});
|
|
495
|
+
} else if (jsTime > 50) {
|
|
496
|
+
detections.push({
|
|
497
|
+
category: 'automation', score: 0.3, confidence: 0.3,
|
|
498
|
+
reason: 'JS execution unusually slow'
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const raf = env.rafConsistency || {};
|
|
504
|
+
if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
|
|
505
|
+
detections.push({
|
|
506
|
+
category: 'automation', score: 0.5, confidence: 0.4,
|
|
507
|
+
reason: 'RequestAnimationFrame timing too consistent'
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const eventVar = b.eventDeltaVariance ?? 10;
|
|
512
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
513
|
+
if (eventVar < 2 && totalPoints > 10) {
|
|
514
|
+
detections.push({
|
|
515
|
+
category: 'automation', score: 0.6, confidence: 0.6,
|
|
516
|
+
reason: 'Mouse event timing unnaturally consistent'
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return detections;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
_detectBehavioral(signals) {
|
|
524
|
+
const detections = [];
|
|
525
|
+
const b = signals.behavioral || {};
|
|
526
|
+
const t = signals.temporal || {};
|
|
527
|
+
|
|
528
|
+
const velVar = b.velocityVariance ?? 1;
|
|
529
|
+
const trajectory = b.trajectoryLength ?? 0;
|
|
530
|
+
if (velVar < 0.02 && trajectory > 50) {
|
|
531
|
+
detections.push({
|
|
532
|
+
category: 'behavioral', score: 0.6, confidence: 0.6,
|
|
533
|
+
reason: 'Mouse velocity too consistent'
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const overshoots = b.overshootCorrections ?? 0;
|
|
538
|
+
if (overshoots === 0 && trajectory > 200) {
|
|
539
|
+
detections.push({
|
|
540
|
+
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
541
|
+
reason: 'No overshoot corrections on long trajectory'
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const interactionTime = b.interactionDuration ?? 1000;
|
|
546
|
+
if (interactionTime > 0 && interactionTime < 200) {
|
|
547
|
+
detections.push({
|
|
548
|
+
category: 'behavioral', score: 0.7, confidence: 0.7,
|
|
549
|
+
reason: 'Interaction completed too quickly'
|
|
550
|
+
});
|
|
551
|
+
} else if (interactionTime > 60000) {
|
|
552
|
+
detections.push({
|
|
553
|
+
category: 'captcha_farm', score: 0.3, confidence: 0.3,
|
|
554
|
+
reason: 'Unusually long interaction time'
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const firstInt = t.pageLoadToFirstInteraction;
|
|
559
|
+
if (firstInt !== null && firstInt > 0 && firstInt < 100) {
|
|
560
|
+
detections.push({
|
|
561
|
+
category: 'behavioral', score: 0.5, confidence: 0.5,
|
|
562
|
+
reason: 'First interaction too soon after page load'
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const eventRate = b.mouseEventRate ?? 60;
|
|
567
|
+
if (eventRate > 200) {
|
|
568
|
+
detections.push({
|
|
569
|
+
category: 'behavioral', score: 0.6, confidence: 0.5,
|
|
570
|
+
reason: 'Mouse event rate abnormally high'
|
|
571
|
+
});
|
|
572
|
+
} else if (eventRate > 0 && eventRate < 10) {
|
|
573
|
+
detections.push({
|
|
574
|
+
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
575
|
+
reason: 'Mouse event rate abnormally low'
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const straight = b.straightLineRatio ?? 0;
|
|
580
|
+
if (straight > 0.8 && trajectory > 100) {
|
|
581
|
+
detections.push({
|
|
582
|
+
category: 'behavioral', score: 0.5, confidence: 0.5,
|
|
583
|
+
reason: 'Mouse movements too straight'
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const dirChanges = b.directionChanges ?? 10;
|
|
588
|
+
const totalPoints = b.totalPoints ?? 0;
|
|
589
|
+
if (totalPoints > 50 && dirChanges < 3) {
|
|
590
|
+
detections.push({
|
|
591
|
+
category: 'behavioral', score: 0.4, confidence: 0.4,
|
|
592
|
+
reason: 'Too few direction changes'
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return detections;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
_detectFingerprint(signals, ip, siteKey) {
|
|
600
|
+
const detections = [];
|
|
601
|
+
const env = signals.environmental || {};
|
|
602
|
+
const automation = env.automationFlags || {};
|
|
603
|
+
|
|
604
|
+
const components = [
|
|
605
|
+
String(this._getNestedValue(env, 'canvasHash', 'hash') || ''),
|
|
606
|
+
String(this._getNestedValue(env, 'webglInfo', 'renderer') || ''),
|
|
607
|
+
String(automation.platform || ''),
|
|
608
|
+
String(automation.hardwareConcurrency || '')
|
|
609
|
+
];
|
|
610
|
+
const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
|
|
611
|
+
|
|
612
|
+
this.fingerprintStore.record(fp, ip, siteKey);
|
|
613
|
+
|
|
614
|
+
const ipFpCount = this.fingerprintStore.getIpFpCount(ip);
|
|
615
|
+
if (ipFpCount > 5) {
|
|
616
|
+
detections.push({
|
|
617
|
+
category: 'fingerprint', score: 0.6, confidence: 0.6,
|
|
618
|
+
reason: 'IP has used many different fingerprints'
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const fpIpCount = this.fingerprintStore.getFpIpCount(fp, siteKey);
|
|
623
|
+
if (fpIpCount > 10) {
|
|
624
|
+
detections.push({
|
|
625
|
+
category: 'fingerprint', score: 0.5, confidence: 0.5,
|
|
626
|
+
reason: 'Fingerprint seen from many IPs'
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const canvas = env.canvasHash || {};
|
|
631
|
+
if (canvas.error || canvas.supported === false) {
|
|
632
|
+
detections.push({
|
|
633
|
+
category: 'fingerprint', score: 0.4, confidence: 0.4,
|
|
634
|
+
reason: 'Canvas fingerprinting blocked or failed'
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return detections;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
_detectRateAbuse(ip, siteKey) {
|
|
642
|
+
const detections = [];
|
|
643
|
+
const key = `${siteKey}:${ip}`;
|
|
644
|
+
|
|
645
|
+
const [exceeded, count] = this.rateLimiter.check(key, 60, 10);
|
|
646
|
+
if (exceeded) {
|
|
647
|
+
detections.push({
|
|
648
|
+
category: 'rate_limit', score: 0.8, confidence: 0.9,
|
|
649
|
+
reason: 'Rate limit exceeded'
|
|
650
|
+
});
|
|
651
|
+
} else if (count > 5) {
|
|
652
|
+
detections.push({
|
|
653
|
+
category: 'rate_limit', score: 0.3, confidence: 0.5,
|
|
654
|
+
reason: 'High request rate'
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return detections;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
_calculateCategoryScores(detections) {
|
|
662
|
+
const categoryData = {};
|
|
663
|
+
|
|
664
|
+
for (const d of detections) {
|
|
665
|
+
if (!categoryData[d.category]) {
|
|
666
|
+
categoryData[d.category] = [];
|
|
667
|
+
}
|
|
668
|
+
categoryData[d.category].push([d.score, d.confidence]);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const result = {};
|
|
672
|
+
for (const [cat, scores] of Object.entries(categoryData)) {
|
|
673
|
+
if (scores.length > 0) {
|
|
674
|
+
const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
|
|
675
|
+
if (totalWeight > 0) {
|
|
676
|
+
const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
|
|
677
|
+
result[cat] = Math.min(1.0, weightedSum / totalWeight);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
for (const cat of Object.keys(this.weights)) {
|
|
683
|
+
if (!(cat in result)) {
|
|
684
|
+
result[cat] = 0.0;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
_calculateFinalScore(categoryScores) {
|
|
692
|
+
let total = 0;
|
|
693
|
+
for (const [cat, weight] of Object.entries(this.weights)) {
|
|
694
|
+
total += (categoryScores[cat] || 0) * weight;
|
|
695
|
+
}
|
|
696
|
+
return Math.min(1.0, total);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
_generateToken(ip, siteKey, score) {
|
|
700
|
+
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
701
|
+
const data = {
|
|
702
|
+
site_key: siteKey,
|
|
703
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
704
|
+
score: Math.round(score * 1000) / 1000,
|
|
705
|
+
ip_hash: ipHash
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
709
|
+
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
|
|
710
|
+
data.sig = sig;
|
|
711
|
+
|
|
712
|
+
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// =============================================================================
|
|
717
|
+
// Express Middleware Factory
|
|
718
|
+
// =============================================================================
|
|
719
|
+
|
|
720
|
+
function createMiddleware(options = {}) {
|
|
721
|
+
const engine = new ScoringEngine(options);
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
engine,
|
|
725
|
+
|
|
726
|
+
// Middleware to extract IP from request
|
|
727
|
+
getIP: (req) => {
|
|
728
|
+
let ip = req.headers['x-real-ip'] || '';
|
|
729
|
+
if (!ip) {
|
|
730
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
731
|
+
if (forwarded) {
|
|
732
|
+
ip = forwarded.split(',')[0].trim();
|
|
733
|
+
} else {
|
|
734
|
+
ip = req.socket?.remoteAddress || '127.0.0.1';
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return ip;
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
// Challenge route handler
|
|
741
|
+
challengeHandler: (req, res) => {
|
|
742
|
+
const siteKey = req.query.siteKey || 'default';
|
|
743
|
+
const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
|
|
744
|
+
const challenge = engine.generateChallenge(siteKey, ip);
|
|
745
|
+
|
|
746
|
+
res.json({
|
|
747
|
+
challengeId: challenge.id,
|
|
748
|
+
prefix: challenge.prefix,
|
|
749
|
+
difficulty: challenge.difficulty,
|
|
750
|
+
expiresAt: challenge.expiresAt,
|
|
751
|
+
sig: challenge.sig
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
// Verify route handler
|
|
756
|
+
verifyHandler: (req, res) => {
|
|
757
|
+
const { siteKey, signals, powSolution } = req.body;
|
|
758
|
+
const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
|
|
759
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
760
|
+
|
|
761
|
+
const headers = {};
|
|
762
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
763
|
+
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
|
|
767
|
+
res.json(result);
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
// Token verify route handler
|
|
771
|
+
tokenVerifyHandler: (req, res) => {
|
|
772
|
+
const { token } = req.body;
|
|
773
|
+
res.json(engine.verifyToken(token));
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// =============================================================================
|
|
779
|
+
// Exports
|
|
780
|
+
// =============================================================================
|
|
781
|
+
|
|
782
|
+
module.exports = {
|
|
783
|
+
// Core classes
|
|
784
|
+
ScoringEngine,
|
|
785
|
+
PoWChallengeStore,
|
|
786
|
+
RateLimiter,
|
|
787
|
+
FingerprintStore,
|
|
788
|
+
|
|
789
|
+
// Detection module (re-export)
|
|
790
|
+
detection,
|
|
791
|
+
|
|
792
|
+
// Factory functions
|
|
793
|
+
createScoringEngine: (options) => new ScoringEngine(options),
|
|
794
|
+
createMiddleware,
|
|
795
|
+
|
|
796
|
+
// Constants
|
|
797
|
+
WEIGHTS,
|
|
798
|
+
AUTOMATION_UA_PATTERNS
|
|
799
|
+
};
|