@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.
Files changed (4) hide show
  1. package/detection.js +442 -0
  2. package/index.js +799 -0
  3. package/package.json +51 -0
  4. 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
+ };