@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 +157 -4
- package/fingerprint.builder.js +104 -0
- package/fingerprint.client.js +399 -0
- package/fingerprint.js +241 -10
- package/mongodb-store.js +53 -0
- package/package.json +75 -48
- package/redis-store.js +43 -0
- package/sql-store.js +78 -0
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
|
|
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
|
-
|
|
203
|
+
**Redis Example:**
|
|
182
204
|
|
|
183
205
|
```javascript
|
|
184
206
|
import { configureStore } from './fingerprint.js';
|
|
185
|
-
import { createRedisStore } from './redis-store.js';
|
|
207
|
+
import { createRedisStore } from './redis-store.js';
|
|
208
|
+
import Redis from 'ioredis';
|
|
186
209
|
|
|
187
|
-
const
|
|
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
|
|
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
|
|
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);
|
package/mongodb-store.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
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
|
+
}
|