@anonympins/fingerprint 0.0.7 → 0.0.9

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/README.md CHANGED
@@ -34,6 +34,7 @@ Once the challenge is solved, a clearance "ticket" is issued via a secure cookie
34
34
  - **Pluggable Datastore**: Supports external datastores like Redis for state persistence and scalability across multiple server instances.
35
35
  - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`. The datastore must support setting a Time-To-Live (TTL) for challenge secrets.
36
36
  - **Timing Attack Protection**: Uses `crypto.timingSafeEqual` for secure ticket validation.
37
+ - **Bot Whitelisting**: Includes a DNS-based verification mechanism to reliably identify and whitelist legitimate crawlers like Googlebot and Bingbot, preventing them from being challenged. The results are cached for optimal performance.
37
38
  - **Automatic Parameter Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust not only suspicion thresholds (`low`, `medium`, `high`) but also the parameters for behavioral pattern detection, improving accuracy and reducing false positives over time.
38
39
 
39
40
  ## Installation and Usage
@@ -60,7 +61,7 @@ The `powMiddleware` requires a configuration object defining the weights of susp
60
61
  import express from 'express';
61
62
  import bodyParser from 'body-parser';
62
63
  import cookieParser from 'cookie-parser';
63
- import { powMiddleware /*, configurePow */ } from './fingerprint.js'; // Adjust the path
64
+ import { powMiddleware, default_whitelist } from './fingerprint.js'; // Adjust the path
64
65
 
65
66
  const app = express();
66
67
  app.use(cookieParser());
@@ -132,6 +133,26 @@ const securityConfig = {
132
133
  }
133
134
  ]
134
135
  },
136
+ // (Optional) Whitelisting configuration.
137
+ whitelist: [
138
+ // Option 1: Static IP Allowlist.
139
+ // A simple array of IPs or CIDR ranges that are always allowed, bypassing all checks.
140
+ // Useful for internal tools, trusted partners, or monitoring services.
141
+ // This check is performed first for maximum efficiency.
142
+ { type: 'allowlist', entries: [
143
+ '192.168.1.100', // A specific internal IP
144
+ '203.0.113.0/24', // A partner's network range
145
+ '2001:db8::/32' // An IPv6 range
146
+ ]},
147
+ // Option 2: DNS-verified bots (e.g., search engine crawlers).
148
+ // This uses a secure DNS lookup (reverse then forward) to verify the bot's identity.
149
+ // The result is cached per IP to avoid repeated DNS lookups.
150
+ // You can use the provided default list, which contains over 50 common bots, and extend it.
151
+ ...default_whitelist(), // Use the defaults
152
+ { userAgent: 'MyIndustrySpecificBot', hostnameSuffix: '.my-bot-verifier.com' }, // Add a custom bot
153
+ ],
154
+ // Or, if you only want the defaults:
155
+ // whitelist: default_whitelist(),
135
156
  // The logger is required for auto-tuning. It collects data on requests.
136
157
  logger: (log) => trafficData.push(log),
137
158
  // (Optional) Configuration for the automatic threshold and pattern tuning.
@@ -177,17 +198,41 @@ The main Express middleware. It orchestrates identification, suspicion calculati
177
198
 
178
199
  #### `configureStore(store)`
179
200
  Allows replacing the in-memory store with an external datastore (like Redis) for persistence and scaling.
201
+ The library provides ready-to-use adapters for popular datastores like Redis and MongoDB, which automatically handle the Time-To-Live (TTL) required for temporary data like challenge secrets.
180
202
 
181
- See the Datastore Integration Guide for a complete example of creating a Redis store.
203
+ **Redis Example:**
182
204
 
183
205
  ```javascript
184
206
  import { configureStore } from './fingerprint.js';
185
- import { createRedisStore } from './redis-store.js'; // Assuming a redis store implementation exists
207
+ import { createRedisStore } from './redis-store.js';
208
+ import Redis from 'ioredis';
186
209
 
187
- const redisStore = createRedisStore(process.env.REDIS_URL);
210
+ const redisClient = new Redis(process.env.REDIS_URL);
211
+ const redisStore = createRedisStore(redisClient);
188
212
  configureStore(redisStore);
