@anonympins/fingerprint 0.1.1 → 0.1.2
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/fingerprint.js +2267 -2267
- package/package.json +76 -76
- package/pow.solver.js +213 -213
package/fingerprint.js
CHANGED
|
@@ -1,2267 +1,2267 @@
|
|
|
1
|
-
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import { BlockList } from "node:net";
|
|
4
|
-
import dns from "node:dns/promises";
|
|
5
|
-
import { Optimization } from "./library.js";
|
|
6
|
-
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { dirname, join } from "node:path";
|
|
10
|
-
export { createRedisStore } from "./redis-store.js";
|
|
11
|
-
export { createMongoDbStore } from "./mongodb-store.js";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Retrieves the POW_SECRET from environment variables with appropriate checks.
|
|
15
|
-
* @returns {string} The secret key.
|
|
16
|
-
*/
|
|
17
|
-
const getPowSecret = () => {
|
|
18
|
-
const secret = process.env.POW_SECRET;
|
|
19
|
-
if (!secret && process.env.NODE_ENV === 'production') {
|
|
20
|
-
throw new Error('POW_SECRET environment variable is not set. This is required for production.');
|
|
21
|
-
}
|
|
22
|
-
return secret || "fallback-dev-secret-32-chars-minimum";
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Loads the pow.solver.js content for inlining in HTML pages.
|
|
27
|
-
* @returns {string} The solver JavaScript code.
|
|
28
|
-
*/
|
|
29
|
-
const getPowSolverCode = () => {
|
|
30
|
-
try {
|
|
31
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
-
const __dirname = dirname(__filename);
|
|
33
|
-
const solverPath = join(__dirname, 'pow.solver.js');
|
|
34
|
-
return readFileSync(solverPath, 'utf-8');
|
|
35
|
-
} catch (error) {
|
|
36
|
-
console.warn('Could not load pow.solver.js for inlining, using fallback inline code');
|
|
37
|
-
// Fallback inline code if file cannot be loaded
|
|
38
|
-
return `(function(global){
|
|
39
|
-
async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
|
|
40
|
-
const cpuTarget = BigInt(target);
|
|
41
|
-
let cpuSolution = 0;
|
|
42
|
-
while(true){
|
|
43
|
-
const msg = clientSecret ? clientIp+':'+nonce+':'+cpuSolution+':'+clientSecret : clientIp+':'+nonce+':'+cpuSolution;
|
|
44
|
-
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
|
|
45
|
-
const hashHex = Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
|
|
46
|
-
if(BigInt('0x'+hashHex) < cpuTarget) break;
|
|
47
|
-
cpuSolution++;
|
|
48
|
-
if(cpuSolution % 100000 === 0) await new Promise(r=>setTimeout(r,0));
|
|
49
|
-
}
|
|
50
|
-
return cpuSolution;
|
|
51
|
-
}
|
|
52
|
-
async function solveMemory(seed, difficulty){
|
|
53
|
-
const size = difficulty * 1024 * 1024;
|
|
54
|
-
const buffer = new Uint32Array(size / 4);
|
|
55
|
-
let h = new TextEncoder().encode(seed).reduce((acc,v)=>acc+v,0);
|
|
56
|
-
for(let i=0;i<buffer.length;i++) buffer[i] = h = Math.imul(h^i,1597334677);
|
|
57
|
-
let solution = 0;
|
|
58
|
-
const iterations = size / 16;
|
|
59
|
-
let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
|
|
60
|
-
for(let i=0;i<iterations;i++){
|
|
61
|
-
addr = buffer[addr] % buffer.length;
|
|
62
|
-
solution ^= addr;
|
|
63
|
-
}
|
|
64
|
-
return solution;
|
|
65
|
-
}
|
|
66
|
-
async function solveTsp(cities, targetMaxDistance){
|
|
67
|
-
function distance(c1,c2){return Math.sqrt(Math.pow(c1.x-c2.x,2)+Math.pow(c1.y-c2.y,2));}
|
|
68
|
-
function evaluatePathDistance(cities,path){
|
|
69
|
-
let total=0;
|
|
70
|
-
for(let i=0;i<path.length-1;i++) total+=distance(cities[path[i]],cities[path[i+1]]);
|
|
71
|
-
total+=distance(cities[path[path.length-1]],cities[path[0]]);
|
|
72
|
-
return total;
|
|
73
|
-
}
|
|
74
|
-
function solveTspNearestNeighbor(cities){
|
|
75
|
-
const n=cities.length;
|
|
76
|
-
if(n===0)return[];
|
|
77
|
-
let path=[0];
|
|
78
|
-
let visited=new Array(n).fill(false);
|
|
79
|
-
visited[0]=true;
|
|
80
|
-
for(let i=1;i<n;i++){
|
|
81
|
-
let nearest=-1, minDist=Infinity;
|
|
82
|
-
for(let j=0;j<n;j++){
|
|
83
|
-
if(!visited[j]){
|
|
84
|
-
const d=distance(cities[path[i-1]],cities[j]);
|
|
85
|
-
if(d<minDist){minDist=d;nearest=j;}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
path.push(nearest);
|
|
89
|
-
visited[nearest]=true;
|
|
90
|
-
}
|
|
91
|
-
return path;
|
|
92
|
-
}
|
|
93
|
-
await new Promise(r=>setTimeout(r,10));
|
|
94
|
-
const solutionPath=solveTspNearestNeighbor(cities);
|
|
95
|
-
const solutionDistance=evaluatePathDistance(cities,solutionPath);
|
|
96
|
-
return{path:solutionPath,distance:solutionDistance};
|
|
97
|
-
}
|
|
98
|
-
global.solveCpuChallengeInline=solveCpuTargetInline;
|
|
99
|
-
global.solveMemoryChallenge=solveMemory;
|
|
100
|
-
global.solveTspChallenge=solveTsp;
|
|
101
|
-
})(typeof window!=='undefined'?window:global);`;
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Calculates the JA3 fingerprint hash from the TLS Client Hello message.
|
|
107
|
-
* JA3 is a more reliable way to identify client applications (e.g., a specific browser or a script)
|
|
108
|
-
* based on the specifics of its TLS handshake.
|
|
109
|
-
* @param {object} context - The request context, containing the raw request object.
|
|
110
|
-
* @returns {string|null} The MD5 hash of the JA3 string, or null if it cannot be computed.
|
|
111
|
-
*/
|
|
112
|
-
function getJa3Hash(context) {
|
|
113
|
-
// 1. Prefer the JA3 hash from a trusted reverse proxy (e.g., Nginx, Cloudflare).
|
|
114
|
-
const ja3FromHeader = context.headers['x-ja3-hash'];
|
|
115
|
-
if (ja3FromHeader) {
|
|
116
|
-
return ja3FromHeader;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 2. Fallback to calculating from the raw socket if available (requires Node.js to handle TLS).
|
|
120
|
-
const clientHello = context.rawReq?.socket?.clientHello;
|
|
121
|
-
if (!clientHello) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
|
|
127
|
-
|
|
128
|
-
// The official JA3 spec includes the TLS version.
|
|
129
|
-
// Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
|
|
130
|
-
const tlsVersionMap = {
|
|
131
|
-
'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
|
|
132
|
-
};
|
|
133
|
-
const tlsVersionId = tlsVersionMap[version] || 0;
|
|
134
|
-
|
|
135
|
-
const ja3String = [
|
|
136
|
-
tlsVersionId,
|
|
137
|
-
// The ciphers array from clientHello is an array of objects, not just IDs.
|
|
138
|
-
Array.isArray(ciphers) ? ciphers.join('-') : '',
|
|
139
|
-
extensions?.join('-') || '',
|
|
140
|
-
ellipticCurves?.join('-') || '',
|
|
141
|
-
ellipticCurvePointFormats?.join('-') || ''
|
|
142
|
-
].join(',');
|
|
143
|
-
|
|
144
|
-
return crypto.createHash('md5').update(ja3String).digest('hex');
|
|
145
|
-
} catch (e) {
|
|
146
|
-
return null; // Could fail if clientHello structure is unexpected.
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Creates a stable hash based on device characteristics, independent of the IP.
|
|
151
|
-
* This is our "level 2 fingerprint".
|
|
152
|
-
* @param {object} context - The request context.
|
|
153
|
-
* @returns {string} A hash representing the device.
|
|
154
|
-
*/
|
|
155
|
-
function getHeaderSignature(context) {
|
|
156
|
-
if (!context.rawHeaders) return '';
|
|
157
|
-
const headerKeys = [];
|
|
158
|
-
for (let i = 0; i < context.rawHeaders.length; i += 2) {
|
|
159
|
-
headerKeys.push(context.rawHeaders[i]);
|
|
160
|
-
}
|
|
161
|
-
return cyrb53(headerKeys.join(','));
|
|
162
|
-
}
|
|
163
|
-
export function getDeviceHash(context) {
|
|
164
|
-
// Prioritize the rich client-side fingerprint if provided.
|
|
165
|
-
const clientFp = context.headers['x-device-fingerprint'];
|
|
166
|
-
if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
|
|
167
|
-
return clientFp;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const srv = new FingerprintBuilder();
|
|
171
|
-
|
|
172
|
-
// 1. SIGNAL FORT: User Agent (poids élevé)
|
|
173
|
-
const ua = context.headers["user-agent"];
|
|
174
|
-
if (ua) {
|
|
175
|
-
srv.add("ua", ua);
|
|
176
|
-
// Extraire des infos supplémentaires du UA
|
|
177
|
-
const uaParts = parseUserAgent(ua);
|
|
178
|
-
if (uaParts.browser) srv.add("browser", uaParts.browser);
|
|
179
|
-
if (uaParts.os) srv.add("os_version", uaParts.os);
|
|
180
|
-
if (uaParts.device) srv.add("device_type", uaParts.device);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// 2. SIGNAL FORT: JA3 TLS Fingerprint
|
|
184
|
-
const ja3 = getJa3Hash(context);
|
|
185
|
-
if (ja3) srv.add("ja3", ja3);
|
|
186
|
-
|
|
187
|
-
// 3. SIGNAL MOYEN: Client Hints (modern browsers)
|
|
188
|
-
if (context.headers["sec-ch-ua"]) {
|
|
189
|
-
srv.add("ch_ua", context.headers["sec-ch-ua"]);
|
|
190
|
-
}
|
|
191
|
-
if (context.headers["sec-ch-ua-platform"]) {
|
|
192
|
-
srv.add("ch_platform", context.headers["sec-ch-ua-platform"]);
|
|
193
|
-
}
|
|
194
|
-
if (context.headers["sec-ch-ua-mobile"]) {
|
|
195
|
-
srv.add("ch_mobile", context.headers["sec-ch-ua-mobile"]);
|
|
196
|
-
}
|
|
197
|
-
if (context.headers["sec-ch-ua-model"]) {
|
|
198
|
-
srv.add("ch_model", context.headers["sec-ch-ua-model"]);
|
|
199
|
-
}
|
|
200
|
-
if (context.headers["sec-ch-ua-arch"]) {
|
|
201
|
-
srv.add("ch_arch", context.headers["sec-ch-ua-arch"]);
|
|
202
|
-
}
|
|
203
|
-
if (context.headers["sec-ch-ua-bitness"]) {
|
|
204
|
-
srv.add("ch_bitness", context.headers["sec-ch-ua-bitness"]);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 4. SIGNAL MOYEN: HTTP Version et protocole
|
|
208
|
-
if (context.httpVersion) {
|
|
209
|
-
srv.add("http_ver", context.httpVersion);
|
|
210
|
-
}
|
|
211
|
-
if (context.headers["upgrade-insecure-requests"]) {
|
|
212
|
-
srv.add("upgrade", context.headers["upgrade-insecure-requests"]);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// 10. SIGNAL FORT: Ordonnancement des headers
|
|
216
|
-
srv.add("h_ord", getHeaderSignature(context));
|
|
217
|
-
|
|
218
|
-
// 11. SIGNAL AVANCÉ: Cookies (si disponible)
|
|
219
|
-
if (context.cookies) {
|
|
220
|
-
const cookieKeys = Object.keys(context.cookies).sort().join(',');
|
|
221
|
-
srv.add("cookie_keys", cookieKeys);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// 12. SIGNAL AVANCÉ: Format de la requête
|
|
225
|
-
if (context.rawHeaders) {
|
|
226
|
-
// Vérifier des headers spécifiques qui indiquent le client
|
|
227
|
-
const clientHeaders = ['x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip'];
|
|
228
|
-
clientHeaders.forEach(h => {
|
|
229
|
-
if (context.headers[h]) {
|
|
230
|
-
srv.add(h.replace(/-/g, '_'), context.headers[h]);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// 13. OPTIONNEL: IP (version simplifiée pour les réseaux partagés)
|
|
236
|
-
// Ne pas inclure l'IP complète, mais un hash du réseau /24 ou /16
|
|
237
|
-
// pour détecter les changements de réseau tout en protégeant la vie privée
|
|
238
|
-
const ip = context.clientIp || context.headers['x-forwarded-for']?.split(',')[0]?.trim();
|
|
239
|
-
if (ip && isPrivateIp(ip)) {
|
|
240
|
-
// Pour les IP privées, on peut prendre le /24
|
|
241
|
-
const networkHash = hashNetwork(ip, 24);
|
|
242
|
-
srv.add("network", networkHash);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return srv.toString();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Fonctions utilitaires
|
|
249
|
-
function parseUserAgent(ua) {
|
|
250
|
-
// Parser basique du User-Agent
|
|
251
|
-
const result = {};
|
|
252
|
-
|
|
253
|
-
// Détection du navigateur
|
|
254
|
-
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
|
255
|
-
result.browser = 'Chrome';
|
|
256
|
-
const match = ua.match(/Chrome\/(\d+)/);
|
|
257
|
-
if (match) result.browser += `/${match[1]}`;
|
|
258
|
-
} else if (ua.includes('Firefox')) {
|
|
259
|
-
result.browser = 'Firefox';
|
|
260
|
-
const match = ua.match(/Firefox\/(\d+)/);
|
|
261
|
-
if (match) result.browser += `/${match[1]}`;
|
|
262
|
-
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
|
263
|
-
result.browser = 'Safari';
|
|
264
|
-
const match = ua.match(/Version\/(\d+)/);
|
|
265
|
-
if (match) result.browser += `/${match[1]}`;
|
|
266
|
-
} else if (ua.includes('Edg')) {
|
|
267
|
-
result.browser = 'Edge';
|
|
268
|
-
const match = ua.match(/Edg\/(\d+)/);
|
|
269
|
-
if (match) result.browser += `/${match[1]}`;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Détection de l'OS
|
|
273
|
-
if (ua.includes('Windows NT 10.0')) result.os = 'Windows 10';
|
|
274
|
-
else if (ua.includes('Windows NT 6.1')) result.os = 'Windows 7';
|
|
275
|
-
else if (ua.includes('Mac OS X')) result.os = 'macOS';
|
|
276
|
-
else if (ua.includes('Linux') && !ua.includes('Android')) result.os = 'Linux';
|
|
277
|
-
else if (ua.includes('Android')) result.os = 'Android';
|
|
278
|
-
else if (ua.includes('iPhone') || ua.includes('iPad')) result.os = 'iOS';
|
|
279
|
-
|
|
280
|
-
// Détection du type d'appareil
|
|
281
|
-
if (ua.includes('Mobile')) result.device = 'mobile';
|
|
282
|
-
else if (ua.includes('Tablet')) result.device = 'tablet';
|
|
283
|
-
else result.device = 'desktop';
|
|
284
|
-
|
|
285
|
-
return result;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function normalizeReferer(referer) {
|
|
289
|
-
try {
|
|
290
|
-
const url = new URL(referer);
|
|
291
|
-
return `${url.protocol}//${url.hostname}`;
|
|
292
|
-
} catch {
|
|
293
|
-
return referer;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function isPrivateIp(ip) {
|
|
298
|
-
// Vérifier si l'IP est privée
|
|
299
|
-
const parts = ip.split('.');
|
|
300
|
-
if (parts.length !== 4) return false;
|
|
301
|
-
const first = parseInt(parts[0]);
|
|
302
|
-
return (first === 10) || (first === 172 && parseInt(parts[1]) >= 16 && parseInt(parts[1]) <= 31) || (first === 192 && parseInt(parts[1]) === 168);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function hashNetwork(ip, prefix = 24) {
|
|
306
|
-
// Hash du réseau (masque /24 ou /16)
|
|
307
|
-
const parts = ip.split('.');
|
|
308
|
-
if (parts.length !== 4) return null;
|
|
309
|
-
const maskBytes = prefix / 8;
|
|
310
|
-
const network = parts.slice(0, maskBytes).join('.');
|
|
311
|
-
// Hash simple
|
|
312
|
-
let hash = 0;
|
|
313
|
-
for (let i = 0; i < network.length; i++) {
|
|
314
|
-
const char = network.charCodeAt(i);
|
|
315
|
-
hash = ((hash << 5) - hash) + char;
|
|
316
|
-
hash = hash & hash;
|
|
317
|
-
}
|
|
318
|
-
return hash.toString(16);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
|
|
323
|
-
* @param {string} nonce - Unique nonce for the challenge.
|
|
324
|
-
* @param {number} numCities - Number of cities to include in the problem.
|
|
325
|
-
* @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
|
|
326
|
-
* @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
|
|
327
|
-
* @param {string} path - Redirect path after solving.
|
|
328
|
-
* @returns {string} HTML of the challenge page.
|
|
329
|
-
*/
|
|
330
|
-
const generateTspChallenge = (
|
|
331
|
-
nonce,
|
|
332
|
-
numCities,
|
|
333
|
-
targetMaxDistance,
|
|
334
|
-
cities,
|
|
335
|
-
path = "",
|
|
336
|
-
) => {
|
|
337
|
-
const citiesJson = JSON.stringify(cities);
|
|
338
|
-
const solverCode = getPowSolverCode();
|
|
339
|
-
return `
|
|
340
|
-
<html>
|
|
341
|
-
<head><title>Advanced Security Check (Level 3)</title></head>
|
|
342
|
-
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
343
|
-
<h1>Ultimate Verification (Level 3)</h1>
|
|
344
|
-
<p>Please solve this small optimization problem to prove you are human.</p>
|
|
345
|
-
<div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
|
|
346
|
-
<script>${solverCode}</script>
|
|
347
|
-
<script>
|
|
348
|
-
const cities = ${citiesJson};
|
|
349
|
-
const nonce = "${nonce}";
|
|
350
|
-
const targetMaxDistance = ${targetMaxDistance};
|
|
351
|
-
|
|
352
|
-
async function solve() {
|
|
353
|
-
const result = await window.solveTspChallenge(cities, targetMaxDistance);
|
|
354
|
-
|
|
355
|
-
if (result.distance <= targetMaxDistance) {
|
|
356
|
-
window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
|
|
357
|
-
} else {
|
|
358
|
-
document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
solve();
|
|
362
|
-
</script>
|
|
363
|
-
</body>
|
|
364
|
-
</html>`;
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Verifies a TSP PoW solution.
|
|
369
|
-
* @param {string} nonce - The challenge nonce.
|
|
370
|
-
* @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
|
|
371
|
-
* @param {number} numCities - The number of cities in the challenge.
|
|
372
|
-
* @param {number} targetMaxDistance - The maximum acceptable distance.
|
|
373
|
-
* @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
|
|
374
|
-
* @returns {boolean} True if the solution is valid.
|
|
375
|
-
*/
|
|
376
|
-
export const verifyTspChallenge = (
|
|
377
|
-
nonce,
|
|
378
|
-
solutionPathJson,
|
|
379
|
-
numCities,
|
|
380
|
-
targetMaxDistance,
|
|
381
|
-
cities,
|
|
382
|
-
) => {
|
|
383
|
-
try {
|
|
384
|
-
const solutionPath = JSON.parse(solutionPathJson);
|
|
385
|
-
if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
|
|
386
|
-
return false;
|
|
387
|
-
|
|
388
|
-
// Verify that the path is a valid permutation of the cities
|
|
389
|
-
const uniqueCities = new Set(solutionPath);
|
|
390
|
-
if (
|
|
391
|
-
uniqueCities.size !== numCities ||
|
|
392
|
-
Math.min(...solutionPath) < 0 ||
|
|
393
|
-
Math.max(...solutionPath) >= numCities
|
|
394
|
-
)
|
|
395
|
-
return false;
|
|
396
|
-
|
|
397
|
-
// Recalculate the distance on the server side
|
|
398
|
-
let totalDistance = 0;
|
|
399
|
-
let totalPenalty = 0;
|
|
400
|
-
|
|
401
|
-
// Function to calculate the angle between 3 points (p1 -> p2 -> p3)
|
|
402
|
-
const calculateAngle = (p1, p2, p3) => {
|
|
403
|
-
const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
|
|
404
|
-
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
|
405
|
-
const dotProduct = v1.x * v2.x + v1.y * v2.y;
|
|
406
|
-
const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
|
|
407
|
-
const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
|
|
408
|
-
if (mag1 === 0 || mag2 === 0) return 180;
|
|
409
|
-
const angleRad = Math.acos(dotProduct / (mag1 * mag2));
|
|
410
|
-
return angleRad * (180 / Math.PI);
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
for (let i = 0; i < solutionPath.length; i++) {
|
|
414
|
-
const p1_idx = solutionPath[i];
|
|
415
|
-
const p2_idx = solutionPath[(i + 1) % numCities];
|
|
416
|
-
const p3_idx = solutionPath[(i + 2) % numCities];
|
|
417
|
-
|
|
418
|
-
// 1. Calculate segment distance
|
|
419
|
-
totalDistance += Math.sqrt(Math.pow(cities[p1_idx].x - cities[p2_idx].x, 2) + Math.pow(cities[p1_idx].y - cities[p2_idx].y, 2));
|
|
420
|
-
|
|
421
|
-
// 2. Calculate turn penalty
|
|
422
|
-
const angle = calculateAngle(
|
|
423
|
-
cities[p1_idx],
|
|
424
|
-
cities[p2_idx],
|
|
425
|
-
cities[p3_idx],
|
|
426
|
-
);
|
|
427
|
-
if (angle < 45) {
|
|
428
|
-
// Penalty for very sharp turns (< 45 degrees)
|
|
429
|
-
totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const finalScore = totalDistance + totalPenalty;
|
|
434
|
-
return finalScore <= targetMaxDistance;
|
|
435
|
-
} catch (e) {
|
|
436
|
-
console.error("Error during TSP challenge verification:", e);
|
|
437
|
-
return false;
|
|
438
|
-
}
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Generates the HTML content for the CPU PoW challenge (SHA-256).
|
|
443
|
-
*/
|
|
444
|
-
const generateCpuPoWChallenge = (
|
|
445
|
-
clientIp,
|
|
446
|
-
nonce,
|
|
447
|
-
difficulty = 4,
|
|
448
|
-
path = "",
|
|
449
|
-
) => {
|
|
450
|
-
return `
|
|
451
|
-
<html>
|
|
452
|
-
<head><title>Security Check</title></head>
|
|
453
|
-
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
454
|
-
<h1>One moment... (Level 1)</h1>
|
|
455
|
-
<p>We are verifying that you are not a bot. This takes a few seconds.</p>
|
|
456
|
-
<div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
|
|
457
|
-
<script>
|
|
458
|
-
async function solve() {
|
|
459
|
-
const ip = "${clientIp}";
|
|
460
|
-
const nonce = "${nonce}";
|
|
461
|
-
const diff = ${difficulty};
|
|
462
|
-
const target = "0".repeat(diff);
|
|
463
|
-
let solution = 0;
|
|
464
|
-
|
|
465
|
-
while (true) {
|
|
466
|
-
const msg = "${ip}" + ":" + "${nonce}" + ":" + solution;
|
|
467
|
-
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
|
|
468
|
-
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
469
|
-
if (hash.startsWith(target)) break;
|
|
470
|
-
solution++;
|
|
471
|
-
if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
|
|
472
|
-
}
|
|
473
|
-
window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
|
|
474
|
-
}
|
|
475
|
-
solve();
|
|
476
|
-
</script>
|
|
477
|
-
</body>
|
|
478
|
-
</html>
|
|
479
|
-
`;
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Generates the HTML content for a memory-intensive PoW challenge.
|
|
484
|
-
*/
|
|
485
|
-
const generateMemoryPoWChallenge = (
|
|
486
|
-
clientIp,
|
|
487
|
-
nonce,
|
|
488
|
-
difficulty = 16,
|
|
489
|
-
path = "",
|
|
490
|
-
) => {
|
|
491
|
-
// difficulty here is the buffer size in MB.
|
|
492
|
-
return `
|
|
493
|
-
<html>
|
|
494
|
-
<head><title>Advanced Security Check</title></head>
|
|
495
|
-
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
496
|
-
<h1>Enhanced Verification... (Level 2)</h1>
|
|
497
|
-
<p>Your activity requires an additional security check.</p>
|
|
498
|
-
<div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
|
|
499
|
-
<script>
|
|
500
|
-
async function solve() {
|
|
501
|
-
const nonce = "${nonce}";
|
|
502
|
-
const size = ${difficulty} * 1024 * 1024; // en octets
|
|
503
|
-
const iterations = size / 16;
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
const buffer = new Uint32Array(size / 4);
|
|
507
|
-
let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
|
|
508
|
-
for (let i = 0; i < buffer.length; i++) {
|
|
509
|
-
buffer[i] = (h = Math.imul(h ^ i, 1597334677));
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
let finalHash = 0;
|
|
513
|
-
for(let i = 0; i < iterations; i++) {
|
|
514
|
-
const addr = buffer[i % buffer.length] % buffer.length;
|
|
515
|
-
finalHash ^= buffer[addr];
|
|
516
|
-
}
|
|
517
|
-
window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
|
|
518
|
-
} catch(e) {
|
|
519
|
-
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
solve();
|
|
523
|
-
</script>
|
|
524
|
-
</body>
|
|
525
|
-
</html>`;
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Verifies if a PoW solution is valid and generates a clearance ticket.
|
|
530
|
-
*/
|
|
531
|
-
export const verifyPoWAndGenerateTicket = (
|
|
532
|
-
ip,
|
|
533
|
-
nonce,
|
|
534
|
-
solution,
|
|
535
|
-
difficulty = 4,
|
|
536
|
-
) => {
|
|
537
|
-
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
538
|
-
const hash = crypto
|
|
539
|
-
.createHash("sha256")
|
|
540
|
-
.update(`${ip}:${nonce}:${solution}`)
|
|
541
|
-
.digest("hex");
|
|
542
|
-
|
|
543
|
-
if (!hash.startsWith("0".repeat(difficulty))) {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
|
|
548
|
-
const expiry = Date.now() + 3600000; // 1 heure
|
|
549
|
-
const signature = crypto
|
|
550
|
-
.createHmac("sha256", getPowSecret())
|
|
551
|
-
.update(`${ip}:${expiry}`)
|
|
552
|
-
.digest("hex");
|
|
553
|
-
|
|
554
|
-
return `${expiry}:${signature}`;
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Verifies a memory PoW solution.
|
|
561
|
-
* The server performs the same calculation to validate.
|
|
562
|
-
*/
|
|
563
|
-
export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
|
|
564
|
-
const size = difficulty * 1024 * 1024;
|
|
565
|
-
const iterations = size / 16;
|
|
566
|
-
const buffer = new Uint32Array(size / 4);
|
|
567
|
-
const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
|
|
568
|
-
let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
|
|
569
|
-
|
|
570
|
-
for (let i = 0; i < buffer.length; i++) {
|
|
571
|
-
buffer[i] = h = Math.imul(h ^ i, 1597334677);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
let finalHash = 0;
|
|
575
|
-
let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
|
|
576
|
-
for (let i = 0; i < iterations; i++) {
|
|
577
|
-
addr = buffer[addr] % buffer.length;
|
|
578
|
-
finalHash ^= addr;
|
|
579
|
-
}
|
|
580
|
-
return finalHash === parseInt(solution, 10);
|
|
581
|
-
};
|
|
582
|
-
export const isTicketValid = (ip, ticket) => {
|
|
583
|
-
if (!ticket) return false;
|
|
584
|
-
const [expiry, sig] = ticket.split(":");
|
|
585
|
-
if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
|
|
586
|
-
const expectedSig = crypto
|
|
587
|
-
.createHmac("sha256", getPowSecret())
|
|
588
|
-
.update(`${ip}:${expiry}`)
|
|
589
|
-
.digest("hex");
|
|
590
|
-
|
|
591
|
-
// Use timingSafeEqual to prevent timing attacks
|
|
592
|
-
try {
|
|
593
|
-
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
594
|
-
} catch (e) {
|
|
595
|
-
// This can happen if the buffers have different lengths, which is a failure case.
|
|
596
|
-
return false;
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
/**
|
|
602
|
-
* Calculates suspicion indicators related to HTTP header anomalies.
|
|
603
|
-
* @param {object} context - The request context.
|
|
604
|
-
* @returns {{headerAnomalyScore: number}}
|
|
605
|
-
*/
|
|
606
|
-
function getHeaderAnomalies(context) {
|
|
607
|
-
let anomalyScore = 0;
|
|
608
|
-
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
609
|
-
if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
|
|
610
|
-
anomalyScore += 60;
|
|
611
|
-
}
|
|
612
|
-
// Penalty if Accept-Language header is missing
|
|
613
|
-
if (!context.headers["accept-language"]) {
|
|
614
|
-
anomalyScore += 25;
|
|
615
|
-
}
|
|
616
|
-
// Penalty for HTTP/1.0 requests, often used by old tools or bots
|
|
617
|
-
if (context.httpVersion === "1.0") {
|
|
618
|
-
anomalyScore += 15;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
return {
|
|
622
|
-
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Checks for submitted honeypot fields to detect bots.
|
|
628
|
-
* @param {object} context - The request context.
|
|
629
|
-
* @param {object} honeypotConfig - The honeypot configuration.
|
|
630
|
-
* @returns {{honeypotScore: number}}
|
|
631
|
-
*/
|
|
632
|
-
function getHoneypotScore(context, honeypotConfig = {}) {
|
|
633
|
-
const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
|
|
634
|
-
// (NOUVEAU) Permettre de brancher des analyseurs externes plus robustes.
|
|
635
|
-
// L'utilisateur pourrait passer une fonction qui prend les données de la requête
|
|
636
|
-
// et retourne `true` si une menace est détectée.
|
|
637
|
-
// Exemple: `(data) => myWafLibrary.isMalicious(data)`
|
|
638
|
-
const externalAnalyzers = honeypotConfig.analyzers || [];
|
|
639
|
-
if (typeof detectInjections === 'object' && detectInjections.analyzers) {
|
|
640
|
-
externalAnalyzers.push(...detectInjections.analyzers);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// 1. Check for trap URL access
|
|
644
|
-
if (trapUrls.some(trap => context.path.startsWith(trap))) {
|
|
645
|
-
return { honeypotScore: 100 };
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (fields.length === 0 && !detectInjections) {
|
|
649
|
-
return { honeypotScore: 0 };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Check both query parameters (for URL probing) and the request body (for hidden form fields).
|
|
653
|
-
const queryData =
|
|
654
|
-
context.query instanceof URLSearchParams
|
|
655
|
-
? Object.fromEntries(context.query.entries())
|
|
656
|
-
: context.query || {};
|
|
657
|
-
const bodyData = context.body || {};
|
|
658
|
-
|
|
659
|
-
// 2. Check for honeypot field names
|
|
660
|
-
for (const field of fields) {
|
|
661
|
-
// A bot is trapped if the field exists in either the query OR the body.
|
|
662
|
-
if (
|
|
663
|
-
Object.prototype.hasOwnProperty.call(queryData, field) ||
|
|
664
|
-
Object.prototype.hasOwnProperty.call(bodyData, field)
|
|
665
|
-
) {
|
|
666
|
-
return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// 3. (NOUVEAU) Utiliser les analyseurs externes
|
|
671
|
-
const allData = { ...queryData, ...bodyData };
|
|
672
|
-
if (externalAnalyzers.length > 0) {
|
|
673
|
-
for (const analyzer of externalAnalyzers) {
|
|
674
|
-
// On passe à l'analyseur l'ensemble des données de la requête.
|
|
675
|
-
if (analyzer(allData)) {
|
|
676
|
-
return { honeypotScore: 100 };
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
if (detectInjections) {
|
|
682
|
-
// 3. Check for injection attempts in values using the centralized isMalicious function.
|
|
683
|
-
const inspect = (obj) => {
|
|
684
|
-
for (const key in obj) {
|
|
685
|
-
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
686
|
-
const value = obj[key];
|
|
687
|
-
if (typeof value === 'string') {
|
|
688
|
-
if (isMalicious(value)) return true;
|
|
689
|
-
} else if (typeof value === 'object' && value !== null) {
|
|
690
|
-
// For nested objects (like in NoSQL injections), we stringify them once
|
|
691
|
-
// to check for malicious patterns within their structure or values.
|
|
692
|
-
if (isMalicious(JSON.stringify(value))) return true;
|
|
693
|
-
// Then, we recurse to check individual string values inside.
|
|
694
|
-
if (inspect(value)) return true;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return false;
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
if (inspect(queryData)) {
|
|
702
|
-
return { honeypotScore: 100 };
|
|
703
|
-
}
|
|
704
|
-
if (inspect(bodyData)) {
|
|
705
|
-
return { honeypotScore: 100 };
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
return { honeypotScore: 0 };
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
714
|
-
* @param {object} context - Le contexte de la requête, contenant les en-têtes.
|
|
715
|
-
* @returns {{behaviorScore: number}}
|
|
716
|
-
*/
|
|
717
|
-
function getBehaviorScore(context) {
|
|
718
|
-
const behaviorHeader = context.headers['x-behavior-metrics'];
|
|
719
|
-
if (!behaviorHeader) {
|
|
720
|
-
return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
try {
|
|
724
|
-
const metrics = JSON.parse(behaviorHeader);
|
|
725
|
-
let score = 0;
|
|
726
|
-
if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
|
|
727
|
-
if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
|
|
728
|
-
return { behaviorScore: Math.min(100, score) };
|
|
729
|
-
} catch (e) {
|
|
730
|
-
return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Analyzes server-side request patterns for a given device to detect bot-like behavior.
|
|
736
|
-
* This is a stateful check that looks for repetitive or unnaturally fast requests.
|
|
737
|
-
* @param {object} context - The request context.
|
|
738
|
-
* @param {object} deviceData - The device's activity data from the store.
|
|
739
|
-
* @returns {{requestPatternScore: number}}
|
|
740
|
-
*/
|
|
741
|
-
function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
742
|
-
if (!deviceData) return { requestPatternScore: 0 };
|
|
743
|
-
|
|
744
|
-
// Default values for the pattern detection logic, which can be overridden by the auto-tuner.
|
|
745
|
-
const {
|
|
746
|
-
velocityThreshold = 800, velocityWeight = 30,
|
|
747
|
-
burstThreshold = 1500, burstWeight = 50,
|
|
748
|
-
scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
|
|
749
|
-
historySize = 10,
|
|
750
|
-
decayFactor = 0.9,
|
|
751
|
-
inactivityReset = 30000,
|
|
752
|
-
// Nouveau paramètre pour la détection de séquences
|
|
753
|
-
sequenceLength = 3, sequenceWeight = 60
|
|
754
|
-
} = patternConfig;
|
|
755
|
-
|
|
756
|
-
const now = Date.now();
|
|
757
|
-
const currentPath = context.path;
|
|
758
|
-
// Make the function robust to handle both URLSearchParams and plain objects for query.
|
|
759
|
-
// Ensure query parameters are consistently handled, whether they come from a URLSearchParams object or a plain object.
|
|
760
|
-
const params = context.query instanceof URLSearchParams ? context.query : new URLSearchParams(context.query);
|
|
761
|
-
params.sort(); // Sort for deterministic order
|
|
762
|
-
const currentQueryString = params.toString();
|
|
763
|
-
|
|
764
|
-
// Initialize request history if it doesn't exist
|
|
765
|
-
if (!deviceData.requestHistory) {
|
|
766
|
-
deviceData.requestHistory = [];
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
const history = deviceData.requestHistory;
|
|
770
|
-
let score = 0;
|
|
771
|
-
|
|
772
|
-
// --- Analyze patterns based on the last few requests ---
|
|
773
|
-
if (history.length > 0) {
|
|
774
|
-
const lastRequest = history[history.length - 1];
|
|
775
|
-
const timeSinceLast = now - lastRequest.timestamp; // 150
|
|
776
|
-
|
|
777
|
-
// 1. Velocity Check: Penalize requests that are too fast to be human.
|
|
778
|
-
if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
|
|
779
|
-
score += velocityWeight; // score = 30
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// 2. Burst Check: Add additional penalty for identical requests in a very short time frame.
|
|
783
|
-
if (currentPath === lastRequest.path && currentQueryString === lastRequest.queryString && timeSinceLast < burstThreshold) { // 150 < 500 -> true
|
|
784
|
-
score += burstWeight; // score = 30 + 50 = 80
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// 3. Sequential Scraping Check: Add additional penalty for same path with different query params (potential scraping).
|
|
788
|
-
// This is a simplified check, now independent of the burst check.
|
|
789
|
-
if (currentPath === lastRequest.path && currentQueryString !== lastRequest.queryString && timeSinceLast < scrapeThreshold) {
|
|
790
|
-
const previousRequest = history.length > 2 ? history[history.length - 2] : null;
|
|
791
|
-
if (previousRequest && previousRequest.path === currentPath) {
|
|
792
|
-
score += scrapeBurstWeight; // This is at least the 3rd request in a sequence to the same path.
|
|
793
|
-
} else {
|
|
794
|
-
score += scrapeWeight; // First sign of a potential scraping pattern
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// 4. (NOUVEAU) Détection de séquences répétitives (ex: A -> B -> C -> A -> B -> C)
|
|
799
|
-
if (history.length >= sequenceLength * 2) {
|
|
800
|
-
const lastSequence = history.slice(-sequenceLength);
|
|
801
|
-
const previousSequence = history.slice(-sequenceLength * 2, -sequenceLength);
|
|
802
|
-
|
|
803
|
-
const isRepeating = lastSequence.every((req, i) =>
|
|
804
|
-
req.path === previousSequence[i].path && req.queryString === previousSequence[i].queryString
|
|
805
|
-
);
|
|
806
|
-
if (isRepeating) score += sequenceWeight;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// --- Update history ---
|
|
811
|
-
history.push({
|
|
812
|
-
timestamp: now,
|
|
813
|
-
path: currentPath,
|
|
814
|
-
queryString: currentQueryString,
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
// Keep history to a reasonable size (e.g., last 10 requests)
|
|
818
|
-
if (history.length > historySize) {
|
|
819
|
-
history.shift();
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// Decay the score over time if behavior becomes normal again.
|
|
823
|
-
// We can store the score in deviceData and decay it.
|
|
824
|
-
deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
|
|
825
|
-
|
|
826
|
-
// If there hasn't been a request in a while, reset the pattern score.
|
|
827
|
-
if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
|
|
828
|
-
deviceData.lastPatternScore = 0;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const trapUrlTemplates = [
|
|
835
|
-
'/includes/config-{RANDOM}.php', // Classic PHP config file
|
|
836
|
-
'/.env.{RANDOM}', // Environment file
|
|
837
|
-
'/backups/db_backup_{RANDOM}.sql.gz', // Database backup
|
|
838
|
-
'/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
|
|
839
|
-
'/_private/deploy_key_{RANDOM}.pem', // Private key file
|
|
840
|
-
'/logs/app_error_{RANDOM}.log', // Log file
|
|
841
|
-
'/.git/config_{RANDOM}' // Exposed git config variant
|
|
842
|
-
];
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* Generates a signed trap URL.
|
|
846
|
-
* @param {string} nonce - The nonce to sign the URL with.
|
|
847
|
-
* @returns {string} The trap URL.
|
|
848
|
-
*/
|
|
849
|
-
function generateTrapUrl(nonce) {
|
|
850
|
-
// Pick a random template to diversify the traps
|
|
851
|
-
const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
|
|
852
|
-
const randomPart = crypto.randomBytes(8).toString('hex');
|
|
853
|
-
const path = template.replace('{RANDOM}', randomPart);
|
|
854
|
-
|
|
855
|
-
const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
|
|
856
|
-
return `${path}?sig=${signature}`;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/**
|
|
860
|
-
* Verifies if a given path is a valid trap URL for a given nonce.
|
|
861
|
-
* @param {string} path - The request path.
|
|
862
|
-
* @param {string} signature - The signature from the query.
|
|
863
|
-
* @param {string} nonce - The nonce to verify against.
|
|
864
|
-
* @returns {boolean}
|
|
865
|
-
*/
|
|
866
|
-
function verifyTrapUrl(path, signature, nonce) {
|
|
867
|
-
const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
|
|
868
|
-
try {
|
|
869
|
-
// Use timingSafeEqual to prevent timing attacks where an attacker could guess the signature byte by byte.
|
|
870
|
-
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
871
|
-
} catch {
|
|
872
|
-
// This will catch errors if buffers have different lengths or contain invalid hex characters, which is a failure case.
|
|
873
|
-
return false;
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
/**
|
|
877
|
-
* @typedef {object} IStore
|
|
878
|
-
* @property {(key: string) => Promise<any>} get
|
|
879
|
-
* @property {(key: string, value: any, ttl?: number) => Promise<void>} set
|
|
880
|
-
* @property {(key: string) => Promise<boolean>} has
|
|
881
|
-
* @property {(key: string) => Promise<void>} delete
|
|
882
|
-
*/
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Default in-memory store implementation.
|
|
886
|
-
* @type {IStore}
|
|
887
|
-
*/
|
|
888
|
-
const inMemoryStore = {
|
|
889
|
-
_map: new Map(),
|
|
890
|
-
_timeouts: new Map(),
|
|
891
|
-
async get(key) { return this._map.get(key); },
|
|
892
|
-
async set(key, value, ttl) {
|
|
893
|
-
this._map.set(key, value);
|
|
894
|
-
// If a timeout already exists for this key, clear it.
|
|
895
|
-
if (this._timeouts.has(key)) {
|
|
896
|
-
clearTimeout(this._timeouts.get(key));
|
|
897
|
-
this._timeouts.delete(key);
|
|
898
|
-
}
|
|
899
|
-
// If a TTL is provided, set a timeout to delete the key.
|
|
900
|
-
if (ttl && ttl > 0) {
|
|
901
|
-
const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
|
|
902
|
-
this._timeouts.set(key, timeoutId);
|
|
903
|
-
}
|
|
904
|
-
},
|
|
905
|
-
async has(key) { return this._map.has(key); },
|
|
906
|
-
async delete(key) { this._map.delete(key); },
|
|
907
|
-
};
|
|
908
|
-
|
|
909
|
-
/** @type {IStore} */
|
|
910
|
-
let store = inMemoryStore;
|
|
911
|
-
|
|
912
|
-
/**
|
|
913
|
-
* Allows configuring an external datastore (e.g., Redis).
|
|
914
|
-
* Must be called before the middleware is used.
|
|
915
|
-
* @param {IStore} externalStore - An implementation of the IStore interface.
|
|
916
|
-
*/
|
|
917
|
-
export const configureStore = (externalStore) => {
|
|
918
|
-
store = externalStore;
|
|
919
|
-
};
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* Orchestrates request identification using a persistent anchor (cookie)
|
|
923
|
-
* and fingerprint verification.
|
|
924
|
-
* @param {object} context - The request context.
|
|
925
|
-
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
926
|
-
*/
|
|
927
|
-
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
928
|
-
const existingDeviceId = context.cookies?.device_id;
|
|
929
|
-
const currentDeviceHash = getDeviceHash(context);
|
|
930
|
-
let deviceId = existingDeviceId;
|
|
931
|
-
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
932
|
-
let deviceData = null;
|
|
933
|
-
let newCookie = null;
|
|
934
|
-
if (deviceId) {
|
|
935
|
-
deviceData = await store.get(`device:${deviceId}`);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
if (deviceData) {
|
|
939
|
-
// Case 1: The user has a "passport" and we know them.
|
|
940
|
-
const storedHash = deviceData.initialDeviceHash;
|
|
941
|
-
|
|
942
|
-
// Compare the current fingerprint with the reference one.
|
|
943
|
-
consistencyScore = FingerprintBuilder.compare(
|
|
944
|
-
storedHash,
|
|
945
|
-
currentDeviceHash,
|
|
946
|
-
);
|
|
947
|
-
} else {
|
|
948
|
-
// Case 2: New user or lost/invalid cookie.
|
|
949
|
-
deviceId = crypto.randomUUID(); // Generate a new "passport".
|
|
950
|
-
|
|
951
|
-
// Return the intention to set a cookie.
|
|
952
|
-
newCookie = {
|
|
953
|
-
name: "device_id",
|
|
954
|
-
value: deviceId,
|
|
955
|
-
options: {
|
|
956
|
-
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
|
|
957
|
-
// Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
|
|
958
|
-
...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
|
|
959
|
-
}
|
|
960
|
-
};
|
|
961
|
-
|
|
962
|
-
// Initialize tracking for this new device.
|
|
963
|
-
deviceData = {
|
|
964
|
-
initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
|
|
965
|
-
ips: new Set(),
|
|
966
|
-
requestHistory: [], // Initialize state for the new pattern score
|
|
967
|
-
lastUpdate: Date.now(),
|
|
968
|
-
lastFpHash: currentDeviceHash,
|
|
969
|
-
lastChangeTimestamp: 0,
|
|
970
|
-
rapidChangeCount: 0,
|
|
971
|
-
highScoreCount: 0,
|
|
972
|
-
lastHighScoreTimestamp: 0,
|
|
973
|
-
};
|
|
974
|
-
// The write will happen in getSuspicionVector after all modifications.
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
return { deviceId, deviceData, consistencyScore, newCookie };
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/*
|
|
981
|
-
* Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
|
|
982
|
-
* @param {object} context - The request context.
|
|
983
|
-
* @param {object} deviceData - The device's activity data.
|
|
984
|
-
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
985
|
-
*/
|
|
986
|
-
async function getBehavioralIndicators(context, deviceData) {
|
|
987
|
-
const now = Date.now();
|
|
988
|
-
const clientIp = context.clientIp;
|
|
989
|
-
|
|
990
|
-
// Get the IP type to modulate the score
|
|
991
|
-
const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
|
|
992
|
-
const isSharedIp = ipProfile.type === "shared";
|
|
993
|
-
|
|
994
|
-
const currentFpHash = getDeviceHash(context); // Use the device hash
|
|
995
|
-
|
|
996
|
-
// --- Behavior analysis (Change frequency) ---
|
|
997
|
-
if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
|
|
998
|
-
const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
|
|
999
|
-
|
|
1000
|
-
if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
|
|
1001
|
-
deviceData.rapidChangeCount = Math.min(
|
|
1002
|
-
deviceData.rapidChangeCount + 1,
|
|
1003
|
-
MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
|
|
1004
|
-
);
|
|
1005
|
-
} else {
|
|
1006
|
-
deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
|
|
1007
|
-
}
|
|
1008
|
-
deviceData.lastChangeTimestamp = now;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
deviceData.lastFpHash = currentFpHash;
|
|
1012
|
-
deviceData.ips.add(clientIp); // Record the IP used by this device
|
|
1013
|
-
|
|
1014
|
-
// NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
|
|
1015
|
-
// Très efficace contre la rotation de proxy.
|
|
1016
|
-
const maxIpsForDevice = isSharedIp
|
|
1017
|
-
? MAX_DISTINCT_IPS_FOR_SHARED_USER
|
|
1018
|
-
: MAX_DISTINCT_IPS_PER_DEVICE;
|
|
1019
|
-
const freeIpChanges = isSharedIp ? 1 : 3;
|
|
1020
|
-
|
|
1021
|
-
const historyScore = Math.min(
|
|
1022
|
-
100,
|
|
1023
|
-
(Math.max(0, deviceData.ips.size - freeIpChanges) /
|
|
1024
|
-
(maxIpsForDevice - freeIpChanges)) *
|
|
1025
|
-
100,
|
|
1026
|
-
);
|
|
1027
|
-
|
|
1028
|
-
// Score based on rapid identity rotation (0-100)
|
|
1029
|
-
const rotationScore = Math.min(
|
|
1030
|
-
100,
|
|
1031
|
-
(deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
|
|
1032
|
-
);
|
|
1033
|
-
|
|
1034
|
-
return { historyScore, rotationScore };
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* Returns a vector of raw (unweighted) suspicion scores.
|
|
1039
|
-
* @param {object} context - The request context object.
|
|
1040
|
-
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
|
|
1041
|
-
*/
|
|
1042
|
-
export const getSuspicionVector = async (context, securityConfig) => {
|
|
1043
|
-
// On récupère la configuration du honeypot pour l'utiliser ici.
|
|
1044
|
-
const honeypotConfig = securityConfig.honeypot || {};
|
|
1045
|
-
|
|
1046
|
-
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
|
|
1047
|
-
|
|
1048
|
-
const clientIp = context.clientIp;
|
|
1049
|
-
|
|
1050
|
-
// If a new cookie needs to be set, attach it to the request object
|
|
1051
|
-
// so the middleware can handle it. This is a temporary state holder.
|
|
1052
|
-
if (newCookie) {
|
|
1053
|
-
context._newCookies = context._newCookies || [];
|
|
1054
|
-
context._newCookies.push(newCookie);
|
|
1055
|
-
}
|
|
1056
|
-
await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
|
|
1057
|
-
|
|
1058
|
-
// Periodically clean up device data
|
|
1059
|
-
if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
|
|
1060
|
-
deviceData.ips.clear();
|
|
1061
|
-
deviceData.rapidChangeCount = 0;
|
|
1062
|
-
}
|
|
1063
|
-
deviceData.lastUpdate = Date.now();
|
|
1064
|
-
|
|
1065
|
-
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
1066
|
-
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
1067
|
-
// Calculate the inconsistency score here, separately.
|
|
1068
|
-
let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
|
|
1069
|
-
|
|
1070
|
-
// NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
|
|
1071
|
-
if (consistencyScore < 0.7) { // Seuil de rupture
|
|
1072
|
-
inconsistencyScore = 100;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
|
|
1076
|
-
|
|
1077
|
-
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1078
|
-
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1079
|
-
|
|
1080
|
-
const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
|
|
1081
|
-
|
|
1082
|
-
// Save the updated device state to the store
|
|
1083
|
-
// Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
|
|
1084
|
-
await store.set(`device:${deviceId}`, deviceData);
|
|
1085
|
-
|
|
1086
|
-
// Ensure deviceData.ips is a Set for subsequent operations within the same request,
|
|
1087
|
-
// even if the store returns an array.
|
|
1088
|
-
if (Array.isArray(deviceData.ips)) {
|
|
1089
|
-
deviceData.ips = new Set(deviceData.ips);
|
|
1090
|
-
}
|
|
1091
|
-
// Le vecteur de suspicion est maintenant complet.
|
|
1092
|
-
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore };
|
|
1093
|
-
};
|
|
1094
|
-
|
|
1095
|
-
// A residential user can change networks (home, 4G, public wifi).
|
|
1096
|
-
const MAX_DISTINCT_IPS_PER_DEVICE = 15;
|
|
1097
|
-
// Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
|
|
1098
|
-
const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
|
|
1099
|
-
|
|
1100
|
-
// Une IP est considérée comme "partagée" si elle est utilisée par plus de 50 appareils différents en 10 minutes.
|
|
1101
|
-
const SHARED_IP_DEVICE_THRESHOLD = 50;
|
|
1102
|
-
|
|
1103
|
-
const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
|
|
1104
|
-
const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
|
|
1105
|
-
|
|
1106
|
-
/**
|
|
1107
|
-
* Identifies a request on the server side in a granular way.
|
|
1108
|
-
* Uses FingerprintBuilder to create a fingerprint based on headers
|
|
1109
|
-
* and IP, making spoofing more complex (requires changing the entire stack).
|
|
1110
|
-
*/
|
|
1111
|
-
export const identifyRequest = (securityConfig) => async (req, res) => {
|
|
1112
|
-
// This function now acts as a lightweight wrapper around the engine's identifyRequest method.
|
|
1113
|
-
// It requires a default configuration to work.
|
|
1114
|
-
const config = securityConfig || {
|
|
1115
|
-
weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
|
|
1116
|
-
thresholds: { low: 20, medium: 40, high: 75 },
|
|
1117
|
-
honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
|
|
1118
|
-
};
|
|
1119
|
-
const engine = new FingerprintEngine(config);
|
|
1120
|
-
|
|
1121
|
-
const requestContext = {
|
|
1122
|
-
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
1123
|
-
query: req.query,
|
|
1124
|
-
body: req.body,
|
|
1125
|
-
cookies: req.cookies,
|
|
1126
|
-
headers: req.headers,
|
|
1127
|
-
rawHeaders: req.rawHeaders,
|
|
1128
|
-
httpVersion: req.httpVersion,
|
|
1129
|
-
};
|
|
1130
|
-
|
|
1131
|
-
const key = await engine.identifyRequest(requestContext);
|
|
1132
|
-
|
|
1133
|
-
if (requestContext._newCookies && res) {
|
|
1134
|
-
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
return key;
|
|
1138
|
-
};
|
|
1139
|
-
// --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
|
|
1140
|
-
|
|
1141
|
-
// Le plus grand nombre possible avec SHA-256 (2^256 - 1)
|
|
1142
|
-
// The largest possible number with SHA-256 (2^256 - 1)
|
|
1143
|
-
const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
|
|
1144
|
-
// Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
|
|
1145
|
-
// (équivalent à 4 zéros en hexadécimal)
|
|
1146
|
-
// A base difficulty, e.g., requires the first 16 bits to be 0
|
|
1147
|
-
// (equivalent to 4 zeros in hexadecimal)
|
|
1148
|
-
const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* Calculates the difficulty target based on the suspicion factor.
|
|
1152
|
-
* @param {number} suspicionFactor - A number from 0 to 1.
|
|
1153
|
-
* @returns {BigInt} The target number.
|
|
1154
|
-
*/
|
|
1155
|
-
function calculateTarget(suspicionFactor) {
|
|
1156
|
-
// Difficulty range adjusted to be realistic.
|
|
1157
|
-
// MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
|
|
1158
|
-
// MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
|
|
1159
|
-
const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
|
|
1160
|
-
const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
|
|
1161
|
-
|
|
1162
|
-
// Use linear interpolation between min and max difficulty.
|
|
1163
|
-
const totalDifficultyBits =
|
|
1164
|
-
MIN_DIFFICULTY_BITS +
|
|
1165
|
-
suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
|
|
1166
|
-
|
|
1167
|
-
// The target is max / 2^bits
|
|
1168
|
-
return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Generates a CPU challenge based on a target.
|
|
1173
|
-
*/
|
|
1174
|
-
export function generateCpuTargetChallenge(
|
|
1175
|
-
clientIp,
|
|
1176
|
-
nonce,
|
|
1177
|
-
suspicionFactor,
|
|
1178
|
-
originalUrl,
|
|
1179
|
-
) {
|
|
1180
|
-
const target = calculateTarget(suspicionFactor);
|
|
1181
|
-
return {
|
|
1182
|
-
type: "cpu_target",
|
|
1183
|
-
nonce: nonce,
|
|
1184
|
-
target: target.toString(16), // Send the target in hexadecimal
|
|
1185
|
-
path: originalUrl,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/**
|
|
1190
|
-
* Generates the HTML page for the CPU target challenge.
|
|
1191
|
-
* @param {object} challengeDetails - The details from generateCpuTargetChallenge.
|
|
1192
|
-
* @param {string} clientIp - The client's IP address.
|
|
1193
|
-
* @returns {string} HTML content.
|
|
1194
|
-
*/
|
|
1195
|
-
function generateCpuTargetChallengePage(challengeDetails, clientIp) {
|
|
1196
|
-
const { nonce, target, path } = challengeDetails;
|
|
1197
|
-
const solverCode = getPowSolverCode();
|
|
1198
|
-
return `
|
|
1199
|
-
<html><head><title>Security Check</title></head>
|
|
1200
|
-
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
1201
|
-
<h1>Please wait... (Level 1)</h1>
|
|
1202
|
-
<p>We are verifying that you are not a bot. This may take a few seconds.</p>
|
|
1203
|
-
<div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
|
|
1204
|
-
<script>${solverCode}</script>
|
|
1205
|
-
<script>
|
|
1206
|
-
async function solve() {
|
|
1207
|
-
const clientIp = "${clientIp}";
|
|
1208
|
-
const nonce = "${nonce}";
|
|
1209
|
-
const cpuTarget = BigInt("0x${target}");
|
|
1210
|
-
const solution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, null, (progress) => {
|
|
1211
|
-
// Optional progress callback
|
|
1212
|
-
});
|
|
1213
|
-
window.location.href = "${path}?pow_type=cpu_target&pow_nonce=${nonce}&pow_solution=" + solution;
|
|
1214
|
-
}
|
|
1215
|
-
solve();
|
|
1216
|
-
</script>
|
|
1217
|
-
</body></html>`;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/**
|
|
1221
|
-
* Generates the HTML content for a combined CPU + Memory PoW challenge.
|
|
1222
|
-
* @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
|
|
1223
|
-
* @param {number} memoryDifficulty - Memory allocation in MB.
|
|
1224
|
-
* @param {string} clientIp - The client's IP address.
|
|
1225
|
-
* @returns {string} HTML content.
|
|
1226
|
-
*/
|
|
1227
|
-
function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
|
|
1228
|
-
const { nonce, target, path } = cpuChallengeDetails;
|
|
1229
|
-
const solverCode = getPowSolverCode();
|
|
1230
|
-
return `
|
|
1231
|
-
<html><head><title>Advanced Security Check</title></head>
|
|
1232
|
-
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
1233
|
-
<h1>Enhanced Verification... (Level 2)</h1>
|
|
1234
|
-
<p>Your activity requires an additional security check. This may take a few moments.</p>
|
|
1235
|
-
<div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
|
|
1236
|
-
<script>${solverCode}</script>
|
|
1237
|
-
<script>
|
|
1238
|
-
async function solve() {
|
|
1239
|
-
const nonce = "${nonce}";
|
|
1240
|
-
const path = "${path}";
|
|
1241
|
-
const clientSecret = "${clientSecret}";
|
|
1242
|
-
const clientIp = "${clientIp}";
|
|
1243
|
-
const cpuTarget = BigInt("0x${target}");
|
|
1244
|
-
const memDifficulty = ${memoryDifficulty};
|
|
1245
|
-
|
|
1246
|
-
// --- CPU Challenge ---
|
|
1247
|
-
document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
|
|
1248
|
-
const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, (progress) => {
|
|
1249
|
-
// Optional progress callback
|
|
1250
|
-
});
|
|
1251
|
-
|
|
1252
|
-
// --- Memory Challenge ---
|
|
1253
|
-
document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
|
|
1254
|
-
await new Promise(r => setTimeout(r, 10)); // Yield to update UI
|
|
1255
|
-
|
|
1256
|
-
let memSolution = 0;
|
|
1257
|
-
try {
|
|
1258
|
-
const memSeed = nonce + ":" + clientSecret;
|
|
1259
|
-
memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
|
|
1260
|
-
} catch(e) {
|
|
1261
|
-
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
|
|
1265
|
-
}
|
|
1266
|
-
solve();
|
|
1267
|
-
</script>
|
|
1268
|
-
</body></html>`;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Verifies a PoW solution based on a target and generates a ticket.
|
|
1273
|
-
*/
|
|
1274
|
-
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1275
|
-
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1276
|
-
ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
|
|
1277
|
-
nonce,
|
|
1278
|
-
solution,
|
|
1279
|
-
suspicionFactor,
|
|
1280
|
-
clientSecret, // Le secret est maintenant requis
|
|
1281
|
-
) {
|
|
1282
|
-
const target = calculateTarget(suspicionFactor);
|
|
1283
|
-
const message = clientSecret
|
|
1284
|
-
? `${clientIp}:${nonce}:${solution}:${clientSecret}`
|
|
1285
|
-
: `${clientIp}:${nonce}:${solution}`;
|
|
1286
|
-
const hash = crypto
|
|
1287
|
-
.createHash("sha256")
|
|
1288
|
-
.update(message)
|
|
1289
|
-
.digest("hex");
|
|
1290
|
-
const hashAsInt = BigInt("0x" + hash);
|
|
1291
|
-
|
|
1292
|
-
if (hashAsInt < target) {
|
|
1293
|
-
// The comparison is direct with native BigInts
|
|
1294
|
-
// The proof is valid, generate the ticket
|
|
1295
|
-
const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
|
|
1296
|
-
const signature = crypto
|
|
1297
|
-
.createHmac("sha256", getPowSecret())
|
|
1298
|
-
.update(`${clientIp}:${expiry}`)
|
|
1299
|
-
.digest("hex");
|
|
1300
|
-
return `${expiry}:${signature}`;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
return null;
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
const staticExtensions = new RegExp(
|
|
1307
|
-
"\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest)$",
|
|
1308
|
-
"i",
|
|
1309
|
-
);
|
|
1310
|
-
const isStaticResource = (path) => staticExtensions.test(path);
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
/**
|
|
1314
|
-
* Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
|
|
1315
|
-
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
1316
|
-
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
1317
|
-
*/
|
|
1318
|
-
function determineOptimalTicketTtl(suspicionScore) {
|
|
1319
|
-
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
1320
|
-
const MIN_TTL = 300000;
|
|
1321
|
-
const MAX_TTL = 86400000;
|
|
1322
|
-
|
|
1323
|
-
const solverFunction = () => {
|
|
1324
|
-
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
1325
|
-
|
|
1326
|
-
// Un "individu" est simplement une valeur de TTL en millisecondes.
|
|
1327
|
-
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
1328
|
-
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
1329
|
-
const mutate = (ttl) => {
|
|
1330
|
-
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
|
|
1331
|
-
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
1332
|
-
};
|
|
1333
|
-
|
|
1334
|
-
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
1335
|
-
createIndividual,
|
|
1336
|
-
fitnessFunction,
|
|
1337
|
-
crossover,
|
|
1338
|
-
mutate,
|
|
1339
|
-
{
|
|
1340
|
-
generations: 40,
|
|
1341
|
-
populationSize: 30,
|
|
1342
|
-
}
|
|
1343
|
-
);
|
|
1344
|
-
|
|
1345
|
-
// Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
|
|
1346
|
-
// Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
|
|
1347
|
-
// en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
|
|
1348
|
-
if (!paretoFront || paretoFront.length === 0) {
|
|
1349
|
-
return { solution: null, fitness: Infinity };
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// Stratégie de sélection :
|
|
1353
|
-
// Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
|
|
1354
|
-
// Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
|
|
1355
|
-
let bestSolutionInFront;
|
|
1356
|
-
if (suspicionScore < 50) {
|
|
1357
|
-
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
1358
|
-
} else {
|
|
1359
|
-
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
1360
|
-
}
|
|
1361
|
-
return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
|
|
1362
|
-
};
|
|
1363
|
-
|
|
1364
|
-
// On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
|
|
1365
|
-
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
1366
|
-
|
|
1367
|
-
if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
|
|
1368
|
-
// Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
|
|
1369
|
-
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
|
|
1373
|
-
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
1374
|
-
return bestResult.solution;
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
/**
|
|
1378
|
-
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
1379
|
-
* @param {string} str - La chaîne à vérifier.
|
|
1380
|
-
* @returns {boolean} - True si un pattern malveillant est détecté.
|
|
1381
|
-
* @private
|
|
1382
|
-
*/
|
|
1383
|
-
function isMalicious(str) {
|
|
1384
|
-
// Regex pour les injections SQL et NoSQL de base
|
|
1385
|
-
// Ajout de la détection des injections basées sur le temps (SLEEP, BENCHMARK, WAITFOR) et d'autres commandes dangereuses.
|
|
1386
|
-
const injectionRegex = /(\$ne|' OR '1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i;
|
|
1387
|
-
// Regex pour les injections plus avancées
|
|
1388
|
-
const log4ShellRegex = /\$\{jndi:(ldap|rmi|dns):/i;
|
|
1389
|
-
const sstiRegex = /\{\{.*\}\}|\{%.*%\}/; // Détecte les syntaxes de type Jinja2, Twig, etc.
|
|
1390
|
-
const xxeRegex = /<!ENTITY\s+.*SYSTEM/i;
|
|
1391
|
-
const pathTraversalRegex = /(\.\.\/|\.\.\\)/;
|
|
1392
|
-
// NOUVEAU : Regex pour les injections de commandes basiques.
|
|
1393
|
-
// Cible les séparateurs de commandes et les backticks d'exécution.
|
|
1394
|
-
const commandInjectionRegex = /(&&|\|\||;|\n|`)/;
|
|
1395
|
-
|
|
1396
|
-
return injectionRegex.test(str) || log4ShellRegex.test(str) || sstiRegex.test(str) || xxeRegex.test(str) || pathTraversalRegex.test(str) || commandInjectionRegex.test(str);
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// --- Middleware Proof-of-Work (Le péage) ---
|
|
1400
|
-
export class FingerprintEngine {
|
|
1401
|
-
constructor(securityConfig) {
|
|
1402
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
1403
|
-
this.securityConfig = securityConfig;
|
|
1404
|
-
this.isProduction = isProduction;
|
|
1405
|
-
this._allowlist = this._buildAllowlist();
|
|
1406
|
-
this.verbose = securityConfig.verbose || false;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
_log(message, data = {}) {
|
|
1410
|
-
if (this.verbose) {
|
|
1411
|
-
console.log(`[FingerprintEngine] ${message}`, data);
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
calculateFinalScore = function(suspicionVector) {
|
|
1415
|
-
const { weights } = this.securityConfig;
|
|
1416
|
-
if (!weights) return 0;
|
|
1417
|
-
|
|
1418
|
-
const score =
|
|
1419
|
-
(suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
|
|
1420
|
-
(suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
|
|
1421
|
-
(suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
|
|
1422
|
-
(suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
|
|
1423
|
-
(suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
|
|
1424
|
-
(suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
|
|
1425
|
-
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
|
|
1426
|
-
|
|
1427
|
-
return Math.min(100, score);
|
|
1428
|
-
}
|
|
1429
|
-
/**
|
|
1430
|
-
* Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
|
|
1431
|
-
* This is the fastest check and should be performed first.
|
|
1432
|
-
* @private
|
|
1433
|
-
* @param {string} clientIp - The IP address of the client.
|
|
1434
|
-
* @returns {boolean} True if the IP is in the allowlist.
|
|
1435
|
-
*/
|
|
1436
|
-
_buildAllowlist() {
|
|
1437
|
-
const blockList = new BlockList();
|
|
1438
|
-
const { whitelist = [] } = this.securityConfig;
|
|
1439
|
-
const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
|
|
1440
|
-
|
|
1441
|
-
if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
|
|
1442
|
-
return blockList; // Retourne une liste vide
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
for (const entry of allowlistRule.entries) {
|
|
1446
|
-
if (entry.includes('/')) { // CIDR range
|
|
1447
|
-
try {
|
|
1448
|
-
const [address, prefix] = entry.split('/');
|
|
1449
|
-
blockList.addSubnet(address, parseInt(prefix, 10));
|
|
1450
|
-
} catch (e) {
|
|
1451
|
-
// Ignore les entrées CIDR invalides
|
|
1452
|
-
}
|
|
1453
|
-
} else { // Direct IP match
|
|
1454
|
-
blockList.addAddress(entry);
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
return blockList;
|
|
1458
|
-
}
|
|
1459
|
-
_isIpInAllowlist(clientIp) {
|
|
1460
|
-
return this._allowlist.check(clientIp);
|
|
1461
|
-
}
|
|
1462
|
-
/**
|
|
1463
|
-
* Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
|
|
1464
|
-
* using reverse and forward DNS lookups. The result is cached.
|
|
1465
|
-
* @private
|
|
1466
|
-
* @param {object} requestContext - The request context.
|
|
1467
|
-
* @returns {Promise<boolean>} True if the request is from a verified whitelisted bot.
|
|
1468
|
-
*/
|
|
1469
|
-
async _verifyWhitelistedBot(requestContext) {
|
|
1470
|
-
const { whitelist = [] } = this.securityConfig;
|
|
1471
|
-
const botRules = whitelist.filter(rule => rule.hostnameSuffix);
|
|
1472
|
-
if (botRules.length === 0) {
|
|
1473
|
-
return false;
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
const { clientIp, headers } = requestContext;
|
|
1477
|
-
const userAgent = headers['user-agent'] || '';
|
|
1478
|
-
|
|
1479
|
-
const matchedRule = botRules.find(rule => {
|
|
1480
|
-
if (!rule.userAgent) return false;
|
|
1481
|
-
try {
|
|
1482
|
-
return new RegExp(rule.userAgent).test(userAgent);
|
|
1483
|
-
} catch (e) {
|
|
1484
|
-
console.error(`[Fingerprint] Invalid regex in whitelist rule: ${rule.userAgent}`);
|
|
1485
|
-
return false;
|
|
1486
|
-
}
|
|
1487
|
-
});
|
|
1488
|
-
if (!matchedRule) {
|
|
1489
|
-
return false;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
const cacheKey = `ip-whitelist:${clientIp}`;
|
|
1493
|
-
const cachedStatus = await store.get(cacheKey);
|
|
1494
|
-
|
|
1495
|
-
if (cachedStatus === 'verified') {
|
|
1496
|
-
return true;
|
|
1497
|
-
}
|
|
1498
|
-
if (cachedStatus === 'failed') {
|
|
1499
|
-
return false;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
try {
|
|
1503
|
-
// 1. Reverse DNS lookup
|
|
1504
|
-
const hostnames = await dns.reverse(clientIp);
|
|
1505
|
-
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
1506
|
-
|
|
1507
|
-
if (!validHostname) {
|
|
1508
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1509
|
-
return false;
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// 2. Forward DNS lookup
|
|
1513
|
-
const addresses = await dns.resolve(validHostname);
|
|
1514
|
-
if (addresses.includes(clientIp)) {
|
|
1515
|
-
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
1516
|
-
return true;
|
|
1517
|
-
}
|
|
1518
|
-
} catch (error) {
|
|
1519
|
-
// DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1523
|
-
return false;
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
async processRequest(requestContext) {
|
|
1527
|
-
|
|
1528
|
-
const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
|
|
1529
|
-
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
1530
|
-
|
|
1531
|
-
this._log('Processing request', { clientIp, path, isStatic });
|
|
1532
|
-
|
|
1533
|
-
if (isStatic) {
|
|
1534
|
-
this._log('Static resource - skipping checks');
|
|
1535
|
-
return { action: 'next', score: 0, vector: {} };
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// 1. Check static IP allowlist first for maximum performance.
|
|
1539
|
-
if (this._isIpInAllowlist(clientIp)) {
|
|
1540
|
-
this._log('IP in allowlist - allowing request', { clientIp });
|
|
1541
|
-
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'allowlist' } };
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
const { pow_nonce } = query;
|
|
1545
|
-
|
|
1546
|
-
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1547
|
-
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1548
|
-
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
1549
|
-
if (pow_nonce) {
|
|
1550
|
-
const powCookie = cookies?.pow_clearance;
|
|
1551
|
-
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
1552
|
-
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
1553
|
-
// The final decision is made later, after calculating the score.
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
|
|
1558
|
-
if (await this._verifyWhitelistedBot(requestContext)) {
|
|
1559
|
-
this._log('Whitelisted bot verified - allowing request', { clientIp });
|
|
1560
|
-
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
1564
|
-
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
1565
|
-
// avant même de recalculer le score de suspicion.
|
|
1566
|
-
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1567
|
-
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1568
|
-
this._log('Challenge solution submitted', { pow_type, pow_nonce });
|
|
1569
|
-
|
|
1570
|
-
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
1571
|
-
// car le TTL optimal en dépend.
|
|
1572
|
-
const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1573
|
-
const preliminaryScore = this.calculateFinalScore(preliminaryVector);
|
|
1574
|
-
|
|
1575
|
-
this._log('Preliminary suspicion vector calculated', {
|
|
1576
|
-
vector: preliminaryVector,
|
|
1577
|
-
score: preliminaryScore
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
let isValid = false;
|
|
1581
|
-
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1582
|
-
let ticket = null;
|
|
1583
|
-
|
|
1584
|
-
if (challengeContext) {
|
|
1585
|
-
const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
1586
|
-
this._log('Challenge context found, verifying solution', { optimalTtl });
|
|
1587
|
-
|
|
1588
|
-
if (pow_type === "cpu_target") {
|
|
1589
|
-
// On passe la durée de vie du ticket configurée
|
|
1590
|
-
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1591
|
-
isValid = ticket !== null;
|
|
1592
|
-
this._log('CPU target challenge verification', { isValid });
|
|
1593
|
-
} else if (pow_type === "cpu_mem") {
|
|
1594
|
-
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1595
|
-
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
|
|
1596
|
-
isValid = cpuTicket !== null && isMemValid;
|
|
1597
|
-
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
1598
|
-
this._log('Combined CPU+Memory challenge verification', {
|
|
1599
|
-
cpuValid: cpuTicket !== null,
|
|
1600
|
-
memValid: isMemValid,
|
|
1601
|
-
isValid
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
} else {
|
|
1605
|
-
this._log('Challenge context not found or expired', { pow_nonce });
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
if (isValid) {
|
|
1609
|
-
// La solution est valide. On supprime le secret et on redirige.
|
|
1610
|
-
await store.delete(`secret:${pow_nonce}`);
|
|
1611
|
-
this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: this.securityConfig.ticketMaxAge || 3600000 });
|
|
1612
|
-
|
|
1613
|
-
if (logger) {
|
|
1614
|
-
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
return {
|
|
1618
|
-
action: 'redirect',
|
|
1619
|
-
path: path,
|
|
1620
|
-
score: 0, // Le score n'est pas pertinent ici, on a passé le test.
|
|
1621
|
-
vector: { challenge_solved: 100 },
|
|
1622
|
-
cookie: {
|
|
1623
|
-
name: 'pow_clearance',
|
|
1624
|
-
value: ticket,
|
|
1625
|
-
options: {
|
|
1626
|
-
httpOnly: true,
|
|
1627
|
-
secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
|
|
1628
|
-
maxAge: this.securityConfig.ticketMaxAge || 3600000,
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
};
|
|
1632
|
-
}
|
|
1633
|
-
// Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
|
|
1634
|
-
// sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
|
|
1635
|
-
// On pourrait même ajouter une pénalité ici si on le voulait.
|
|
1636
|
-
this._log('Challenge solution invalid', { reason: challengeContext ? 'Invalid solution' : 'Nonce not found or expired' });
|
|
1637
|
-
|
|
1638
|
-
if (logger && challengeContext) {
|
|
1639
|
-
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
|
|
1640
|
-
} else if (logger && !challengeContext) {
|
|
1641
|
-
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
1645
|
-
|
|
1646
|
-
// Resolve identity and check for persisted "condemned" status early.
|
|
1647
|
-
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
1648
|
-
const isNewDevice = !!newCookie;
|
|
1649
|
-
|
|
1650
|
-
this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
|
|
1651
|
-
|
|
1652
|
-
if (deviceData?.condemned) {
|
|
1653
|
-
this._log('Device condemned - blocking request', { deviceId });
|
|
1654
|
-
if (onDeviceCompromised) {
|
|
1655
|
-
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
|
|
1656
|
-
}
|
|
1657
|
-
return { action: 'block', status: 404, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
// The engine now works with the context directly, no more rawReq dependency here.
|
|
1661
|
-
const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1662
|
-
// honeypotScore et behaviorScore sont maintenant inclus directement dans le vecteur de suspicion.
|
|
1663
|
-
|
|
1664
|
-
this._log('Suspicion vector calculated', {
|
|
1665
|
-
vector: suspicionVector,
|
|
1666
|
-
weights: this.securityConfig.weights
|
|
1667
|
-
});
|
|
1668
|
-
|
|
1669
|
-
let finalScore = this.calculateFinalScore(suspicionVector);
|
|
1670
|
-
|
|
1671
|
-
this._log('Final score calculated', { finalScore });
|
|
1672
|
-
|
|
1673
|
-
// Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
|
|
1674
|
-
// Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
|
|
1675
|
-
// NOUVEAU : Cette logique est maintenant configurable.
|
|
1676
|
-
const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
|
|
1677
|
-
if (isNewDevice && finalScore < thresholds.low) {
|
|
1678
|
-
this._log('New device - enforcing minimum challenge score', {
|
|
1679
|
-
originalScore: finalScore,
|
|
1680
|
-
enforcedScore: thresholds.low
|
|
1681
|
-
});
|
|
1682
|
-
finalScore = thresholds.low;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
const blockThreshold = thresholds.block ?? 95;
|
|
1686
|
-
const isBlocked = finalScore >= blockThreshold;
|
|
1687
|
-
|
|
1688
|
-
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
1689
|
-
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1690
|
-
const isSuspicious = finalScore >= thresholds.low;
|
|
1691
|
-
|
|
1692
|
-
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1693
|
-
const suspicionFactor = isSuspicious
|
|
1694
|
-
? Math.min(
|
|
1695
|
-
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
1696
|
-
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
1697
|
-
)
|
|
1698
|
-
: 0;
|
|
1699
|
-
|
|
1700
|
-
this._log('Suspicion levels evaluated', {
|
|
1701
|
-
finalScore,
|
|
1702
|
-
isBlocked,
|
|
1703
|
-
isSuspiciousHigh,
|
|
1704
|
-
isSuspiciousMedium,
|
|
1705
|
-
isSuspicious,
|
|
1706
|
-
suspicionFactor,
|
|
1707
|
-
thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
|
|
1708
|
-
});
|
|
1709
|
-
|
|
1710
|
-
const powCookie = cookies?.pow_clearance;
|
|
1711
|
-
|
|
1712
|
-
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
1713
|
-
if (isBlocked) {
|
|
1714
|
-
this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
|
|
1715
|
-
if (onDeviceCompromised) {
|
|
1716
|
-
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Score exceeded block threshold', score: finalScore, vector: suspicionVector });
|
|
1717
|
-
}
|
|
1718
|
-
return { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
// Honeypot: Check if the request is for a trap URL generated in a previous challenge.
|
|
1722
|
-
// This requires a nonce from a *previous* challenge, which we can look up via the device ID.
|
|
1723
|
-
const lastNonce = deviceData?.lastChallengeNonce;
|
|
1724
|
-
if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
|
|
1725
|
-
this._log('Honeypot trap URL triggered - condemning device', { path, deviceId });
|
|
1726
|
-
deviceData.condemned = true; // This device is a bot. Condemn it.
|
|
1727
|
-
if (onDeviceCompromised) {
|
|
1728
|
-
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
|
|
1729
|
-
}
|
|
1730
|
-
if (logger) {
|
|
1731
|
-
logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
|
|
1732
|
-
}
|
|
1733
|
-
await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
|
|
1734
|
-
return { action: 'block', status: 404, score: 100, vector: { honeypotScore: 100 } };
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1738
|
-
this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
|
|
1739
|
-
|
|
1740
|
-
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1741
|
-
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1742
|
-
// If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
|
|
1743
|
-
// AND it's not a legitimate response to a challenge we issued, it's a probe.
|
|
1744
|
-
const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
|
|
1745
|
-
if (pow_nonce && !isChallengeResponse) {
|
|
1746
|
-
this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
|
|
1747
|
-
if (logger) {
|
|
1748
|
-
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
|
|
1749
|
-
}
|
|
1750
|
-
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
1751
|
-
// Recalculate the final score with the updated vector.
|
|
1752
|
-
const newFinalScore = this.calculateFinalScore(suspicionVector);
|
|
1753
|
-
return { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
1757
|
-
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1758
|
-
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
1759
|
-
|
|
1760
|
-
// LEVEL 3: CAPTCHA (the highest)
|
|
1761
|
-
if (isSuspiciousHigh) {
|
|
1762
|
-
// ... logic for TSP/Captcha challenge
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
// UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
|
|
1766
|
-
if (isSuspicious) { // Couvre à la fois low et medium
|
|
1767
|
-
// Generate some trap URLs to embed in the challenge page.
|
|
1768
|
-
// These links are visually hidden but present in the DOM to trap bots.
|
|
1769
|
-
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
|
|
1770
|
-
const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
|
|
1774
|
-
|
|
1775
|
-
// La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
|
|
1776
|
-
// Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
|
|
1777
|
-
const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
|
|
1778
|
-
|
|
1779
|
-
const minMemDifficulty = 0; // Peut être 0 Mo !
|
|
1780
|
-
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
1781
|
-
const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
|
|
1782
|
-
|
|
1783
|
-
this._log('Challenge parameters calculated', {
|
|
1784
|
-
suspicionFactor,
|
|
1785
|
-
memActivationFactor,
|
|
1786
|
-
memDifficulty,
|
|
1787
|
-
cpuTarget: cpuChallengeDetails.target
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
1791
|
-
await store.set(`secret:${nonce}`, {
|
|
1792
|
-
clientSecret,
|
|
1793
|
-
cpuTarget: cpuChallengeDetails.target,
|
|
1794
|
-
memDifficulty: memDifficulty
|
|
1795
|
-
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
1796
|
-
|
|
1797
|
-
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1798
|
-
if (deviceData) {
|
|
1799
|
-
deviceData.lastChallengeNonce = nonce;
|
|
1800
|
-
await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
this._log('Challenge issued', {
|
|
1804
|
-
nonce,
|
|
1805
|
-
challengeTtl: this.securityConfig.challengeTtl || 300,
|
|
1806
|
-
trapUrlsCount: trapUrls.length
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
if (logger) {
|
|
1810
|
-
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
// Check if the request is an API request to return a JSON challenge
|
|
1814
|
-
const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
|
|
1815
|
-
|
|
1816
|
-
if (isApi) {
|
|
1817
|
-
// For API clients, send a JSON response with challenge details.
|
|
1818
|
-
const challengePayload = {
|
|
1819
|
-
challenge: {
|
|
1820
|
-
type: 'cpu_mem',
|
|
1821
|
-
nonce: nonce,
|
|
1822
|
-
clientSecret: clientSecret, // The client needs this to solve the challenge
|
|
1823
|
-
cpuTarget: cpuChallengeDetails.target,
|
|
1824
|
-
memDifficulty: memDifficulty,
|
|
1825
|
-
}
|
|
1826
|
-
};
|
|
1827
|
-
this._log('API challenge response generated', { challengePayload });
|
|
1828
|
-
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
|
|
1829
|
-
} else {
|
|
1830
|
-
// For browsers, send the HTML page.
|
|
1831
|
-
const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
|
|
1832
|
-
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
|
|
1833
|
-
this._log('Browser challenge page generated', {
|
|
1834
|
-
pageLength: page.length,
|
|
1835
|
-
hasTrapContainer: true
|
|
1836
|
-
});
|
|
1837
|
-
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: page };
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
// Basic log for each non-static request that passed without a challenge
|
|
1843
|
-
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
|
|
1844
|
-
|
|
1845
|
-
if (logger) {
|
|
1846
|
-
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
return { action: 'next', score: finalScore, vector: suspicionVector };
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
/**
|
|
1853
|
-
* Identifies a request in a granular way for non-Express environments.
|
|
1854
|
-
* @param {object} requestContext - The request context object.
|
|
1855
|
-
* @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
|
|
1856
|
-
*/
|
|
1857
|
-
async identifyRequest(requestContext) {
|
|
1858
|
-
const { clientIp, cookies, rawReq, rawRes } = requestContext;
|
|
1859
|
-
|
|
1860
|
-
// --- Update IP reputation ---
|
|
1861
|
-
const ipProfile = (await store.get(`ip:${clientIp}`)) || {
|
|
1862
|
-
type: "residential",
|
|
1863
|
-
deviceIds: new Set(),
|
|
1864
|
-
statelessCount: 0,
|
|
1865
|
-
lastSeen: 0,
|
|
1866
|
-
};
|
|
1867
|
-
ipProfile.lastSeen = Date.now();
|
|
1868
|
-
if (cookies?.device_id) {
|
|
1869
|
-
ipProfile.deviceIds.add(cookies.device_id);
|
|
1870
|
-
} else {
|
|
1871
|
-
ipProfile.statelessCount++;
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
|
|
1875
|
-
ipProfile.type = "shared";
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
|
|
1879
|
-
if (ipProfile.statelessCount > statelessLimit) {
|
|
1880
|
-
return `suspicious_high:${clientIp}`;
|
|
1881
|
-
}
|
|
1882
|
-
await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
|
|
1883
|
-
|
|
1884
|
-
const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
|
|
1885
|
-
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
|
1886
|
-
const { requestPatternScore } = getRequestPatternScore(requestContext, (await store.get(`device:${requestContext.cookies?.device_id}`)), this.securityConfig.patterns);
|
|
1887
|
-
const score =
|
|
1888
|
-
vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
|
|
1889
|
-
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
1890
|
-
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
1891
|
-
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
|
|
1892
|
-
honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
|
|
1893
|
-
requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
|
|
1894
|
-
vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
|
|
1895
|
-
|
|
1896
|
-
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
1897
|
-
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
1898
|
-
if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
|
|
1899
|
-
|
|
1900
|
-
// If a new device_id was created, it's in the context.
|
|
1901
|
-
const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
|
|
1902
|
-
const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
|
|
1903
|
-
|
|
1904
|
-
return `device:${finalDeviceId}`;
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
/**
|
|
1909
|
-
* Returns a default list of whitelisting rules for common and legitimate web crawlers.
|
|
1910
|
-
* This list can be used as a base and extended with custom rules.
|
|
1911
|
-
* @returns {Array<{userAgent: string, hostnameSuffix: string}>}
|
|
1912
|
-
*/
|
|
1913
|
-
export const default_whitelist = () => [
|
|
1914
|
-
// === Moteurs de recherche majeurs ===
|
|
1915
|
-
{ userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
|
|
1916
|
-
{ userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
|
|
1917
|
-
{ userAgent: 'AdsBot-Google', hostnameSuffix: '.googlebot.com' },
|
|
1918
|
-
{ userAgent: 'Mediapartners-Google', hostnameSuffix: '.google.com' },
|
|
1919
|
-
{ userAgent: 'Google-InspectionTool', hostnameSuffix: '.google.com' },
|
|
1920
|
-
{ userAgent: '(bingbot|adidxbot)', hostnameSuffix: '.search.msn.com' },
|
|
1921
|
-
{ userAgent: 'DuckDuckBot', hostnameSuffix: '.duckduckgo.com' },
|
|
1922
|
-
{ userAgent: 'YandexBot', hostnameSuffix: '.yandex.com' },
|
|
1923
|
-
{ userAgent: 'YandexImages', hostnameSuffix: '.yandex.com' },
|
|
1924
|
-
{ userAgent: 'Baiduspider', hostnameSuffix: '.crawl.baidu.com' },
|
|
1925
|
-
{ userAgent: 'Slurp', hostnameSuffix: '.crawl.yahoo.net' },
|
|
1926
|
-
{ userAgent: 'Sogou web spider', hostnameSuffix: '.sogou.com' },
|
|
1927
|
-
{ userAgent: 'Exabot', hostnameSuffix: '.exabot.com' },
|
|
1928
|
-
{ userAgent: 'ia_archiver', hostnameSuffix: '.alexa.com' },
|
|
1929
|
-
{ userAgent: 'SeznamBot', hostnameSuffix: '.seznam.cz' },
|
|
1930
|
-
{ userAgent: 'Mail.RU_Bot', hostnameSuffix: '.mail.ru' },
|
|
1931
|
-
{ userAgent: 'Yeti', hostnameSuffix: '.naver.com' }, // Naver
|
|
1932
|
-
|
|
1933
|
-
// === Outils SEO et d'analyse ===
|
|
1934
|
-
{ userAgent: 'AhrefsBot', hostnameSuffix: '.ahrefs.com' },
|
|
1935
|
-
{ userAgent: 'SemrushBot', hostnameSuffix: '.semrush.com' },
|
|
1936
|
-
{ userAgent: 'MJ12bot', hostnameSuffix: '.mj12bot.com' }, // Majestic
|
|
1937
|
-
{ userAgent: 'rogerbot', hostnameSuffix: '.moz.com' }, // Moz
|
|
1938
|
-
{ userAgent: 'DotBot', hostnameSuffix: '.moz.com' }, // Moz (anciennement opensiteexplorer.org)
|
|
1939
|
-
{ userAgent: 'Screaming Frog SEO Spider', hostnameSuffix: '.screamingfrog.co.uk' },
|
|
1940
|
-
{ userAgent: 'cognitiveseo', hostnameSuffix: '.cognitiveseo.com' },
|
|
1941
|
-
{ userAgent: 'SEOkicks', hostnameSuffix: '.seokicks.com' },
|
|
1942
|
-
{ userAgent: 'serpstatbot', hostnameSuffix: '.serpstatbot.com' },
|
|
1943
|
-
{ userAgent: 'MegaIndex', hostnameSuffix: '.megaindex.com' },
|
|
1944
|
-
{ userAgent: 'LinkpadBot', hostnameSuffix: '.linkpad.ru' },
|
|
1945
|
-
{ userAgent: 'Sistrix', hostnameSuffix: '.sistrix.com' },
|
|
1946
|
-
{ userAgent: 'RyteBot', hostnameSuffix: '.ryte.com' },
|
|
1947
|
-
{ userAgent: 'linkfluence', hostnameSuffix: '.linkfluence.com' },
|
|
1948
|
-
{ userAgent: 'TurnitinBot', hostnameSuffix: '.turnitin.com' },
|
|
1949
|
-
{ userAgent: 'GrapeshotCrawler', hostnameSuffix: '.grapeshot.co.uk' },
|
|
1950
|
-
|
|
1951
|
-
// === Robots d'IA et de données ===
|
|
1952
|
-
{ userAgent: 'GPTBot', hostnameSuffix: '.openai.com' },
|
|
1953
|
-
{ userAgent: 'ChatGPT-User', hostnameSuffix: '.openai.com' },
|
|
1954
|
-
{ userAgent: 'Applebot', hostnameSuffix: '.applebot.apple.com' },
|
|
1955
|
-
{ userAgent: 'CCBot', hostnameSuffix: '.commoncrawl.org' },
|
|
1956
|
-
{ userAgent: 'Bytespider', hostnameSuffix: '.bytespider.com' }, // ByteDance (TikTok)
|
|
1957
|
-
{ userAgent: 'Diffbot', hostnameSuffix: '.diffbot.com' },
|
|
1958
|
-
{ userAgent: 'PerplexityBot', hostnameSuffix: '.perplexity.ai' },
|
|
1959
|
-
{ userAgent: 'ClaudeBot', hostnameSuffix: '.anthropic.com' },
|
|
1960
|
-
{ userAgent: 'cohere.io', hostnameSuffix: '.cohere.io' },
|
|
1961
|
-
{ userAgent: 'DataForSeoBot', hostnameSuffix: '.dataforseo.com' },
|
|
1962
|
-
{ userAgent: 'YouBot', hostnameSuffix: '.you.com' },
|
|
1963
|
-
{ userAgent: 'omgili', hostnameSuffix: '.omgili.com' },
|
|
1964
|
-
|
|
1965
|
-
// === Réseaux sociaux et partage ===
|
|
1966
|
-
{ userAgent: 'facebookexternalhit', hostnameSuffix: '.facebook.com' },
|
|
1967
|
-
{ userAgent: 'facebot', hostnameSuffix: '.facebook.com' },
|
|
1968
|
-
{ userAgent: 'Twitterbot', hostnameSuffix: '.twttr.com' },
|
|
1969
|
-
{ userAgent: 'Pinterestbot', hostnameSuffix: '.pinterest.com' },
|
|
1970
|
-
{ userAgent: 'LinkedInBot', hostnameSuffix: '.linkedin.com' },
|
|
1971
|
-
{ userAgent: 'Slackbot', hostnameSuffix: '.slack.com' },
|
|
1972
|
-
{ userAgent: 'Discordbot', hostnameSuffix: '.discord.com' },
|
|
1973
|
-
{ userAgent: 'TelegramBot', hostnameSuffix: '.telegram.org' },
|
|
1974
|
-
{ userAgent: 'WhatsApp', hostnameSuffix: '.wa.me' },
|
|
1975
|
-
{ userAgent: 'SkypeUriPreview', hostnameSuffix: '.skype.com' },
|
|
1976
|
-
{ userAgent: 'redditbot', hostnameSuffix: '.reddit.com' },
|
|
1977
|
-
|
|
1978
|
-
// === Services de monitoring et d'uptime ===
|
|
1979
|
-
{ userAgent: 'UptimeRobot', hostnameSuffix: '.uptimerobot.com' },
|
|
1980
|
-
{ userAgent: 'Pingdom', hostnameSuffix: '.pingdom.com' },
|
|
1981
|
-
{ userAgent: 'StatusCake', hostnameSuffix: '.statuscake.com' },
|
|
1982
|
-
{ userAgent: 'Site24x7', hostnameSuffix: '.site24x7.com' },
|
|
1983
|
-
{ userAgent: 'Freshping', hostnameSuffix: '.freshping.io' },
|
|
1984
|
-
{ userAgent: 'Better Uptime', hostnameSuffix: '.betteruptime.com' },
|
|
1985
|
-
{ userAgent: 'Checkly', hostnameSuffix: '.checkly-infra.com' },
|
|
1986
|
-
{ userAgent: 'Datadog', hostnameSuffix: '.datadoghq.com' },
|
|
1987
|
-
{ userAgent: 'NewRelicPinger', hostnameSuffix: '.newrelic.com' },
|
|
1988
|
-
|
|
1989
|
-
// === Archives et agrégateurs de contenu ===
|
|
1990
|
-
{ userAgent: 'archive.org_bot', hostnameSuffix: '.archive.org' },
|
|
1991
|
-
{ userAgent: 'Feedly', hostnameSuffix: '.feedly.com' },
|
|
1992
|
-
{ userAgent: 'FeedFetcher-Google', hostnameSuffix: '.google.com' },
|
|
1993
|
-
{ userAgent: 'TheOldReader', hostnameSuffix: '.theoldreader.com' },
|
|
1994
|
-
{ userAgent: 'Inoreader', hostnameSuffix: '.inoreader.com' },
|
|
1995
|
-
{ userAgent: 'FlipboardProxy', hostnameSuffix: '.flipboard.com' },
|
|
1996
|
-
{ userAgent: 'PaperLiBot', hostnameSuffix: '.paper.li' },
|
|
1997
|
-
|
|
1998
|
-
// === Services Cloud et Plateformes ===
|
|
1999
|
-
{ userAgent: 'Amazon Route 53 Health Check', hostnameSuffix: '.amazonaws.com' },
|
|
2000
|
-
{ userAgent: 'Google-Cloud-Scheduler', hostnameSuffix: '.google.com' },
|
|
2001
|
-
{ userAgent: 'APIs-Google', hostnameSuffix: '.google.com' },
|
|
2002
|
-
|
|
2003
|
-
// === Divers ===
|
|
2004
|
-
{ userAgent: 'W3C_Validator', hostnameSuffix: '.w3.org' },
|
|
2005
|
-
{ userAgent: 'GTmetrix', hostnameSuffix: '.gtmetrix.com' },
|
|
2006
|
-
{ userAgent: 'WebPageTest', hostnameSuffix: '.webpagetest.org' },
|
|
2007
|
-
{ userAgent: 'Google-Site-Verification', hostnameSuffix: '.google.com' },
|
|
2008
|
-
{ userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
|
|
2009
|
-
];
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
// --- Proof-of-Work Middleware (The Tollbooth) ---
|
|
2014
|
-
export const powMiddleware = (securityConfig) => {
|
|
2015
|
-
const engine = new FingerprintEngine(securityConfig);
|
|
2016
|
-
|
|
2017
|
-
if (securityConfig.autotuning) {
|
|
2018
|
-
startThresholdAutoTuning({
|
|
2019
|
-
securityConfig: securityConfig,
|
|
2020
|
-
...securityConfig.autotuning,
|
|
2021
|
-
});
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
// Provide a default for isApiRequest if not specified by the user.
|
|
2025
|
-
// This makes API challenge handling work more seamlessly out-of-the-box.
|
|
2026
|
-
if (!securityConfig.thresholds?.isApiRequest) {
|
|
2027
|
-
if (!securityConfig.thresholds) securityConfig.thresholds = {};
|
|
2028
|
-
securityConfig.thresholds.isApiRequest = (req) =>
|
|
2029
|
-
req.headers?.accept?.includes('application/json');
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
return async (req, res, next) => {
|
|
2033
|
-
const requestContext = {
|
|
2034
|
-
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
2035
|
-
path: req.path,
|
|
2036
|
-
cookies: req.cookies,
|
|
2037
|
-
query: req.query,
|
|
2038
|
-
body: req.body,
|
|
2039
|
-
headers: req.headers,
|
|
2040
|
-
isStatic: isStaticResource(req.path),
|
|
2041
|
-
// Pass the original request object for the isApiRequest function
|
|
2042
|
-
rawReq: req,
|
|
2043
|
-
// Add the newly required properties for full decoupling
|
|
2044
|
-
rawHeaders: req.rawHeaders,
|
|
2045
|
-
// Pass the raw request object for advanced inspection (e.g., JA3)
|
|
2046
|
-
httpVersion: req.httpVersion,
|
|
2047
|
-
};
|
|
2048
|
-
|
|
2049
|
-
const decision = await engine.processRequest(requestContext);
|
|
2050
|
-
|
|
2051
|
-
// Attach the fingerprinting result to the request object for downstream middlewares.
|
|
2052
|
-
req.fingerprint = {
|
|
2053
|
-
score: decision.score,
|
|
2054
|
-
vector: decision.vector,
|
|
2055
|
-
};
|
|
2056
|
-
|
|
2057
|
-
// After getSuspicionVector runs, it might have attached cookies to be set.
|
|
2058
|
-
if (requestContext._newCookies) {
|
|
2059
|
-
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
switch (decision.action) {
|
|
2063
|
-
case 'block':
|
|
2064
|
-
return res.status(decision.status).send(decision.body);
|
|
2065
|
-
|
|
2066
|
-
case 'challenge': // Gère à la fois les réponses HTML et JSON
|
|
2067
|
-
if (typeof decision.body === 'object' && decision.body !== null) {
|
|
2068
|
-
return res.status(decision.status).json(decision.body);
|
|
2069
|
-
}
|
|
2070
|
-
// Par défaut, envoie du HTML
|
|
2071
|
-
return res.status(decision.status).send(decision.body);
|
|
2072
|
-
|
|
2073
|
-
case 'redirect':
|
|
2074
|
-
if (decision.cookie) {
|
|
2075
|
-
res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
|
|
2076
|
-
}
|
|
2077
|
-
return res.redirect(decision.path);
|
|
2078
|
-
|
|
2079
|
-
case 'next':
|
|
2080
|
-
default:
|
|
2081
|
-
return next();
|
|
2082
|
-
}
|
|
2083
|
-
};
|
|
2084
|
-
};
|
|
2085
|
-
|
|
2086
|
-
/**
|
|
2087
|
-
* @internal
|
|
2088
|
-
* Exporting an object containing the functions to make them mockable in tests.
|
|
2089
|
-
* This is a common pattern to allow mocking of ES module functions.
|
|
2090
|
-
*/
|
|
2091
|
-
export const __internal = {
|
|
2092
|
-
getDeviceHash,
|
|
2093
|
-
isMalicious,
|
|
2094
|
-
getSuspicionVector,
|
|
2095
|
-
cyrb53, // Export for testing
|
|
2096
|
-
FingerprintBuilder, // Export for testing
|
|
2097
|
-
calculateTarget,
|
|
2098
|
-
determineOptimalTicketTtl,
|
|
2099
|
-
getRequestPatternScore, // Expose for testing
|
|
2100
|
-
getBehaviorScore, // Expose for testing
|
|
2101
|
-
};
|
|
2102
|
-
|
|
2103
|
-
// --- THRESHOLD AUTO-TUNING SECTION ---
|
|
2104
|
-
|
|
2105
|
-
let autoTuningJobId = null;
|
|
2106
|
-
|
|
2107
|
-
/**
|
|
2108
|
-
* Executes a threshold optimization pass using collected traffic data.
|
|
2109
|
-
* @private
|
|
2110
|
-
* @param {object} securityConfig - The security configuration object to update.
|
|
2111
|
-
* @param {Array<object>} trafficData - The array containing traffic logs.
|
|
2112
|
-
* @param {number} minDataPoints - The minimum number of data points required to start optimization.
|
|
2113
|
-
*/
|
|
2114
|
-
function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
|
|
2115
|
-
if (trafficData.length < minDataPoints) {
|
|
2116
|
-
console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
|
|
2120
|
-
|
|
2121
|
-
// Classify historical requests with a confidence weight.
|
|
2122
|
-
const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
|
|
2123
|
-
const challengedDevices = new Set(trafficData.filter(e => e.type === 'challenge_issued').map(e => e.deviceId));
|
|
2124
|
-
|
|
2125
|
-
const historicalRequests = trafficData.map(log => {
|
|
2126
|
-
// Assign a label ('bot' or 'human') and a confidence weight to each log entry.
|
|
2127
|
-
switch (log.type) {
|
|
2128
|
-
case 'honeypot_probe':
|
|
2129
|
-
case 'trap_triggered':
|
|
2130
|
-
return { score: log.score, label: 'bot', confidence: 10.0 }; // Very high confidence
|
|
2131
|
-
|
|
2132
|
-
case 'challenge_issued':
|
|
2133
|
-
// A challenge issued to a device that never solved it is a strong bot signal.
|
|
2134
|
-
if (!solvedDevices.has(log.deviceId)) {
|
|
2135
|
-
return { score: log.score, label: 'bot', confidence: 3.0 }; // High confidence
|
|
2136
|
-
}
|
|
2137
|
-
// If the challenge was eventually solved, this specific log is neutral.
|
|
2138
|
-
return null;
|
|
2139
|
-
|
|
2140
|
-
case 'challenge_solved':
|
|
2141
|
-
return { score: log.score, label: 'human', confidence: 5.0 }; // High confidence
|
|
2142
|
-
|
|
2143
|
-
case 'request_passed':
|
|
2144
|
-
// A passed request from a device that was never even challenged is likely a human.
|
|
2145
|
-
if (!challengedDevices.has(log.deviceId)) {
|
|
2146
|
-
return { score: log.score, label: 'human', confidence: 0.5 }; // Low confidence
|
|
2147
|
-
}
|
|
2148
|
-
// If the device was challenged at some point, this log is ambiguous.
|
|
2149
|
-
return null;
|
|
2150
|
-
|
|
2151
|
-
default:
|
|
2152
|
-
return null;
|
|
2153
|
-
}
|
|
2154
|
-
}).filter(Boolean); // Remove null entries
|
|
2155
|
-
|
|
2156
|
-
// The "fitness" function evaluates the quality of a set of thresholds.
|
|
2157
|
-
// A lower score is better.
|
|
2158
|
-
const fitnessFunction = (solution) => {
|
|
2159
|
-
const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
|
|
2160
|
-
if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
|
|
2161
|
-
if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
|
|
2162
|
-
|
|
2163
|
-
let weightedFalsePositives = 0; // Humans challenged unnecessarily.
|
|
2164
|
-
let weightedFalseNegatives = 0; // Undetected bots.
|
|
2165
|
-
|
|
2166
|
-
for (const req of historicalRequests) {
|
|
2167
|
-
if (req.label === 'bot') {
|
|
2168
|
-
if (req.score < low) weightedFalseNegatives += req.confidence;
|
|
2169
|
-
} else { // 'human'
|
|
2170
|
-
if (req.score >= low) weightedFalsePositives += req.confidence;
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
// The penalty for false negatives is implicitly higher due to the higher confidence scores of bot signals.
|
|
2174
|
-
return weightedFalsePositives + weightedFalseNegatives;
|
|
2175
|
-
};
|
|
2176
|
-
|
|
2177
|
-
// Functions for the genetic algorithm.
|
|
2178
|
-
const createIndividual = () => [
|
|
2179
|
-
10 + Math.random() * 20, // low
|
|
2180
|
-
30 + Math.random() * 30, // medium
|
|
2181
|
-
60 + Math.random() * 30, // high
|
|
2182
|
-
100 + Math.random() * 150, // velocityThreshold (100-250ms)
|
|
2183
|
-
300 + Math.random() * 400, // burstThreshold (300-700ms)
|
|
2184
|
-
800 + Math.random() * 700, // scrapeThreshold (800-1500ms)
|
|
2185
|
-
];
|
|
2186
|
-
const crossover = (p1, p2) => p1.map((val, i) => (val + p2[i]) / 2);
|
|
2187
|
-
const mutate = (s) => {
|
|
2188
|
-
const n = [...s];
|
|
2189
|
-
const i = Math.floor(Math.random() * n.length);
|
|
2190
|
-
// Adjust mutation range based on parameter
|
|
2191
|
-
const mutationRange = i < 3 ? 5 : 50;
|
|
2192
|
-
n[i] += (Math.random() - 0.5) * mutationRange;
|
|
2193
|
-
return n;
|
|
2194
|
-
};
|
|
2195
|
-
|
|
2196
|
-
// Start optimization.
|
|
2197
|
-
const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
|
|
2198
|
-
generations: 50,
|
|
2199
|
-
populationSize: 40
|
|
2200
|
-
});
|
|
2201
|
-
|
|
2202
|
-
const [newLow, newMedium, newHigh, newVelocity, newBurst, newScrape] = result.solution;
|
|
2203
|
-
|
|
2204
|
-
// Update the configuration live.
|
|
2205
|
-
// Ensure thresholds object exists
|
|
2206
|
-
if (!securityConfig.thresholds) securityConfig.thresholds = {};
|
|
2207
|
-
securityConfig.thresholds.low = Math.round(newLow);
|
|
2208
|
-
securityConfig.thresholds.medium = Math.round(newMedium);
|
|
2209
|
-
securityConfig.thresholds.high = Math.round(newHigh);
|
|
2210
|
-
|
|
2211
|
-
// Update pattern detection parameters
|
|
2212
|
-
if (!securityConfig.patterns) securityConfig.patterns = {};
|
|
2213
|
-
securityConfig.patterns.velocityThreshold = Math.round(newVelocity);
|
|
2214
|
-
securityConfig.patterns.burstThreshold = Math.round(newBurst);
|
|
2215
|
-
securityConfig.patterns.scrapeThreshold = Math.round(newScrape);
|
|
2216
|
-
// Weights could also be optimized, but let's keep it to thresholds for now for simplicity.
|
|
2217
|
-
|
|
2218
|
-
console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
|
|
2219
|
-
if (securityConfig.patterns) {
|
|
2220
|
-
console.log("[AutoTuning] Nouveaux paramètres de pattern appliqués :", securityConfig.patterns);
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
/**
|
|
2225
|
-
* Starts the background process for auto-tuning security thresholds.
|
|
2226
|
-
* @export
|
|
2227
|
-
* @param {object} options - Configuration options for auto-tuning.
|
|
2228
|
-
* @param {object} options.securityConfig - The live security configuration object that will be mutated.
|
|
2229
|
-
* @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
|
|
2230
|
-
* @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
|
|
2231
|
-
* @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
|
|
2232
|
-
*/
|
|
2233
|
-
export function startThresholdAutoTuning(options) {
|
|
2234
|
-
if (autoTuningJobId) {
|
|
2235
|
-
console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
|
|
2236
|
-
return;
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
const {
|
|
2240
|
-
securityConfig,
|
|
2241
|
-
trafficData,
|
|
2242
|
-
interval = 1800000,
|
|
2243
|
-
minDataPoints = 200
|
|
2244
|
-
} = options;
|
|
2245
|
-
|
|
2246
|
-
if (!securityConfig || !trafficData) {
|
|
2247
|
-
throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
|
|
2251
|
-
|
|
2252
|
-
autoTuningJobId = setInterval(() => {
|
|
2253
|
-
runThresholdOptimization(securityConfig, trafficData, minDataPoints);
|
|
2254
|
-
}, interval);
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
/**
|
|
2258
|
-
* Stops the threshold auto-tuning process.
|
|
2259
|
-
* @export
|
|
2260
|
-
*/
|
|
2261
|
-
export function stopThresholdAutoTuning() {
|
|
2262
|
-
if (autoTuningJobId) {
|
|
2263
|
-
clearInterval(autoTuningJobId);
|
|
2264
|
-
autoTuningJobId = null;
|
|
2265
|
-
console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
1
|
+
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { BlockList } from "node:net";
|
|
4
|
+
import dns from "node:dns/promises";
|
|
5
|
+
import { Optimization } from "./library.js";
|
|
6
|
+
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
export { createRedisStore } from "./redis-store.js";
|
|
11
|
+
export { createMongoDbStore } from "./mongodb-store.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retrieves the POW_SECRET from environment variables with appropriate checks.
|
|
15
|
+
* @returns {string} The secret key.
|
|
16
|
+
*/
|
|
17
|
+
const getPowSecret = () => {
|
|
18
|
+
const secret = process.env.POW_SECRET;
|
|
19
|
+
if (!secret && process.env.NODE_ENV === 'production') {
|
|
20
|
+
throw new Error('POW_SECRET environment variable is not set. This is required for production.');
|
|
21
|
+
}
|
|
22
|
+
return secret || "fallback-dev-secret-32-chars-minimum";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Loads the pow.solver.js content for inlining in HTML pages.
|
|
27
|
+
* @returns {string} The solver JavaScript code.
|
|
28
|
+
*/
|
|
29
|
+
const getPowSolverCode = () => {
|
|
30
|
+
try {
|
|
31
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
+
const __dirname = dirname(__filename);
|
|
33
|
+
const solverPath = join(__dirname, 'pow.solver.inline.js'); // Use the inline version
|
|
34
|
+
return readFileSync(solverPath, 'utf-8');
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn('Could not load pow.solver.js for inlining, using fallback inline code');
|
|
37
|
+
// Fallback inline code if file cannot be loaded
|
|
38
|
+
return `(function(global){
|
|
39
|
+
async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
|
|
40
|
+
const cpuTarget = BigInt(target);
|
|
41
|
+
let cpuSolution = 0;
|
|
42
|
+
while(true){
|
|
43
|
+
const msg = clientSecret ? clientIp+':'+nonce+':'+cpuSolution+':'+clientSecret : clientIp+':'+nonce+':'+cpuSolution;
|
|
44
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
|
|
45
|
+
const hashHex = Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
|
|
46
|
+
if(BigInt('0x'+hashHex) < cpuTarget) break;
|
|
47
|
+
cpuSolution++;
|
|
48
|
+
if(cpuSolution % 100000 === 0) await new Promise(r=>setTimeout(r,0));
|
|
49
|
+
}
|
|
50
|
+
return cpuSolution;
|
|
51
|
+
}
|
|
52
|
+
async function solveMemory(seed, difficulty){
|
|
53
|
+
const size = difficulty * 1024 * 1024;
|
|
54
|
+
const buffer = new Uint32Array(size / 4);
|
|
55
|
+
let h = new TextEncoder().encode(seed).reduce((acc,v)=>acc+v,0);
|
|
56
|
+
for(let i=0;i<buffer.length;i++) buffer[i] = h = Math.imul(h^i,1597334677);
|
|
57
|
+
let solution = 0;
|
|
58
|
+
const iterations = size / 16;
|
|
59
|
+
let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
|
|
60
|
+
for(let i=0;i<iterations;i++){
|
|
61
|
+
addr = buffer[addr] % buffer.length;
|
|
62
|
+
solution ^= addr;
|
|
63
|
+
}
|
|
64
|
+
return solution;
|
|
65
|
+
}
|
|
66
|
+
async function solveTsp(cities, targetMaxDistance){
|
|
67
|
+
function distance(c1,c2){return Math.sqrt(Math.pow(c1.x-c2.x,2)+Math.pow(c1.y-c2.y,2));}
|
|
68
|
+
function evaluatePathDistance(cities,path){
|
|
69
|
+
let total=0;
|
|
70
|
+
for(let i=0;i<path.length-1;i++) total+=distance(cities[path[i]],cities[path[i+1]]);
|
|
71
|
+
total+=distance(cities[path[path.length-1]],cities[path[0]]);
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
function solveTspNearestNeighbor(cities){
|
|
75
|
+
const n=cities.length;
|
|
76
|
+
if(n===0)return[];
|
|
77
|
+
let path=[0];
|
|
78
|
+
let visited=new Array(n).fill(false);
|
|
79
|
+
visited[0]=true;
|
|
80
|
+
for(let i=1;i<n;i++){
|
|
81
|
+
let nearest=-1, minDist=Infinity;
|
|
82
|
+
for(let j=0;j<n;j++){
|
|
83
|
+
if(!visited[j]){
|
|
84
|
+
const d=distance(cities[path[i-1]],cities[j]);
|
|
85
|
+
if(d<minDist){minDist=d;nearest=j;}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
path.push(nearest);
|
|
89
|
+
visited[nearest]=true;
|
|
90
|
+
}
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
await new Promise(r=>setTimeout(r,10));
|
|
94
|
+
const solutionPath=solveTspNearestNeighbor(cities);
|
|
95
|
+
const solutionDistance=evaluatePathDistance(cities,solutionPath);
|
|
96
|
+
return{path:solutionPath,distance:solutionDistance};
|
|
97
|
+
}
|
|
98
|
+
global.solveCpuChallengeInline=solveCpuTargetInline;
|
|
99
|
+
global.solveMemoryChallenge=solveMemory;
|
|
100
|
+
global.solveTspChallenge=solveTsp;
|
|
101
|
+
})(typeof window!=='undefined'?window:global);`;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Calculates the JA3 fingerprint hash from the TLS Client Hello message.
|
|
107
|
+
* JA3 is a more reliable way to identify client applications (e.g., a specific browser or a script)
|
|
108
|
+
* based on the specifics of its TLS handshake.
|
|
109
|
+
* @param {object} context - The request context, containing the raw request object.
|
|
110
|
+
* @returns {string|null} The MD5 hash of the JA3 string, or null if it cannot be computed.
|
|
111
|
+
*/
|
|
112
|
+
function getJa3Hash(context) {
|
|
113
|
+
// 1. Prefer the JA3 hash from a trusted reverse proxy (e.g., Nginx, Cloudflare).
|
|
114
|
+
const ja3FromHeader = context.headers['x-ja3-hash'];
|
|
115
|
+
if (ja3FromHeader) {
|
|
116
|
+
return ja3FromHeader;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. Fallback to calculating from the raw socket if available (requires Node.js to handle TLS).
|
|
120
|
+
const clientHello = context.rawReq?.socket?.clientHello;
|
|
121
|
+
if (!clientHello) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const { version, ciphers, extensions, ellipticCurves, ellipticCurvePointFormats } = clientHello;
|
|
127
|
+
|
|
128
|
+
// The official JA3 spec includes the TLS version.
|
|
129
|
+
// Node.js provides it as a string like 'TLSv1.3', we need the corresponding decimal value.
|
|
130
|
+
const tlsVersionMap = {
|
|
131
|
+
'TLSv1': 769, 'TLSv1.1': 770, 'TLSv1.2': 771, 'TLSv1.3': 772
|
|
132
|
+
};
|
|
133
|
+
const tlsVersionId = tlsVersionMap[version] || 0;
|
|
134
|
+
|
|
135
|
+
const ja3String = [
|
|
136
|
+
tlsVersionId,
|
|
137
|
+
// The ciphers array from clientHello is an array of objects, not just IDs.
|
|
138
|
+
Array.isArray(ciphers) ? ciphers.join('-') : '',
|
|
139
|
+
extensions?.join('-') || '',
|
|
140
|
+
ellipticCurves?.join('-') || '',
|
|
141
|
+
ellipticCurvePointFormats?.join('-') || ''
|
|
142
|
+
].join(',');
|
|
143
|
+
|
|
144
|
+
return crypto.createHash('md5').update(ja3String).digest('hex');
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return null; // Could fail if clientHello structure is unexpected.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Creates a stable hash based on device characteristics, independent of the IP.
|
|
151
|
+
* This is our "level 2 fingerprint".
|
|
152
|
+
* @param {object} context - The request context.
|
|
153
|
+
* @returns {string} A hash representing the device.
|
|
154
|
+
*/
|
|
155
|
+
function getHeaderSignature(context) {
|
|
156
|
+
if (!context.rawHeaders) return '';
|
|
157
|
+
const headerKeys = [];
|
|
158
|
+
for (let i = 0; i < context.rawHeaders.length; i += 2) {
|
|
159
|
+
headerKeys.push(context.rawHeaders[i]);
|
|
160
|
+
}
|
|
161
|
+
return cyrb53(headerKeys.join(','));
|
|
162
|
+
}
|
|
163
|
+
export function getDeviceHash(context) {
|
|
164
|
+
// Prioritize the rich client-side fingerprint if provided.
|
|
165
|
+
const clientFp = context.headers['x-device-fingerprint'];
|
|
166
|
+
if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
|
|
167
|
+
return clientFp;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const srv = new FingerprintBuilder();
|
|
171
|
+
|
|
172
|
+
// 1. SIGNAL FORT: User Agent (poids élevé)
|
|
173
|
+
const ua = context.headers["user-agent"];
|
|
174
|
+
if (ua) {
|
|
175
|
+
srv.add("ua", ua);
|
|
176
|
+
// Extraire des infos supplémentaires du UA
|
|
177
|
+
const uaParts = parseUserAgent(ua);
|
|
178
|
+
if (uaParts.browser) srv.add("browser", uaParts.browser);
|
|
179
|
+
if (uaParts.os) srv.add("os_version", uaParts.os);
|
|
180
|
+
if (uaParts.device) srv.add("device_type", uaParts.device);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. SIGNAL FORT: JA3 TLS Fingerprint
|
|
184
|
+
const ja3 = getJa3Hash(context);
|
|
185
|
+
if (ja3) srv.add("ja3", ja3);
|
|
186
|
+
|
|
187
|
+
// 3. SIGNAL MOYEN: Client Hints (modern browsers)
|
|
188
|
+
if (context.headers["sec-ch-ua"]) {
|
|
189
|
+
srv.add("ch_ua", context.headers["sec-ch-ua"]);
|
|
190
|
+
}
|
|
191
|
+
if (context.headers["sec-ch-ua-platform"]) {
|
|
192
|
+
srv.add("ch_platform", context.headers["sec-ch-ua-platform"]);
|
|
193
|
+
}
|
|
194
|
+
if (context.headers["sec-ch-ua-mobile"]) {
|
|
195
|
+
srv.add("ch_mobile", context.headers["sec-ch-ua-mobile"]);
|
|
196
|
+
}
|
|
197
|
+
if (context.headers["sec-ch-ua-model"]) {
|
|
198
|
+
srv.add("ch_model", context.headers["sec-ch-ua-model"]);
|
|
199
|
+
}
|
|
200
|
+
if (context.headers["sec-ch-ua-arch"]) {
|
|
201
|
+
srv.add("ch_arch", context.headers["sec-ch-ua-arch"]);
|
|
202
|
+
}
|
|
203
|
+
if (context.headers["sec-ch-ua-bitness"]) {
|
|
204
|
+
srv.add("ch_bitness", context.headers["sec-ch-ua-bitness"]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. SIGNAL MOYEN: HTTP Version et protocole
|
|
208
|
+
if (context.httpVersion) {
|
|
209
|
+
srv.add("http_ver", context.httpVersion);
|
|
210
|
+
}
|
|
211
|
+
if (context.headers["upgrade-insecure-requests"]) {
|
|
212
|
+
srv.add("upgrade", context.headers["upgrade-insecure-requests"]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 10. SIGNAL FORT: Ordonnancement des headers
|
|
216
|
+
srv.add("h_ord", getHeaderSignature(context));
|
|
217
|
+
|
|
218
|
+
// 11. SIGNAL AVANCÉ: Cookies (si disponible)
|
|
219
|
+
if (context.cookies) {
|
|
220
|
+
const cookieKeys = Object.keys(context.cookies).sort().join(',');
|
|
221
|
+
srv.add("cookie_keys", cookieKeys);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 12. SIGNAL AVANCÉ: Format de la requête
|
|
225
|
+
if (context.rawHeaders) {
|
|
226
|
+
// Vérifier des headers spécifiques qui indiquent le client
|
|
227
|
+
const clientHeaders = ['x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip'];
|
|
228
|
+
clientHeaders.forEach(h => {
|
|
229
|
+
if (context.headers[h]) {
|
|
230
|
+
srv.add(h.replace(/-/g, '_'), context.headers[h]);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 13. OPTIONNEL: IP (version simplifiée pour les réseaux partagés)
|
|
236
|
+
// Ne pas inclure l'IP complète, mais un hash du réseau /24 ou /16
|
|
237
|
+
// pour détecter les changements de réseau tout en protégeant la vie privée
|
|
238
|
+
const ip = context.clientIp || context.headers['x-forwarded-for']?.split(',')[0]?.trim();
|
|
239
|
+
if (ip && isPrivateIp(ip)) {
|
|
240
|
+
// Pour les IP privées, on peut prendre le /24
|
|
241
|
+
const networkHash = hashNetwork(ip, 24);
|
|
242
|
+
srv.add("network", networkHash);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return srv.toString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fonctions utilitaires
|
|
249
|
+
function parseUserAgent(ua) {
|
|
250
|
+
// Parser basique du User-Agent
|
|
251
|
+
const result = {};
|
|
252
|
+
|
|
253
|
+
// Détection du navigateur
|
|
254
|
+
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
|
255
|
+
result.browser = 'Chrome';
|
|
256
|
+
const match = ua.match(/Chrome\/(\d+)/);
|
|
257
|
+
if (match) result.browser += `/${match[1]}`;
|
|
258
|
+
} else if (ua.includes('Firefox')) {
|
|
259
|
+
result.browser = 'Firefox';
|
|
260
|
+
const match = ua.match(/Firefox\/(\d+)/);
|
|
261
|
+
if (match) result.browser += `/${match[1]}`;
|
|
262
|
+
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
|
263
|
+
result.browser = 'Safari';
|
|
264
|
+
const match = ua.match(/Version\/(\d+)/);
|
|
265
|
+
if (match) result.browser += `/${match[1]}`;
|
|
266
|
+
} else if (ua.includes('Edg')) {
|
|
267
|
+
result.browser = 'Edge';
|
|
268
|
+
const match = ua.match(/Edg\/(\d+)/);
|
|
269
|
+
if (match) result.browser += `/${match[1]}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Détection de l'OS
|
|
273
|
+
if (ua.includes('Windows NT 10.0')) result.os = 'Windows 10';
|
|
274
|
+
else if (ua.includes('Windows NT 6.1')) result.os = 'Windows 7';
|
|
275
|
+
else if (ua.includes('Mac OS X')) result.os = 'macOS';
|
|
276
|
+
else if (ua.includes('Linux') && !ua.includes('Android')) result.os = 'Linux';
|
|
277
|
+
else if (ua.includes('Android')) result.os = 'Android';
|
|
278
|
+
else if (ua.includes('iPhone') || ua.includes('iPad')) result.os = 'iOS';
|
|
279
|
+
|
|
280
|
+
// Détection du type d'appareil
|
|
281
|
+
if (ua.includes('Mobile')) result.device = 'mobile';
|
|
282
|
+
else if (ua.includes('Tablet')) result.device = 'tablet';
|
|
283
|
+
else result.device = 'desktop';
|
|
284
|
+
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeReferer(referer) {
|
|
289
|
+
try {
|
|
290
|
+
const url = new URL(referer);
|
|
291
|
+
return `${url.protocol}//${url.hostname}`;
|
|
292
|
+
} catch {
|
|
293
|
+
return referer;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isPrivateIp(ip) {
|
|
298
|
+
// Vérifier si l'IP est privée
|
|
299
|
+
const parts = ip.split('.');
|
|
300
|
+
if (parts.length !== 4) return false;
|
|
301
|
+
const first = parseInt(parts[0]);
|
|
302
|
+
return (first === 10) || (first === 172 && parseInt(parts[1]) >= 16 && parseInt(parts[1]) <= 31) || (first === 192 && parseInt(parts[1]) === 168);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hashNetwork(ip, prefix = 24) {
|
|
306
|
+
// Hash du réseau (masque /24 ou /16)
|
|
307
|
+
const parts = ip.split('.');
|
|
308
|
+
if (parts.length !== 4) return null;
|
|
309
|
+
const maskBytes = prefix / 8;
|
|
310
|
+
const network = parts.slice(0, maskBytes).join('.');
|
|
311
|
+
// Hash simple
|
|
312
|
+
let hash = 0;
|
|
313
|
+
for (let i = 0; i < network.length; i++) {
|
|
314
|
+
const char = network.charCodeAt(i);
|
|
315
|
+
hash = ((hash << 5) - hash) + char;
|
|
316
|
+
hash = hash & hash;
|
|
317
|
+
}
|
|
318
|
+
return hash.toString(16);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
|
|
323
|
+
* @param {string} nonce - Unique nonce for the challenge.
|
|
324
|
+
* @param {number} numCities - Number of cities to include in the problem.
|
|
325
|
+
* @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
|
|
326
|
+
* @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
|
|
327
|
+
* @param {string} path - Redirect path after solving.
|
|
328
|
+
* @returns {string} HTML of the challenge page.
|
|
329
|
+
*/
|
|
330
|
+
const generateTspChallenge = (
|
|
331
|
+
nonce,
|
|
332
|
+
numCities,
|
|
333
|
+
targetMaxDistance,
|
|
334
|
+
cities,
|
|
335
|
+
path = "",
|
|
336
|
+
) => {
|
|
337
|
+
const citiesJson = JSON.stringify(cities);
|
|
338
|
+
const solverCode = getPowSolverCode();
|
|
339
|
+
return `
|
|
340
|
+
<html>
|
|
341
|
+
<head><title>Advanced Security Check (Level 3)</title></head>
|
|
342
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
343
|
+
<h1>Ultimate Verification (Level 3)</h1>
|
|
344
|
+
<p>Please solve this small optimization problem to prove you are human.</p>
|
|
345
|
+
<div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
|
|
346
|
+
<script>${solverCode}</script>
|
|
347
|
+
<script>
|
|
348
|
+
const cities = ${citiesJson};
|
|
349
|
+
const nonce = "${nonce}";
|
|
350
|
+
const targetMaxDistance = ${targetMaxDistance};
|
|
351
|
+
|
|
352
|
+
async function solve() {
|
|
353
|
+
const result = await window.solveTspChallenge(cities, targetMaxDistance);
|
|
354
|
+
|
|
355
|
+
if (result.distance <= targetMaxDistance) {
|
|
356
|
+
window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
|
|
357
|
+
} else {
|
|
358
|
+
document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
solve();
|
|
362
|
+
</script>
|
|
363
|
+
</body>
|
|
364
|
+
</html>`;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Verifies a TSP PoW solution.
|
|
369
|
+
* @param {string} nonce - The challenge nonce.
|
|
370
|
+
* @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
|
|
371
|
+
* @param {number} numCities - The number of cities in the challenge.
|
|
372
|
+
* @param {number} targetMaxDistance - The maximum acceptable distance.
|
|
373
|
+
* @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
|
|
374
|
+
* @returns {boolean} True if the solution is valid.
|
|
375
|
+
*/
|
|
376
|
+
export const verifyTspChallenge = (
|
|
377
|
+
nonce,
|
|
378
|
+
solutionPathJson,
|
|
379
|
+
numCities,
|
|
380
|
+
targetMaxDistance,
|
|
381
|
+
cities,
|
|
382
|
+
) => {
|
|
383
|
+
try {
|
|
384
|
+
const solutionPath = JSON.parse(solutionPathJson);
|
|
385
|
+
if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
|
|
386
|
+
return false;
|
|
387
|
+
|
|
388
|
+
// Verify that the path is a valid permutation of the cities
|
|
389
|
+
const uniqueCities = new Set(solutionPath);
|
|
390
|
+
if (
|
|
391
|
+
uniqueCities.size !== numCities ||
|
|
392
|
+
Math.min(...solutionPath) < 0 ||
|
|
393
|
+
Math.max(...solutionPath) >= numCities
|
|
394
|
+
)
|
|
395
|
+
return false;
|
|
396
|
+
|
|
397
|
+
// Recalculate the distance on the server side
|
|
398
|
+
let totalDistance = 0;
|
|
399
|
+
let totalPenalty = 0;
|
|
400
|
+
|
|
401
|
+
// Function to calculate the angle between 3 points (p1 -> p2 -> p3)
|
|
402
|
+
const calculateAngle = (p1, p2, p3) => {
|
|
403
|
+
const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
|
|
404
|
+
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
|
405
|
+
const dotProduct = v1.x * v2.x + v1.y * v2.y;
|
|
406
|
+
const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
|
|
407
|
+
const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
|
|
408
|
+
if (mag1 === 0 || mag2 === 0) return 180;
|
|
409
|
+
const angleRad = Math.acos(dotProduct / (mag1 * mag2));
|
|
410
|
+
return angleRad * (180 / Math.PI);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
for (let i = 0; i < solutionPath.length; i++) {
|
|
414
|
+
const p1_idx = solutionPath[i];
|
|
415
|
+
const p2_idx = solutionPath[(i + 1) % numCities];
|
|
416
|
+
const p3_idx = solutionPath[(i + 2) % numCities];
|
|
417
|
+
|
|
418
|
+
// 1. Calculate segment distance
|
|
419
|
+
totalDistance += Math.sqrt(Math.pow(cities[p1_idx].x - cities[p2_idx].x, 2) + Math.pow(cities[p1_idx].y - cities[p2_idx].y, 2));
|
|
420
|
+
|
|
421
|
+
// 2. Calculate turn penalty
|
|
422
|
+
const angle = calculateAngle(
|
|
423
|
+
cities[p1_idx],
|
|
424
|
+
cities[p2_idx],
|
|
425
|
+
cities[p3_idx],
|
|
426
|
+
);
|
|
427
|
+
if (angle < 45) {
|
|
428
|
+
// Penalty for very sharp turns (< 45 degrees)
|
|
429
|
+
totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const finalScore = totalDistance + totalPenalty;
|
|
434
|
+
return finalScore <= targetMaxDistance;
|
|
435
|
+
} catch (e) {
|
|
436
|
+
console.error("Error during TSP challenge verification:", e);
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Generates the HTML content for the CPU PoW challenge (SHA-256).
|
|
443
|
+
*/
|
|
444
|
+
const generateCpuPoWChallenge = (
|
|
445
|
+
clientIp,
|
|
446
|
+
nonce,
|
|
447
|
+
difficulty = 4,
|
|
448
|
+
path = "",
|
|
449
|
+
) => {
|
|
450
|
+
return `
|
|
451
|
+
<html>
|
|
452
|
+
<head><title>Security Check</title></head>
|
|
453
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
454
|
+
<h1>One moment... (Level 1)</h1>
|
|
455
|
+
<p>We are verifying that you are not a bot. This takes a few seconds.</p>
|
|
456
|
+
<div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
|
|
457
|
+
<script>
|
|
458
|
+
async function solve() {
|
|
459
|
+
const ip = "${clientIp}";
|
|
460
|
+
const nonce = "${nonce}";
|
|
461
|
+
const diff = ${difficulty};
|
|
462
|
+
const target = "0".repeat(diff);
|
|
463
|
+
let solution = 0;
|
|
464
|
+
|
|
465
|
+
while (true) {
|
|
466
|
+
const msg = "${ip}" + ":" + "${nonce}" + ":" + solution;
|
|
467
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
|
|
468
|
+
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
469
|
+
if (hash.startsWith(target)) break;
|
|
470
|
+
solution++;
|
|
471
|
+
if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
|
|
472
|
+
}
|
|
473
|
+
window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
|
|
474
|
+
}
|
|
475
|
+
solve();
|
|
476
|
+
</script>
|
|
477
|
+
</body>
|
|
478
|
+
</html>
|
|
479
|
+
`;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Generates the HTML content for a memory-intensive PoW challenge.
|
|
484
|
+
*/
|
|
485
|
+
const generateMemoryPoWChallenge = (
|
|
486
|
+
clientIp,
|
|
487
|
+
nonce,
|
|
488
|
+
difficulty = 16,
|
|
489
|
+
path = "",
|
|
490
|
+
) => {
|
|
491
|
+
// difficulty here is the buffer size in MB.
|
|
492
|
+
return `
|
|
493
|
+
<html>
|
|
494
|
+
<head><title>Advanced Security Check</title></head>
|
|
495
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
496
|
+
<h1>Enhanced Verification... (Level 2)</h1>
|
|
497
|
+
<p>Your activity requires an additional security check.</p>
|
|
498
|
+
<div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
|
|
499
|
+
<script>
|
|
500
|
+
async function solve() {
|
|
501
|
+
const nonce = "${nonce}";
|
|
502
|
+
const size = ${difficulty} * 1024 * 1024; // en octets
|
|
503
|
+
const iterations = size / 16;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const buffer = new Uint32Array(size / 4);
|
|
507
|
+
let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
|
|
508
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
509
|
+
buffer[i] = (h = Math.imul(h ^ i, 1597334677));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let finalHash = 0;
|
|
513
|
+
for(let i = 0; i < iterations; i++) {
|
|
514
|
+
const addr = buffer[i % buffer.length] % buffer.length;
|
|
515
|
+
finalHash ^= buffer[addr];
|
|
516
|
+
}
|
|
517
|
+
window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
|
|
518
|
+
} catch(e) {
|
|
519
|
+
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
solve();
|
|
523
|
+
</script>
|
|
524
|
+
</body>
|
|
525
|
+
</html>`;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Verifies if a PoW solution is valid and generates a clearance ticket.
|
|
530
|
+
*/
|
|
531
|
+
export const verifyPoWAndGenerateTicket = (
|
|
532
|
+
ip,
|
|
533
|
+
nonce,
|
|
534
|
+
solution,
|
|
535
|
+
difficulty = 4,
|
|
536
|
+
) => {
|
|
537
|
+
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
538
|
+
const hash = crypto
|
|
539
|
+
.createHash("sha256")
|
|
540
|
+
.update(`${ip}:${nonce}:${solution}`)
|
|
541
|
+
.digest("hex");
|
|
542
|
+
|
|
543
|
+
if (!hash.startsWith("0".repeat(difficulty))) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
|
|
548
|
+
const expiry = Date.now() + 3600000; // 1 heure
|
|
549
|
+
const signature = crypto
|
|
550
|
+
.createHmac("sha256", getPowSecret())
|
|
551
|
+
.update(`${ip}:${expiry}`)
|
|
552
|
+
.digest("hex");
|
|
553
|
+
|
|
554
|
+
return `${expiry}:${signature}`;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Verifies a memory PoW solution.
|
|
561
|
+
* The server performs the same calculation to validate.
|
|
562
|
+
*/
|
|
563
|
+
export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
|
|
564
|
+
const size = difficulty * 1024 * 1024;
|
|
565
|
+
const iterations = size / 16;
|
|
566
|
+
const buffer = new Uint32Array(size / 4);
|
|
567
|
+
const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
|
|
568
|
+
let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
|
|
569
|
+
|
|
570
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
571
|
+
buffer[i] = h = Math.imul(h ^ i, 1597334677);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let finalHash = 0;
|
|
575
|
+
let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
|
|
576
|
+
for (let i = 0; i < iterations; i++) {
|
|
577
|
+
addr = buffer[addr] % buffer.length;
|
|
578
|
+
finalHash ^= addr;
|
|
579
|
+
}
|
|
580
|
+
return finalHash === parseInt(solution, 10);
|
|
581
|
+
};
|
|
582
|
+
export const isTicketValid = (ip, ticket) => {
|
|
583
|
+
if (!ticket) return false;
|
|
584
|
+
const [expiry, sig] = ticket.split(":");
|
|
585
|
+
if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
|
|
586
|
+
const expectedSig = crypto
|
|
587
|
+
.createHmac("sha256", getPowSecret())
|
|
588
|
+
.update(`${ip}:${expiry}`)
|
|
589
|
+
.digest("hex");
|
|
590
|
+
|
|
591
|
+
// Use timingSafeEqual to prevent timing attacks
|
|
592
|
+
try {
|
|
593
|
+
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
594
|
+
} catch (e) {
|
|
595
|
+
// This can happen if the buffers have different lengths, which is a failure case.
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Calculates suspicion indicators related to HTTP header anomalies.
|
|
603
|
+
* @param {object} context - The request context.
|
|
604
|
+
* @returns {{headerAnomalyScore: number}}
|
|
605
|
+
*/
|
|
606
|
+
function getHeaderAnomalies(context) {
|
|
607
|
+
let anomalyScore = 0;
|
|
608
|
+
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
609
|
+
if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
|
|
610
|
+
anomalyScore += 60;
|
|
611
|
+
}
|
|
612
|
+
// Penalty if Accept-Language header is missing
|
|
613
|
+
if (!context.headers["accept-language"]) {
|
|
614
|
+
anomalyScore += 25;
|
|
615
|
+
}
|
|
616
|
+
// Penalty for HTTP/1.0 requests, often used by old tools or bots
|
|
617
|
+
if (context.httpVersion === "1.0") {
|
|
618
|
+
anomalyScore += 15;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Checks for submitted honeypot fields to detect bots.
|
|
628
|
+
* @param {object} context - The request context.
|
|
629
|
+
* @param {object} honeypotConfig - The honeypot configuration.
|
|
630
|
+
* @returns {{honeypotScore: number}}
|
|
631
|
+
*/
|
|
632
|
+
function getHoneypotScore(context, honeypotConfig = {}) {
|
|
633
|
+
const { fields = [], trapUrls = [], detectInjections = true } = honeypotConfig;
|
|
634
|
+
// (NOUVEAU) Permettre de brancher des analyseurs externes plus robustes.
|
|
635
|
+
// L'utilisateur pourrait passer une fonction qui prend les données de la requête
|
|
636
|
+
// et retourne `true` si une menace est détectée.
|
|
637
|
+
// Exemple: `(data) => myWafLibrary.isMalicious(data)`
|
|
638
|
+
const externalAnalyzers = honeypotConfig.analyzers || [];
|
|
639
|
+
if (typeof detectInjections === 'object' && detectInjections.analyzers) {
|
|
640
|
+
externalAnalyzers.push(...detectInjections.analyzers);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 1. Check for trap URL access
|
|
644
|
+
if (trapUrls.some(trap => context.path.startsWith(trap))) {
|
|
645
|
+
return { honeypotScore: 100 };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (fields.length === 0 && !detectInjections) {
|
|
649
|
+
return { honeypotScore: 0 };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Check both query parameters (for URL probing) and the request body (for hidden form fields).
|
|
653
|
+
const queryData =
|
|
654
|
+
context.query instanceof URLSearchParams
|
|
655
|
+
? Object.fromEntries(context.query.entries())
|
|
656
|
+
: context.query || {};
|
|
657
|
+
const bodyData = context.body || {};
|
|
658
|
+
|
|
659
|
+
// 2. Check for honeypot field names
|
|
660
|
+
for (const field of fields) {
|
|
661
|
+
// A bot is trapped if the field exists in either the query OR the body.
|
|
662
|
+
if (
|
|
663
|
+
Object.prototype.hasOwnProperty.call(queryData, field) ||
|
|
664
|
+
Object.prototype.hasOwnProperty.call(bodyData, field)
|
|
665
|
+
) {
|
|
666
|
+
return { honeypotScore: 100 }; // A bot fell into the trap, maximum score.
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 3. (NOUVEAU) Utiliser les analyseurs externes
|
|
671
|
+
const allData = { ...queryData, ...bodyData };
|
|
672
|
+
if (externalAnalyzers.length > 0) {
|
|
673
|
+
for (const analyzer of externalAnalyzers) {
|
|
674
|
+
// On passe à l'analyseur l'ensemble des données de la requête.
|
|
675
|
+
if (analyzer(allData)) {
|
|
676
|
+
return { honeypotScore: 100 };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (detectInjections) {
|
|
682
|
+
// 3. Check for injection attempts in values using the centralized isMalicious function.
|
|
683
|
+
const inspect = (obj) => {
|
|
684
|
+
for (const key in obj) {
|
|
685
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
686
|
+
const value = obj[key];
|
|
687
|
+
if (typeof value === 'string') {
|
|
688
|
+
if (isMalicious(value)) return true;
|
|
689
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
690
|
+
// For nested objects (like in NoSQL injections), we stringify them once
|
|
691
|
+
// to check for malicious patterns within their structure or values.
|
|
692
|
+
if (isMalicious(JSON.stringify(value))) return true;
|
|
693
|
+
// Then, we recurse to check individual string values inside.
|
|
694
|
+
if (inspect(value)) return true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return false;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
if (inspect(queryData)) {
|
|
702
|
+
return { honeypotScore: 100 };
|
|
703
|
+
}
|
|
704
|
+
if (inspect(bodyData)) {
|
|
705
|
+
return { honeypotScore: 100 };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return { honeypotScore: 0 };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
714
|
+
* @param {object} context - Le contexte de la requête, contenant les en-têtes.
|
|
715
|
+
* @returns {{behaviorScore: number}}
|
|
716
|
+
*/
|
|
717
|
+
function getBehaviorScore(context) {
|
|
718
|
+
const behaviorHeader = context.headers['x-behavior-metrics'];
|
|
719
|
+
if (!behaviorHeader) {
|
|
720
|
+
return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const metrics = JSON.parse(behaviorHeader);
|
|
725
|
+
let score = 0;
|
|
726
|
+
if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
|
|
727
|
+
if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
|
|
728
|
+
return { behaviorScore: Math.min(100, score) };
|
|
729
|
+
} catch (e) {
|
|
730
|
+
return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Analyzes server-side request patterns for a given device to detect bot-like behavior.
|
|
736
|
+
* This is a stateful check that looks for repetitive or unnaturally fast requests.
|
|
737
|
+
* @param {object} context - The request context.
|
|
738
|
+
* @param {object} deviceData - The device's activity data from the store.
|
|
739
|
+
* @returns {{requestPatternScore: number}}
|
|
740
|
+
*/
|
|
741
|
+
function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
742
|
+
if (!deviceData) return { requestPatternScore: 0 };
|
|
743
|
+
|
|
744
|
+
// Default values for the pattern detection logic, which can be overridden by the auto-tuner.
|
|
745
|
+
const {
|
|
746
|
+
velocityThreshold = 800, velocityWeight = 30,
|
|
747
|
+
burstThreshold = 1500, burstWeight = 50,
|
|
748
|
+
scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
|
|
749
|
+
historySize = 10,
|
|
750
|
+
decayFactor = 0.9,
|
|
751
|
+
inactivityReset = 30000,
|
|
752
|
+
// Nouveau paramètre pour la détection de séquences
|
|
753
|
+
sequenceLength = 3, sequenceWeight = 60
|
|
754
|
+
} = patternConfig;
|
|
755
|
+
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
const currentPath = context.path;
|
|
758
|
+
// Make the function robust to handle both URLSearchParams and plain objects for query.
|
|
759
|
+
// Ensure query parameters are consistently handled, whether they come from a URLSearchParams object or a plain object.
|
|
760
|
+
const params = context.query instanceof URLSearchParams ? context.query : new URLSearchParams(context.query);
|
|
761
|
+
params.sort(); // Sort for deterministic order
|
|
762
|
+
const currentQueryString = params.toString();
|
|
763
|
+
|
|
764
|
+
// Initialize request history if it doesn't exist
|
|
765
|
+
if (!deviceData.requestHistory) {
|
|
766
|
+
deviceData.requestHistory = [];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const history = deviceData.requestHistory;
|
|
770
|
+
let score = 0;
|
|
771
|
+
|
|
772
|
+
// --- Analyze patterns based on the last few requests ---
|
|
773
|
+
if (history.length > 0) {
|
|
774
|
+
const lastRequest = history[history.length - 1];
|
|
775
|
+
const timeSinceLast = now - lastRequest.timestamp; // 150
|
|
776
|
+
|
|
777
|
+
// 1. Velocity Check: Penalize requests that are too fast to be human.
|
|
778
|
+
if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
|
|
779
|
+
score += velocityWeight; // score = 30
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// 2. Burst Check: Add additional penalty for identical requests in a very short time frame.
|
|
783
|
+
if (currentPath === lastRequest.path && currentQueryString === lastRequest.queryString && timeSinceLast < burstThreshold) { // 150 < 500 -> true
|
|
784
|
+
score += burstWeight; // score = 30 + 50 = 80
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 3. Sequential Scraping Check: Add additional penalty for same path with different query params (potential scraping).
|
|
788
|
+
// This is a simplified check, now independent of the burst check.
|
|
789
|
+
if (currentPath === lastRequest.path && currentQueryString !== lastRequest.queryString && timeSinceLast < scrapeThreshold) {
|
|
790
|
+
const previousRequest = history.length > 2 ? history[history.length - 2] : null;
|
|
791
|
+
if (previousRequest && previousRequest.path === currentPath) {
|
|
792
|
+
score += scrapeBurstWeight; // This is at least the 3rd request in a sequence to the same path.
|
|
793
|
+
} else {
|
|
794
|
+
score += scrapeWeight; // First sign of a potential scraping pattern
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// 4. (NOUVEAU) Détection de séquences répétitives (ex: A -> B -> C -> A -> B -> C)
|
|
799
|
+
if (history.length >= sequenceLength * 2) {
|
|
800
|
+
const lastSequence = history.slice(-sequenceLength);
|
|
801
|
+
const previousSequence = history.slice(-sequenceLength * 2, -sequenceLength);
|
|
802
|
+
|
|
803
|
+
const isRepeating = lastSequence.every((req, i) =>
|
|
804
|
+
req.path === previousSequence[i].path && req.queryString === previousSequence[i].queryString
|
|
805
|
+
);
|
|
806
|
+
if (isRepeating) score += sequenceWeight;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// --- Update history ---
|
|
811
|
+
history.push({
|
|
812
|
+
timestamp: now,
|
|
813
|
+
path: currentPath,
|
|
814
|
+
queryString: currentQueryString,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Keep history to a reasonable size (e.g., last 10 requests)
|
|
818
|
+
if (history.length > historySize) {
|
|
819
|
+
history.shift();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Decay the score over time if behavior becomes normal again.
|
|
823
|
+
// We can store the score in deviceData and decay it.
|
|
824
|
+
deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
|
|
825
|
+
|
|
826
|
+
// If there hasn't been a request in a while, reset the pattern score.
|
|
827
|
+
if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
|
|
828
|
+
deviceData.lastPatternScore = 0;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const trapUrlTemplates = [
|
|
835
|
+
'/includes/config-{RANDOM}.php', // Classic PHP config file
|
|
836
|
+
'/.env.{RANDOM}', // Environment file
|
|
837
|
+
'/backups/db_backup_{RANDOM}.sql.gz', // Database backup
|
|
838
|
+
'/api/v1/internal/status?trace={RANDOM}', // Internal API endpoint
|
|
839
|
+
'/_private/deploy_key_{RANDOM}.pem', // Private key file
|
|
840
|
+
'/logs/app_error_{RANDOM}.log', // Log file
|
|
841
|
+
'/.git/config_{RANDOM}' // Exposed git config variant
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Generates a signed trap URL.
|
|
846
|
+
* @param {string} nonce - The nonce to sign the URL with.
|
|
847
|
+
* @returns {string} The trap URL.
|
|
848
|
+
*/
|
|
849
|
+
function generateTrapUrl(nonce) {
|
|
850
|
+
// Pick a random template to diversify the traps
|
|
851
|
+
const template = trapUrlTemplates[Math.floor(Math.random() * trapUrlTemplates.length)];
|
|
852
|
+
const randomPart = crypto.randomBytes(8).toString('hex');
|
|
853
|
+
const path = template.replace('{RANDOM}', randomPart);
|
|
854
|
+
|
|
855
|
+
const signature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
|
|
856
|
+
return `${path}?sig=${signature}`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Verifies if a given path is a valid trap URL for a given nonce.
|
|
861
|
+
* @param {string} path - The request path.
|
|
862
|
+
* @param {string} signature - The signature from the query.
|
|
863
|
+
* @param {string} nonce - The nonce to verify against.
|
|
864
|
+
* @returns {boolean}
|
|
865
|
+
*/
|
|
866
|
+
function verifyTrapUrl(path, signature, nonce) {
|
|
867
|
+
const expectedSignature = crypto.createHmac('sha256', getPowSecret()).update(nonce + path).digest('hex').substring(0, 16);
|
|
868
|
+
try {
|
|
869
|
+
// Use timingSafeEqual to prevent timing attacks where an attacker could guess the signature byte by byte.
|
|
870
|
+
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
871
|
+
} catch {
|
|
872
|
+
// This will catch errors if buffers have different lengths or contain invalid hex characters, which is a failure case.
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* @typedef {object} IStore
|
|
878
|
+
* @property {(key: string) => Promise<any>} get
|
|
879
|
+
* @property {(key: string, value: any, ttl?: number) => Promise<void>} set
|
|
880
|
+
* @property {(key: string) => Promise<boolean>} has
|
|
881
|
+
* @property {(key: string) => Promise<void>} delete
|
|
882
|
+
*/
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Default in-memory store implementation.
|
|
886
|
+
* @type {IStore}
|
|
887
|
+
*/
|
|
888
|
+
const inMemoryStore = {
|
|
889
|
+
_map: new Map(),
|
|
890
|
+
_timeouts: new Map(),
|
|
891
|
+
async get(key) { return this._map.get(key); },
|
|
892
|
+
async set(key, value, ttl) {
|
|
893
|
+
this._map.set(key, value);
|
|
894
|
+
// If a timeout already exists for this key, clear it.
|
|
895
|
+
if (this._timeouts.has(key)) {
|
|
896
|
+
clearTimeout(this._timeouts.get(key));
|
|
897
|
+
this._timeouts.delete(key);
|
|
898
|
+
}
|
|
899
|
+
// If a TTL is provided, set a timeout to delete the key.
|
|
900
|
+
if (ttl && ttl > 0) {
|
|
901
|
+
const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
|
|
902
|
+
this._timeouts.set(key, timeoutId);
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
async has(key) { return this._map.has(key); },
|
|
906
|
+
async delete(key) { this._map.delete(key); },
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/** @type {IStore} */
|
|
910
|
+
let store = inMemoryStore;
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Allows configuring an external datastore (e.g., Redis).
|
|
914
|
+
* Must be called before the middleware is used.
|
|
915
|
+
* @param {IStore} externalStore - An implementation of the IStore interface.
|
|
916
|
+
*/
|
|
917
|
+
export const configureStore = (externalStore) => {
|
|
918
|
+
store = externalStore;
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Orchestrates request identification using a persistent anchor (cookie)
|
|
923
|
+
* and fingerprint verification.
|
|
924
|
+
* @param {object} context - The request context.
|
|
925
|
+
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
926
|
+
*/
|
|
927
|
+
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
928
|
+
const existingDeviceId = context.cookies?.device_id;
|
|
929
|
+
const currentDeviceHash = getDeviceHash(context);
|
|
930
|
+
let deviceId = existingDeviceId;
|
|
931
|
+
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
932
|
+
let deviceData = null;
|
|
933
|
+
let newCookie = null;
|
|
934
|
+
if (deviceId) {
|
|
935
|
+
deviceData = await store.get(`device:${deviceId}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (deviceData) {
|
|
939
|
+
// Case 1: The user has a "passport" and we know them.
|
|
940
|
+
const storedHash = deviceData.initialDeviceHash;
|
|
941
|
+
|
|
942
|
+
// Compare the current fingerprint with the reference one.
|
|
943
|
+
consistencyScore = FingerprintBuilder.compare(
|
|
944
|
+
storedHash,
|
|
945
|
+
currentDeviceHash,
|
|
946
|
+
);
|
|
947
|
+
} else {
|
|
948
|
+
// Case 2: New user or lost/invalid cookie.
|
|
949
|
+
deviceId = crypto.randomUUID(); // Generate a new "passport".
|
|
950
|
+
|
|
951
|
+
// Return the intention to set a cookie.
|
|
952
|
+
newCookie = {
|
|
953
|
+
name: "device_id",
|
|
954
|
+
value: deviceId,
|
|
955
|
+
options: {
|
|
956
|
+
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
|
|
957
|
+
// Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
|
|
958
|
+
...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// Initialize tracking for this new device.
|
|
963
|
+
deviceData = {
|
|
964
|
+
initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
|
|
965
|
+
ips: new Set(),
|
|
966
|
+
requestHistory: [], // Initialize state for the new pattern score
|
|
967
|
+
lastUpdate: Date.now(),
|
|
968
|
+
lastFpHash: currentDeviceHash,
|
|
969
|
+
lastChangeTimestamp: 0,
|
|
970
|
+
rapidChangeCount: 0,
|
|
971
|
+
highScoreCount: 0,
|
|
972
|
+
lastHighScoreTimestamp: 0,
|
|
973
|
+
};
|
|
974
|
+
// The write will happen in getSuspicionVector after all modifications.
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return { deviceId, deviceData, consistencyScore, newCookie };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/*
|
|
981
|
+
* Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
|
|
982
|
+
* @param {object} context - The request context.
|
|
983
|
+
* @param {object} deviceData - The device's activity data.
|
|
984
|
+
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
985
|
+
*/
|
|
986
|
+
async function getBehavioralIndicators(context, deviceData) {
|
|
987
|
+
const now = Date.now();
|
|
988
|
+
const clientIp = context.clientIp;
|
|
989
|
+
|
|
990
|
+
// Get the IP type to modulate the score
|
|
991
|
+
const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
|
|
992
|
+
const isSharedIp = ipProfile.type === "shared";
|
|
993
|
+
|
|
994
|
+
const currentFpHash = getDeviceHash(context); // Use the device hash
|
|
995
|
+
|
|
996
|
+
// --- Behavior analysis (Change frequency) ---
|
|
997
|
+
if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
|
|
998
|
+
const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
|
|
999
|
+
|
|
1000
|
+
if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
|
|
1001
|
+
deviceData.rapidChangeCount = Math.min(
|
|
1002
|
+
deviceData.rapidChangeCount + 1,
|
|
1003
|
+
MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
|
|
1004
|
+
);
|
|
1005
|
+
} else {
|
|
1006
|
+
deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
|
|
1007
|
+
}
|
|
1008
|
+
deviceData.lastChangeTimestamp = now;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
deviceData.lastFpHash = currentFpHash;
|
|
1012
|
+
deviceData.ips.add(clientIp); // Record the IP used by this device
|
|
1013
|
+
|
|
1014
|
+
// NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
|
|
1015
|
+
// Très efficace contre la rotation de proxy.
|
|
1016
|
+
const maxIpsForDevice = isSharedIp
|
|
1017
|
+
? MAX_DISTINCT_IPS_FOR_SHARED_USER
|
|
1018
|
+
: MAX_DISTINCT_IPS_PER_DEVICE;
|
|
1019
|
+
const freeIpChanges = isSharedIp ? 1 : 3;
|
|
1020
|
+
|
|
1021
|
+
const historyScore = Math.min(
|
|
1022
|
+
100,
|
|
1023
|
+
(Math.max(0, deviceData.ips.size - freeIpChanges) /
|
|
1024
|
+
(maxIpsForDevice - freeIpChanges)) *
|
|
1025
|
+
100,
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
// Score based on rapid identity rotation (0-100)
|
|
1029
|
+
const rotationScore = Math.min(
|
|
1030
|
+
100,
|
|
1031
|
+
(deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
return { historyScore, rotationScore };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Returns a vector of raw (unweighted) suspicion scores.
|
|
1039
|
+
* @param {object} context - The request context object.
|
|
1040
|
+
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
|
|
1041
|
+
*/
|
|
1042
|
+
export const getSuspicionVector = async (context, securityConfig) => {
|
|
1043
|
+
// On récupère la configuration du honeypot pour l'utiliser ici.
|
|
1044
|
+
const honeypotConfig = securityConfig.honeypot || {};
|
|
1045
|
+
|
|
1046
|
+
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
|
|
1047
|
+
|
|
1048
|
+
const clientIp = context.clientIp;
|
|
1049
|
+
|
|
1050
|
+
// If a new cookie needs to be set, attach it to the request object
|
|
1051
|
+
// so the middleware can handle it. This is a temporary state holder.
|
|
1052
|
+
if (newCookie) {
|
|
1053
|
+
context._newCookies = context._newCookies || [];
|
|
1054
|
+
context._newCookies.push(newCookie);
|
|
1055
|
+
}
|
|
1056
|
+
await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
|
|
1057
|
+
|
|
1058
|
+
// Periodically clean up device data
|
|
1059
|
+
if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
|
|
1060
|
+
deviceData.ips.clear();
|
|
1061
|
+
deviceData.rapidChangeCount = 0;
|
|
1062
|
+
}
|
|
1063
|
+
deviceData.lastUpdate = Date.now();
|
|
1064
|
+
|
|
1065
|
+
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
1066
|
+
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
1067
|
+
// Calculate the inconsistency score here, separately.
|
|
1068
|
+
let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
|
|
1069
|
+
|
|
1070
|
+
// NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
|
|
1071
|
+
if (consistencyScore < 0.7) { // Seuil de rupture
|
|
1072
|
+
inconsistencyScore = 100;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
|
|
1076
|
+
|
|
1077
|
+
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1078
|
+
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1079
|
+
|
|
1080
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
|
|
1081
|
+
|
|
1082
|
+
// Save the updated device state to the store
|
|
1083
|
+
// Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
|
|
1084
|
+
await store.set(`device:${deviceId}`, deviceData);
|
|
1085
|
+
|
|
1086
|
+
// Ensure deviceData.ips is a Set for subsequent operations within the same request,
|
|
1087
|
+
// even if the store returns an array.
|
|
1088
|
+
if (Array.isArray(deviceData.ips)) {
|
|
1089
|
+
deviceData.ips = new Set(deviceData.ips);
|
|
1090
|
+
}
|
|
1091
|
+
// Le vecteur de suspicion est maintenant complet.
|
|
1092
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore };
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
// A residential user can change networks (home, 4G, public wifi).
|
|
1096
|
+
const MAX_DISTINCT_IPS_PER_DEVICE = 15;
|
|
1097
|
+
// Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
|
|
1098
|
+
const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
|
|
1099
|
+
|
|
1100
|
+
// Une IP est considérée comme "partagée" si elle est utilisée par plus de 50 appareils différents en 10 minutes.
|
|
1101
|
+
const SHARED_IP_DEVICE_THRESHOLD = 50;
|
|
1102
|
+
|
|
1103
|
+
const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
|
|
1104
|
+
const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Identifies a request on the server side in a granular way.
|
|
1108
|
+
* Uses FingerprintBuilder to create a fingerprint based on headers
|
|
1109
|
+
* and IP, making spoofing more complex (requires changing the entire stack).
|
|
1110
|
+
*/
|
|
1111
|
+
export const identifyRequest = (securityConfig) => async (req, res) => {
|
|
1112
|
+
// This function now acts as a lightweight wrapper around the engine's identifyRequest method.
|
|
1113
|
+
// It requires a default configuration to work.
|
|
1114
|
+
const config = securityConfig || {
|
|
1115
|
+
weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8, honeypotScore: 1.0 },
|
|
1116
|
+
thresholds: { low: 20, medium: 40, high: 75 },
|
|
1117
|
+
honeypot: { fields: [] } // Ensure honeypot config exists to prevent errors
|
|
1118
|
+
};
|
|
1119
|
+
const engine = new FingerprintEngine(config);
|
|
1120
|
+
|
|
1121
|
+
const requestContext = {
|
|
1122
|
+
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
1123
|
+
query: req.query,
|
|
1124
|
+
body: req.body,
|
|
1125
|
+
cookies: req.cookies,
|
|
1126
|
+
headers: req.headers,
|
|
1127
|
+
rawHeaders: req.rawHeaders,
|
|
1128
|
+
httpVersion: req.httpVersion,
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const key = await engine.identifyRequest(requestContext);
|
|
1132
|
+
|
|
1133
|
+
if (requestContext._newCookies && res) {
|
|
1134
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return key;
|
|
1138
|
+
};
|
|
1139
|
+
// --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
|
|
1140
|
+
|
|
1141
|
+
// Le plus grand nombre possible avec SHA-256 (2^256 - 1)
|
|
1142
|
+
// The largest possible number with SHA-256 (2^256 - 1)
|
|
1143
|
+
const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
|
|
1144
|
+
// Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
|
|
1145
|
+
// (équivalent à 4 zéros en hexadécimal)
|
|
1146
|
+
// A base difficulty, e.g., requires the first 16 bits to be 0
|
|
1147
|
+
// (equivalent to 4 zeros in hexadecimal)
|
|
1148
|
+
const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Calculates the difficulty target based on the suspicion factor.
|
|
1152
|
+
* @param {number} suspicionFactor - A number from 0 to 1.
|
|
1153
|
+
* @returns {BigInt} The target number.
|
|
1154
|
+
*/
|
|
1155
|
+
function calculateTarget(suspicionFactor) {
|
|
1156
|
+
// Difficulty range adjusted to be realistic.
|
|
1157
|
+
// MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
|
|
1158
|
+
// MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
|
|
1159
|
+
const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
|
|
1160
|
+
const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
|
|
1161
|
+
|
|
1162
|
+
// Use linear interpolation between min and max difficulty.
|
|
1163
|
+
const totalDifficultyBits =
|
|
1164
|
+
MIN_DIFFICULTY_BITS +
|
|
1165
|
+
suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
|
|
1166
|
+
|
|
1167
|
+
// The target is max / 2^bits
|
|
1168
|
+
return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Generates a CPU challenge based on a target.
|
|
1173
|
+
*/
|
|
1174
|
+
export function generateCpuTargetChallenge(
|
|
1175
|
+
clientIp,
|
|
1176
|
+
nonce,
|
|
1177
|
+
suspicionFactor,
|
|
1178
|
+
originalUrl,
|
|
1179
|
+
) {
|
|
1180
|
+
const target = calculateTarget(suspicionFactor);
|
|
1181
|
+
return {
|
|
1182
|
+
type: "cpu_target",
|
|
1183
|
+
nonce: nonce,
|
|
1184
|
+
target: target.toString(16), // Send the target in hexadecimal
|
|
1185
|
+
path: originalUrl,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Generates the HTML page for the CPU target challenge.
|
|
1191
|
+
* @param {object} challengeDetails - The details from generateCpuTargetChallenge.
|
|
1192
|
+
* @param {string} clientIp - The client's IP address.
|
|
1193
|
+
* @returns {string} HTML content.
|
|
1194
|
+
*/
|
|
1195
|
+
function generateCpuTargetChallengePage(challengeDetails, clientIp) {
|
|
1196
|
+
const { nonce, target, path } = challengeDetails;
|
|
1197
|
+
const solverCode = getPowSolverCode();
|
|
1198
|
+
return `
|
|
1199
|
+
<html><head><title>Security Check</title></head>
|
|
1200
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
1201
|
+
<h1>Please wait... (Level 1)</h1>
|
|
1202
|
+
<p>We are verifying that you are not a bot. This may take a few seconds.</p>
|
|
1203
|
+
<div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
|
|
1204
|
+
<script>${solverCode}</script>
|
|
1205
|
+
<script>
|
|
1206
|
+
async function solve() {
|
|
1207
|
+
const clientIp = "${clientIp}";
|
|
1208
|
+
const nonce = "${nonce}";
|
|
1209
|
+
const cpuTarget = BigInt("0x${target}");
|
|
1210
|
+
const solution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, null, (progress) => {
|
|
1211
|
+
// Optional progress callback
|
|
1212
|
+
});
|
|
1213
|
+
window.location.href = "${path}?pow_type=cpu_target&pow_nonce=${nonce}&pow_solution=" + solution;
|
|
1214
|
+
}
|
|
1215
|
+
solve();
|
|
1216
|
+
</script>
|
|
1217
|
+
</body></html>`;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Generates the HTML content for a combined CPU + Memory PoW challenge.
|
|
1222
|
+
* @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
|
|
1223
|
+
* @param {number} memoryDifficulty - Memory allocation in MB.
|
|
1224
|
+
* @param {string} clientIp - The client's IP address.
|
|
1225
|
+
* @returns {string} HTML content.
|
|
1226
|
+
*/
|
|
1227
|
+
function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
|
|
1228
|
+
const { nonce, target, path } = cpuChallengeDetails;
|
|
1229
|
+
const solverCode = getPowSolverCode();
|
|
1230
|
+
return `
|
|
1231
|
+
<html><head><title>Advanced Security Check</title></head>
|
|
1232
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
1233
|
+
<h1>Enhanced Verification... (Level 2)</h1>
|
|
1234
|
+
<p>Your activity requires an additional security check. This may take a few moments.</p>
|
|
1235
|
+
<div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
|
|
1236
|
+
<script>${solverCode}</script>
|
|
1237
|
+
<script>
|
|
1238
|
+
async function solve() {
|
|
1239
|
+
const nonce = "${nonce}";
|
|
1240
|
+
const path = "${path}";
|
|
1241
|
+
const clientSecret = "${clientSecret}";
|
|
1242
|
+
const clientIp = "${clientIp}";
|
|
1243
|
+
const cpuTarget = BigInt("0x${target}");
|
|
1244
|
+
const memDifficulty = ${memoryDifficulty};
|
|
1245
|
+
|
|
1246
|
+
// --- CPU Challenge ---
|
|
1247
|
+
document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
|
|
1248
|
+
const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, (progress) => {
|
|
1249
|
+
// Optional progress callback
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// --- Memory Challenge ---
|
|
1253
|
+
document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
|
|
1254
|
+
await new Promise(r => setTimeout(r, 10)); // Yield to update UI
|
|
1255
|
+
|
|
1256
|
+
let memSolution = 0;
|
|
1257
|
+
try {
|
|
1258
|
+
const memSeed = nonce + ":" + clientSecret;
|
|
1259
|
+
memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
|
|
1260
|
+
} catch(e) {
|
|
1261
|
+
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
|
|
1265
|
+
}
|
|
1266
|
+
solve();
|
|
1267
|
+
</script>
|
|
1268
|
+
</body></html>`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Verifies a PoW solution based on a target and generates a ticket.
|
|
1273
|
+
*/
|
|
1274
|
+
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1275
|
+
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1276
|
+
ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
|
|
1277
|
+
nonce,
|
|
1278
|
+
solution,
|
|
1279
|
+
suspicionFactor,
|
|
1280
|
+
clientSecret, // Le secret est maintenant requis
|
|
1281
|
+
) {
|
|
1282
|
+
const target = calculateTarget(suspicionFactor);
|
|
1283
|
+
const message = clientSecret
|
|
1284
|
+
? `${clientIp}:${nonce}:${solution}:${clientSecret}`
|
|
1285
|
+
: `${clientIp}:${nonce}:${solution}`;
|
|
1286
|
+
const hash = crypto
|
|
1287
|
+
.createHash("sha256")
|
|
1288
|
+
.update(message)
|
|
1289
|
+
.digest("hex");
|
|
1290
|
+
const hashAsInt = BigInt("0x" + hash);
|
|
1291
|
+
|
|
1292
|
+
if (hashAsInt < target) {
|
|
1293
|
+
// The comparison is direct with native BigInts
|
|
1294
|
+
// The proof is valid, generate the ticket
|
|
1295
|
+
const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
|
|
1296
|
+
const signature = crypto
|
|
1297
|
+
.createHmac("sha256", getPowSecret())
|
|
1298
|
+
.update(`${clientIp}:${expiry}`)
|
|
1299
|
+
.digest("hex");
|
|
1300
|
+
return `${expiry}:${signature}`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const staticExtensions = new RegExp(
|
|
1307
|
+
"\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest)$",
|
|
1308
|
+
"i",
|
|
1309
|
+
);
|
|
1310
|
+
const isStaticResource = (path) => staticExtensions.test(path);
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
|
|
1315
|
+
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
1316
|
+
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
1317
|
+
*/
|
|
1318
|
+
function determineOptimalTicketTtl(suspicionScore) {
|
|
1319
|
+
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
1320
|
+
const MIN_TTL = 300000;
|
|
1321
|
+
const MAX_TTL = 86400000;
|
|
1322
|
+
|
|
1323
|
+
const solverFunction = () => {
|
|
1324
|
+
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
1325
|
+
|
|
1326
|
+
// Un "individu" est simplement une valeur de TTL en millisecondes.
|
|
1327
|
+
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
1328
|
+
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
1329
|
+
const mutate = (ttl) => {
|
|
1330
|
+
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
|
|
1331
|
+
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
1335
|
+
createIndividual,
|
|
1336
|
+
fitnessFunction,
|
|
1337
|
+
crossover,
|
|
1338
|
+
mutate,
|
|
1339
|
+
{
|
|
1340
|
+
generations: 40,
|
|
1341
|
+
populationSize: 30,
|
|
1342
|
+
}
|
|
1343
|
+
);
|
|
1344
|
+
|
|
1345
|
+
// Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
|
|
1346
|
+
// Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
|
|
1347
|
+
// en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
|
|
1348
|
+
if (!paretoFront || paretoFront.length === 0) {
|
|
1349
|
+
return { solution: null, fitness: Infinity };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Stratégie de sélection :
|
|
1353
|
+
// Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
|
|
1354
|
+
// Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
|
|
1355
|
+
let bestSolutionInFront;
|
|
1356
|
+
if (suspicionScore < 50) {
|
|
1357
|
+
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
1358
|
+
} else {
|
|
1359
|
+
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
1360
|
+
}
|
|
1361
|
+
return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
// On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
|
|
1365
|
+
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
1366
|
+
|
|
1367
|
+
if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
|
|
1368
|
+
// Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
|
|
1369
|
+
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
|
|
1373
|
+
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
1374
|
+
return bestResult.solution;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
1379
|
+
* @param {string} str - La chaîne à vérifier.
|
|
1380
|
+
* @returns {boolean} - True si un pattern malveillant est détecté.
|
|
1381
|
+
* @private
|
|
1382
|
+
*/
|
|
1383
|
+
function isMalicious(str) {
|
|
1384
|
+
// Regex pour les injections SQL et NoSQL de base
|
|
1385
|
+
// Ajout de la détection des injections basées sur le temps (SLEEP, BENCHMARK, WAITFOR) et d'autres commandes dangereuses.
|
|
1386
|
+
const injectionRegex = /(\$ne|' OR '1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i;
|
|
1387
|
+
// Regex pour les injections plus avancées
|
|
1388
|
+
const log4ShellRegex = /\$\{jndi:(ldap|rmi|dns):/i;
|
|
1389
|
+
const sstiRegex = /\{\{.*\}\}|\{%.*%\}/; // Détecte les syntaxes de type Jinja2, Twig, etc.
|
|
1390
|
+
const xxeRegex = /<!ENTITY\s+.*SYSTEM/i;
|
|
1391
|
+
const pathTraversalRegex = /(\.\.\/|\.\.\\)/;
|
|
1392
|
+
// NOUVEAU : Regex pour les injections de commandes basiques.
|
|
1393
|
+
// Cible les séparateurs de commandes et les backticks d'exécution.
|
|
1394
|
+
const commandInjectionRegex = /(&&|\|\||;|\n|`)/;
|
|
1395
|
+
|
|
1396
|
+
return injectionRegex.test(str) || log4ShellRegex.test(str) || sstiRegex.test(str) || xxeRegex.test(str) || pathTraversalRegex.test(str) || commandInjectionRegex.test(str);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// --- Middleware Proof-of-Work (Le péage) ---
|
|
1400
|
+
export class FingerprintEngine {
|
|
1401
|
+
constructor(securityConfig) {
|
|
1402
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
1403
|
+
this.securityConfig = securityConfig;
|
|
1404
|
+
this.isProduction = isProduction;
|
|
1405
|
+
this._allowlist = this._buildAllowlist();
|
|
1406
|
+
this.verbose = securityConfig.verbose || false;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
_log(message, data = {}) {
|
|
1410
|
+
if (this.verbose) {
|
|
1411
|
+
console.log(`[FingerprintEngine] ${message}`, data);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
calculateFinalScore = function(suspicionVector) {
|
|
1415
|
+
const { weights } = this.securityConfig;
|
|
1416
|
+
if (!weights) return 0;
|
|
1417
|
+
|
|
1418
|
+
const score =
|
|
1419
|
+
(suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
|
|
1420
|
+
(suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
|
|
1421
|
+
(suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
|
|
1422
|
+
(suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
|
|
1423
|
+
(suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
|
|
1424
|
+
(suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
|
|
1425
|
+
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
|
|
1426
|
+
|
|
1427
|
+
return Math.min(100, score);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
|
|
1431
|
+
* This is the fastest check and should be performed first.
|
|
1432
|
+
* @private
|
|
1433
|
+
* @param {string} clientIp - The IP address of the client.
|
|
1434
|
+
* @returns {boolean} True if the IP is in the allowlist.
|
|
1435
|
+
*/
|
|
1436
|
+
_buildAllowlist() {
|
|
1437
|
+
const blockList = new BlockList();
|
|
1438
|
+
const { whitelist = [] } = this.securityConfig;
|
|
1439
|
+
const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
|
|
1440
|
+
|
|
1441
|
+
if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
|
|
1442
|
+
return blockList; // Retourne une liste vide
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
for (const entry of allowlistRule.entries) {
|
|
1446
|
+
if (entry.includes('/')) { // CIDR range
|
|
1447
|
+
try {
|
|
1448
|
+
const [address, prefix] = entry.split('/');
|
|
1449
|
+
blockList.addSubnet(address, parseInt(prefix, 10));
|
|
1450
|
+
} catch (e) {
|
|
1451
|
+
// Ignore les entrées CIDR invalides
|
|
1452
|
+
}
|
|
1453
|
+
} else { // Direct IP match
|
|
1454
|
+
blockList.addAddress(entry);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return blockList;
|
|
1458
|
+
}
|
|
1459
|
+
_isIpInAllowlist(clientIp) {
|
|
1460
|
+
return this._allowlist.check(clientIp);
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
|
|
1464
|
+
* using reverse and forward DNS lookups. The result is cached.
|
|
1465
|
+
* @private
|
|
1466
|
+
* @param {object} requestContext - The request context.
|
|
1467
|
+
* @returns {Promise<boolean>} True if the request is from a verified whitelisted bot.
|
|
1468
|
+
*/
|
|
1469
|
+
async _verifyWhitelistedBot(requestContext) {
|
|
1470
|
+
const { whitelist = [] } = this.securityConfig;
|
|
1471
|
+
const botRules = whitelist.filter(rule => rule.hostnameSuffix);
|
|
1472
|
+
if (botRules.length === 0) {
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const { clientIp, headers } = requestContext;
|
|
1477
|
+
const userAgent = headers['user-agent'] || '';
|
|
1478
|
+
|
|
1479
|
+
const matchedRule = botRules.find(rule => {
|
|
1480
|
+
if (!rule.userAgent) return false;
|
|
1481
|
+
try {
|
|
1482
|
+
return new RegExp(rule.userAgent).test(userAgent);
|
|
1483
|
+
} catch (e) {
|
|
1484
|
+
console.error(`[Fingerprint] Invalid regex in whitelist rule: ${rule.userAgent}`);
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
if (!matchedRule) {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const cacheKey = `ip-whitelist:${clientIp}`;
|
|
1493
|
+
const cachedStatus = await store.get(cacheKey);
|
|
1494
|
+
|
|
1495
|
+
if (cachedStatus === 'verified') {
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
if (cachedStatus === 'failed') {
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
try {
|
|
1503
|
+
// 1. Reverse DNS lookup
|
|
1504
|
+
const hostnames = await dns.reverse(clientIp);
|
|
1505
|
+
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
1506
|
+
|
|
1507
|
+
if (!validHostname) {
|
|
1508
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// 2. Forward DNS lookup
|
|
1513
|
+
const addresses = await dns.resolve(validHostname);
|
|
1514
|
+
if (addresses.includes(clientIp)) {
|
|
1515
|
+
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
// DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async processRequest(requestContext) {
|
|
1527
|
+
|
|
1528
|
+
const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
|
|
1529
|
+
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
1530
|
+
|
|
1531
|
+
this._log('Processing request', { clientIp, path, isStatic });
|
|
1532
|
+
|
|
1533
|
+
if (isStatic) {
|
|
1534
|
+
this._log('Static resource - skipping checks');
|
|
1535
|
+
return { action: 'next', score: 0, vector: {} };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// 1. Check static IP allowlist first for maximum performance.
|
|
1539
|
+
if (this._isIpInAllowlist(clientIp)) {
|
|
1540
|
+
this._log('IP in allowlist - allowing request', { clientIp });
|
|
1541
|
+
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'allowlist' } };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const { pow_nonce } = query;
|
|
1545
|
+
|
|
1546
|
+
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1547
|
+
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1548
|
+
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
1549
|
+
if (pow_nonce) {
|
|
1550
|
+
const powCookie = cookies?.pow_clearance;
|
|
1551
|
+
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
1552
|
+
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
1553
|
+
// The final decision is made later, after calculating the score.
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
|
|
1558
|
+
if (await this._verifyWhitelistedBot(requestContext)) {
|
|
1559
|
+
this._log('Whitelisted bot verified - allowing request', { clientIp });
|
|
1560
|
+
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
1564
|
+
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
1565
|
+
// avant même de recalculer le score de suspicion.
|
|
1566
|
+
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1567
|
+
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1568
|
+
this._log('Challenge solution submitted', { pow_type, pow_nonce });
|
|
1569
|
+
|
|
1570
|
+
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
1571
|
+
// car le TTL optimal en dépend.
|
|
1572
|
+
const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1573
|
+
const preliminaryScore = this.calculateFinalScore(preliminaryVector);
|
|
1574
|
+
|
|
1575
|
+
this._log('Preliminary suspicion vector calculated', {
|
|
1576
|
+
vector: preliminaryVector,
|
|
1577
|
+
score: preliminaryScore
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
let isValid = false;
|
|
1581
|
+
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1582
|
+
let ticket = null;
|
|
1583
|
+
|
|
1584
|
+
if (challengeContext) {
|
|
1585
|
+
const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
1586
|
+
this._log('Challenge context found, verifying solution', { optimalTtl });
|
|
1587
|
+
|
|
1588
|
+
if (pow_type === "cpu_target") {
|
|
1589
|
+
// On passe la durée de vie du ticket configurée
|
|
1590
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1591
|
+
isValid = ticket !== null;
|
|
1592
|
+
this._log('CPU target challenge verification', { isValid });
|
|
1593
|
+
} else if (pow_type === "cpu_mem") {
|
|
1594
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1595
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
|
|
1596
|
+
isValid = cpuTicket !== null && isMemValid;
|
|
1597
|
+
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
1598
|
+
this._log('Combined CPU+Memory challenge verification', {
|
|
1599
|
+
cpuValid: cpuTicket !== null,
|
|
1600
|
+
memValid: isMemValid,
|
|
1601
|
+
isValid
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
} else {
|
|
1605
|
+
this._log('Challenge context not found or expired', { pow_nonce });
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
if (isValid) {
|
|
1609
|
+
// La solution est valide. On supprime le secret et on redirige.
|
|
1610
|
+
await store.delete(`secret:${pow_nonce}`);
|
|
1611
|
+
this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: this.securityConfig.ticketMaxAge || 3600000 });
|
|
1612
|
+
|
|
1613
|
+
if (logger) {
|
|
1614
|
+
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return {
|
|
1618
|
+
action: 'redirect',
|
|
1619
|
+
path: path,
|
|
1620
|
+
score: 0, // Le score n'est pas pertinent ici, on a passé le test.
|
|
1621
|
+
vector: { challenge_solved: 100 },
|
|
1622
|
+
cookie: {
|
|
1623
|
+
name: 'pow_clearance',
|
|
1624
|
+
value: ticket,
|
|
1625
|
+
options: {
|
|
1626
|
+
httpOnly: true,
|
|
1627
|
+
secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
|
|
1628
|
+
maxAge: this.securityConfig.ticketMaxAge || 3600000,
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
// Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
|
|
1634
|
+
// sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
|
|
1635
|
+
// On pourrait même ajouter une pénalité ici si on le voulait.
|
|
1636
|
+
this._log('Challenge solution invalid', { reason: challengeContext ? 'Invalid solution' : 'Nonce not found or expired' });
|
|
1637
|
+
|
|
1638
|
+
if (logger && challengeContext) {
|
|
1639
|
+
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
|
|
1640
|
+
} else if (logger && !challengeContext) {
|
|
1641
|
+
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
1645
|
+
|
|
1646
|
+
// Resolve identity and check for persisted "condemned" status early.
|
|
1647
|
+
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
1648
|
+
const isNewDevice = !!newCookie;
|
|
1649
|
+
|
|
1650
|
+
this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
|
|
1651
|
+
|
|
1652
|
+
if (deviceData?.condemned) {
|
|
1653
|
+
this._log('Device condemned - blocking request', { deviceId });
|
|
1654
|
+
if (onDeviceCompromised) {
|
|
1655
|
+
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
|
|
1656
|
+
}
|
|
1657
|
+
return { action: 'block', status: 404, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// The engine now works with the context directly, no more rawReq dependency here.
|
|
1661
|
+
const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1662
|
+
// honeypotScore et behaviorScore sont maintenant inclus directement dans le vecteur de suspicion.
|
|
1663
|
+
|
|
1664
|
+
this._log('Suspicion vector calculated', {
|
|
1665
|
+
vector: suspicionVector,
|
|
1666
|
+
weights: this.securityConfig.weights
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
let finalScore = this.calculateFinalScore(suspicionVector);
|
|
1670
|
+
|
|
1671
|
+
this._log('Final score calculated', { finalScore });
|
|
1672
|
+
|
|
1673
|
+
// Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
|
|
1674
|
+
// Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
|
|
1675
|
+
// NOUVEAU : Cette logique est maintenant configurable.
|
|
1676
|
+
const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
|
|
1677
|
+
if (isNewDevice && finalScore < thresholds.low) {
|
|
1678
|
+
this._log('New device - enforcing minimum challenge score', {
|
|
1679
|
+
originalScore: finalScore,
|
|
1680
|
+
enforcedScore: thresholds.low
|
|
1681
|
+
});
|
|
1682
|
+
finalScore = thresholds.low;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const blockThreshold = thresholds.block ?? 95;
|
|
1686
|
+
const isBlocked = finalScore >= blockThreshold;
|
|
1687
|
+
|
|
1688
|
+
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
1689
|
+
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1690
|
+
const isSuspicious = finalScore >= thresholds.low;
|
|
1691
|
+
|
|
1692
|
+
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1693
|
+
const suspicionFactor = isSuspicious
|
|
1694
|
+
? Math.min(
|
|
1695
|
+
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
1696
|
+
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
1697
|
+
)
|
|
1698
|
+
: 0;
|
|
1699
|
+
|
|
1700
|
+
this._log('Suspicion levels evaluated', {
|
|
1701
|
+
finalScore,
|
|
1702
|
+
isBlocked,
|
|
1703
|
+
isSuspiciousHigh,
|
|
1704
|
+
isSuspiciousMedium,
|
|
1705
|
+
isSuspicious,
|
|
1706
|
+
suspicionFactor,
|
|
1707
|
+
thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
const powCookie = cookies?.pow_clearance;
|
|
1711
|
+
|
|
1712
|
+
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
1713
|
+
if (isBlocked) {
|
|
1714
|
+
this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
|
|
1715
|
+
if (onDeviceCompromised) {
|
|
1716
|
+
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Score exceeded block threshold', score: finalScore, vector: suspicionVector });
|
|
1717
|
+
}
|
|
1718
|
+
return { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Honeypot: Check if the request is for a trap URL generated in a previous challenge.
|
|
1722
|
+
// This requires a nonce from a *previous* challenge, which we can look up via the device ID.
|
|
1723
|
+
const lastNonce = deviceData?.lastChallengeNonce;
|
|
1724
|
+
if (lastNonce && query.sig && verifyTrapUrl(path, query.sig, lastNonce)) {
|
|
1725
|
+
this._log('Honeypot trap URL triggered - condemning device', { path, deviceId });
|
|
1726
|
+
deviceData.condemned = true; // This device is a bot. Condemn it.
|
|
1727
|
+
if (onDeviceCompromised) {
|
|
1728
|
+
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Triggered signed honeypot trap URL', score: 100, vector: { honeypotScore: 100 } });
|
|
1729
|
+
}
|
|
1730
|
+
if (logger) {
|
|
1731
|
+
logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
|
|
1732
|
+
}
|
|
1733
|
+
await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
|
|
1734
|
+
return { action: 'block', status: 404, score: 100, vector: { honeypotScore: 100 } };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1738
|
+
this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
|
|
1739
|
+
|
|
1740
|
+
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1741
|
+
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1742
|
+
// If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
|
|
1743
|
+
// AND it's not a legitimate response to a challenge we issued, it's a probe.
|
|
1744
|
+
const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
|
|
1745
|
+
if (pow_nonce && !isChallengeResponse) {
|
|
1746
|
+
this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
|
|
1747
|
+
if (logger) {
|
|
1748
|
+
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
|
|
1749
|
+
}
|
|
1750
|
+
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
1751
|
+
// Recalculate the final score with the updated vector.
|
|
1752
|
+
const newFinalScore = this.calculateFinalScore(suspicionVector);
|
|
1753
|
+
return { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
1757
|
+
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1758
|
+
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
1759
|
+
|
|
1760
|
+
// LEVEL 3: CAPTCHA (the highest)
|
|
1761
|
+
if (isSuspiciousHigh) {
|
|
1762
|
+
// ... logic for TSP/Captcha challenge
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
|
|
1766
|
+
if (isSuspicious) { // Couvre à la fois low et medium
|
|
1767
|
+
// Generate some trap URLs to embed in the challenge page.
|
|
1768
|
+
// These links are visually hidden but present in the DOM to trap bots.
|
|
1769
|
+
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
|
|
1770
|
+
const trapLinksHtml = trapUrls.map(url => `<a href="${url}" tabindex="-1">config</a>`).join(' ');
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
|
|
1774
|
+
|
|
1775
|
+
// La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
|
|
1776
|
+
// Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
|
|
1777
|
+
const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
|
|
1778
|
+
|
|
1779
|
+
const minMemDifficulty = 0; // Peut être 0 Mo !
|
|
1780
|
+
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
1781
|
+
const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
|
|
1782
|
+
|
|
1783
|
+
this._log('Challenge parameters calculated', {
|
|
1784
|
+
suspicionFactor,
|
|
1785
|
+
memActivationFactor,
|
|
1786
|
+
memDifficulty,
|
|
1787
|
+
cpuTarget: cpuChallengeDetails.target
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
1791
|
+
await store.set(`secret:${nonce}`, {
|
|
1792
|
+
clientSecret,
|
|
1793
|
+
cpuTarget: cpuChallengeDetails.target,
|
|
1794
|
+
memDifficulty: memDifficulty
|
|
1795
|
+
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
1796
|
+
|
|
1797
|
+
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1798
|
+
if (deviceData) {
|
|
1799
|
+
deviceData.lastChallengeNonce = nonce;
|
|
1800
|
+
await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
this._log('Challenge issued', {
|
|
1804
|
+
nonce,
|
|
1805
|
+
challengeTtl: this.securityConfig.challengeTtl || 300,
|
|
1806
|
+
trapUrlsCount: trapUrls.length
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
if (logger) {
|
|
1810
|
+
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Check if the request is an API request to return a JSON challenge
|
|
1814
|
+
const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
|
|
1815
|
+
|
|
1816
|
+
if (isApi) {
|
|
1817
|
+
// For API clients, send a JSON response with challenge details.
|
|
1818
|
+
const challengePayload = {
|
|
1819
|
+
challenge: {
|
|
1820
|
+
type: 'cpu_mem',
|
|
1821
|
+
nonce: nonce,
|
|
1822
|
+
clientSecret: clientSecret, // The client needs this to solve the challenge
|
|
1823
|
+
cpuTarget: cpuChallengeDetails.target,
|
|
1824
|
+
memDifficulty: memDifficulty,
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
this._log('API challenge response generated', { challengePayload });
|
|
1828
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
|
|
1829
|
+
} else {
|
|
1830
|
+
// For browsers, send the HTML page.
|
|
1831
|
+
const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
|
|
1832
|
+
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
|
|
1833
|
+
this._log('Browser challenge page generated', {
|
|
1834
|
+
pageLength: page.length,
|
|
1835
|
+
hasTrapContainer: true
|
|
1836
|
+
});
|
|
1837
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: page };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Basic log for each non-static request that passed without a challenge
|
|
1843
|
+
this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
|
|
1844
|
+
|
|
1845
|
+
if (logger) {
|
|
1846
|
+
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
return { action: 'next', score: finalScore, vector: suspicionVector };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Identifies a request in a granular way for non-Express environments.
|
|
1854
|
+
* @param {object} requestContext - The request context object.
|
|
1855
|
+
* @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
|
|
1856
|
+
*/
|
|
1857
|
+
async identifyRequest(requestContext) {
|
|
1858
|
+
const { clientIp, cookies, rawReq, rawRes } = requestContext;
|
|
1859
|
+
|
|
1860
|
+
// --- Update IP reputation ---
|
|
1861
|
+
const ipProfile = (await store.get(`ip:${clientIp}`)) || {
|
|
1862
|
+
type: "residential",
|
|
1863
|
+
deviceIds: new Set(),
|
|
1864
|
+
statelessCount: 0,
|
|
1865
|
+
lastSeen: 0,
|
|
1866
|
+
};
|
|
1867
|
+
ipProfile.lastSeen = Date.now();
|
|
1868
|
+
if (cookies?.device_id) {
|
|
1869
|
+
ipProfile.deviceIds.add(cookies.device_id);
|
|
1870
|
+
} else {
|
|
1871
|
+
ipProfile.statelessCount++;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
|
|
1875
|
+
ipProfile.type = "shared";
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
|
|
1879
|
+
if (ipProfile.statelessCount > statelessLimit) {
|
|
1880
|
+
return `suspicious_high:${clientIp}`;
|
|
1881
|
+
}
|
|
1882
|
+
await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
|
|
1883
|
+
|
|
1884
|
+
const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
|
|
1885
|
+
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
|
1886
|
+
const { requestPatternScore } = getRequestPatternScore(requestContext, (await store.get(`device:${requestContext.cookies?.device_id}`)), this.securityConfig.patterns);
|
|
1887
|
+
const score =
|
|
1888
|
+
vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
|
|
1889
|
+
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
1890
|
+
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
1891
|
+
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
|
|
1892
|
+
honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
|
|
1893
|
+
requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
|
|
1894
|
+
vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
|
|
1895
|
+
|
|
1896
|
+
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
1897
|
+
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
1898
|
+
if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
|
|
1899
|
+
|
|
1900
|
+
// If a new device_id was created, it's in the context.
|
|
1901
|
+
const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
|
|
1902
|
+
const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
|
|
1903
|
+
|
|
1904
|
+
return `device:${finalDeviceId}`;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Returns a default list of whitelisting rules for common and legitimate web crawlers.
|
|
1910
|
+
* This list can be used as a base and extended with custom rules.
|
|
1911
|
+
* @returns {Array<{userAgent: string, hostnameSuffix: string}>}
|
|
1912
|
+
*/
|
|
1913
|
+
export const default_whitelist = () => [
|
|
1914
|
+
// === Moteurs de recherche majeurs ===
|
|
1915
|
+
{ userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
|
|
1916
|
+
{ userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
|
|
1917
|
+
{ userAgent: 'AdsBot-Google', hostnameSuffix: '.googlebot.com' },
|
|
1918
|
+
{ userAgent: 'Mediapartners-Google', hostnameSuffix: '.google.com' },
|
|
1919
|
+
{ userAgent: 'Google-InspectionTool', hostnameSuffix: '.google.com' },
|
|
1920
|
+
{ userAgent: '(bingbot|adidxbot)', hostnameSuffix: '.search.msn.com' },
|
|
1921
|
+
{ userAgent: 'DuckDuckBot', hostnameSuffix: '.duckduckgo.com' },
|
|
1922
|
+
{ userAgent: 'YandexBot', hostnameSuffix: '.yandex.com' },
|
|
1923
|
+
{ userAgent: 'YandexImages', hostnameSuffix: '.yandex.com' },
|
|
1924
|
+
{ userAgent: 'Baiduspider', hostnameSuffix: '.crawl.baidu.com' },
|
|
1925
|
+
{ userAgent: 'Slurp', hostnameSuffix: '.crawl.yahoo.net' },
|
|
1926
|
+
{ userAgent: 'Sogou web spider', hostnameSuffix: '.sogou.com' },
|
|
1927
|
+
{ userAgent: 'Exabot', hostnameSuffix: '.exabot.com' },
|
|
1928
|
+
{ userAgent: 'ia_archiver', hostnameSuffix: '.alexa.com' },
|
|
1929
|
+
{ userAgent: 'SeznamBot', hostnameSuffix: '.seznam.cz' },
|
|
1930
|
+
{ userAgent: 'Mail.RU_Bot', hostnameSuffix: '.mail.ru' },
|
|
1931
|
+
{ userAgent: 'Yeti', hostnameSuffix: '.naver.com' }, // Naver
|
|
1932
|
+
|
|
1933
|
+
// === Outils SEO et d'analyse ===
|
|
1934
|
+
{ userAgent: 'AhrefsBot', hostnameSuffix: '.ahrefs.com' },
|
|
1935
|
+
{ userAgent: 'SemrushBot', hostnameSuffix: '.semrush.com' },
|
|
1936
|
+
{ userAgent: 'MJ12bot', hostnameSuffix: '.mj12bot.com' }, // Majestic
|
|
1937
|
+
{ userAgent: 'rogerbot', hostnameSuffix: '.moz.com' }, // Moz
|
|
1938
|
+
{ userAgent: 'DotBot', hostnameSuffix: '.moz.com' }, // Moz (anciennement opensiteexplorer.org)
|
|
1939
|
+
{ userAgent: 'Screaming Frog SEO Spider', hostnameSuffix: '.screamingfrog.co.uk' },
|
|
1940
|
+
{ userAgent: 'cognitiveseo', hostnameSuffix: '.cognitiveseo.com' },
|
|
1941
|
+
{ userAgent: 'SEOkicks', hostnameSuffix: '.seokicks.com' },
|
|
1942
|
+
{ userAgent: 'serpstatbot', hostnameSuffix: '.serpstatbot.com' },
|
|
1943
|
+
{ userAgent: 'MegaIndex', hostnameSuffix: '.megaindex.com' },
|
|
1944
|
+
{ userAgent: 'LinkpadBot', hostnameSuffix: '.linkpad.ru' },
|
|
1945
|
+
{ userAgent: 'Sistrix', hostnameSuffix: '.sistrix.com' },
|
|
1946
|
+
{ userAgent: 'RyteBot', hostnameSuffix: '.ryte.com' },
|
|
1947
|
+
{ userAgent: 'linkfluence', hostnameSuffix: '.linkfluence.com' },
|
|
1948
|
+
{ userAgent: 'TurnitinBot', hostnameSuffix: '.turnitin.com' },
|
|
1949
|
+
{ userAgent: 'GrapeshotCrawler', hostnameSuffix: '.grapeshot.co.uk' },
|
|
1950
|
+
|
|
1951
|
+
// === Robots d'IA et de données ===
|
|
1952
|
+
{ userAgent: 'GPTBot', hostnameSuffix: '.openai.com' },
|
|
1953
|
+
{ userAgent: 'ChatGPT-User', hostnameSuffix: '.openai.com' },
|
|
1954
|
+
{ userAgent: 'Applebot', hostnameSuffix: '.applebot.apple.com' },
|
|
1955
|
+
{ userAgent: 'CCBot', hostnameSuffix: '.commoncrawl.org' },
|
|
1956
|
+
{ userAgent: 'Bytespider', hostnameSuffix: '.bytespider.com' }, // ByteDance (TikTok)
|
|
1957
|
+
{ userAgent: 'Diffbot', hostnameSuffix: '.diffbot.com' },
|
|
1958
|
+
{ userAgent: 'PerplexityBot', hostnameSuffix: '.perplexity.ai' },
|
|
1959
|
+
{ userAgent: 'ClaudeBot', hostnameSuffix: '.anthropic.com' },
|
|
1960
|
+
{ userAgent: 'cohere.io', hostnameSuffix: '.cohere.io' },
|
|
1961
|
+
{ userAgent: 'DataForSeoBot', hostnameSuffix: '.dataforseo.com' },
|
|
1962
|
+
{ userAgent: 'YouBot', hostnameSuffix: '.you.com' },
|
|
1963
|
+
{ userAgent: 'omgili', hostnameSuffix: '.omgili.com' },
|
|
1964
|
+
|
|
1965
|
+
// === Réseaux sociaux et partage ===
|
|
1966
|
+
{ userAgent: 'facebookexternalhit', hostnameSuffix: '.facebook.com' },
|
|
1967
|
+
{ userAgent: 'facebot', hostnameSuffix: '.facebook.com' },
|
|
1968
|
+
{ userAgent: 'Twitterbot', hostnameSuffix: '.twttr.com' },
|
|
1969
|
+
{ userAgent: 'Pinterestbot', hostnameSuffix: '.pinterest.com' },
|
|
1970
|
+
{ userAgent: 'LinkedInBot', hostnameSuffix: '.linkedin.com' },
|
|
1971
|
+
{ userAgent: 'Slackbot', hostnameSuffix: '.slack.com' },
|
|
1972
|
+
{ userAgent: 'Discordbot', hostnameSuffix: '.discord.com' },
|
|
1973
|
+
{ userAgent: 'TelegramBot', hostnameSuffix: '.telegram.org' },
|
|
1974
|
+
{ userAgent: 'WhatsApp', hostnameSuffix: '.wa.me' },
|
|
1975
|
+
{ userAgent: 'SkypeUriPreview', hostnameSuffix: '.skype.com' },
|
|
1976
|
+
{ userAgent: 'redditbot', hostnameSuffix: '.reddit.com' },
|
|
1977
|
+
|
|
1978
|
+
// === Services de monitoring et d'uptime ===
|
|
1979
|
+
{ userAgent: 'UptimeRobot', hostnameSuffix: '.uptimerobot.com' },
|
|
1980
|
+
{ userAgent: 'Pingdom', hostnameSuffix: '.pingdom.com' },
|
|
1981
|
+
{ userAgent: 'StatusCake', hostnameSuffix: '.statuscake.com' },
|
|
1982
|
+
{ userAgent: 'Site24x7', hostnameSuffix: '.site24x7.com' },
|
|
1983
|
+
{ userAgent: 'Freshping', hostnameSuffix: '.freshping.io' },
|
|
1984
|
+
{ userAgent: 'Better Uptime', hostnameSuffix: '.betteruptime.com' },
|
|
1985
|
+
{ userAgent: 'Checkly', hostnameSuffix: '.checkly-infra.com' },
|
|
1986
|
+
{ userAgent: 'Datadog', hostnameSuffix: '.datadoghq.com' },
|
|
1987
|
+
{ userAgent: 'NewRelicPinger', hostnameSuffix: '.newrelic.com' },
|
|
1988
|
+
|
|
1989
|
+
// === Archives et agrégateurs de contenu ===
|
|
1990
|
+
{ userAgent: 'archive.org_bot', hostnameSuffix: '.archive.org' },
|
|
1991
|
+
{ userAgent: 'Feedly', hostnameSuffix: '.feedly.com' },
|
|
1992
|
+
{ userAgent: 'FeedFetcher-Google', hostnameSuffix: '.google.com' },
|
|
1993
|
+
{ userAgent: 'TheOldReader', hostnameSuffix: '.theoldreader.com' },
|
|
1994
|
+
{ userAgent: 'Inoreader', hostnameSuffix: '.inoreader.com' },
|
|
1995
|
+
{ userAgent: 'FlipboardProxy', hostnameSuffix: '.flipboard.com' },
|
|
1996
|
+
{ userAgent: 'PaperLiBot', hostnameSuffix: '.paper.li' },
|
|
1997
|
+
|
|
1998
|
+
// === Services Cloud et Plateformes ===
|
|
1999
|
+
{ userAgent: 'Amazon Route 53 Health Check', hostnameSuffix: '.amazonaws.com' },
|
|
2000
|
+
{ userAgent: 'Google-Cloud-Scheduler', hostnameSuffix: '.google.com' },
|
|
2001
|
+
{ userAgent: 'APIs-Google', hostnameSuffix: '.google.com' },
|
|
2002
|
+
|
|
2003
|
+
// === Divers ===
|
|
2004
|
+
{ userAgent: 'W3C_Validator', hostnameSuffix: '.w3.org' },
|
|
2005
|
+
{ userAgent: 'GTmetrix', hostnameSuffix: '.gtmetrix.com' },
|
|
2006
|
+
{ userAgent: 'WebPageTest', hostnameSuffix: '.webpagetest.org' },
|
|
2007
|
+
{ userAgent: 'Google-Site-Verification', hostnameSuffix: '.google.com' },
|
|
2008
|
+
{ userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
|
|
2009
|
+
];
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
// --- Proof-of-Work Middleware (The Tollbooth) ---
|
|
2014
|
+
export const powMiddleware = (securityConfig) => {
|
|
2015
|
+
const engine = new FingerprintEngine(securityConfig);
|
|
2016
|
+
|
|
2017
|
+
if (securityConfig.autotuning) {
|
|
2018
|
+
startThresholdAutoTuning({
|
|
2019
|
+
securityConfig: securityConfig,
|
|
2020
|
+
...securityConfig.autotuning,
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// Provide a default for isApiRequest if not specified by the user.
|
|
2025
|
+
// This makes API challenge handling work more seamlessly out-of-the-box.
|
|
2026
|
+
if (!securityConfig.thresholds?.isApiRequest) {
|
|
2027
|
+
if (!securityConfig.thresholds) securityConfig.thresholds = {};
|
|
2028
|
+
securityConfig.thresholds.isApiRequest = (req) =>
|
|
2029
|
+
req.headers?.accept?.includes('application/json');
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
return async (req, res, next) => {
|
|
2033
|
+
const requestContext = {
|
|
2034
|
+
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
2035
|
+
path: req.path,
|
|
2036
|
+
cookies: req.cookies,
|
|
2037
|
+
query: req.query,
|
|
2038
|
+
body: req.body,
|
|
2039
|
+
headers: req.headers,
|
|
2040
|
+
isStatic: isStaticResource(req.path),
|
|
2041
|
+
// Pass the original request object for the isApiRequest function
|
|
2042
|
+
rawReq: req,
|
|
2043
|
+
// Add the newly required properties for full decoupling
|
|
2044
|
+
rawHeaders: req.rawHeaders,
|
|
2045
|
+
// Pass the raw request object for advanced inspection (e.g., JA3)
|
|
2046
|
+
httpVersion: req.httpVersion,
|
|
2047
|
+
};
|
|
2048
|
+
|
|
2049
|
+
const decision = await engine.processRequest(requestContext);
|
|
2050
|
+
|
|
2051
|
+
// Attach the fingerprinting result to the request object for downstream middlewares.
|
|
2052
|
+
req.fingerprint = {
|
|
2053
|
+
score: decision.score,
|
|
2054
|
+
vector: decision.vector,
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
// After getSuspicionVector runs, it might have attached cookies to be set.
|
|
2058
|
+
if (requestContext._newCookies) {
|
|
2059
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
switch (decision.action) {
|
|
2063
|
+
case 'block':
|
|
2064
|
+
return res.status(decision.status).send(decision.body);
|
|
2065
|
+
|
|
2066
|
+
case 'challenge': // Gère à la fois les réponses HTML et JSON
|
|
2067
|
+
if (typeof decision.body === 'object' && decision.body !== null) {
|
|
2068
|
+
return res.status(decision.status).json(decision.body);
|
|
2069
|
+
}
|
|
2070
|
+
// Par défaut, envoie du HTML
|
|
2071
|
+
return res.status(decision.status).send(decision.body);
|
|
2072
|
+
|
|
2073
|
+
case 'redirect':
|
|
2074
|
+
if (decision.cookie) {
|
|
2075
|
+
res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
|
|
2076
|
+
}
|
|
2077
|
+
return res.redirect(decision.path);
|
|
2078
|
+
|
|
2079
|
+
case 'next':
|
|
2080
|
+
default:
|
|
2081
|
+
return next();
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* @internal
|
|
2088
|
+
* Exporting an object containing the functions to make them mockable in tests.
|
|
2089
|
+
* This is a common pattern to allow mocking of ES module functions.
|
|
2090
|
+
*/
|
|
2091
|
+
export const __internal = {
|
|
2092
|
+
getDeviceHash,
|
|
2093
|
+
isMalicious,
|
|
2094
|
+
getSuspicionVector,
|
|
2095
|
+
cyrb53, // Export for testing
|
|
2096
|
+
FingerprintBuilder, // Export for testing
|
|
2097
|
+
calculateTarget,
|
|
2098
|
+
determineOptimalTicketTtl,
|
|
2099
|
+
getRequestPatternScore, // Expose for testing
|
|
2100
|
+
getBehaviorScore, // Expose for testing
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
// --- THRESHOLD AUTO-TUNING SECTION ---
|
|
2104
|
+
|
|
2105
|
+
let autoTuningJobId = null;
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Executes a threshold optimization pass using collected traffic data.
|
|
2109
|
+
* @private
|
|
2110
|
+
* @param {object} securityConfig - The security configuration object to update.
|
|
2111
|
+
* @param {Array<object>} trafficData - The array containing traffic logs.
|
|
2112
|
+
* @param {number} minDataPoints - The minimum number of data points required to start optimization.
|
|
2113
|
+
*/
|
|
2114
|
+
function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
|
|
2115
|
+
if (trafficData.length < minDataPoints) {
|
|
2116
|
+
console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
|
|
2120
|
+
|
|
2121
|
+
// Classify historical requests with a confidence weight.
|
|
2122
|
+
const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
|
|
2123
|
+
const challengedDevices = new Set(trafficData.filter(e => e.type === 'challenge_issued').map(e => e.deviceId));
|
|
2124
|
+
|
|
2125
|
+
const historicalRequests = trafficData.map(log => {
|
|
2126
|
+
// Assign a label ('bot' or 'human') and a confidence weight to each log entry.
|
|
2127
|
+
switch (log.type) {
|
|
2128
|
+
case 'honeypot_probe':
|
|
2129
|
+
case 'trap_triggered':
|
|
2130
|
+
return { score: log.score, label: 'bot', confidence: 10.0 }; // Very high confidence
|
|
2131
|
+
|
|
2132
|
+
case 'challenge_issued':
|
|
2133
|
+
// A challenge issued to a device that never solved it is a strong bot signal.
|
|
2134
|
+
if (!solvedDevices.has(log.deviceId)) {
|
|
2135
|
+
return { score: log.score, label: 'bot', confidence: 3.0 }; // High confidence
|
|
2136
|
+
}
|
|
2137
|
+
// If the challenge was eventually solved, this specific log is neutral.
|
|
2138
|
+
return null;
|
|
2139
|
+
|
|
2140
|
+
case 'challenge_solved':
|
|
2141
|
+
return { score: log.score, label: 'human', confidence: 5.0 }; // High confidence
|
|
2142
|
+
|
|
2143
|
+
case 'request_passed':
|
|
2144
|
+
// A passed request from a device that was never even challenged is likely a human.
|
|
2145
|
+
if (!challengedDevices.has(log.deviceId)) {
|
|
2146
|
+
return { score: log.score, label: 'human', confidence: 0.5 }; // Low confidence
|
|
2147
|
+
}
|
|
2148
|
+
// If the device was challenged at some point, this log is ambiguous.
|
|
2149
|
+
return null;
|
|
2150
|
+
|
|
2151
|
+
default:
|
|
2152
|
+
return null;
|
|
2153
|
+
}
|
|
2154
|
+
}).filter(Boolean); // Remove null entries
|
|
2155
|
+
|
|
2156
|
+
// The "fitness" function evaluates the quality of a set of thresholds.
|
|
2157
|
+
// A lower score is better.
|
|
2158
|
+
const fitnessFunction = (solution) => {
|
|
2159
|
+
const [low, medium, high, velocityThreshold, burstThreshold, scrapeThreshold] = solution;
|
|
2160
|
+
if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
|
|
2161
|
+
if (velocityThreshold < 50 || velocityThreshold > burstThreshold || burstThreshold > scrapeThreshold) return Infinity;
|
|
2162
|
+
|
|
2163
|
+
let weightedFalsePositives = 0; // Humans challenged unnecessarily.
|
|
2164
|
+
let weightedFalseNegatives = 0; // Undetected bots.
|
|
2165
|
+
|
|
2166
|
+
for (const req of historicalRequests) {
|
|
2167
|
+
if (req.label === 'bot') {
|
|
2168
|
+
if (req.score < low) weightedFalseNegatives += req.confidence;
|
|
2169
|
+
} else { // 'human'
|
|
2170
|
+
if (req.score >= low) weightedFalsePositives += req.confidence;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
// The penalty for false negatives is implicitly higher due to the higher confidence scores of bot signals.
|
|
2174
|
+
return weightedFalsePositives + weightedFalseNegatives;
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
// Functions for the genetic algorithm.
|
|
2178
|
+
const createIndividual = () => [
|
|
2179
|
+
10 + Math.random() * 20, // low
|
|
2180
|
+
30 + Math.random() * 30, // medium
|
|
2181
|
+
60 + Math.random() * 30, // high
|
|
2182
|
+
100 + Math.random() * 150, // velocityThreshold (100-250ms)
|
|
2183
|
+
300 + Math.random() * 400, // burstThreshold (300-700ms)
|
|
2184
|
+
800 + Math.random() * 700, // scrapeThreshold (800-1500ms)
|
|
2185
|
+
];
|
|
2186
|
+
const crossover = (p1, p2) => p1.map((val, i) => (val + p2[i]) / 2);
|
|
2187
|
+
const mutate = (s) => {
|
|
2188
|
+
const n = [...s];
|
|
2189
|
+
const i = Math.floor(Math.random() * n.length);
|
|
2190
|
+
// Adjust mutation range based on parameter
|
|
2191
|
+
const mutationRange = i < 3 ? 5 : 50;
|
|
2192
|
+
n[i] += (Math.random() - 0.5) * mutationRange;
|
|
2193
|
+
return n;
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
// Start optimization.
|
|
2197
|
+
const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
|
|
2198
|
+
generations: 50,
|
|
2199
|
+
populationSize: 40
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
const [newLow, newMedium, newHigh, newVelocity, newBurst, newScrape] = result.solution;
|
|
2203
|
+
|
|
2204
|
+
// Update the configuration live.
|
|
2205
|
+
// Ensure thresholds object exists
|
|
2206
|
+
if (!securityConfig.thresholds) securityConfig.thresholds = {};
|
|
2207
|
+
securityConfig.thresholds.low = Math.round(newLow);
|
|
2208
|
+
securityConfig.thresholds.medium = Math.round(newMedium);
|
|
2209
|
+
securityConfig.thresholds.high = Math.round(newHigh);
|
|
2210
|
+
|
|
2211
|
+
// Update pattern detection parameters
|
|
2212
|
+
if (!securityConfig.patterns) securityConfig.patterns = {};
|
|
2213
|
+
securityConfig.patterns.velocityThreshold = Math.round(newVelocity);
|
|
2214
|
+
securityConfig.patterns.burstThreshold = Math.round(newBurst);
|
|
2215
|
+
securityConfig.patterns.scrapeThreshold = Math.round(newScrape);
|
|
2216
|
+
// Weights could also be optimized, but let's keep it to thresholds for now for simplicity.
|
|
2217
|
+
|
|
2218
|
+
console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
|
|
2219
|
+
if (securityConfig.patterns) {
|
|
2220
|
+
console.log("[AutoTuning] Nouveaux paramètres de pattern appliqués :", securityConfig.patterns);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Starts the background process for auto-tuning security thresholds.
|
|
2226
|
+
* @export
|
|
2227
|
+
* @param {object} options - Configuration options for auto-tuning.
|
|
2228
|
+
* @param {object} options.securityConfig - The live security configuration object that will be mutated.
|
|
2229
|
+
* @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
|
|
2230
|
+
* @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
|
|
2231
|
+
* @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
|
|
2232
|
+
*/
|
|
2233
|
+
export function startThresholdAutoTuning(options) {
|
|
2234
|
+
if (autoTuningJobId) {
|
|
2235
|
+
console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
const {
|
|
2240
|
+
securityConfig,
|
|
2241
|
+
trafficData,
|
|
2242
|
+
interval = 1800000,
|
|
2243
|
+
minDataPoints = 200
|
|
2244
|
+
} = options;
|
|
2245
|
+
|
|
2246
|
+
if (!securityConfig || !trafficData) {
|
|
2247
|
+
throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
|
|
2251
|
+
|
|
2252
|
+
autoTuningJobId = setInterval(() => {
|
|
2253
|
+
runThresholdOptimization(securityConfig, trafficData, minDataPoints);
|
|
2254
|
+
}, interval);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* Stops the threshold auto-tuning process.
|
|
2259
|
+
* @export
|
|
2260
|
+
*/
|
|
2261
|
+
export function stopThresholdAutoTuning() {
|
|
2262
|
+
if (autoTuningJobId) {
|
|
2263
|
+
clearInterval(autoTuningJobId);
|
|
2264
|
+
autoTuningJobId = null;
|
|
2265
|
+
console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
|
|
2266
|
+
}
|
|
2267
|
+
}
|