@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/detection.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FCaptcha Detection Module - Additional Detection Capabilities
|
|
3
|
+
*
|
|
4
|
+
* IP Reputation, Header Analysis, Browser Consistency, TLS Fingerprinting
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const dns = require('dns').promises;
|
|
8
|
+
const net = require('net');
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Datacenter IP Ranges
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
const DATACENTER_CIDRS = [
|
|
15
|
+
// AWS
|
|
16
|
+
'3.0.0.0/8', '13.0.0.0/8', '18.0.0.0/8', '34.0.0.0/8', '35.0.0.0/8',
|
|
17
|
+
'52.0.0.0/8', '54.0.0.0/8', '99.0.0.0/8',
|
|
18
|
+
// Google Cloud
|
|
19
|
+
'34.64.0.0/10', '35.184.0.0/13', '104.154.0.0/15', '104.196.0.0/14',
|
|
20
|
+
// Azure
|
|
21
|
+
'13.64.0.0/11', '20.0.0.0/8', '40.64.0.0/10', '52.224.0.0/11',
|
|
22
|
+
// DigitalOcean
|
|
23
|
+
'64.225.0.0/16', '68.183.0.0/16', '104.131.0.0/16', '134.209.0.0/16',
|
|
24
|
+
'138.68.0.0/16', '139.59.0.0/16', '142.93.0.0/16', '157.245.0.0/16',
|
|
25
|
+
// Linode
|
|
26
|
+
'45.33.0.0/16', '45.56.0.0/16', '45.79.0.0/16', '139.162.0.0/16',
|
|
27
|
+
// Vultr
|
|
28
|
+
'45.32.0.0/16', '45.63.0.0/16', '45.76.0.0/16', '108.61.0.0/16',
|
|
29
|
+
// Hetzner
|
|
30
|
+
'5.9.0.0/16', '46.4.0.0/14', '78.46.0.0/15', '88.99.0.0/16',
|
|
31
|
+
'95.216.0.0/14', '135.181.0.0/16',
|
|
32
|
+
// OVH
|
|
33
|
+
'51.38.0.0/16', '51.68.0.0/16', '51.75.0.0/16', '137.74.0.0/16',
|
|
34
|
+
'139.99.0.0/16', '144.217.0.0/16', '149.56.0.0/16',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const VPN_PROXY_PATTERNS = [
|
|
38
|
+
/vpn/i, /proxy/i, /tor-exit/i, /exit-?node/i,
|
|
39
|
+
/anonymizer/i, /tunnel/i, /relay/i
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function ipToLong(ip) {
|
|
43
|
+
const parts = ip.split('.').map(Number);
|
|
44
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cidrContains(cidr, ip) {
|
|
48
|
+
const [range, bits] = cidr.split('/');
|
|
49
|
+
const mask = ~((1 << (32 - parseInt(bits))) - 1) >>> 0;
|
|
50
|
+
const rangeStart = ipToLong(range) & mask;
|
|
51
|
+
const ipLong = ipToLong(ip);
|
|
52
|
+
return (ipLong & mask) === rangeStart;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isDatacenterIP(ip) {
|
|
56
|
+
if (!net.isIPv4(ip)) return false;
|
|
57
|
+
return DATACENTER_CIDRS.some(cidr => cidrContains(cidr, ip));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function checkIPReputation(ip) {
|
|
61
|
+
const detections = [];
|
|
62
|
+
|
|
63
|
+
// Datacenter check
|
|
64
|
+
if (isDatacenterIP(ip)) {
|
|
65
|
+
detections.push({
|
|
66
|
+
category: 'datacenter',
|
|
67
|
+
score: 0.6,
|
|
68
|
+
confidence: 0.8,
|
|
69
|
+
reason: 'Request from known datacenter IP range'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reverse DNS check
|
|
74
|
+
try {
|
|
75
|
+
const hostnames = await dns.reverse(ip);
|
|
76
|
+
for (const hostname of hostnames) {
|
|
77
|
+
for (const pattern of VPN_PROXY_PATTERNS) {
|
|
78
|
+
if (pattern.test(hostname)) {
|
|
79
|
+
detections.push({
|
|
80
|
+
category: 'tor_vpn',
|
|
81
|
+
score: 0.5,
|
|
82
|
+
confidence: 0.6,
|
|
83
|
+
reason: `Reverse DNS suggests VPN/proxy: ${hostname}`
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// DNS lookup failed, not suspicious
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return detections;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// HTTP Header Analysis
|
|
98
|
+
// =============================================================================
|
|
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'
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const EXPECTED_BROWSER_HEADERS = new Set([
|
|
107
|
+
'accept', 'accept-language', 'accept-encoding', 'user-agent'
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function analyzeHeaders(headers) {
|
|
111
|
+
const detections = [];
|
|
112
|
+
const headersLower = {};
|
|
113
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
114
|
+
headersLower[key.toLowerCase()] = value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check for missing expected headers
|
|
118
|
+
let missingCount = 0;
|
|
119
|
+
for (const header of EXPECTED_BROWSER_HEADERS) {
|
|
120
|
+
if (!(header in headersLower)) {
|
|
121
|
+
missingCount++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (missingCount > 1) {
|
|
125
|
+
detections.push({
|
|
126
|
+
category: 'bot',
|
|
127
|
+
score: 0.4,
|
|
128
|
+
confidence: 0.5,
|
|
129
|
+
reason: `Missing ${missingCount} expected browser headers`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for suspicious headers
|
|
134
|
+
for (const header of Object.keys(headersLower)) {
|
|
135
|
+
if (SUSPICIOUS_HEADERS.has(header)) {
|
|
136
|
+
detections.push({
|
|
137
|
+
category: 'bot',
|
|
138
|
+
score: 0.3,
|
|
139
|
+
confidence: 0.4,
|
|
140
|
+
reason: `Suspicious header present: ${header}`
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check Accept-Language
|
|
146
|
+
const acceptLang = headersLower['accept-language'] || '';
|
|
147
|
+
if (acceptLang === '' || acceptLang === '*') {
|
|
148
|
+
detections.push({
|
|
149
|
+
category: 'bot',
|
|
150
|
+
score: 0.3,
|
|
151
|
+
confidence: 0.4,
|
|
152
|
+
reason: 'Invalid Accept-Language header'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check Accept-Encoding
|
|
157
|
+
const acceptEnc = headersLower['accept-encoding'] || '';
|
|
158
|
+
if (acceptEnc && !acceptEnc.includes('gzip') && !acceptEnc.includes('deflate')) {
|
|
159
|
+
detections.push({
|
|
160
|
+
category: 'bot',
|
|
161
|
+
score: 0.2,
|
|
162
|
+
confidence: 0.3,
|
|
163
|
+
reason: 'Unusual Accept-Encoding'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return detections;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// =============================================================================
|
|
171
|
+
// Browser Consistency Checks
|
|
172
|
+
// =============================================================================
|
|
173
|
+
|
|
174
|
+
const BOT_UA_PATTERNS = [
|
|
175
|
+
/bot/i, /spider/i, /crawler/i, /scraper/i, /curl/i, /wget/i,
|
|
176
|
+
/python/i, /java\//i, /httpie/i, /postman/i, /insomnia/i,
|
|
177
|
+
/axios/i, /node-fetch/i, /go-http/i, /okhttp/i
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
function parseUserAgent(ua) {
|
|
181
|
+
const info = { browser: null, os: null, isMobile: false, isBot: false, botName: null };
|
|
182
|
+
|
|
183
|
+
// Check for bots
|
|
184
|
+
for (const pattern of BOT_UA_PATTERNS) {
|
|
185
|
+
const match = ua.match(pattern);
|
|
186
|
+
if (match) {
|
|
187
|
+
info.isBot = true;
|
|
188
|
+
info.botName = match[0];
|
|
189
|
+
return info;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Detect browser
|
|
194
|
+
if (ua.includes('Edg/')) info.browser = 'Edge';
|
|
195
|
+
else if (ua.includes('Chrome/')) info.browser = 'Chrome';
|
|
196
|
+
else if (ua.includes('Firefox/')) info.browser = 'Firefox';
|
|
197
|
+
else if (ua.includes('Safari/') && !ua.includes('Chrome')) info.browser = 'Safari';
|
|
198
|
+
|
|
199
|
+
// Detect OS
|
|
200
|
+
if (ua.includes('Windows')) info.os = 'Windows';
|
|
201
|
+
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) info.os = 'macOS';
|
|
202
|
+
else if (ua.includes('Linux')) info.os = 'Linux';
|
|
203
|
+
else if (ua.includes('Android')) { info.os = 'Android'; info.isMobile = true; }
|
|
204
|
+
else if (ua.includes('iPhone') || ua.includes('iPad')) { info.os = 'iOS'; info.isMobile = true; }
|
|
205
|
+
|
|
206
|
+
if (ua.includes('Mobile')) info.isMobile = true;
|
|
207
|
+
|
|
208
|
+
return info;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkBrowserConsistency(ua, signals) {
|
|
212
|
+
const detections = [];
|
|
213
|
+
const uaInfo = parseUserAgent(ua);
|
|
214
|
+
|
|
215
|
+
// If UA is a known bot
|
|
216
|
+
if (uaInfo.isBot) {
|
|
217
|
+
detections.push({
|
|
218
|
+
category: 'bot',
|
|
219
|
+
score: 0.9,
|
|
220
|
+
confidence: 0.95,
|
|
221
|
+
reason: `User-Agent indicates bot: ${uaInfo.botName}`
|
|
222
|
+
});
|
|
223
|
+
return detections;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const env = signals.environmental || {};
|
|
227
|
+
const nav = env.navigator || {};
|
|
228
|
+
const automation = env.automationFlags || {};
|
|
229
|
+
const platform = nav.platform || automation.platform || '';
|
|
230
|
+
|
|
231
|
+
// Check platform consistency
|
|
232
|
+
if (uaInfo.os === 'Windows' && !platform.includes('Win')) {
|
|
233
|
+
detections.push({
|
|
234
|
+
category: 'bot',
|
|
235
|
+
score: 0.6,
|
|
236
|
+
confidence: 0.7,
|
|
237
|
+
reason: `UA/platform mismatch: UA claims Windows, platform=${platform}`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (uaInfo.os === 'macOS' && !platform.includes('Mac')) {
|
|
242
|
+
detections.push({
|
|
243
|
+
category: 'bot',
|
|
244
|
+
score: 0.6,
|
|
245
|
+
confidence: 0.7,
|
|
246
|
+
reason: `UA/platform mismatch: UA claims macOS, platform=${platform}`
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (uaInfo.os === 'Linux' && !platform.includes('Linux')) {
|
|
251
|
+
detections.push({
|
|
252
|
+
category: 'bot',
|
|
253
|
+
score: 0.6,
|
|
254
|
+
confidence: 0.7,
|
|
255
|
+
reason: `UA/platform mismatch: UA claims Linux, platform=${platform}`
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check mobile consistency
|
|
260
|
+
const maxTouch = nav.maxTouchPoints || automation.maxTouchPoints || 0;
|
|
261
|
+
if (uaInfo.isMobile && maxTouch === 0) {
|
|
262
|
+
detections.push({
|
|
263
|
+
category: 'bot',
|
|
264
|
+
score: 0.5,
|
|
265
|
+
confidence: 0.6,
|
|
266
|
+
reason: 'UA claims mobile but no touch support'
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check Chrome-specific properties
|
|
271
|
+
if (uaInfo.browser === 'Chrome' && !automation.chrome) {
|
|
272
|
+
detections.push({
|
|
273
|
+
category: 'bot',
|
|
274
|
+
score: 0.7,
|
|
275
|
+
confidence: 0.8,
|
|
276
|
+
reason: 'UA claims Chrome but window.chrome missing'
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return detections;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// TLS Fingerprinting (JA3)
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
const KNOWN_BOT_JA3_HASHES = {
|
|
288
|
+
'3b5074b1b5d032e5620f69f9f700ff0e': 'Python requests',
|
|
289
|
+
'b32309a26951912be7dba376398abc3b': 'Python urllib',
|
|
290
|
+
'9e10692f1b7f78228b2d4e424db3a98c': 'Go net/http',
|
|
291
|
+
'473cd7cb9faa642487833865d516e578': 'curl',
|
|
292
|
+
'c12f54a3f91dc7bafd92cb59fe009a35': 'Wget',
|
|
293
|
+
'2d1eb5817ece335c24904f516ad5da2f': 'Java HttpClient',
|
|
294
|
+
'fc54fe03db02a25e1be5bb5a7678b7a4': 'Node.js axios',
|
|
295
|
+
'579ccef312d18482fc42e2b822ca2430': 'Node.js node-fetch',
|
|
296
|
+
'5d7974c9fe7862e0f9a3eb35a6a5d9c8': 'Puppeteer default',
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function checkJA3Fingerprint(ja3Hash) {
|
|
300
|
+
if (!ja3Hash) return [];
|
|
301
|
+
|
|
302
|
+
if (KNOWN_BOT_JA3_HASHES[ja3Hash]) {
|
|
303
|
+
return [{
|
|
304
|
+
category: 'bot',
|
|
305
|
+
score: 0.8,
|
|
306
|
+
confidence: 0.9,
|
|
307
|
+
reason: `TLS fingerprint matches: ${KNOWN_BOT_JA3_HASHES[ja3Hash]}`
|
|
308
|
+
}];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// =============================================================================
|
|
315
|
+
// Form Interaction Analysis (Credential Stuffing & Spam Detection)
|
|
316
|
+
// =============================================================================
|
|
317
|
+
|
|
318
|
+
function analyzeFormInteraction(formAnalysis) {
|
|
319
|
+
if (!formAnalysis) return [];
|
|
320
|
+
|
|
321
|
+
const detections = [];
|
|
322
|
+
const submit = formAnalysis.submit || {};
|
|
323
|
+
|
|
324
|
+
// Check for programmatic form submission (credential stuffing)
|
|
325
|
+
if (submit.method === 'programmatic' || submit.method === 'programmatic_click') {
|
|
326
|
+
detections.push({
|
|
327
|
+
category: 'bot',
|
|
328
|
+
score: 0.8,
|
|
329
|
+
confidence: 0.85,
|
|
330
|
+
reason: `Form submitted programmatically (${submit.method})`
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check timing - too fast from page load to submit
|
|
335
|
+
if (submit.timeSincePageLoad !== null && submit.timeSincePageLoad < 800) {
|
|
336
|
+
detections.push({
|
|
337
|
+
category: 'bot',
|
|
338
|
+
score: 0.7,
|
|
339
|
+
confidence: 0.75,
|
|
340
|
+
reason: `Form submitted too quickly after page load (${Math.round(submit.timeSincePageLoad)}ms)`
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Check timing - too fast from page load to first interaction
|
|
345
|
+
const pageToFirst = formAnalysis.pageLoadToFirstInteraction;
|
|
346
|
+
if (pageToFirst !== null && pageToFirst < 300) {
|
|
347
|
+
detections.push({
|
|
348
|
+
category: 'bot',
|
|
349
|
+
score: 0.6,
|
|
350
|
+
confidence: 0.65,
|
|
351
|
+
reason: `First interaction too fast after page load (${Math.round(pageToFirst)}ms)`
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check for no trigger event before submit
|
|
356
|
+
if (submit.eventsBeforeSubmit === 0 && submit.method !== 'none') {
|
|
357
|
+
detections.push({
|
|
358
|
+
category: 'bot',
|
|
359
|
+
score: 0.9,
|
|
360
|
+
confidence: 0.9,
|
|
361
|
+
reason: 'Form submitted with no user interaction events'
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check for very low event count before submit
|
|
366
|
+
if (submit.eventsBeforeSubmit > 0 && submit.eventsBeforeSubmit < 3 && submit.method !== 'none') {
|
|
367
|
+
detections.push({
|
|
368
|
+
category: 'bot',
|
|
369
|
+
score: 0.5,
|
|
370
|
+
confidence: 0.6,
|
|
371
|
+
reason: `Very few events before submit (${submit.eventsBeforeSubmit})`
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Textarea keyboard analysis (spam detection)
|
|
376
|
+
const textareaData = formAnalysis.textareaKeyboard;
|
|
377
|
+
if (textareaData) {
|
|
378
|
+
for (const [fieldId, stats] of Object.entries(textareaData)) {
|
|
379
|
+
// Check for paste-heavy input (spam bots often paste content)
|
|
380
|
+
if (stats.pasteCount > 0 && stats.keyCount < 5) {
|
|
381
|
+
detections.push({
|
|
382
|
+
category: 'bot',
|
|
383
|
+
score: 0.6,
|
|
384
|
+
confidence: 0.6,
|
|
385
|
+
reason: `Textarea "${fieldId}" filled mostly by paste (${stats.pasteCount} pastes, ${stats.keyCount} keystrokes)`
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check for unnaturally consistent typing (bots have perfect timing)
|
|
390
|
+
if (stats.keyCount > 10 && stats.keyIntervalVariance < 100) {
|
|
391
|
+
detections.push({
|
|
392
|
+
category: 'bot',
|
|
393
|
+
score: 0.5,
|
|
394
|
+
confidence: 0.55,
|
|
395
|
+
reason: `Textarea "${fieldId}" has unnaturally consistent typing rhythm`
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Check for impossibly fast typing (< 50ms between keys = 1200+ WPM)
|
|
400
|
+
if (stats.keyCount > 10 && stats.avgKeyInterval > 0 && stats.avgKeyInterval < 50) {
|
|
401
|
+
detections.push({
|
|
402
|
+
category: 'bot',
|
|
403
|
+
score: 0.7,
|
|
404
|
+
confidence: 0.7,
|
|
405
|
+
reason: `Textarea "${fieldId}" typing speed impossibly fast (${Math.round(stats.avgKeyInterval)}ms/key)`
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check keydown/keyup ratio (should be ~1.0 for real typing)
|
|
410
|
+
if (stats.keyCount > 10 && (stats.keydownUpRatio < 0.8 || stats.keydownUpRatio > 1.2)) {
|
|
411
|
+
detections.push({
|
|
412
|
+
category: 'bot',
|
|
413
|
+
score: 0.4,
|
|
414
|
+
confidence: 0.5,
|
|
415
|
+
reason: `Textarea "${fieldId}" has abnormal keydown/keyup ratio (${stats.keydownUpRatio.toFixed(2)})`
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Check for content without keyboard events (DOM manipulation - browser extensions, bots)
|
|
420
|
+
if (stats.noKeyboardEvents && stats.contentLength > 0) {
|
|
421
|
+
detections.push({
|
|
422
|
+
category: 'bot',
|
|
423
|
+
score: 0.75,
|
|
424
|
+
confidence: 0.8,
|
|
425
|
+
reason: `Textarea "${fieldId}" has ${stats.contentLength} chars but no keyboard events (DOM manipulation)`
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return detections;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = {
|
|
435
|
+
isDatacenterIP,
|
|
436
|
+
checkIPReputation,
|
|
437
|
+
analyzeHeaders,
|
|
438
|
+
parseUserAgent,
|
|
439
|
+
checkBrowserConsistency,
|
|
440
|
+
checkJA3Fingerprint,
|
|
441
|
+
analyzeFormInteraction
|
|
442
|
+
};
|