189
213
  ```
190
214
 
215
+ **MongoDB Example:**
216
+
217
+ ```javascript
218
+ import { configureStore } from './fingerprint.js';
219
+ import { createMongoDbStore } from './mongodb-store.js';
220
+ import { MongoClient } from 'mongodb';
221
+
222
+ const mongoClient = new MongoClient(process.env.MONGODB_URL);
223
+
224
+ // It's recommended to connect before your application starts listening.
225
+ await mongoClient.connect();
226
+
227
+ const mongoStore = createMongoDbStore(mongoClient.db('your-db-name'), 'sessions'); // 'sessions' is the collection name
228
+ configureStore(mongoStore);
229
+
230
+ // IMPORTANT: For automatic expiration of challenges and other temporary data to work,
231
+ // you must create a TTL index on the `expiresAt` field in your MongoDB collection.
232
+ // Run this command in the mongo shell:
233
+ // db.sessions.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })
234
+ ```
235
+
191
236
  #### `identifyRequest(req, res)`
192
237
  An asynchronous function that returns an identification string for a given request, based on its suspicion level (`device:<id>`, `suspicious_medium:<ip>`, etc.). Useful for integration with a custom rate-limiter.
193
238
 
@@ -245,6 +290,114 @@ const signature = generateRequestSignature({ action: 'update', id: 123 });
245
290
  **Security Note:** This function is powerful but should be used with caution. The `secret` must be managed securely. It is typically used with a temporary, single-use secret provided by the server for a specific action, rather than a long-lived shared secret embedded in the client-side code.
246
291
 
247
292
  ---
293
+
294
+ ## Why Use the Client-Side Library? The Client + Server Synergy
295
+
296
+ At first glance, client-side checks might seem redundant with server-side honeypots and analysis. In reality, they form two complementary and synergistic lines of defense.
297
+
298
+ Imagine your server is a fortified castle:
299
+
300
+ - **Server-Side Defense (the guards on the walls):** They inspect anyone who knocks on the gate. They are effective, but this means the enemy is already at your door, and your resources (guards) are mobilized for every interaction, legitimate or not.
301
+ - **Client-Side Defense (scouts and traps in the forest):** They detect suspicious movements and neutralize threats *before* they even reach the castle walls. This saves the castle's resources for genuine visitors.
302
+
303
+ The client-side library (`fingerprint.client.js`) gives your server "eyes and ears" where it was previously blind, offering three major advantages:
304
+
305
+ 1. **Early Detection & Resource Savings:** A bot filling a client-side honeypot is flagged in its own browser. The server can then immediately block it based on the `X-Behavior-Metrics` header, saving CPU, memory, and bandwidth that would have been wasted processing a malicious request.
306
+ 2. **Richer Behavioral Data:** The server cannot see how a user interacts with a page. The client-side library can detect non-human behavior (no mouse movement, instant form fills) that is impossible to spot from the server alone.
307
+ 3. **More Robust Fingerprinting:** Server-side signals (IP, User-Agent) are easy to spoof. Client-side fingerprinting adds much stronger, hardware-based signals (Canvas, WebGL, CPU cores) that are significantly harder for bots to fake consistently.
308
+
309
+ ### Strengths at a Glance
310
+
311
+ | Feature | Server-Side Only Approach | Client + Server Approach (with the library) |
312
+ | :---------------------- | :------------------------------------------------------ | :------------------------------------------------------------------------- |
313
+ | **Detection Point** | **Reactive** (after the request is received) | **Proactive** (before or during the request) |
314
+ | **Resource Usage** | Server is engaged for every request, good or bad. | Client-side pre-filtering saves significant server resources. |
315
+ | **Behavioral Analysis** | Limited to request velocity and sequence. | Rich data: mouse movement, typing rhythm, interaction with hidden elements. |
316
+ | **Fingerprint Evasion** | **Easy** for bots to spoof headers and rotate IPs. | **Hard** for bots to fake hardware-level fingerprints (Canvas/WebGL). |
317
+ | **Overall Strategy** | Guards at the gate. | Scouts in the field + Guards at the gate. |
318
+
319
+ In short, the client-side library is not an alternative, but a **force multiplier** for the server-side defenses.
320
+
321
+ ### Client-Side Integration: The `initializeClient` function
322
+
323
+ To simplify setup, all client-side features can be enabled and configured through a single, unified function: `initializeClient(config)`. This is the recommended approach.
324
+
325
+ ```javascript
326
+ import { initializeClient } from './path/to/fingerprint.client.js';
327
+
328
+ /**
329
+ * Initializes all client-side protections.
330
+ * This is the recommended way to set up the client-side library.
331
+ */
332
+ initializeClient({
333
+ // (Optional, default: true) Enable mouse movement tracking to detect non-human patterns.
334
+ // Set to `false` to disable.
335
+ mouse: true,
336
+
337
+ // (Optional, default: true) Enable keystroke dynamics tracking (timing between key presses).
338
+ // Set to `false` to disable.
339
+ keystrokes: true,
340
+
341
+ // (Optional) An array of `name` attributes for hidden form fields that act as bot traps.
342
+ honeypots: ['email_confirm', 'user_nickname', 'website_url'],
343
+
344
+ // (Optional) Enables automatic protection for `fetch` requests.
345
+ // If the `fetch` object is present, the protection is active.
346
+ fetch: {
347
+ // (Optional) An array of domains to protect. If empty or not provided,
348
+ // it protects same-origin requests by default.
349
+ targetDomains: ['api.yourdomain.com', 'auth.yourdomain.com']
350
+ }
351
+ });
352
+ ```
353
+
354
+ ### Client-Side Behavioral Analysis
355
+
356
+ The following functions, available in `fingerprint.client.js`, allow for proactive, client-side detection of bot-like behavior. They collect metrics on user interaction which can be sent to the server for more accurate suspicion scoring. The server-side logic to interpret these metrics (via the `X-Behavior-Metrics` header) would need to be implemented as part of a custom scoring extension.
357
+
358
+ #### `startKeystrokeDynamicsTracker()`
359
+ *Client-side function only.* Starts tracking the timing between keystrokes. The average latency between key presses is a strong behavioral indicator. Humans have a natural, somewhat variable typing rhythm, whereas bots often simulate keystrokes with a fixed, unnaturally consistent delay, or paste text instantly (zero latency).
360
+
361
+ #### `startMouseEntropyTracker()`
362
+ *Client-side function only.* Starts tracking mouse movements on the page. It calculates a simple entropy score based on movement patterns. Human mouse movements are typically chaotic, whereas bots often have linear or no movement at all. This should be called once when your application's main component mounts.
363
+
364
+ #### `initializeHoneypots(fieldNames)`
365
+ *Client-side function only.* Sets up "traps" on hidden form fields. If a script automatically fills one of these fields, it's immediately flagged as a bot on the client side.
366
+
367
+ This provides a proactive, first-line defense against simple bots. By setting up traps directly in the browser, you can detect a bot the moment it interacts with a hidden field, rather than waiting for it to submit a form and consume server resources. This detection is then reported to the server via the `X-Behavior-Metrics` header, allowing for an immediate and efficient block.
368
+
369
+ - `fieldNames`: An array of strings corresponding to the `name` attributes of the honeypot input fields in your HTML.
370
+
371
+ ### Advanced: Manual Wrapping with `protectedFetch`
372
+
373
+ If you prefer not to modify global functions or need fine-grained control over which requests are protected, you can use the `protectedFetch` wrapper. You must use this function instead of the standard `fetch` for your API calls.
374
+
375
+ - **`protectedFetch(resource, options)`**: A wrapper around the native `fetch` API that automatically enriches requests with security headers. It adds:
376
+ - `X-Device-Fingerprint`: The client's device fingerprint.
377
+ - `X-Behavior-Metrics`: A JSON string containing the metrics collected by the behavioral trackers (e.g., mouse entropy, honeypot interaction).
378
+
379
+ **Example:**
380
+
381
+ ```javascript
382
+ import {
383
+ initializeClient,
384
+ protectedFetch
385
+ } from './path/to/fingerprint.client.js';
386
+
387
+ // Start tracking user behavior as soon as the app loads.
388
+ // Note: You still need to initialize the trackers even if you use protectedFetch manually.
389
+ initializeClient({ fetch: false }); // Disables automatic fetch patching
390
+
391
+ // Now, use protectedFetch for your specific API calls.
392
+ async function submitForm(data) {
393
+ const response = await protectedFetch('/api/submit-data', {
394
+ method: 'POST',
395
+ body: JSON.stringify(data),
396
+ headers: {'Content-Type': 'application/json'}
397
+ });
398
+ }
399
+ ```
400
+
248
401
  ## Advanced Features
249
402
 
250
403
  ### Architecture: `FingerprintEngine`
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Algorithme de hachage cyrb53 (rapide et faible taux de collision).
3
+ */
4
+ export const cyrb53 = (str, seed = 0) => {
5
+ let h1 = 0xdeadbeef ^ seed,
6
+ h2 = 0x41c6ce57 ^ seed;
7
+ for (let i = 0, ch; i < str.length; i++) {
8
+ ch = str.charCodeAt(i);
9
+ h1 = Math.imul(h1 ^ ch, 2654435761);
10
+ h2 = Math.imul(h2 ^ ch, 1597334677);
11
+ }
12
+ h1 =
13
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
14
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
15
+ h2 =
16
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
17
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
18
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
19
+ };
20
+
21
+ /**
22
+ * Classe pour construire une empreinte composite (Multi-Hash).
23
+ * Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
24
+ */
25
+ export class FingerprintBuilder {
26
+ constructor() {
27
+ this.components = new Map();
28
+ }
29
+
30
+ /**
31
+ * Ajoute un composant au hash global.
32
+ * @param {string} group - Le nom du groupe (ex: 'hw', 'screen', 'geo')
33
+ * @param {string|number|boolean} value - La valeur brute à hasher
34
+ */
35
+ add(group, value) {
36
+ if (value === undefined || value === null) return this;
37
+ // On hash la valeur individuellement pour l'anonymiser et réduire sa taille
38
+ this.components.set(group, cyrb53(String(value)));
39
+ return this;
40
+ }
41
+
42
+ /**
43
+ * Adds a raw component without hashing it.
44
+ * Useful for metrics that need to be read on the server.
45
+ * @param {string} group - The name of the group.
46
+ * @param {string|number} value - The raw value.
47
+ */
48
+ addRaw(group, value) {
49
+ if (value === undefined || value === null) return this;
50
+ this.components.set(group, value);
51
+ return this;
52
+ }
53
+ /**
54
+ * Génère la chaîne de signature finale.
55
+ * Trie les clés pour garantir un ordre déterministe.
56
+ */
57
+ toString() {
58
+ return Array.from(this.components.entries())
59
+ .sort((a, b) => a[0].localeCompare(b[0])) // Tri alphabétique des clés
60
+ .map(([key, hash]) => `${key}:${hash}`)
61
+ .join("|");
62
+ }
63
+
64
+ /**
65
+ * Compares two fingerprints and returns a similarity score (0 to 1).
66
+ * Uses weights to give more importance to strong invariants (Canvas, GPU).
67
+ * @param {string} fpString1 - Fingerprint A
68
+ * @param {string} fpString2 - Fingerprint B
69
+ */
70
+ static compare(fpString1, fpString2) {
71
+ if (!fpString1 || !fpString2) return 0;
72
+
73
+ const parse = (str) => new Map(str.split("|").map(part => part.split(":")).filter(([k,v]) => k && v));
74
+
75
+ const map1 = parse(fpString1);
76
+ const map2 = parse(fpString2);
77
+
78
+ // Poids de "véracité" (Entropie/Stabilité)
79
+ const weights = {
80
+ cvs: 4.0, // Canvas: Très haute entropie (Rendu unique)
81
+ gpu: 3.0, // GPU: Haute entropie (Matériel spécifique)
82
+ hw: 1.5, // Hardware: Moyenne entropie
83
+ scr: 1.0, // Screen: Moyenne
84
+ geo: 0.5, // Geo: Faible (VPN/Voyage)
85
+ os: 0.5, // OS: Faible (Générique)
86
+ bot: 0.0, // Bot: Informatif
87
+ };
88
+
89
+ let weightedMatches = 0;
90
+ let totalWeight = 0;
91
+
92
+ const allKeys = new Set([...map1.keys(), ...map2.keys()]);
93
+
94
+ allKeys.forEach((key) => {
95
+ const weight = weights[key] || 1.0;
96
+ totalWeight += weight;
97
+ if (map1.get(key) === map2.get(key)) {
98
+ weightedMatches += weight;
99
+ }
100
+ });
101
+
102
+ return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
103
+ }
104
+ }
@@ -0,0 +1,399 @@
1
+ import { cyrb53, FingerprintBuilder } from './fingerprint.builder.js';
2
+
3
+ const ClientLibrary = {
4
+ // Cache pour éviter de recalculer les constantes (Hardware, etc.)
5
+ _cachedBuilder: null,
6
+ /**
7
+ * Génère l'empreinte de l'appareil actuel.
8
+ */
9
+ getDeviceFingerprint() {
10
+ if (typeof window === "undefined") {
11
+ console.error("getDeviceFingerprint can only be called on the client-side.");
12
+ return "";
13
+ }
14
+
15
+ if (!this._cachedBuilder) {
16
+ const nav = window.navigator;
17
+ const screen = window.screen;
18
+
19
+ this._cachedBuilder = new FingerprintBuilder();
20
+
21
+ // 1. Hardware (Très stable) : Cœurs, RAM, GPU (si dispo via canvas), Touch
22
+ this._cachedBuilder.add(
23
+ "hw",
24
+ `${nav.hardwareConcurrency}_${nav.deviceMemory}_${nav.maxTouchPoints}`,
25
+ );
26
+
27
+ // 2. Geo/Locale (Stable sauf voyage/VPN) : Timezone, Langue
28
+ this._cachedBuilder.add(
29
+ "geo",
30
+ `${Intl.DateTimeFormat().resolvedOptions().timeZone}_${nav.language}_${new Date().getTimezoneOffset()}`,
31
+ );
32
+
33
+ // 3. Screen (Stable sauf changement moniteur/zoom) : Dimensions, ColorDepth
34
+ this._cachedBuilder.add(
35
+ "scr",
36
+ `${screen.width}x${screen.height}_${screen.colorDepth}`,
37
+ );
38
+
39
+ // 4. Platform (Stable) : OS, Engine
40
+ this._cachedBuilder.add("os", nav.platform);
41
+
42
+ // 5. Graphics (WebGL Vendor/Renderer) - Invariant matériel fort
43
+ try {
44
+ const canvas = document.createElement("canvas");
45
+ const gl =
46
+ canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
47
+ if (gl) {
48
+ const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
49
+ if (debugInfo) {
50
+ const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
51
+ const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
52
+ this._cachedBuilder.add("gpu", `${vendor}_${renderer}`);
53
+ }
54
+ }
55
+ } catch (e) {
56
+ }
57
+
58
+ // 6. Canvas Fingerprinting (Rendering quirks)
59
+ try {
60
+ const canvas = document.createElement("canvas");
61
+ const ctx = canvas.getContext("2d");
62
+ if (ctx) {
63
+ canvas.width = 200;
64
+ canvas.height = 50;
65
+ ctx.textBaseline = "alphabetic";
66
+ ctx.font = "14px 'Arial'";
67
+ ctx.fillStyle = "#f60";
68
+ ctx.fillRect(125, 1, 62, 20);
69
+ ctx.fillStyle = "#069";
70
+ ctx.fillText("fingerprint", 2, 15);
71
+ ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
72
+ ctx.fillText("fingerprint", 4, 17);
73
+ this._cachedBuilder.add("cvs", canvas.toDataURL());
74
+ }
75
+ } catch (e) {
76
+ }
77
+
78
+ // 7. Bot Detection (Indication cachée)
79
+ if (nav.webdriver) this._cachedBuilder.add("bot", "true");
80
+ }
81
+
82
+ return this._cachedBuilder.toString();
83
+ },
84
+
85
+ /**
86
+ * Génère une signature de requête incluant le contexte.
87
+ * @param {object} payload
88
+ */
89
+ generateRequestSignature(payload = {}) {
90
+ const deviceFp = this.getDeviceFingerprint();
91
+ const sortedPayload = Object.keys(payload)
92
+ .sort()
93
+ .map((k) => `${k}=${payload[k]}`)
94
+ .join("&");
95
+ const payloadHash = cyrb53(sortedPayload);
96
+ return `${deviceFp}|req:${payloadHash}`;
97
+ },
98
+
99
+ /**
100
+ * Génère une signature HMAC-SHA256 en utilisant l'API Web Crypto.
101
+ * @param {object} payload - Les données à signer.
102
+ * @param {string} secret - La clé secrète partagée.
103
+ * @returns {Promise<string>} La signature hexadécimale.
104
+ */
105
+ async generateClientSideSignature(payload, secret) {
106
+ const sortedPayload = Object.keys(payload).sort().map((k) => `${k}=${payload[k]}`).join("&");
107
+ const encoder = new TextEncoder();
108
+ const key = await window.crypto.subtle.importKey("raw", encoder.encode(secret), {
109
+ name: "HMAC",
110
+ hash: "SHA-256"
111
+ }, false, ["sign"]);
112
+ const signatureBuffer = await window.crypto.subtle.sign("HMAC", key, encoder.encode(sortedPayload));
113
+ const hashArray = Array.from(new Uint8Array(signatureBuffer));
114
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
115
+ },
116
+
117
+ /**
118
+ * @internal
119
+ * Resets the cached fingerprint builder. Used for testing purposes.
120
+ */
121
+ _resetCache() {
122
+ this._cachedBuilder = null;
123
+ },
124
+
125
+ /**
126
+ * Démarre le suivi des mouvements de la souris pour calculer l'entropie.
127
+ * À appeler une fois sur la page.
128
+ */
129
+ startMouseEntropyTracker() {
130
+ // S'assurer de ne pas attacher l'écouteur plusieurs fois
131
+ if (mouseMovements > 0) return;
132
+
133
+ document.addEventListener('mousemove', (e) => {
134
+ const dx = e.clientX - lastMousePos.x;
135
+ const dy = e.clientY - lastMousePos.y;
136
+ // Une métrique simple : la somme des distances. Un bot aura souvent 0.
137
+ metrics.mouseEntropy += Math.sqrt(dx * dx + dy * dy);
138
+ lastMousePos = {x: e.clientX, y: e.clientY};
139
+ mouseMovements++;
140
+ }, {passive: true});
141
+ },
142
+
143
+ /**
144
+ * Démarre le suivi de la dynamique de frappe pour calculer la latence.
145
+ * À appeler une fois sur la page.
146
+ */
147
+ startKeystrokeDynamicsTracker() {
148
+ // S'assurer de ne pas attacher l'écouteur plusieurs fois
149
+ if (keystrokeTimestamps.length > 0) return;
150
+
151
+ document.addEventListener('keydown', () => {
152
+ const now = performance.now();
153
+ if (keystrokeTimestamps.length > 0) {
154
+ const lastTimestamp = keystrokeTimestamps[keystrokeTimestamps.length - 1];
155
+ const latency = now - lastTimestamp;
156
+ // On ignore les latences irréalistes (trop longues ou trop courtes)
157
+ if (latency > 10 && latency < 2000) { // Augmenté à 2s
158
+ if (keystrokeLatencies.length >= KEYSTROKE_HISTORY_MAX) {
159
+ keystrokeLatencies.shift(); // Garder la taille de l'historique
160
+ }
161
+ keystrokeLatencies.push(latency);
162
+ }
163
+ }
164
+ keystrokeTimestamps.push(now);
165
+ }, {passive: true});
166
+ },
167
+
168
+ /**
169
+ * Initialise ou réinitialise les honeypots côté client pour une détection immédiate.
170
+ * Les anciens écouteurs sont supprimés avant d'en ajouter de nouveaux.
171
+ * @param {string[]} honeypotFieldNames - Noms des champs de formulaire cachés.
172
+ */
173
+ initializeHoneypots(honeypotFieldNames) {
174
+ // 1. Nettoyer les anciens écouteurs
175
+ activeHoneypotListeners.forEach((listener, field) => {
176
+ field.removeEventListener('input', listener);
177
+ });
178
+ activeHoneypotListeners.clear();
179
+
180
+ // 2. Ajouter les nouveaux écouteurs
181
+ honeypotFieldNames.forEach(fieldName => {
182
+ const field = document.querySelector(`[name="${fieldName}"]`);
183
+ if (field) {
184
+ // On utilise une fonction nommée (ou une référence) pour pouvoir la supprimer plus tard.
185
+ // L'option { once: true } est excellente, mais pour une réinitialisation complète,
186
+ // il est plus propre de gérer le nettoyage nous-mêmes.
187
+ const listener = () => {
188
+ this.onHoneypotTrigger();
189
+ // Se supprime lui-même après exécution, comme { once: true }
190
+ field.removeEventListener('input', listener);
191
+ };
192
+ field.addEventListener('input', listener);
193
+ activeHoneypotListeners.set(field, listener); // On stocke la référence
194
+ }
195
+ });
196
+ },
197
+
198
+ /**
199
+ * Récupère les métriques comportementales collectées.
200
+ * À appeler avant d'envoyer une requête sensible.
201
+ * @returns {ClientBehaviorMetrics}
202
+ */
203
+ getClientBehaviorMetrics() {
204
+ // Normalise l'entropie de la souris
205
+ if (mouseMovements > 10) {
206
+ metrics.mouseEntropy /= mouseMovements;
207
+ }
208
+ // Calcule la latence moyenne des frappes
209
+ if (keystrokeLatencies.length > 0) {
210
+ const sum = keystrokeLatencies.reduce((a, b) => a + b, 0);
211
+ metrics.keystrokeLatency = sum / keystrokeLatencies.length;
212
+ } else {
213
+ metrics.keystrokeLatency = 0;
214
+ }
215
+ return metrics;
216
+ },
217
+
218
+ /**
219
+ * Enrichit une requête fetch avec les en-têtes de fingerprinting et de comportement.
220
+ * @param {RequestInfo} resource
221
+ * @param {RequestInit} [options]
222
+ * @returns {Promise<Response>}
223
+ */
224
+ async protectedFetch(resource, options = {}) {
225
+ const fp = this.getDeviceFingerprint();
226
+ const behavior = this.getClientBehaviorMetrics();
227
+
228
+ const headers = new Headers(options.headers || {});
229
+ headers.set('X-Device-Fingerprint', fp);
230
+ headers.set('X-Behavior-Metrics', JSON.stringify(behavior));
231
+
232
+ options.headers = headers;
233
+ return fetch(resource, options);
234
+ },
235
+
236
+ // --- Système d'interception de Fetch robuste et anti-conflit ---
237
+
238
+ _isFetchPatched: false,
239
+ _interceptorChain: [],
240
+ _originalFetch: (typeof window !== 'undefined') ? window.fetch : () => Promise.reject(new Error('fetch is not available')),
241
+
242
+ /**
243
+ * Adds an interceptor function to the `fetch` chain.
244
+ * Chaque intercepteur reçoit `resource`, `options`, et une fonction `next`.
245
+ * Il DOIT appeler `next(resource, options)` pour continuer la chaîne.
246
+ * @param {function(RequestInfo, RequestInit, function): Promise<Response>} interceptor
247
+ */
248
+ addFetchInterceptor(interceptor) {
249
+ if (!this._isFetchPatched) {
250
+ this.patchGlobalFetch();
251
+ }
252
+ this._interceptorChain.push(interceptor);
253
+ },
254
+
255
+ patchGlobalFetch() {
256
+ if (this._isFetchPatched || typeof window === 'undefined') return;
257
+
258
+ this._isFetchPatched = true;
259
+ window.fetch = (resource, options) => {
260
+ // Le "dispatcher" qui exécute la chaîne.
261
+ const dispatch = (index, res, opts) => {
262
+ if (index >= this._interceptorChain.length) {
263
+ // Fin de la chaîne, on appelle le fetch original.
264
+ return this._originalFetch(res, opts);
265
+ }
266
+ const nextInterceptor = this._interceptorChain[index];
267
+ // Appelle l'intercepteur actuel en lui passant la fonction pour appeler le suivant.
268
+ return nextInterceptor(res, opts, (nextRes, nextOpts) => dispatch(index + 1, nextRes, nextOpts));
269
+ };
270
+ return dispatch(0, resource, options || {});
271
+ };
272
+ },
273
+
274
+ /**
275
+ * La fonction qui est appelée lorsqu'un honeypot est déclenché.
276
+ * @private
277
+ */
278
+ onHoneypotTrigger : () => {
279
+ metrics.honeypotInteraction = true;
280
+ // On pourrait même envoyer un signalement au serveur immédiatement.
281
+ },
282
+
283
+ /**
284
+ * Initialise l'intercepteur de fingerprinting.
285
+ * Il s'ajoute à la chaîne d'interception sans écraser les autres.
286
+ * @param {string[]} [targetDomains] - Optionnel. Liste de domaines à protéger.
287
+ * Si non fourni, protège les requêtes de même origine.
288
+ */
289
+ initializeFetch(targetDomains = []) {
290
+ const fingerprintInterceptor = (resource, options, next) => {
291
+ const requestUrl = (resource instanceof Request) ? resource.url : String(resource);
292
+ let shouldProtect = false;
293
+
294
+ try {
295
+ const url = new URL(requestUrl, window.location.origin);
296
+ // Protéger si la liste de domaines est vide ET que la requête est de même origine,
297
+ // OU si le domaine de la requête est dans la liste fournie.
298
+ shouldProtect = (targetDomains.length === 0 && url.origin === window.location.origin) ||
299
+ (targetDomains.length > 0 && targetDomains.includes(url.hostname));
300
+ } catch (e) {
301
+ // Si l'URL est relative (ex: '/api/data'), new URL() ne lèvera pas d'erreur.
302
+ // Ce bloc est une sécurité pour les cas où l'URL serait malformée.
303
+ // On protège par défaut si aucune liste de domaines n'est spécifiée.
304
+ shouldProtect = targetDomains.length === 0;
305
+ }
306
+
307
+ if (shouldProtect) {
308
+ const fp = this.getDeviceFingerprint();
309
+ const behavior = this.getClientBehaviorMetrics();
310
+ const headers = new Headers(options.headers || {});
311
+ headers.set('X-Device-Fingerprint', fp);
312
+ headers.set('X-Behavior-Metrics', JSON.stringify(behavior));
313
+ options.headers = headers;
314
+ }
315
+
316
+ // Passe la main à l'intercepteur suivant dans la chaîne.
317
+ return next(resource, options);
318
+ };
319
+
320
+ this.addFetchInterceptor(fingerprintInterceptor);
321
+ },
322
+
323
+ /**
324
+ * @typedef {object} ClientConfig
325
+ * @property {boolean} [mouse=true] - Activer le suivi de l'entropie de la souris.
326
+ * @property {boolean} [keystrokes=true] - Activer le suivi de la dynamique de frappe.
327
+ * @property {string[]} [honeypots] - Noms des champs de formulaire honeypot à initialiser.
328
+ * @property {object} [fetch] - Configuration pour l'interception de fetch.
329
+ * @property {string[]} [fetch.targetDomains] - Domaines à protéger. Si non fourni, protège les requêtes de même origine.
330
+ */
331
+
332
+ /**
333
+ * Initialise toutes les protections côté client en une seule fois.
334
+ * C'est la méthode d'initialisation recommandée.
335
+ * @param {ClientConfig} [config={}] - L'objet de configuration.
336
+ */
337
+ initializeClient(config = {}) {
338
+ const {
339
+ mouse = true,
340
+ keystrokes = true,
341
+ honeypots = [],
342
+ fetch: fetchConfig,
343
+ } = config;
344
+
345
+ if (mouse) {
346
+ this.startMouseEntropyTracker();
347
+ }
348
+ if (keystrokes) {
349
+ this.startKeystrokeDynamicsTracker();
350
+ }
351
+ if (honeypots.length > 0) {
352
+ this.initializeHoneypots(honeypots);
353
+ }
354
+ if (fetchConfig !== undefined) {
355
+ this.initializeFetch(fetchConfig.targetDomains);
356
+ }
357
+ }
358
+ };
359
+
360
+ /**
361
+ * @typedef {object} ClientBehaviorMetrics
362
+ * @property {number} mouseEntropy - Entropie des mouvements de la souris.
363
+ * @property {number} keystrokeLatency - Latence moyenne entre les frappes.
364
+ * @property {boolean} honeypotInteraction - Vrai si un honeypot a été touché.
365
+ */
366
+
367
+ /** @type {ClientBehaviorMetrics} */
368
+ const metrics = {
369
+ mouseEntropy: 0,
370
+ keystrokeLatency: 0,
371
+ honeypotInteraction: false,
372
+ };
373
+
374
+ let lastMousePos = { x: 0, y: 0 };
375
+ let mouseMovements = 0;
376
+ let activeHoneypotListeners = new Map(); // Garde une trace des écouteurs actifs
377
+ let keystrokeTimestamps = [];
378
+ let keystrokeLatencies = []; // NOUVEAU: Tableau dédié pour les latences
379
+ const KEYSTROKE_HISTORY_MAX = 20; // On garde l'historique des 20 dernières frappes
380
+
381
+
382
+
383
+ // Exporter les fonctions individuellement pour la compatibilité ascendante
384
+ export const getDeviceFingerprint = ClientLibrary.getDeviceFingerprint.bind(ClientLibrary);
385
+ export const generateRequestSignature = ClientLibrary.generateRequestSignature.bind(ClientLibrary);
386
+ export const generateClientSideSignature = ClientLibrary.generateClientSideSignature.bind(ClientLibrary);
387
+ export const _resetCache = ClientLibrary._resetCache.bind(ClientLibrary);
388
+ export const startMouseEntropyTracker = ClientLibrary.startMouseEntropyTracker.bind(ClientLibrary);
389
+ export const startKeystrokeDynamicsTracker = ClientLibrary.startKeystrokeDynamicsTracker.bind(ClientLibrary);
390
+ export const initializeHoneypots = ClientLibrary.initializeHoneypots.bind(ClientLibrary);
391
+ export const getClientBehaviorMetrics = ClientLibrary.getClientBehaviorMetrics.bind(ClientLibrary);
392
+ export const protectedFetch = ClientLibrary.protectedFetch.bind(ClientLibrary);
393
+ export const addFetchInterceptor = ClientLibrary.addFetchInterceptor.bind(ClientLibrary);
394
+ export const patchGlobalFetch = ClientLibrary.patchGlobalFetch.bind(ClientLibrary);
395
+ export const initializeFetch = ClientLibrary.initializeFetch.bind(ClientLibrary);
396
+ export const initializeClient = ClientLibrary.initializeClient.bind(ClientLibrary);
397
+
398
+ // Export the internal object for testing purposes
399
+ export default ClientLibrary;
package/fingerprint.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
2
  import crypto from "node:crypto";
3
+ import { BlockList } from "node:net";
4
+ import dns from "node:dns/promises";
3
5
  import { Optimization } from "./library.js";
4
6
  import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
7
+ export { createRedisStore } from "./redis-store.js";
8
+ export { createMongoDbStore } from "./mongodb-store.js";
5
9
 
6
10
  /**
7
11
  * Retrieves the POW_SECRET from environment variables with appropriate checks.
@@ -700,7 +704,7 @@ function verifyTrapUrl(path, signature, nonce) {
700
704
  /**
701
705
  * @typedef {object} IStore
702
706
  * @property {(key: string) => Promise<any>} get
703
- * @property {(key: string, value: any) => Promise<void>} set
707
+ * @property {(key: string, value: any, ttl?: number) => Promise<void>} set
704
708
  * @property {(key: string) => Promise<boolean>} has
705
709
  * @property {(key: string) => Promise<void>} delete
706
710
  */
@@ -711,8 +715,21 @@ function verifyTrapUrl(path, signature, nonce) {
711
715
  */
712
716
  const inMemoryStore = {
713
717
  _map: new Map(),
718
+ _timeouts: new Map(),
714
719
  async get(key) { return this._map.get(key); },
715
- async set(key, value) { this._map.set(key, value); },
720
+ async set(key, value, ttl) {
721
+ this._map.set(key, value);
722
+ // If a timeout already exists for this key, clear it.
723
+ if (this._timeouts.has(key)) {
724
+ clearTimeout(this._timeouts.get(key));
725
+ this._timeouts.delete(key);
726
+ }
727
+ // If a TTL is provided, set a timeout to delete the key.
728
+ if (ttl && ttl > 0) {
729
+ const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
730
+ this._timeouts.set(key, timeoutId);
731
+ }
732
+ },
716
733
  async has(key) { return this._map.has(key); },
717
734
  async delete(key) { this._map.delete(key); },
718
735
  };
@@ -859,7 +876,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
859
876
  context._newCookies = context._newCookies || [];
860
877
  context._newCookies.push(newCookie);
861
878
  }
862
- await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
879
+ await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
863
880
 
864
881
  // Periodically clean up device data
865
882
  if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
@@ -1082,14 +1099,16 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
1082
1099
  * Verifies a PoW solution based on a target and generates a ticket.
1083
1100
  */
1084
1101
  export function verifyCpuTargetPoWAndGenerateTicket(
1085
- clientIp,
1102
+ clientIp, // This parameter is crucial and must be the actual client IP
1086
1103
  nonce,
1087
1104
  solution,
1088
1105
  suspicionFactor,
1089
1106
  clientSecret, // Le secret est maintenant requis
1090
1107
  ) {
1091
1108
  const target = calculateTarget(suspicionFactor);
1092
- const message = clientSecret ? `${clientIp}:${nonce}:${solution}:${clientSecret}` : `${clientIp}:${nonce}:${solution}`;
1109
+ const message = clientSecret
1110
+ ? `${clientIp}:${nonce}:${solution}:${clientSecret}`
1111
+ : `${clientIp}:${nonce}:${solution}`;
1093
1112
  const hash = crypto
1094
1113
  .createHash("sha256")
1095
1114
  .update(message)
@@ -1122,16 +1141,118 @@ export class FingerprintEngine {
1122
1141
  const isProduction = process.env.NODE_ENV === 'production';
1123
1142
  this.securityConfig = securityConfig;
1124
1143
  this.isProduction = isProduction;
1144
+ this._allowlist = this._buildAllowlist();
1145
+ }
1146
+
1147
+ /**
1148
+ * Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
1149
+ * This is the fastest check and should be performed first.
1150
+ * @private
1151
+ * @param {string} clientIp - The IP address of the client.
1152
+ * @returns {boolean} True if the IP is in the allowlist.
1153
+ */
1154
+ _buildAllowlist() {
1155
+ const blockList = new BlockList();
1156
+ const { whitelist = [] } = this.securityConfig;
1157
+ const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
1158
+
1159
+ if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
1160
+ return blockList; // Retourne une liste vide
1161
+ }
1162
+
1163
+ for (const entry of allowlistRule.entries) {
1164
+ if (entry.includes('/')) { // CIDR range
1165
+ try {
1166
+ const [address, prefix] = entry.split('/');
1167
+ blockList.addSubnet(address, parseInt(prefix, 10));
1168
+ } catch (e) {
1169
+ // Ignore les entrées CIDR invalides
1170
+ }
1171
+ } else { // Direct IP match
1172
+ blockList.addAddress(entry);
1173
+ }
1174
+ }
1175
+ return blockList;
1176
+ }
1177
+ _isIpInAllowlist(clientIp) {
1178
+ return this._allowlist.check(clientIp);
1179
+ }
1180
+ /**
1181
+ * Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
1182
+ * using reverse and forward DNS lookups. The result is cached.
1183
+ * @private
1184
+ * @param {object} requestContext - The request context.
1185
+ * @returns {Promise<boolean>} True if the request is from a verified whitelisted bot.
1186
+ */
1187
+ async _verifyWhitelistedBot(requestContext) {
1188
+ const { whitelist = [] } = this.securityConfig;
1189
+ const botRules = whitelist.filter(rule => rule.hostnameSuffix);
1190
+ if (botRules.length === 0) {
1191
+ return false;
1192
+ }
1193
+
1194
+ const { clientIp, headers } = requestContext;
1195
+ const userAgent = headers['user-agent'] || '';
1196
+
1197
+ const matchedRule = botRules.find(rule => {
1198
+ if (!rule.userAgent) return false;
1199
+ try {
1200
+ return new RegExp(rule.userAgent).test(userAgent);
1201
+ } catch (e) {
1202
+ console.error(`[Fingerprint] Invalid regex in whitelist rule: ${rule.userAgent}`);
1203
+ return false;
1204
+ }
1205
+ });
1206
+ if (!matchedRule) {
1207
+ return false;
1208
+ }
1209
+
1210
+ const cacheKey = `ip-whitelist:${clientIp}`;
1211
+ const cachedStatus = await store.get(cacheKey);
1212
+
1213
+ if (cachedStatus === 'verified') {
1214
+ return true;
1215
+ }
1216
+ if (cachedStatus === 'failed') {
1217
+ return false;
1218
+ }
1219
+
1220
+ try {
1221
+ // 1. Reverse DNS lookup
1222
+ const hostnames = await dns.reverse(clientIp);
1223
+ const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
1224
+
1225
+ if (!validHostname) {
1226
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1227
+ return false;
1228
+ }
1229
+
1230
+ // 2. Forward DNS lookup
1231
+ const addresses = await dns.resolve(validHostname);
1232
+ if (addresses.includes(clientIp)) {
1233
+ await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
1234
+ return true;
1235
+ }
1236
+ } catch (error) {
1237
+ // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
1238
+ }
1239
+
1240
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1241
+ return false;
1125
1242
  }
1126
1243
 
1127
1244
  async processRequest(requestContext) {
1128
- const { clientIp, path, cookies, query, isStatic } = requestContext;
1245
+ const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1129
1246
  const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1130
-
1131
1247
  if (isStatic) {
1132
1248
  return { action: 'next', score: 0, vector: {} };
1133
1249
  }
1134
1250
 
1251
+ // 1. Check static IP allowlist first for maximum performance.
1252
+ if (this._isIpInAllowlist(clientIp)) {
1253
+ return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'allowlist' } };
1254
+ }
1255
+
1135
1256
  const { pow_nonce } = query;
1136
1257
 
1137
1258
  // Honeypot: Direct probing of challenge endpoints is highly suspicious.
@@ -1145,6 +1266,11 @@ export class FingerprintEngine {
1145
1266
  }
1146
1267
  }
1147
1268
 
1269
+ // Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
1270
+ if (await this._verifyWhitelistedBot(requestContext)) {
1271
+ return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
1272
+ }
1273
+
1148
1274
  // Check for persisted "condemned" status early.
1149
1275
  const { deviceData } = await resolveRequestIdentity(requestContext);
1150
1276
  if (deviceData?.condemned) {
@@ -1223,7 +1349,7 @@ export class FingerprintEngine {
1223
1349
  if (logger) {
1224
1350
  logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1225
1351
  }
1226
- await store.set(`device:${cookies.device_id}`, deviceData);
1352
+ await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
1227
1353
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1228
1354
  }
1229
1355
 
@@ -1309,7 +1435,7 @@ export class FingerprintEngine {
1309
1435
 
1310
1436
  // Associate the current challenge nonce with the device for trap URL verification later.
1311
1437
  if (deviceData) {
1312
- deviceData.lastChallengeNonce = nonce;
1438
+ deviceData.lastChallengeNonce = nonce; // No TTL, part of the main device object
1313
1439
  await store.set(`device:${cookies.device_id}`, deviceData);
1314
1440
  }
1315
1441
 
@@ -1385,7 +1511,7 @@ export class FingerprintEngine {
1385
1511
  if (ipProfile.statelessCount > statelessLimit) {
1386
1512
  return `suspicious_high:${clientIp}`;
1387
1513
  }
1388
- await store.set(`ip:${clientIp}`, ipProfile);
1514
+ await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
1389
1515
 
1390
1516
  const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1391
1517
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
@@ -1410,6 +1536,111 @@ export class FingerprintEngine {
1410
1536
  }
1411
1537
  }
1412
1538
 
1539
+ /**
1540
+ * Returns a default list of whitelisting rules for common and legitimate web crawlers.
1541
+ * This list can be used as a base and extended with custom rules.
1542
+ * @returns {Array<{userAgent: string, hostnameSuffix: string}>}
1543
+ */
1544
+ export const default_whitelist = () => [
1545
+ // === Moteurs de recherche majeurs ===
1546
+ { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
1547
+ { userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
1548
+ { userAgent: 'AdsBot-Google', hostnameSuffix: '.googlebot.com' },
1549
+ { userAgent: 'Mediapartners-Google', hostnameSuffix: '.google.com' },
1550
+ { userAgent: 'Google-InspectionTool', hostnameSuffix: '.google.com' },
1551
+ { userAgent: '(bingbot|adidxbot)', hostnameSuffix: '.search.msn.com' },
1552
+ { userAgent: 'DuckDuckBot', hostnameSuffix: '.duckduckgo.com' },
1553
+ { userAgent: 'YandexBot', hostnameSuffix: '.yandex.com' },
1554
+ { userAgent: 'YandexImages', hostnameSuffix: '.yandex.com' },
1555
+ { userAgent: 'Baiduspider', hostnameSuffix: '.crawl.baidu.com' },
1556
+ { userAgent: 'Slurp', hostnameSuffix: '.crawl.yahoo.net' },
1557
+ { userAgent: 'Sogou web spider', hostnameSuffix: '.sogou.com' },
1558
+ { userAgent: 'Exabot', hostnameSuffix: '.exabot.com' },
1559
+ { userAgent: 'ia_archiver', hostnameSuffix: '.alexa.com' },
1560
+ { userAgent: 'SeznamBot', hostnameSuffix: '.seznam.cz' },
1561
+ { userAgent: 'Mail.RU_Bot', hostnameSuffix: '.mail.ru' },
1562
+ { userAgent: 'Yeti', hostnameSuffix: '.naver.com' }, // Naver
1563
+
1564
+ // === Outils SEO et d'analyse ===
1565
+ { userAgent: 'AhrefsBot', hostnameSuffix: '.ahrefs.com' },
1566
+ { userAgent: 'SemrushBot', hostnameSuffix: '.semrush.com' },
1567
+ { userAgent: 'MJ12bot', hostnameSuffix: '.mj12bot.com' }, // Majestic
1568
+ { userAgent: 'rogerbot', hostnameSuffix: '.moz.com' }, // Moz
1569
+ { userAgent: 'DotBot', hostnameSuffix: '.moz.com' }, // Moz (anciennement opensiteexplorer.org)
1570
+ { userAgent: 'Screaming Frog SEO Spider', hostnameSuffix: '.screamingfrog.co.uk' },
1571
+ { userAgent: 'cognitiveseo', hostnameSuffix: '.cognitiveseo.com' },
1572
+ { userAgent: 'SEOkicks', hostnameSuffix: '.seokicks.com' },
1573
+ { userAgent: 'serpstatbot', hostnameSuffix: '.serpstatbot.com' },
1574
+ { userAgent: 'MegaIndex', hostnameSuffix: '.megaindex.com' },
1575
+ { userAgent: 'LinkpadBot', hostnameSuffix: '.linkpad.ru' },
1576
+ { userAgent: 'Sistrix', hostnameSuffix: '.sistrix.com' },
1577
+ { userAgent: 'RyteBot', hostnameSuffix: '.ryte.com' },
1578
+ { userAgent: 'linkfluence', hostnameSuffix: '.linkfluence.com' },
1579
+ { userAgent: 'TurnitinBot', hostnameSuffix: '.turnitin.com' },
1580
+ { userAgent: 'GrapeshotCrawler', hostnameSuffix: '.grapeshot.co.uk' },
1581
+
1582
+ // === Robots d'IA et de données ===
1583
+ { userAgent: 'GPTBot', hostnameSuffix: '.openai.com' },
1584
+ { userAgent: 'ChatGPT-User', hostnameSuffix: '.openai.com' },
1585
+ { userAgent: 'Applebot', hostnameSuffix: '.applebot.apple.com' },
1586
+ { userAgent: 'CCBot', hostnameSuffix: '.commoncrawl.org' },
1587
+ { userAgent: 'Bytespider', hostnameSuffix: '.bytespider.com' }, // ByteDance (TikTok)
1588
+ { userAgent: 'Diffbot', hostnameSuffix: '.diffbot.com' },
1589
+ { userAgent: 'PerplexityBot', hostnameSuffix: '.perplexity.ai' },
1590
+ { userAgent: 'ClaudeBot', hostnameSuffix: '.anthropic.com' },
1591
+ { userAgent: 'cohere.io', hostnameSuffix: '.cohere.io' },
1592
+ { userAgent: 'DataForSeoBot', hostnameSuffix: '.dataforseo.com' },
1593
+ { userAgent: 'YouBot', hostnameSuffix: '.you.com' },
1594
+ { userAgent: 'omgili', hostnameSuffix: '.omgili.com' },
1595
+
1596
+ // === Réseaux sociaux et partage ===
1597
+ { userAgent: 'facebookexternalhit', hostnameSuffix: '.facebook.com' },
1598
+ { userAgent: 'facebot', hostnameSuffix: '.facebook.com' },
1599
+ { userAgent: 'Twitterbot', hostnameSuffix: '.twttr.com' },
1600
+ { userAgent: 'Pinterestbot', hostnameSuffix: '.pinterest.com' },
1601
+ { userAgent: 'LinkedInBot', hostnameSuffix: '.linkedin.com' },
1602
+ { userAgent: 'Slackbot', hostnameSuffix: '.slack.com' },
1603
+ { userAgent: 'Discordbot', hostnameSuffix: '.discord.com' },
1604
+ { userAgent: 'TelegramBot', hostnameSuffix: '.telegram.org' },
1605
+ { userAgent: 'WhatsApp', hostnameSuffix: '.wa.me' },
1606
+ { userAgent: 'SkypeUriPreview', hostnameSuffix: '.skype.com' },
1607
+ { userAgent: 'redditbot', hostnameSuffix: '.reddit.com' },
1608
+
1609
+ // === Services de monitoring et d'uptime ===
1610
+ { userAgent: 'UptimeRobot', hostnameSuffix: '.uptimerobot.com' },
1611
+ { userAgent: 'Pingdom', hostnameSuffix: '.pingdom.com' },
1612
+ { userAgent: 'StatusCake', hostnameSuffix: '.statuscake.com' },
1613
+ { userAgent: 'Site24x7', hostnameSuffix: '.site24x7.com' },
1614
+ { userAgent: 'Freshping', hostnameSuffix: '.freshping.io' },
1615
+ { userAgent: 'Better Uptime', hostnameSuffix: '.betteruptime.com' },
1616
+ { userAgent: 'Checkly', hostnameSuffix: '.checkly-infra.com' },
1617
+ { userAgent: 'Datadog', hostnameSuffix: '.datadoghq.com' },
1618
+ { userAgent: 'NewRelicPinger', hostnameSuffix: '.newrelic.com' },
1619
+
1620
+ // === Archives et agrégateurs de contenu ===
1621
+ { userAgent: 'archive.org_bot', hostnameSuffix: '.archive.org' },
1622
+ { userAgent: 'Feedly', hostnameSuffix: '.feedly.com' },
1623
+ { userAgent: 'FeedFetcher-Google', hostnameSuffix: '.google.com' },
1624
+ { userAgent: 'TheOldReader', hostnameSuffix: '.theoldreader.com' },
1625
+ { userAgent: 'Inoreader', hostnameSuffix: '.inoreader.com' },
1626
+ { userAgent: 'FlipboardProxy', hostnameSuffix: '.flipboard.com' },
1627
+ { userAgent: 'PaperLiBot', hostnameSuffix: '.paper.li' },
1628
+
1629
+ // === Services Cloud et Plateformes ===
1630
+ { userAgent: 'Amazon Route 53 Health Check', hostnameSuffix: '.amazonaws.com' },
1631
+ { userAgent: 'Google-Cloud-Scheduler', hostnameSuffix: '.google.com' },
1632
+ { userAgent: 'APIs-Google', hostnameSuffix: '.google.com' },
1633
+
1634
+ // === Divers ===
1635
+ { userAgent: 'W3C_Validator', hostnameSuffix: '.w3.org' },
1636
+ { userAgent: 'GTmetrix', hostnameSuffix: '.gtmetrix.com' },
1637
+ { userAgent: 'WebPageTest', hostnameSuffix: '.webpagetest.org' },
1638
+ { userAgent: 'Google-Site-Verification', hostnameSuffix: '.google.com' },
1639
+ { userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
1640
+ ];
1641
+
1642
+
1643
+
1413
1644
  // --- Proof-of-Work Middleware (The Tollbooth) ---
1414
1645
  export const powMiddleware = (securityConfig) => {
1415
1646
  const engine = new FingerprintEngine(securityConfig);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file Creates a store adapter for MongoDB.
3
+ * This adapter uses a collection as a key-value store and leverages MongoDB's TTL indexes
4
+ * for automatic expiration of documents.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for a MongoDB collection.
9
+ * It's recommended to pass the `db` object and let the adapter handle the collection.
10
+ *
11
+ * **Note:** For TTL to work, you must create a TTL index on the `expiresAt` field in your collection.
12
+ * In the mongo shell, run:
13
+ * `db.yourCollectionName.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })`
14
+ *
15
+ * @param {import('mongodb').Db} db - An instance of a MongoDB Db object.
16
+ * @param {string} [collectionName='fingerprint_store'] - The name of the collection to use.
17
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
18
+ */
19
+ export function createMongoDbStore(db, collectionName = 'fingerprint_store') {
20
+ const collection = db.collection(collectionName);
21
+
22
+ return {
23
+ async get(key) {
24
+ const doc = await collection.findOne({ _id: key });
25
+ // The TTL index automatically removes expired documents, so no need to check `expiresAt` here.
26
+ return doc ? doc.value : null;
27
+ },
28
+ async set(key, value, ttl) {
29
+ const doc = {
30
+ _id: key,
31
+ value: value,
32
+ };
33
+
34
+ if (ttl && ttl > 0) {
35
+ // Set the expiration date for the TTL index.
36
+ doc.expiresAt = new Date(Date.now() + ttl * 1000);
37
+ }
38
+
39
+ await collection.updateOne(
40
+ { _id: key },
41
+ { $set: doc },
42
+ { upsert: true }
43
+ );
44
+ },
45
+ async has(key) {
46
+ const count = await collection.countDocuments({ _id: key });
47
+ return count > 0;
48
+ },
49
+ async delete(key) {
50
+ await collection.deleteOne({ _id: key });
51
+ },
52
+ };
53
+ }
package/package.json CHANGED
@@ -1,48 +1,75 @@
1
- {
2
- "name": "@anonympins/fingerprint",
3
- "version": "0.0.7",
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
- }
1
+ {
2
+ "name": "@anonympins/fingerprint",
3
+ "version": "0.0.9",
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
+ "fingerprint.client.js",
16
+ "fingerprint.builder.js",
17
+ "library.js",
18
+ "redis-store.js",
19
+ "mongodb-store.js",
20
+ "sql-store.js",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/anonympins/fingerprint.git"
27
+ },
28
+ "keywords": [
29
+ "fingerprint",
30
+ "bot",
31
+ "anti-bot",
32
+ "security",
33
+ "express",
34
+ "middleware",
35
+ "proof-of-work",
36
+ "pow",
37
+ "rate-limiting",
38
+ "mitigation",
39
+ "captcha",
40
+ "dns"
41
+ ],
42
+ "author": "anonympins",
43
+ "license": "MIT",
44
+ "bugs": {
45
+ "url": "https://github.com/anonympins/fingerprint/issues"
46
+ },
47
+ "homepage": "https://github.com/anonympins/fingerprint#readme",
48
+ "devDependencies": {
49
+ "body-parser": "^1.20.2",
50
+ "cookie-parser": "^1.4.6",
51
+ "express": "^4.18.2",
52
+ "prom-client": "^15.1.2",
53
+ "vitest": "^4.1.11"
54
+ },
55
+ "peerDependencies": {
56
+ "ioredis": "^5.3.2",
57
+ "knex": ">=3.0.0",
58
+ "mongodb": "^6.3.0",
59
+ "sqlite3": "^5.1.7"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "ioredis": {
63
+ "optional": true
64
+ },
65
+ "mongodb": {
66
+ "optional": true
67
+ },
68
+ "knex": {
69
+ "optional": true
70
+ },
71
+ "sqlite3": {
72
+ "optional": true
73
+ }
74
+ }
75
+ }
package/redis-store.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file Creates a store adapter for ioredis.
3
+ * This adapter handles the serialization and deserialization of complex objects,
4
+ * including the conversion of Set objects to arrays for storage in Redis.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for an ioredis client.
9
+ * @param {import('ioredis').Redis} redisClient - An instance of the ioredis client.
10
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
11
+ */
12
+ export function createRedisStore(redisClient) {
13
+ return {
14
+ async get(key) {
15
+ const value = await redisClient.get(key);
16
+ if (!value) return null;
17
+ // Use a reviver to convert arrays back to Sets for specific keys like 'ips'.
18
+ return JSON.parse(value, (k, v) => {
19
+ if (k === 'ips' && Array.isArray(v)) {
20
+ return new Set(v);
21
+ }
22
+ return v;
23
+ });
24
+ },
25
+ async set(key, value, ttl) {
26
+ // Use a replacer to convert Set objects into arrays before serialization.
27
+ const stringValue = JSON.stringify(value, (k, v) => {
28
+ if (v instanceof Set) {
29
+ return Array.from(v);
30
+ }
31
+ return v;
32
+ });
33
+
34
+ if (ttl && ttl > 0) {
35
+ await redisClient.set(key, stringValue, 'EX', ttl);
36
+ } else {
37
+ await redisClient.set(key, stringValue);
38
+ }
39
+ },
40
+ async has(key) { return (await redisClient.exists(key)) === 1; },
41
+ async delete(key) { await redisClient.del(key); },
42
+ };
43
+ }
package/sql-store.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @file Creates a store adapter for Knex.js.
3
+ * This adapter is compatible with various SQL databases like PostgreSQL, MySQL, and SQLite.
4
+ * It handles serialization of complex objects and TTL for automatic data expiration.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for a Knex.js client.
9
+ *
10
+ * **Note:** You must create the table yourself before using the store.
11
+ * The table should have at least the following columns:
12
+ * - `key` (string, primary key)
13
+ * - `value` (text or json/jsonb)
14
+ * - `expiresAt` (datetime or timestamp with time zone)
15
+ *
16
+ * Example schema for PostgreSQL:
17
+ * ```sql
18
+ * CREATE TABLE your_table_name (
19
+ * "key" VARCHAR(255) PRIMARY KEY,
20
+ * "value" TEXT NOT NULL,
21
+ * "expiresAt" TIMESTAMPTZ
22
+ * );
23
+ * ```
24
+ *
25
+ * @param {import('knex').Knex} knex - An instance of the Knex client.
26
+ * @param {string} [tableName='fingerprint_store'] - The name of the table to use.
27
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
28
+ */
29
+ export function createSqlStore(knex, tableName = 'fingerprint_store') {
30
+ // Custom replacer/reviver to handle Set serialization, similar to the Redis store.
31
+ const replacer = (k, v) => (v instanceof Set ? Array.from(v) : v);
32
+ const reviver = (k, v) => (k === 'ips' && Array.isArray(v) ? new Set(v) : v);
33
+
34
+ return {
35
+ async get(key) {
36
+ const row = await knex(tableName).where('key', key).first();
37
+ if (!row) return null;
38
+
39
+ // Manually check for expiration, as not all SQL databases have automatic TTL cleanup.
40
+ if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
41
+ await this.delete(key); // Clean up expired key.
42
+ return null;
43
+ }
44
+
45
+ try {
46
+ return JSON.parse(row.value, reviver);
47
+ } catch (e) {
48
+ // In case of malformed JSON, treat it as a miss.
49
+ return null;
50
+ }
51
+ },
52
+
53
+ async set(key, value, ttl) {
54
+ const stringValue = JSON.stringify(value, replacer);
55
+ const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null;
56
+
57
+ // Use native upsert capabilities of Knex for different SQL dialects.
58
+ await knex(tableName)
59
+ .insert({ key, value: stringValue, expiresAt })
60
+ .onConflict('key')
61
+ .merge();
62
+ },
63
+
64
+ async has(key) {
65
+ const row = await knex(tableName).where('key', key).first('key');
66
+ if (!row) return false;
67
+ // Also check for expiration here.
68
+ if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
69
+ return false;
70
+ }
71
+ return true;
72
+ },
73
+
74
+ async delete(key) {
75
+ await knex(tableName).where('key', key).del();
76
+ },
77
+ };
78
+ }