@anonympins/fingerprint 0.0.8 → 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 +57 -36
- package/fingerprint.builder.js +104 -0
- package/fingerprint.js +45 -46
- package/mongodb-store.js +53 -0
- package/package.json +22 -8
- package/redis-store.js +43 -0
- package/sql-store.js +78 -0
package/README.md
CHANGED
|
@@ -140,10 +140,10 @@ const securityConfig = {
|
|
|
140
140
|
// Useful for internal tools, trusted partners, or monitoring services.
|
|
141
141
|
// This check is performed first for maximum efficiency.
|
|
142
142
|
{ type: 'allowlist', entries: [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
147
|
// Option 2: DNS-verified bots (e.g., search engine crawlers).
|
|
148
148
|
// This uses a secure DNS lookup (reverse then forward) to verify the bot's identity.
|
|
149
149
|
// The result is cached per IP to avoid repeated DNS lookups.
|
|
@@ -198,17 +198,41 @@ The main Express middleware. It orchestrates identification, suspicion calculati
|
|
|
198
198
|
|
|
199
199
|
#### `configureStore(store)`
|
|
200
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.
|
|
201
202
|
|
|
202
|
-
|
|
203
|
+
**Redis Example:**
|
|
203
204
|
|
|
204
205
|
```javascript
|
|
205
206
|
import { configureStore } from './fingerprint.js';
|
|
206
|
-
import { createRedisStore } from './redis-store.js';
|
|
207
|
+
import { createRedisStore } from './redis-store.js';
|
|
208
|
+
import Redis from 'ioredis';
|
|
207
209
|
|
|
208
|
-
const
|
|
210
|
+
const redisClient = new Redis(process.env.REDIS_URL);
|
|
211
|
+
const redisStore = createRedisStore(redisClient);
|
|
209
212
|
configureStore(redisStore);
|
|
210
213
|
```
|
|
211
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
|
+
|
|
212
236
|
#### `identifyRequest(req, res)`
|
|
213
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.
|
|
214
238
|
|
|
@@ -300,34 +324,31 @@ To simplify setup, all client-side features can be enabled and configured throug
|
|
|
300
324
|
|
|
301
325
|
```javascript
|
|
302
326
|
import { initializeClient } from './path/to/fingerprint.client.js';
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Initializes all client-side protections.
|
|
330
|
+
* This is the recommended way to set up the client-side library.
|
|
331
|
+
*/
|
|
307
332
|
initializeClient({
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
315
346
|
fetch: {
|
|
316
|
-
|
|
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']
|
|
317
350
|
}
|
|
318
351
|
});
|
|
319
|
-
|
|
320
|
-
// Example 3: Enable only mouse and keystroke tracking, without patching fetch.
|
|
321
|
-
initializeClient({
|
|
322
|
-
mouse: true,
|
|
323
|
-
keystrokes: true
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// Example 4: Disable keystroke tracking but keep other defaults.
|
|
327
|
-
initializeClient({
|
|
328
|
-
keystrokes: false,
|
|
329
|
-
fetch: {}
|
|
330
|
-
});
|
|
331
352
|
```
|
|
332
353
|
|
|
333
354
|
### Client-Side Behavioral Analysis
|
|
@@ -352,15 +373,15 @@ This provides a proactive, first-line defense against simple bots. By setting up
|
|
|
352
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.
|
|
353
374
|
|
|
354
375
|
- **`protectedFetch(resource, options)`**: A wrapper around the native `fetch` API that automatically enriches requests with security headers. It adds:
|
|
355
|
-
|
|
356
|
-
|
|
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).
|
|
357
378
|
|
|
358
379
|
**Example:**
|
|
359
380
|
|
|
360
381
|
```javascript
|
|
361
|
-
import {
|
|
362
|
-
|
|
363
|
-
|
|
382
|
+
import {
|
|
383
|
+
initializeClient,
|
|
384
|
+
protectedFetch
|
|
364
385
|
} from './path/to/fingerprint.client.js';
|
|
365
386
|
|
|
366
387
|
// Start tracking user behavior as soon as the app loads.
|
|
@@ -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
|
+
}
|
package/fingerprint.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { BlockList } from "node:net";
|
|
4
4
|
import dns from "node:dns/promises";
|
|
5
5
|
import { Optimization } from "./library.js";
|
|
6
6
|
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
7
|
+
export { createRedisStore } from "./redis-store.js";
|
|
8
|
+
export { createMongoDbStore } from "./mongodb-store.js";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Retrieves the POW_SECRET from environment variables with appropriate checks.
|
|
@@ -702,7 +704,7 @@ function verifyTrapUrl(path, signature, nonce) {
|
|
|
702
704
|
/**
|
|
703
705
|
* @typedef {object} IStore
|
|
704
706
|
* @property {(key: string) => Promise<any>} get
|
|
705
|
-
* @property {(key: string, value: any) => Promise<void>} set
|
|
707
|
+
* @property {(key: string, value: any, ttl?: number) => Promise<void>} set
|
|
706
708
|
* @property {(key: string) => Promise<boolean>} has
|
|
707
709
|
* @property {(key: string) => Promise<void>} delete
|
|
708
710
|
*/
|
|
@@ -713,8 +715,21 @@ function verifyTrapUrl(path, signature, nonce) {
|
|
|
713
715
|
*/
|
|
714
716
|
const inMemoryStore = {
|
|
715
717
|
_map: new Map(),
|
|
718
|
+
_timeouts: new Map(),
|
|
716
719
|
async get(key) { return this._map.get(key); },
|
|
717
|
-
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
|
+
},
|
|
718
733
|
async has(key) { return this._map.has(key); },
|
|
719
734
|
async delete(key) { this._map.delete(key); },
|
|
720
735
|
};
|
|
@@ -861,7 +876,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
861
876
|
context._newCookies = context._newCookies || [];
|
|
862
877
|
context._newCookies.push(newCookie);
|
|
863
878
|
}
|
|
864
|
-
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
|
|
865
880
|
|
|
866
881
|
// Periodically clean up device data
|
|
867
882
|
if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
|
|
@@ -1084,14 +1099,16 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1084
1099
|
* Verifies a PoW solution based on a target and generates a ticket.
|
|
1085
1100
|
*/
|
|
1086
1101
|
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1087
|
-
clientIp,
|
|
1102
|
+
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1088
1103
|
nonce,
|
|
1089
1104
|
solution,
|
|
1090
1105
|
suspicionFactor,
|
|
1091
1106
|
clientSecret, // Le secret est maintenant requis
|
|
1092
1107
|
) {
|
|
1093
1108
|
const target = calculateTarget(suspicionFactor);
|
|
1094
|
-
const message = clientSecret
|
|
1109
|
+
const message = clientSecret
|
|
1110
|
+
? `${clientIp}:${nonce}:${solution}:${clientSecret}`
|
|
1111
|
+
: `${clientIp}:${nonce}:${solution}`;
|
|
1095
1112
|
const hash = crypto
|
|
1096
1113
|
.createHash("sha256")
|
|
1097
1114
|
.update(message)
|
|
@@ -1124,6 +1141,7 @@ export class FingerprintEngine {
|
|
|
1124
1141
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
1125
1142
|
this.securityConfig = securityConfig;
|
|
1126
1143
|
this.isProduction = isProduction;
|
|
1144
|
+
this._allowlist = this._buildAllowlist();
|
|
1127
1145
|
}
|
|
1128
1146
|
|
|
1129
1147
|
/**
|
|
@@ -1133,50 +1151,31 @@ export class FingerprintEngine {
|
|
|
1133
1151
|
* @param {string} clientIp - The IP address of the client.
|
|
1134
1152
|
* @returns {boolean} True if the IP is in the allowlist.
|
|
1135
1153
|
*/
|
|
1136
|
-
|
|
1154
|
+
_buildAllowlist() {
|
|
1155
|
+
const blockList = new BlockList();
|
|
1137
1156
|
const { whitelist = [] } = this.securityConfig;
|
|
1138
1157
|
const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
|
|
1139
1158
|
|
|
1140
1159
|
if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
|
|
1141
|
-
return
|
|
1160
|
+
return blockList; // Retourne une liste vide
|
|
1142
1161
|
}
|
|
1143
1162
|
|
|
1144
|
-
const ip = parse(clientIp);
|
|
1145
|
-
const ipVersion = ip.family;
|
|
1146
|
-
|
|
1147
1163
|
for (const entry of allowlistRule.entries) {
|
|
1148
1164
|
if (entry.includes('/')) { // CIDR range
|
|
1149
1165
|
try {
|
|
1150
|
-
const [
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const rangeBytes = rangeIp.toBuffer();
|
|
1158
|
-
const mask = Buffer.alloc(ipBytes.length, 0xff);
|
|
1159
|
-
|
|
1160
|
-
for (let i = 0; i < Math.floor(prefix / 8); i++) {
|
|
1161
|
-
if (ipBytes[i] !== rangeBytes[i]) {
|
|
1162
|
-
break; // Mismatch in full byte, move to next entry
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
const remainingBits = prefix % 8;
|
|
1166
|
-
if (remainingBits > 0) {
|
|
1167
|
-
const byteIndex = Math.floor(prefix / 8);
|
|
1168
|
-
const bitmask = (0xff << (8 - remainingBits)) & 0xff;
|
|
1169
|
-
if ((ipBytes[byteIndex] & bitmask) !== (rangeBytes[byteIndex] & bitmask)) {
|
|
1170
|
-
continue; // Mismatch in partial byte
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
return true; // IP is in CIDR range
|
|
1174
|
-
} catch (e) { continue; /* Ignore invalid CIDR entries */ }
|
|
1175
|
-
} else if (entry === clientIp) { // Direct IP match
|
|
1176
|
-
return true;
|
|
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);
|
|
1177
1173
|
}
|
|
1178
1174
|
}
|
|
1179
|
-
return
|
|
1175
|
+
return blockList;
|
|
1176
|
+
}
|
|
1177
|
+
_isIpInAllowlist(clientIp) {
|
|
1178
|
+
return this._allowlist.check(clientIp);
|
|
1180
1179
|
}
|
|
1181
1180
|
/**
|
|
1182
1181
|
* Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
|
|
@@ -1224,26 +1223,26 @@ export class FingerprintEngine {
|
|
|
1224
1223
|
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
1225
1224
|
|
|
1226
1225
|
if (!validHostname) {
|
|
1227
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
|
|
1226
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1228
1227
|
return false;
|
|
1229
1228
|
}
|
|
1230
1229
|
|
|
1231
1230
|
// 2. Forward DNS lookup
|
|
1232
1231
|
const addresses = await dns.resolve(validHostname);
|
|
1233
1232
|
if (addresses.includes(clientIp)) {
|
|
1234
|
-
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h
|
|
1233
|
+
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
1235
1234
|
return true;
|
|
1236
1235
|
}
|
|
1237
1236
|
} catch (error) {
|
|
1238
1237
|
// DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
|
|
1239
1238
|
}
|
|
1240
1239
|
|
|
1241
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
|
|
1240
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1242
1241
|
return false;
|
|
1243
1242
|
}
|
|
1244
1243
|
|
|
1245
1244
|
async processRequest(requestContext) {
|
|
1246
|
-
const { clientIp, path, cookies, query, isStatic } = requestContext;
|
|
1245
|
+
const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
|
|
1247
1246
|
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
1248
1247
|
if (isStatic) {
|
|
1249
1248
|
return { action: 'next', score: 0, vector: {} };
|
|
@@ -1350,7 +1349,7 @@ export class FingerprintEngine {
|
|
|
1350
1349
|
if (logger) {
|
|
1351
1350
|
logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
|
|
1352
1351
|
}
|
|
1353
|
-
await store.set(`device:${cookies.device_id}`, deviceData);
|
|
1352
|
+
await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
|
|
1354
1353
|
return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1355
1354
|
}
|
|
1356
1355
|
|
|
@@ -1436,7 +1435,7 @@ export class FingerprintEngine {
|
|
|
1436
1435
|
|
|
1437
1436
|
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1438
1437
|
if (deviceData) {
|
|
1439
|
-
deviceData.lastChallengeNonce = nonce;
|
|
1438
|
+
deviceData.lastChallengeNonce = nonce; // No TTL, part of the main device object
|
|
1440
1439
|
await store.set(`device:${cookies.device_id}`, deviceData);
|
|
1441
1440
|
}
|
|
1442
1441
|
|
|
@@ -1512,7 +1511,7 @@ export class FingerprintEngine {
|
|
|
1512
1511
|
if (ipProfile.statelessCount > statelessLimit) {
|
|
1513
1512
|
return `suspicious_high:${clientIp}`;
|
|
1514
1513
|
}
|
|
1515
|
-
await store.set(`ip:${clientIp}`, ipProfile);
|
|
1514
|
+
await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
|
|
1516
1515
|
|
|
1517
1516
|
const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
|
|
1518
1517
|
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anonympins/fingerprint",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
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
5
|
"main": "fingerprint.js",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"fingerprint.js",
|
|
15
15
|
"fingerprint.client.js",
|
|
16
|
+
"fingerprint.builder.js",
|
|
16
17
|
"library.js",
|
|
18
|
+
"redis-store.js",
|
|
19
|
+
"mongodb-store.js",
|
|
20
|
+
"sql-store.js",
|
|
17
21
|
"README.md",
|
|
18
22
|
"LICENSE"
|
|
19
23
|
],
|
|
@@ -45,17 +49,27 @@
|
|
|
45
49
|
"body-parser": "^1.20.2",
|
|
46
50
|
"cookie-parser": "^1.4.6",
|
|
47
51
|
"express": "^4.18.2",
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"mongodb": "^6.3.0",
|
|
51
|
-
"prom-client": "^15.1.2"
|
|
52
|
+
"prom-client": "^15.1.2",
|
|
53
|
+
"vitest": "^4.1.11"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
54
56
|
"ioredis": "^5.3.2",
|
|
55
|
-
"
|
|
57
|
+
"knex": ">=3.0.0",
|
|
58
|
+
"mongodb": "^6.3.0",
|
|
59
|
+
"sqlite3": "^5.1.7"
|
|
56
60
|
},
|
|
57
61
|
"peerDependenciesMeta": {
|
|
58
|
-
"ioredis": {
|
|
59
|
-
|
|
62
|
+
"ioredis": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"mongodb": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"knex": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"sqlite3": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
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
|
+
}
|