@anonympins/fingerprint 0.0.3 → 0.0.5

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 (3) hide show
  1. package/README.md +31 -21
  2. package/fingerprint.js +291 -76
  3. package/package.json +48 -47
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  [![CI](https://img.shields.io/github/actions/workflow/status/anonympins/fingerprint/ci.yml)](https://github.com/anonympins/fingerprint/actions/workflows/ci.yml)
3
3
  [![Release](https://img.shields.io/github/v/release/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/releases)
4
4
  [![License](https://img.shields.io/github/license/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/blob/main/LICENSE)
5
- [![Downloads](https://img.shields.io/github/downloads/anonympins/fingerprint/total)](https://github.com/anonympins/fingerprint/releases)
5
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/anonympins/fingerprint)
6
6
  [![Watchers](https://img.shields.io/github/watchers/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/watchers)
7
7
 
8
8
  An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.
@@ -19,6 +19,7 @@ The process unfolds in three steps:
19
19
  * **Device Behavior**: Rapid fingerprint changes (User-Agent rotation).
20
20
  * **IP Behavior**: An excessive number of different devices seen from the same IP, or a single device using a large number of IPs (proxy rotation).
21
21
  * **Inconsistency**: A low similarity score between the current fingerprint and the initial one associated with the `device_id` (cookie theft detection).
22
+ * **Honeypot Trap**: Detection of bots that automatically fill hidden form fields or probe for common but unused URL parameters (e.g., `?debug=true`).
22
23
  3. **Dynamic Challenge**: If the suspicion score exceeds a certain threshold, a challenge is presented to the user. The difficulty and type of challenge depend on the score:
23
24
  * **Low to Medium Suspicion**: A combined CPU and Memory Proof-of-Work (PoW) challenge is issued. The difficulty of both the CPU (hash calculation) and Memory (allocation and computation) components scales progressively with the suspicion score. For low scores, the memory challenge is negligible, making it primarily a CPU task.
24
25
  * **High Suspicion**: For the most suspicious requests, the system issues a high-difficulty combined CPU/Memory challenge. The architecture allows for plugging in more complex challenges like CAPTCHAs if needed.
@@ -30,7 +31,7 @@ Once the challenge is solved, a clearance "ticket" is issued via a secure cookie
30
31
  - **Multi-Factor Fingerprinting**: Combines client-side data (`hardwareConcurrency`, `deviceMemory`, `screen`, `canvas`, `webgl`) and server-side data (`User-Agent`, `Client-Hints`).
31
32
  - **Secure Ticket System**: Uses HMAC-SHA256 signatures to validate clearances and prevent tampering.
32
33
  - **Pluggable Datastore**: Supports external datastores like Redis for state persistence and scalability across multiple server instances.
33
- - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`.
34
+ - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`. The datastore must support setting a Time-To-Live (TTL) for challenge secrets.
34
35
  - **Timing Attack Protection**: Uses `crypto.timingSafeEqual` for secure ticket validation.
35
36
  - **Automatic Threshold Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust suspicion thresholds (`low`, `medium`, `high`), improving bot detection accuracy and reducing false positives over time.
36
37
 
@@ -40,7 +41,7 @@ This module is designed for a Node.js environment.
40
41
 
41
42
  ### Prerequisites
42
43
 
43
- Ensure you have a cookie-parser middleware (like `cookie-parser`) set up in your Express application.
44
+ Ensure you have middleware for parsing cookies (like `cookie-parser`) and request bodies (like `express.json` and `express.urlencoded`) set up in your Express application *before* the `powMiddleware`.
44
45
 
45
46
  ### Configuration
46
47
 
@@ -56,11 +57,14 @@ The `powMiddleware` requires a configuration object defining the weights of susp
56
57
 
57
58
  ```javascript
58
59
  import express from 'express';
60
+ import bodyParser from 'body-parser';
59
61
  import cookieParser from 'cookie-parser';
60
62
  import { powMiddleware /*, configurePow */ } from './fingerprint.js'; // Adjust the path
61
63
 
62
64
  const app = express();
63
65
  app.use(cookieParser());
66
+ app.use(bodyParser.json()); // For parsing application/json
67
+ app.use(bodyParser.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
64
68
 
65
69
  // Configuration of weights and thresholds for calculating the suspicion score.
66
70
  // These values should be adjusted based on traffic and expected user behavior.
@@ -69,13 +73,25 @@ const securityConfig = {
69
73
  historyScore: 0.3, // Penalizes IP rotation (proxy)
70
74
  rotationScore: 0.5, // Penalizes rapid fingerprint changes (user-agent, etc.)
71
75
  headerAnomalyScore: 0.1, // Penalizes abnormal headers (missing UA, etc.)
72
- inconsistencyScore: 0.8 // Strongly penalizes inconsistency between the current and initial fingerprint (stolen cookie)
76
+ inconsistencyScore: 0.8, // Strongly penalizes inconsistency between the current and initial fingerprint (stolen cookie)
77
+ honeypotScore: 1.0 // Strongly penalizes bots filling hidden form fields
73
78
  },
74
79
  thresholds: {
75
80
  low: 20, // Score from which a CPU challenge is issued
76
81
  medium: 45, // Score for a more difficult combined CPU/Memory challenge
77
82
  high: 75, // Score for a very difficult challenge
78
- block: 95 // Score above which the request is blocked outright (HTTP 403)
83
+ block: 95, // Score above which the request is blocked outright (HTTP 403)
84
+ isStaticResource: (req) => req.path.startsWith('/static/') // Optional: Custom function to identify static resources
85
+ },
86
+ honeypot: {
87
+ // List of field names that are traps for bots.
88
+ // These should be hidden in forms for humans, or be URL parameters your app never uses.
89
+ fields: ['email_confirm', 'user_nickname', 'debug', 'test_mode', 'admin'], // (Optional)
90
+ // List of URL paths that should never be accessed by a legitimate user.
91
+ // A request to one of these paths will immediately flag the device as malicious.
92
+ trapUrls: ['/wp-admin', '/.env', '/admin.php', '/phpmyadmin'], // (Optional)
93
+ // Automatically detect common SQL/NoSQL injection and RCE patterns in request values. (Optional, default: true)
94
+ detectInjections: true
79
95
  }
80
96
  };
81
97
 
@@ -222,9 +238,8 @@ const server = http.createServer(async (req, res) => {
222
238
  clientIp: req.socket.remoteAddress,
223
239
  path: req.url.split('?')[0],
224
240
  cookies: {}, // Parse cookies from req.headers.cookie
225
- query: {}, // Parse query string from req.url
241
+ query: new URL(req.url, `http://${req.headers.host}`).searchParams,
226
242
  headers: req.headers,
227
- isStatic: /\.(js|css|png)$/.test(req.url),
228
243
  rawReq: req, // Pass the raw request
229
244
  rawRes: res, // Pass the raw response for cookie setting
230
245
  };
@@ -261,35 +276,30 @@ Manually setting the `low`, `medium`, and `high` thresholds can be challenging.
261
276
 
262
277
  1. **Enable Logging**: The auto-tuner needs data. You must provide a `logger` function in your security configuration. This function will be called for significant events (`challenge_issued`, `challenge_solved`, etc.).
263
278
 
264
- 2. **Start the Tuner**: Call `startThresholdAutoTuning` with your live security configuration and the array where logs are stored.
279
+ 2. **Enable Auto-tuning**: Add an `autotuning` property to your security configuration. The middleware will automatically start the tuning process.
265
280
 
266
281
  ```javascript
267
- import { powMiddleware, startThresholdAutoTuning } from './fingerprint.js';
282
+ import { powMiddleware } from './fingerprint.js';
268
283
 
269
284
  // Array to store traffic analysis data. In a real application, this could be
270
285
  // a more robust logging system.
271
286
  const trafficData = [];
272
287
 
273
288
  const securityConfig = {
274
- weights: { /* ... your weights ... */ },
289
+ weights: { /* ... */ },
275
290
  thresholds: {
276
291
  low: 20, // Initial values, will be optimized
277
292
  medium: 45,
278
293
  high: 75
279
294
  },
280
- // The logger is required for auto-tuning
281
- logger: (log) => trafficData.push(log)
295
+ logger: (log) => trafficData.push(log), // The logger is required for auto-tuning
296
+ autotune: {
297
+ trafficData: trafficData, // The data source for the algorithm
298
+ interval: 1800000, // Optimization cycle every 30 minutes (optional)
299
+ minDataPoints: 200 // Minimum requests before starting optimization (optional)
300
+ }
282
301
  };
283
302
 
284
- // Start the background optimization process.
285
- // The `securityConfig.thresholds` object will be mutated with optimized values.
286
- startThresholdAutoTuning({
287
- securityConfig: securityConfig, // The config object to be updated
288
- trafficData: trafficData, // The data source for the algorithm
289
- interval: 1800000, // Optimization cycle every 30 minutes
290
- minDataPoints: 200 // Minimum requests before starting optimization
291
- });
292
-
293
303
  const powMiddlewareInstance = powMiddleware(securityConfig);
294
304
  app.use(powMiddlewareInstance);
295
305
  ```
package/fingerprint.js CHANGED
@@ -2,14 +2,49 @@
2
2
  import crypto from "node:crypto";
3
3
  import { Optimization } from "./library.js";
4
4
  import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
5
- import { getDeviceHash } from "./fingerprint.server.js";
6
5
 
7
- const POW_SECRET = process.env.POW_SECRET;
6
+ /**
7
+ * Retrieves the POW_SECRET from environment variables with appropriate checks.
8
+ * @returns {string} The secret key.
9
+ */
10
+ const getPowSecret = () => {
11
+ const secret = process.env.POW_SECRET;
12
+ if (!secret && process.env.NODE_ENV === 'production') {
13
+ throw new Error('POW_SECRET environment variable is not set. This is required for production.');
14
+ }
15
+ return secret || "fallback-dev-secret-32-chars-minimum";
16
+ };
17
+
18
+ /**
19
+ * Creates a stable hash based on device characteristics, independent of the IP.
20
+ * This is our "level 2 fingerprint".
21
+ * @param {object} context - The request context.
22
+ * @returns {string} A hash representing the device.
23
+ */
24
+ function getHeaderSignature(context) {
25
+ if (!context.rawHeaders) return '';
26
+ const headerKeys = [];
27
+ for (let i = 0; i < context.rawHeaders.length; i += 2) {
28
+ headerKeys.push(context.rawHeaders[i]);
29
+ }
30
+ return cyrb53(headerKeys.join(','));
31
+ }
32
+ export function getDeviceHash(context) {
33
+ // Prioritize the rich client-side fingerprint if provided.
34
+ const clientFp = context.headers['x-device-fingerprint'];
35
+ if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
36
+ // Basic validation to ensure it looks like our client-side fingerprint.
37
+ return clientFp;
38
+ }
8
39
 
9
- if (!POW_SECRET && process.env.NODE_ENV === 'production') {
10
- throw new Error('POW_SECRET environment variable is not set. This is required for production.');
11
- } else if (!POW_SECRET) {
12
- console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
40
+ // Fallback to server-side only fingerprinting if the header is missing.
41
+ const srv = new FingerprintBuilder();
42
+ srv.add("ua", context.headers["user-agent"]);
43
+ if (context.headers["sec-ch-ua-platform"])
44
+ srv.add("os", context.headers["sec-ch-ua-platform"]);
45
+ if (context.headers["sec-ch-ua"]) srv.add("ch", context.headers["sec-ch-ua"]);
46
+ srv.add("h_ord", getHeaderSignature(context));
47
+ return srv.toString();
13
48
  }
14
49
 
15
50
  /**
@@ -289,7 +324,7 @@ export const verifyPoWAndGenerateTicket = (
289
324
  // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
290
325
  const expiry = Date.now() + 3600000; // 1 heure
291
326
  const signature = crypto
292
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
327
+ .createHmac("sha256", getPowSecret())
293
328
  .update(`${ip}:${expiry}`)
294
329
  .digest("hex");
295
330
 
@@ -300,18 +335,22 @@ export const verifyPoWAndGenerateTicket = (
300
335
  * Verifies a memory PoW solution.
301
336
  * The server performs the same calculation to validate.
302
337
  */
303
- export const verifyMemoryPoW = (nonce, solution, difficulty = 16) => {
338
+ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
304
339
  const size = difficulty * 1024 * 1024;
305
340
  const iterations = size / 16;
306
341
  const buffer = new Uint32Array(size / 4);
307
- let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
342
+ const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
343
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
344
+
308
345
  for (let i = 0; i < buffer.length; i++) {
309
346
  buffer[i] = h = Math.imul(h ^ i, 1597334677);
310
347
  }
348
+
311
349
  let finalHash = 0;
350
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
312
351
  for (let i = 0; i < iterations; i++) {
313
- const addr = buffer[i % buffer.length] % buffer.length;
314
- finalHash ^= buffer[addr];
352
+ addr = buffer[addr] % buffer.length;
353
+ finalHash ^= addr;
315
354
  }
316
355
  return finalHash === parseInt(solution, 10);
317
356
  };
@@ -320,7 +359,7 @@ export const isTicketValid = (ip, ticket) => {
320
359
  const [expiry, sig] = ticket.split(":");
321
360
  if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
322
361
  const expectedSig = crypto
323
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
362
+ .createHmac("sha256", getPowSecret())
324
363
  .update(`${ip}:${expiry}`)
325
364
  .digest("hex");
326
365
 
@@ -359,6 +398,122 @@ function getHeaderAnomalies(context) {
359
398
  };
360
399
  }
361
400
 
401
+ /**
402
+ * Checks for submitted honeypot fields to detect bots.
403
+ * @param {object} context - The request context.
404
+ * @param {object} honeypotConfig - The honeypot configuration.
405
+ * @returns {{honeypotScore: number}}
406
+ */
407
+ function getHoneypotScore(context, honeypotConfig = {}) {
408
+ const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
409
+
410
+ // 1. Check for trap URL access
411
+ if (trapUrls.some(trap => context.path.startsWith(trap))) {
412
+ return { honeypotScore: 100 };
413
+ }
414
+
415
+ if (fields.length === 0 && !detectInjections) {
416
+ return { honeypotScore: 0 };
417
+ }
418
+
419
+ // Check both query parameters (for URL probing) and the request body (for hidden form fields).
420
+ const queryData =
421
+ context.query instanceof URLSearchParams
422
+ ? Object.fromEntries(context.query.entries())
423
+ : context.query || {};
424
+ const bodyData = context.body || {};
425
+
426
+ // 2. Check for honeypot field names
427
+ for (const field of fields) {
428
+ // A bot is trapped if the field exists in either the query OR the body.
429
+ if (
430
+ Object.prototype.hasOwnProperty.call(queryData, field) ||
431
+ Object.prototype.hasOwnProperty.call(bodyData, field)
432
+ ) {
433
+ return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
434
+ }
435
+ }
436
+
437
+ // 3. Check for injection attempts in values
438
+ if (detectInjections) {
439
+ // Regex for common SQL injection patterns
440
+ const sqlRegex = new RegExp(
441
+ "('|\"|;|--|#|/\\*.*\\*/)|\\b(union|select|insert|update|delete|drop|truncate)\\b",
442
+ "i"
443
+ );
444
+ // Regex for common NoSQL (MongoDB) injection patterns (e.g., keys starting with '$')
445
+ const nosqlKeyRegex = /"\$[^"]*":/;
446
+ // Regex for common Remote Code Execution (RCE) patterns
447
+ const rceRegex = new RegExp(
448
+ // File traversal, command execution functions, and shell commands
449
+ "(\\.\\./|\\.\\.\\\\)|\\b(exec|system|shell_exec|passthru|popen|proc_open|eval|assert|require|include)(_once)?\\s*\\(|\\b(wget|curl|bash|sh|powershell)\\b",
450
+ "i"
451
+ );
452
+
453
+ const inspect = (obj) => {
454
+ for (const key in obj) {
455
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
456
+ const value = obj[key];
457
+ if (typeof value === 'string') {
458
+ if (rceRegex.test(value) || sqlRegex.test(value)) return true;
459
+ } else if (typeof value === 'object' && value !== null) {
460
+ // For NoSQL, we check the stringified version of the object to find keys like "$gt"
461
+ // This is more accurate when done on the object itself.
462
+ if (nosqlKeyRegex.test(JSON.stringify(value))) return true;
463
+ if (inspect(value)) return true;
464
+ }
465
+ }
466
+ }
467
+ return false;
468
+ };
469
+
470
+ if (inspect(queryData)) {
471
+ return { honeypotScore: 100 };
472
+ }
473
+ if (inspect(bodyData)) {
474
+ return { honeypotScore: 100 };
475
+ }
476
+ }
477
+
478
+ return { honeypotScore: 0 };
479
+ }
480
+
481
+ const trapUrlTemplates = [
482
+ '/includes/config-{RANDOM}.php', // Classic PHP config file
483
+ '/.env.{RANDOM}', // Environment file
484
+ '/backups/db_backup_{RANDOM}.sql.gz', // Database backup
485
+ '/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
486
+ '/_private/deploy_key_{RANDOM}.pem', // Private key file
487
+ '/logs/app_error_{RANDOM}.log', // Log file
488
+ '/.git/config_{RANDOM}' // Exposed git config variant
489
+ ];
490
+
491
+ /**
492
+ * Generates a signed trap URL.
493
+ * @param {string} nonce - The nonce to sign the URL with.
494
+ * @returns {string} The trap URL.
495
+ */
496
+ function generateTrapUrl(nonce) {
497
+ // Pick a random template to diversify the traps
498
+ const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
499
+ const randomPart = crypto.randomBytes(8).toString('hex');
500
+ const path = template.replace('{RANDOM}', randomPart);
501
+
502
+ const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
503
+ return `${path}?sig=${signature}`;
504
+ }
505
+
506
+ /**
507
+ * Verifies if a given path is a valid trap URL for a given nonce.
508
+ * @param {string} path - The request path.
509
+ * @param {string} signature - The signature from the query.
510
+ * @param {string} nonce - The nonce to verify against.
511
+ * @returns {boolean}
512
+ */
513
+ function verifyTrapUrl(path, signature, nonce) {
514
+ const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
515
+ return signature === expectedSignature;
516
+ }
362
517
  /**
363
518
  * @typedef {object} IStore
364
519
  * @property {(key: string) => Promise<any>} get
@@ -369,7 +524,7 @@ function getHeaderAnomalies(context) {
369
524
 
370
525
  /**
371
526
  * Default in-memory store implementation.
372
- * @type {IStore}
527
+ * @type {IStore}
373
528
  */
374
529
  const inMemoryStore = {
375
530
  _map: new Map(),
@@ -395,7 +550,7 @@ export const configureStore = (externalStore) => {
395
550
  * Orchestrates request identification using a persistent anchor (cookie)
396
551
  * and fingerprint verification.
397
552
  * @param {object} context - The request context.
398
- * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
553
+ * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
399
554
  */
400
555
  async function resolveRequestIdentity(context) {
401
556
  const existingDeviceId = context.cookies?.device_id;
@@ -404,7 +559,6 @@ async function resolveRequestIdentity(context) {
404
559
  let consistencyScore = 1.0; // 1.0 = perfectly consistent
405
560
  let deviceData = null;
406
561
  let newCookie = null;
407
-
408
562
  if (deviceId) {
409
563
  deviceData = await store.get(`device:${deviceId}`);
410
564
  }
@@ -470,7 +624,7 @@ async function getBehavioralIndicators(context, deviceData) {
470
624
  deviceData.rapidChangeCount = Math.min(
471
625
  deviceData.rapidChangeCount + 1,
472
626
  MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
473
- );
627
+ );
474
628
  } else {
475
629
  deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
476
630
  }
@@ -506,10 +660,10 @@ async function getBehavioralIndicators(context, deviceData) {
506
660
  /**
507
661
  * Returns a vector of raw (unweighted) suspicion scores.
508
662
  * @param {object} context - The request context object.
509
- * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
663
+ * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
510
664
  */
511
- export const getSuspicionVector = async (context) => {
512
- const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
665
+ export const getSuspicionVector = async (context, securityConfig) => {
666
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
513
667
 
514
668
  const clientIp = context.clientIp;
515
669
 
@@ -531,10 +685,11 @@ export const getSuspicionVector = async (context) => {
531
685
  const behavioral = await getBehavioralIndicators(context, deviceData);
532
686
  const { headerAnomalyScore } = getHeaderAnomalies(context);
533
687
  // Calculate the inconsistency score here, separately.
534
- const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200));
688
+ const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
535
689
 
536
690
 
537
691
  // Save the updated device state to the store
692
+ // Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
538
693
  await store.set(`device:${deviceId}`, deviceData);
539
694
 
540
695
  return { ...behavioral, headerAnomalyScore, inconsistencyScore };
@@ -556,17 +711,20 @@ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes a
556
711
  * Uses FingerprintBuilder to create a fingerprint based on headers
557
712
  * and IP, making spoofing more complex (requires changing the entire stack).
558
713
  */
559
- export const identifyRequest = async (req, res) => {
714
+ export const identifyRequest = (securityConfig) => async (req, res) => {
560
715
  // This function now acts as a lightweight wrapper around the engine's identifyRequest method.
561
716
  // It requires a default configuration to work.
562
- const defaultConfig = {
563
- weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8 },
564
- thresholds: { low: 20, medium: 40, high: 75 }
717
+ const config = securityConfig || {
718
+ weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
719
+ thresholds: { low: 20, medium: 40, high: 75 },
720
+ honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
565
721
  };
566
- const engine = new FingerprintEngine(defaultConfig);
722
+ const engine = new FingerprintEngine(config);
567
723
 
568
724
  const requestContext = {
569
725
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
726
+ query: req.query,
727
+ body: req.body,
570
728
  cookies: req.cookies,
571
729
  headers: req.headers,
572
730
  rawHeaders: req.rawHeaders,
@@ -673,7 +831,7 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
673
831
  * @param {string} clientIp - The client's IP address.
674
832
  * @returns {string} HTML content.
675
833
  */
676
- function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp) {
834
+ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
677
835
  const { nonce, target, path } = cpuChallengeDetails;
678
836
  return `
679
837
  <html><head><title>Advanced Security Check</title></head>
@@ -685,13 +843,14 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
685
843
  async function solve() {
686
844
  const nonce = "${nonce}";
687
845
  const path = "${path}";
846
+ const clientSecret = "${clientSecret}"; // Secret is now available to the client
688
847
 
689
848
  // --- CPU Challenge ---
690
849
  document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
691
850
  const cpuTarget = BigInt("0x${target}");
692
851
  let cpuSolution = 0;
693
852
  while (true) {
694
- const msg = "${clientIp}:${nonce}:" + cpuSolution;
853
+ const msg = "${clientIp}:${nonce}:" + cpuSolution + ":" + clientSecret;
695
854
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
696
855
  const hashHex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
697
856
  if (BigInt('0x' + hashHex) < cpuTarget) break;
@@ -706,12 +865,14 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
706
865
  let memSolution = 0;
707
866
  try {
708
867
  const size = ${memoryDifficulty} * 1024 * 1024;
868
+ const iterations = size / 16;
709
869
  const buffer = new Uint32Array(size / 4);
710
- let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
870
+ const seed = nonce + ":" + clientSecret;
871
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
711
872
  for (let i = 0; i < buffer.length; i++) {
712
- buffer[i] = (h = Math.imul(h ^ i, 1597334677));
873
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
713
874
  }
714
- for(let i = 0; i < (size / 16); i++) {
875
+ for(let i = 0; i < iterations; i++) {
715
876
  const addr = buffer[i % buffer.length] % buffer.length;
716
877
  memSolution ^= buffer[addr];
717
878
  }
@@ -734,11 +895,13 @@ export function verifyCpuTargetPoWAndGenerateTicket(
734
895
  nonce,
735
896
  solution,
736
897
  suspicionFactor,
898
+ clientSecret, // Le secret est maintenant requis
737
899
  ) {
738
900
  const target = calculateTarget(suspicionFactor);
901
+ const message = clientSecret ? `${clientIp}:${nonce}:${solution}:${clientSecret}` : `${clientIp}:${nonce}:${solution}`;
739
902
  const hash = crypto
740
903
  .createHash("sha256")
741
- .update(`${clientIp}:${nonce}:${solution}`)
904
+ .update(message)
742
905
  .digest("hex");
743
906
  const hashAsInt = BigInt("0x" + hash);
744
907
 
@@ -747,7 +910,7 @@ export function verifyCpuTargetPoWAndGenerateTicket(
747
910
  // The proof is valid, generate the ticket
748
911
  const expiry = Date.now() + 3600000; // 1 heure
749
912
  const signature = crypto
750
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
913
+ .createHmac("sha256", getPowSecret())
751
914
  .update(`${clientIp}:${expiry}`)
752
915
  .digest("hex");
753
916
  return `${expiry}:${signature}`;
@@ -756,9 +919,11 @@ export function verifyCpuTargetPoWAndGenerateTicket(
756
919
  return null;
757
920
  }
758
921
 
759
- const staticExtensions =
760
- /\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map)$/i;
761
- const isStaticResource = (req) => staticExtensions.test(req.path);
922
+ const staticExtensions = new RegExp(
923
+ "\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map)$",
924
+ "i",
925
+ );
926
+ const isStaticResource = (path) => staticExtensions.test(path);
762
927
 
763
928
  // --- Middleware Proof-of-Work (Le péage) ---
764
929
  class FingerprintEngine {
@@ -776,16 +941,27 @@ class FingerprintEngine {
776
941
  return { action: 'next', score: 0, vector: {} };
777
942
  }
778
943
 
944
+ // Check for persisted "condemned" status early.
945
+ const { deviceData } = await resolveRequestIdentity(requestContext);
946
+ if (deviceData?.condemned) {
947
+ return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
948
+ }
949
+
779
950
  // The engine now works with the context directly, no more rawReq dependency here.
780
- const suspicionVector = await __internal.getSuspicionVector(requestContext);
951
+ const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
952
+ const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
953
+ suspicionVector.honeypotScore = honeypotScore;
781
954
 
782
955
  const finalScore =
783
956
  suspicionVector.historyScore * (weights.historyScore || 0) +
784
957
  suspicionVector.rotationScore * (weights.rotationScore || 0) +
785
958
  suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) +
786
- suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0);
959
+ suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
960
+ honeypotScore * (weights.honeypotScore || 0);
787
961
 
788
- const isSuspiciousHigh = finalScore >= thresholds.high;
962
+ const isBlocked = finalScore >= (thresholds.block || 95);
963
+
964
+ const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
789
965
  const isSuspiciousMedium = finalScore >= thresholds.medium;
790
966
  const isSuspicious = finalScore >= thresholds.low;
791
967
 
@@ -796,13 +972,41 @@ class FingerprintEngine {
796
972
  (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
797
973
  )
798
974
  : 0;
799
- const powCookie = cookies?.pow_clearance;
975
+ const powCookie = cookies?.pow_clearance;
800
976
  const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
801
977
 
978
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
979
+ // A legitimate user only hits these endpoints via the challenge page itself.
980
+ if (pow_nonce && !isSuspicious) {
981
+ if (logger) {
982
+ logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
983
+ }
984
+ suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
985
+ // Recalculate score and block immediately.
986
+ const newFinalScore = finalScore + (100 * (weights.honeypotScore || 0));
987
+ return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
988
+ }
989
+
990
+ // If the action is to block, we should still include the score and vector for logging/testing.
991
+ if (isBlocked) {
992
+ return { action: 'block', status: 403, body: 'Forbidden', score: finalScore, vector: suspicionVector };
993
+ }
994
+
995
+ // Honeypot: Check if the request is for a trap URL generated in a previous challenge.
996
+ // This requires a nonce from a *previous* challenge, which we can look up via the device ID.
997
+ const lastNonce = deviceData?.lastChallengeNonce;
998
+ if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
999
+ deviceData.condemned = true; // This device is a bot. Condemn it.
1000
+ await store.set(`device:${cookies.device_id}`, deviceData);
1001
+ return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1002
+ }
1003
+
802
1004
  if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
803
1005
  // --- CHALLENGE SOLUTION HANDLING ---
804
1006
  if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
805
1007
  let isValid = false,
1008
+ // Retrieve the client-side secret associated with this nonce
1009
+ clientSecret = await store.get(`secret:${pow_nonce}`),
806
1010
  ticket = null;
807
1011
  if (pow_type === "cpu_target") {
808
1012
  // Verify the new type
@@ -810,25 +1014,20 @@ class FingerprintEngine {
810
1014
  clientIp,
811
1015
  pow_nonce,
812
1016
  pow_solution,
813
- suspicionFactor, // Pass the analog factor directly
1017
+ suspicionFactor, // Pass the analog factor
1018
+ clientSecret,
814
1019
  );
815
- isValid = ticket !== null;
816
- } else if (pow_type === "mem") {
817
- const minDifficulty = 16; // 16Mo
818
- const maxDifficulty = 48; // 48Mo
819
- const difficulty =
820
- minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
821
- isValid = verifyMemoryPoW(pow_nonce, pow_solution, difficulty);
822
- } else if (pow_type === "cpu_mem") {
1020
+ isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
823
1021
  // Verify combined challenge
824
1022
  const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
825
- clientIp, pow_nonce, pow_solution_cpu, suspicionFactor
1023
+ clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
826
1024
  );
827
-
828
1025
  const minDifficulty = 16; // 16Mo
829
1026
  const maxDifficulty = 48; // 48Mo
830
- const memDifficulty = minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
831
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty);
1027
+ const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1028
+ const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
1029
+
1030
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
832
1031
 
833
1032
  isValid = cpuTicket !== null && isMemValid;
834
1033
  if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
@@ -838,11 +1037,16 @@ class FingerprintEngine {
838
1037
  }
839
1038
 
840
1039
  if (isValid) {
1040
+ // The secret has been used, delete it to prevent replay.
1041
+ if (clientSecret) {
1042
+ await store.delete(`secret:${pow_nonce}`);
1043
+ }
1044
+
841
1045
  if (!ticket) {
842
1046
  // If the ticket has not already been generated (CPU case)
843
1047
  const expiry = Date.now() + 3600000; // 1 heure
844
1048
  const signature = crypto
845
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
1049
+ .createHmac("sha256", getPowSecret())
846
1050
  .update(`${clientIp}:${expiry}`)
847
1051
  .digest("hex");
848
1052
  ticket = `${expiry}:${signature}`;
@@ -872,6 +1076,16 @@ class FingerprintEngine {
872
1076
 
873
1077
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
874
1078
  const nonce = crypto.randomBytes(16).toString("hex");
1079
+ const clientSecret = crypto.randomBytes(16).toString("hex");
1080
+
1081
+ // Store the secret with a short TTL (e.g., 5 minutes)
1082
+ await store.set(`secret:${nonce}`, clientSecret, 300);
1083
+
1084
+ // Associate the current challenge nonce with the device for trap URL verification later.
1085
+ if (deviceData) {
1086
+ deviceData.lastChallengeNonce = nonce;
1087
+ await store.set(`device:${cookies.device_id}`, deviceData);
1088
+ }
875
1089
 
876
1090
  if (logger) {
877
1091
  logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
@@ -882,24 +1096,14 @@ class FingerprintEngine {
882
1096
  // ... logic for TSP/Captcha challenge
883
1097
  }
884
1098
 
885
- // LEVEL 2: Memory-Intensive PoW
886
- if (isSuspiciousMedium) {
887
- // Utilisons notre nouveau challenge combiné !
888
- const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
889
-
890
- const minMemDifficulty = 16; // 16Mo
891
- const maxMemDifficulty = 48; // 48Mo
892
- const memDifficulty = minMemDifficulty + suspicionFactor * (maxMemDifficulty - minMemDifficulty);
893
-
894
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
895
- return {
896
- action: 'challenge', score: finalScore, vector: suspicionVector,
897
- status: 429, body: page
898
- };
899
- }
900
-
901
- // NOUVELLE LOGIQUE UNIFIÉE POUR TOUS LES NIVEAUX DE SUSPICION (low et medium)
1099
+ // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
902
1100
  if (isSuspicious) { // Couvre à la fois low et medium
1101
+ // Generate some trap URLs to embed in the challenge page.
1102
+ // These links are visually hidden but present in the DOM to trap bots.
1103
+ const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
1104
+ const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
1105
+
1106
+
903
1107
  const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
904
1108
 
905
1109
  // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
@@ -908,10 +1112,11 @@ class FingerprintEngine {
908
1112
 
909
1113
  const minMemDifficulty = 0; // Peut être 0 Mo !
910
1114
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
911
- const memDifficulty = minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty);
1115
+ const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
912
1116
 
913
- // On utilise toujours la page combinée, même si la difficulté mémoire est 0 (le calcul sera quasi instantané).
914
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
1117
+ // Always use the combined page, even if memory difficulty is 0 (it will be almost instant).
1118
+ const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1119
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
915
1120
  return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
916
1121
  }
917
1122
  }
@@ -956,12 +1161,14 @@ class FingerprintEngine {
956
1161
  }
957
1162
  await store.set(`ip:${clientIp}`, ipProfile);
958
1163
 
959
- const vector = await __internal.getSuspicionVector(requestContext);
1164
+ const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1165
+ const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
960
1166
  const score =
961
1167
  vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
962
1168
  vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
963
1169
  vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
964
- vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8);
1170
+ vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1171
+ honeypotScore * (this.securityConfig.weights.honeypotScore || 0);
965
1172
 
966
1173
  if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
967
1174
  if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
@@ -979,14 +1186,22 @@ class FingerprintEngine {
979
1186
  export const powMiddleware = (securityConfig) => {
980
1187
  const engine = new FingerprintEngine(securityConfig);
981
1188
 
1189
+ if (securityConfig.autotuning) {
1190
+ startThresholdAutoTuning({
1191
+ securityConfig: securityConfig,
1192
+ ...securityConfig.autotuning,
1193
+ });
1194
+ }
1195
+
982
1196
  return async (req, res, next) => {
983
1197
  const requestContext = {
984
1198
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
985
1199
  path: req.path,
986
1200
  cookies: req.cookies,
987
1201
  query: req.query,
1202
+ body: req.body,
988
1203
  headers: req.headers,
989
- isStatic: isStaticResource(req),
1204
+ isStatic: isStaticResource(req.path),
990
1205
  // Add the newly required properties for full decoupling
991
1206
  rawHeaders: req.rawHeaders,
992
1207
  httpVersion: req.httpVersion,
package/package.json CHANGED
@@ -1,47 +1,48 @@
1
- {
2
- "name": "@anonympins/fingerprint",
3
- "version": "0.0.3",
4
- "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
- "main": "fingerprint.js",
6
- "type": "module",
7
- "engines": {
8
- "node": ">=20.0.0"
9
- },
10
- "scripts": {
11
- "test": "vitest run"
12
- },
13
- "files": [
14
- "fingerprint.js",
15
- "library.js",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/anonympins/fingerprint.git"
22
- },
23
- "keywords": [
24
- "fingerprint",
25
- "bot",
26
- "anti-bot",
27
- "security",
28
- "express",
29
- "middleware",
30
- "proof-of-work",
31
- "pow",
32
- "rate-limiting",
33
- "mitigation",
34
- "captcha"
35
- ],
36
- "author": "anonympins",
37
- "license": "MIT",
38
- "bugs": {
39
- "url": "https://github.com/anonympins/fingerprint/issues"
40
- },
41
- "homepage": "https://github.com/anonympins/fingerprint#readme",
42
- "devDependencies": {
43
- "cookie-parser": "^1.4.6",
44
- "express": "^4.18.2",
45
- "vitest": "^4.1.11"
46
- }
47
- }
1
+ {
2
+ "name": "@anonympins/fingerprint",
3
+ "version": "0.0.5",
4
+ "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
+ "main": "fingerprint.js",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20.0.0"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run"
12
+ },
13
+ "files": [
14
+ "fingerprint.js",
15
+ "library.js",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/anonympins/fingerprint.git"
22
+ },
23
+ "keywords": [
24
+ "fingerprint",
25
+ "bot",
26
+ "anti-bot",
27
+ "security",
28
+ "express",
29
+ "middleware",
30
+ "proof-of-work",
31
+ "pow",
32
+ "rate-limiting",
33
+ "mitigation",
34
+ "captcha"
35
+ ],
36
+ "author": "anonympins",
37
+ "license": "MIT",
38
+ "bugs": {
39
+ "url": "https://github.com/anonympins/fingerprint/issues"
40
+ },
41
+ "homepage": "https://github.com/anonympins/fingerprint#readme",
42
+ "devDependencies": {
43
+ "body-parser": "^1.20.2",
44
+ "cookie-parser": "^1.4.6",
45
+ "express": "^4.18.2",
46
+ "vitest": "^4.1.11"
47
+ }
48
+ }