@anonympins/fingerprint 0.5.0 → 0.5.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.
@@ -8,7 +8,20 @@ import {DynamicWasmGenerator} from "./dynamic-wasm.js";
8
8
  import {readFileSync, existsSync} from "node:fs";
9
9
  import {fileURLToPath} from "node:url";
10
10
  import {dirname, join, resolve} from "node:path";
11
- import { verifyZkpProof, decodePolymorphicFingerprint, deepMerge, getHeaderSignature, parseJa3, modPow, hashNetwork, normalizeReferer, isPrivateIp, parseUserAgent } from "./fingerprint.utils.js";
11
+ import {
12
+ verifyZkpProof,
13
+ sanitizeRedirectPath,
14
+ decodePolymorphicFingerprint,
15
+ deepMerge,
16
+ getHeaderSignature,
17
+ parseJa3,
18
+ modPow,
19
+ hashNetwork,
20
+ normalizeReferer,
21
+ isPrivateIp,
22
+ parseUserAgent,
23
+ safeJsonStringify
24
+ } from "./fingerprint.utils.js";
12
25
 
13
26
 
14
27
  const __filename = fileURLToPath(import.meta.url);
@@ -22,6 +35,42 @@ let lastMappingTime = 0;
22
35
  let isCompilingMapping = false;
23
36
  const MAPPING_ROTATION_INTERVAL = 60000; // 60 seconds
24
37
 
38
+ const configDir = resolve(__dirname, '../../config');
39
+
40
+ const loadBotWhitelist = (filename, fallbackEntries) => {
41
+ const filePath = join(configDir, filename);
42
+ if (existsSync(filePath)) {
43
+ try {
44
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
45
+ } catch (e) {
46
+ console.error(`[Fingerprint] Error loading whitelist file ${filename}:`, e.message);
47
+ }
48
+ }
49
+ return fallbackEntries;
50
+ };
51
+
52
+ const googlebotEntries = loadBotWhitelist('googlebot.json', [
53
+ "2001:4860:4801:10::/64",
54
+ "2001:4860:4801:11::/64",
55
+ "2001:4860:4801:12::/64",
56
+ // ... [Keep fallback inline values for safety]
57
+ "66.249.79.64"
58
+ ]);
59
+
60
+ const bingbotEntries = loadBotWhitelist('bingbot.json', [
61
+ "157.55.39.0/24",
62
+ "207.46.13.0/24",
63
+ // ... [Keep fallback inline values for safety]
64
+ "40.77.178.0/23"
65
+ ]);
66
+
67
+ const yandexEntries = loadBotWhitelist('yandex.json', [
68
+ "2a02:6b8::/29",
69
+ "5.45.192.0/18",
70
+ // ... [Keep fallback inline values for safety]
71
+ "213.180.192.0/19"
72
+ ]);
73
+
25
74
  function generateSessionMapping() {
26
75
  const randomStr = (len = 6) => crypto.randomBytes(len).toString('hex').replace(/[0-9]/g, 'g').substring(0, len);
27
76
  const randomHeader = () => `X-Sess-${crypto.randomBytes(4).toString('hex')}`;
@@ -189,7 +238,82 @@ export function generateStatelessTicket(payload) {
189
238
  const signature = crypto.createHmac('sha256', key).update(Buffer.concat([iv, encrypted])).digest();
190
239
  return `${base64UrlEncode(iv)}.${base64UrlEncode(encrypted)}.${base64UrlEncode(signature)}`;
191
240
  }
241
+ /**
242
+ * Détecte les anomalies de flux QUIC/HTTP3 par rapport au User-Agent.
243
+ * @private
244
+ * @param {object} context - Le contexte de la requête.
245
+ * @returns {{quicAnomalyScore: number}}
246
+ */
247
+ function getQuicAnomalyScore(context) {
248
+ const quicFp = context.headers?.['x-quic-fp'] || context.quicFingerprint || null;
249
+ if (!quicFp || typeof quicFp !== 'string') {
250
+ return { quicAnomalyScore: 0.0 };
251
+ }
252
+
253
+ const parts = quicFp.split(';');
254
+ if (parts.length < 2) return { quicAnomalyScore: 0.0 };
255
+
256
+ const params = {};
257
+ parts[1].split(',').forEach(p => {
258
+ const kv = p.split('=');
259
+ if (kv.length === 2) params[kv[0]] = kv[1];
260
+ });
261
+ const priorityOrder = parts[2] || '';
262
+
263
+ const ua = context.headers?.['user-agent'] || '';
264
+ const uaParts = parseUserAgent(ua);
265
+ const browser = uaParts.browser;
192
266
 
267
+ if (!browser) return { quicAnomalyScore: 0.0 };
268
+
269
+ let anomaly = 0.0;
270
+ if (browser.startsWith('Chrome') || browser.startsWith('Edge')) {
271
+ const maxData = parseInt(params['1'] || '0', 10);
272
+ const maxStreams = parseInt(params['4'] || '0', 10);
273
+ if (maxData > 0 && maxData < 1048576) anomaly += 40.0;
274
+ if (maxStreams > 0 && maxStreams !== 100) anomaly += 30.0;
275
+ if (priorityOrder && !priorityOrder.includes('u=')) anomaly += 30.0;
276
+ } else if (browser.startsWith('Firefox')) {
277
+ const maxData = parseInt(params['1'] || '0', 10);
278
+ if (maxData > 0 && maxData > 5000000) anomaly += 40.0;
279
+ }
280
+
281
+ return { quicAnomalyScore: Math.max(0.0, Math.min(100.0, anomaly)) };
282
+ }
283
+ /**
284
+ * Détecte les anomalies de rendu (V-Sync, FPS, gigue) à partir des métriques d'affichage.
285
+ * @private
286
+ * @param {object} context - Le contexte de la requête.
287
+ * @returns {{renderingAnomalyScore: number}}
288
+ */
289
+ function getRenderingAnomalyScore(context) {
290
+ const behaviorHeader = context.headers?.['x-behavior-metrics'];
291
+ if (!behaviorHeader) {
292
+ return { renderingAnomalyScore: 0.0 };
293
+ }
294
+ try {
295
+ const metrics = JSON.parse(behaviorHeader);
296
+ if (!metrics || !metrics.rendering) {
297
+ return { renderingAnomalyScore: 0.0 };
298
+ }
299
+ const rendering = metrics.rendering;
300
+ let score = 0.0;
301
+ if (rendering.offscreenAnom) {
302
+ score += 100.0;
303
+ }
304
+ const fps = parseFloat(rendering.fps || 0.0);
305
+ const jitter = parseFloat(rendering.jitter || 0.0);
306
+ if (fps > 250.0 || (fps > 0.0 && fps < 15.0)) {
307
+ score += 50.0;
308
+ }
309
+ if (jitter > 6.0) {
310
+ score += Math.min(80.0, (jitter - 6.0) * 10.0);
311
+ }
312
+ return { renderingAnomalyScore: Math.min(100.0, score) };
313
+ } catch (e) {
314
+ return { renderingAnomalyScore: 0.0 };
315
+ }
316
+ }
193
317
  export function parseStatelessTicket(ticket) {
194
318
  try {
195
319
  if (ticket.startsWith('ed25519.')) {
@@ -304,7 +428,10 @@ const securityProfiles = {
304
428
  tlsSpoofingScore: 0.8, // NOUVEAU: Poids pour la détection de spoofing TLS
305
429
  subnetScore: 0.4, // NOUVEAU: Poids pour la réputation du sous-réseau
306
430
  ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
307
- botnetClusterScore: 0.6 // NOUVEAU: Poids pour le clustering botnet
431
+ botnetClusterScore: 0.6, // NOUVEAU: Poids pour le clustering botnet
432
+ tcpAnomalyScore: 0.8, // NEW: Anomalie de pile TCP/IP
433
+ quicAnomalyScore: 0.8, // NOUVEAU: Poids pour l'anomalie QUIC
434
+ renderingAnomalyScore: 0.8 // NOUVEAU: Poids pour l'anomalie de rendu
308
435
  },
309
436
  thresholds: { low: 20, medium: 45, high: 75, block: 95 },
310
437
  patterns: {
@@ -340,7 +467,8 @@ const securityProfiles = {
340
467
  tlsSpoofingScore: 1.0, // Plus agressif pour le spoofing TLS
341
468
  subnetScore: 0.5,
342
469
  ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
343
- botnetClusterScore: 0.8 // NOUVEAU: Poids pour le clustering botnet
470
+ botnetClusterScore: 0.8, // NOUVEAU: Poids pour le clustering botnet
471
+ renderingAnomalyScore: 1.0 // NOUVEAU: Poids pour l'anomalie de rendu
344
472
  },
345
473
  thresholds: { low: 10, medium: 35, high: 65, block: 90 },
346
474
  patterns: {
@@ -377,7 +505,9 @@ const securityProfiles = {
377
505
  tlsSpoofingScore: 0.7, // Important pour les API
378
506
  subnetScore: 0.4,
379
507
  ipReputationScore: 0.5, // NOUVEAU: Poids pour la réputation IP
380
- botnetClusterScore: 0.7 // NOUVEAU: Poids pour le clustering botnet
508
+ botnetClusterScore: 0.7, // NOUVEAU: Poids pour le clustering botnet
509
+ tcpAnomalyScore: 0.8, // NEW: Anomalie de pile TCP/IP
510
+ quicAnomalyScore: 0.8 // NOUVEAU: Poids pour l'anomalie QUIC
381
511
  },
382
512
  thresholds: { low: 25, medium: 50, high: 80, block: 95 },
383
513
  patterns: {
@@ -415,7 +545,10 @@ const securityProfiles = {
415
545
  tlsSpoofingScore: 0.6, // Moins critique pour les blogs
416
546
  subnetScore: 0.2,
417
547
  ipReputationScore: 0.3, // NOUVEAU: Poids pour la réputation IP
418
- botnetClusterScore: 0.5 // NOUVEAU: Poids pour le clustering botnet
548
+ botnetClusterScore: 0.5, // NOUVEAU: Poids pour le clustering botnet
549
+ tcpAnomalyScore: 0.5, // NEW: Anomalie de pile TCP/IP
550
+ quicAnomalyScore: 0.5, // NOUVEAU: Poids pour l'anomalie QUIC
551
+ renderingAnomalyScore: 0.5 // NOUVEAU: Poids pour l'anomalie de rendu
419
552
  },
420
553
  thresholds: { low: 25, medium: 55, high: 80, block: 95 },
421
554
  patterns: {
@@ -452,7 +585,10 @@ const securityProfiles = {
452
585
  tlsSpoofingScore: 0.9, // Très important pour l'e-commerce
453
586
  subnetScore: 0.5,
454
587
  ipReputationScore: 0.6, // NOUVEAU: Poids pour la réputation IP
455
- botnetClusterScore: 0.9 // NOUVEAU: Poids pour le clustering botnet
588
+ botnetClusterScore: 0.9, // NOUVEAU: Poids pour le clustering botnet
589
+ tcpAnomalyScore: 0.9, // NEW: Anomalie de pile TCP/IP
590
+ quicAnomalyScore: 0.9, // NOUVEAU: Poids pour l'anomalie QUIC
591
+ renderingAnomalyScore: 0.9 // NOUVEAU: Poids pour l'anomalie de rendu
456
592
  },
457
593
  thresholds: { low: 15, medium: 40, high: 70, block: 90 },
458
594
  patterns: {
@@ -497,15 +633,17 @@ const getPowSecret = () => {
497
633
  return secret || "fallback-dev-secret-32-chars-minimum";
498
634
  };
499
635
 
636
+ let cachedPowSolverCode = null;
500
637
  /**
501
638
  * Loads the pow.solver.js content for inlining in HTML pages.
502
639
  * @returns {string} The solver JavaScript code.
503
640
  */
504
641
  const getPowSolverCode = () => {
505
- // On supprime le try/catch. Si le fichier n'est pas trouvé, le processus plantera,
506
- // ce qui est préférable à servir un code de secours potentiellement désynchronisé.
507
- const solverPath = join(__dirname, 'pow.solver.inline.js'); // Utilise la version inline
508
- return readFileSync(solverPath, 'utf-8');
642
+ if (!cachedPowSolverCode) {
643
+ const solverPath = join(__dirname, 'pow.solver.inline.js'); // Utilise la version inline
644
+ cachedPowSolverCode = readFileSync(solverPath, 'utf-8');
645
+ }
646
+ return cachedPowSolverCode;
509
647
  };
510
648
  /**
511
649
  * Extracts the "stable" part of a fingerprint string.
@@ -771,6 +909,7 @@ const generateTspChallenge = (
771
909
  ) => {
772
910
  const citiesJson = JSON.stringify(cities);
773
911
  const solverCode = getPowSolverCode();
912
+ const safePath = sanitizeRedirectPath(path);
774
913
  return `
775
914
  <html>
776
915
  <head><title>Advanced Security Check (Level 3)</title></head>
@@ -781,14 +920,14 @@ const generateTspChallenge = (
781
920
  <script>${solverCode}</script>
782
921
  <script>
783
922
  const cities = ${citiesJson}; // Safe, as it's JSON
784
- const nonce = ${JSON.stringify(nonce)}; // Safe
923
+ const nonce = ${safeJsonStringify(nonce)}; // Safe
785
924
  const targetMaxDistance = ${targetMaxDistance};
786
925
 
787
926
  async function solve() {
788
927
  const result = await window.solveTspChallenge(cities, targetMaxDistance);
789
928
 
790
929
  if (result.distance <= targetMaxDistance) {
791
- window.location.href = ${JSON.stringify(path)} + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
930
+ window.location.href = ${safeJsonStringify(safePath)} + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
792
931
  } else {
793
932
  document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
794
933
  }
@@ -885,6 +1024,7 @@ const generateMemoryPoWChallenge = (
885
1024
  difficulty = 16,
886
1025
  path = "",
887
1026
  ) => {
1027
+ const safePath = sanitizeRedirectPath(path);
888
1028
  // difficulty here is the buffer size in MB.
889
1029
  return `
890
1030
  <html>
@@ -895,8 +1035,8 @@ const generateMemoryPoWChallenge = (
895
1035
  <div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
896
1036
  <script>
897
1037
  async function solve() {
898
- const nonce = "${nonce}";
899
- const size = ${difficulty} * 1024 * 1024; // en octets
1038
+ const nonce = ${safeJsonStringify(nonce)};
1039
+ const size = ${difficulty} * 1024 * 1024; // en octets
900
1040
  const iterations = size / 16;
901
1041
 
902
1042
  try {
@@ -913,8 +1053,8 @@ const generateMemoryPoWChallenge = (
913
1053
  }
914
1054
  window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
915
1055
  } catch(e) {
916
- document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
917
- }
1056
+ window.location.href = ${safeJsonStringify(safePath)} + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
1057
+ }
918
1058
  }
919
1059
  solve();
920
1060
  </script>
@@ -969,40 +1109,123 @@ export const verifyPoWAndGenerateTicket = async (
969
1109
  * For higher difficulties (production workloads), we skip the massive memory allocation on the server,
970
1110
  * avoiding server-side memory DoS vectors completely.
971
1111
  */
1112
+ function getChallengedIndices(seed, solution, numBlocks, k = 4) {
1113
+ const indices = [];
1114
+ let h = cyrb53(seed + ":" + solution);
1115
+ for (let i = 0; i < k; i++) {
1116
+ h = Math.imul(h ^ i, 1597334677);
1117
+ indices.push(Math.abs(h) % numBlocks);
1118
+ }
1119
+ return indices;
1120
+ }
1121
+
1122
+ function verifyMerkleProof(leafHash, index, proof, root) {
1123
+ let currentHash = leafHash;
1124
+ let idx = index;
1125
+ for (let i = 0; i < proof.length; i++) {
1126
+ const sibling = proof[i];
1127
+ const combined = idx % 2 === 0 ? currentHash + sibling : sibling + currentHash;
1128
+ currentHash = crypto.createHash('sha256').update(Buffer.from(combined, 'hex')).digest('hex');
1129
+ idx = Math.floor(idx / 2);
1130
+ }
1131
+ return currentHash === root;
1132
+ }
1133
+
1134
+ function verifyMemoryPoWLegacy(nonce, solution, difficulty, clientSecret) {
1135
+ const size = difficulty * 1024 * 1024;
1136
+ const iterations = size / 16;
1137
+ const buffer = new Uint32Array(size / 4);
1138
+ const seed = `:${nonce}:${clientSecret}`;
1139
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
1140
+
1141
+ for (let i = 0; i < buffer.length; i++) {
1142
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
1143
+ }
1144
+
1145
+ let finalHash = 0;
1146
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
1147
+ for (let i = 0; i < iterations; i++) {
1148
+ addr = buffer[addr] % buffer.length;
1149
+ finalHash ^= addr;
1150
+ }
1151
+ return finalHash === solution;
1152
+ }
1153
+
972
1154
  export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret = '') => {
973
1155
  const MAX_ALLOWED_MEM_DIFFICULTY = 128; // 128MB
974
1156
  if (difficulty > MAX_ALLOWED_MEM_DIFFICULTY) {
975
1157
  console.warn(`[Security] Memory PoW verification attempt with excessive difficulty: ${difficulty}MB. Denied.`);
976
1158
  return false;
977
1159
  }
1160
+ if (Number(difficulty) === 0) {
1161
+ return true;
1162
+ }
978
1163
  if (!solution) {
979
1164
  return false;
980
1165
  }
981
1166
 
982
- // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
983
- // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
984
- if (difficulty > 4) {
985
- return true;
1167
+ let data;
1168
+ try {
1169
+ data = typeof solution === 'string' ? JSON.parse(solution) : solution;
1170
+ } catch (e) {
1171
+ data = null;
986
1172
  }
987
1173
 
988
- // Fallback: Cryptographic verification path for low-difficulty challenges / unit tests
989
- const size = difficulty * 1024 * 1024;
990
- const iterations = size / 16;
991
- const buffer = new Uint32Array(size / 4);
1174
+ if (!data || typeof data !== 'object' || data.solution === undefined || !data.merkleRoot || !data.proofs) {
1175
+ if (difficulty <= 4 && /^\d+$/.test(String(solution))) {
1176
+ return verifyMemoryPoWLegacy(nonce, parseInt(solution, 10), difficulty, clientSecret);
1177
+ }
1178
+ return false;
1179
+ }
1180
+
1181
+ const { solution: sol, merkleRoot, proofs } = data;
1182
+ const numBlocks = difficulty * 256;
992
1183
  const seed = `:${nonce}:${clientSecret}`;
993
- let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
994
1184
 
995
- for (let i = 0; i < buffer.length; i++) {
996
- buffer[i] = h = Math.imul(h ^ i, 1597334677);
1185
+ const challengedIndices = getChallengedIndices(seed, sol, numBlocks, 4);
1186
+
1187
+ for (const b of challengedIndices) {
1188
+ const proof = proofs[b] || proofs[String(b)];
1189
+ if (!proof) return false;
1190
+
1191
+ const block = new Uint32Array(1024);
1192
+ let h = cyrb53(seed + ":" + b);
1193
+ for (let i = 0; i < 1024; i++) {
1194
+ block[i] = (h = Math.imul(h ^ i, 1597334677));
1195
+ }
1196
+
1197
+ const expectedLeaf = crypto.createHash('sha256').update(Buffer.from(block.buffer)).digest('hex');
1198
+
1199
+ if (!verifyMerkleProof(expectedLeaf, b, proof, merkleRoot)) {
1200
+ return false;
1201
+ }
1202
+ }
1203
+
1204
+ const blockCache = new Map();
1205
+ function getBlockElement(blockIdx, elementIdx) {
1206
+ if (!blockCache.has(blockIdx)) {
1207
+ const block = new Uint32Array(1024);
1208
+ let h = cyrb53(seed + ":" + blockIdx);
1209
+ for (let i = 0; i < 1024; i++) {
1210
+ block[i] = (h = Math.imul(h ^ i, 1597334677));
1211
+ }
1212
+ blockCache.set(blockIdx, block);
1213
+ }
1214
+ return blockCache.get(blockIdx)[elementIdx];
997
1215
  }
998
1216
 
999
- let finalHash = 0;
1000
- let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
1217
+ const totalElements = numBlocks * 1024;
1218
+ let addr = totalElements > 0 ? getBlockElement(0, 0) % totalElements : 0;
1219
+ let expectedSolution = 0;
1220
+ const iterations = 1024;
1001
1221
  for (let i = 0; i < iterations; i++) {
1002
- addr = buffer[addr] % buffer.length;
1003
- finalHash ^= addr;
1222
+ const blockIdx = Math.floor(addr / 1024);
1223
+ const elementIdx = addr % 1024;
1224
+ addr = getBlockElement(blockIdx, elementIdx) % totalElements;
1225
+ expectedSolution ^= addr;
1004
1226
  }
1005
- return finalHash === parseInt(solution, 10);
1227
+
1228
+ return expectedSolution === parseInt(sol, 10);
1006
1229
  };
1007
1230
 
1008
1231
  export async function verifySpacePoW(nonce, solution, queries, seed, clientSecret) {
@@ -1234,12 +1457,13 @@ export async function generateSpaceChallenge(clientIp, nonce, suspicionFactor, o
1234
1457
  function generateSpaceChallengePage(challengeDetails, clientSecret, securityConfig) {
1235
1458
  const { nonce, sizeMb, queries, path } = challengeDetails;
1236
1459
  const solverCode = getPowSolverCode();
1237
-
1238
- const challengeScript = `
1460
+ const safePath = sanitizeRedirectPath(path);
1461
+
1462
+ const challengeScript = `
1239
1463
  async function solve() {
1240
- const nonce = ${JSON.stringify(nonce)};
1241
- const path = ${JSON.stringify(path)};
1242
- const clientSecret = ${JSON.stringify(clientSecret)};
1464
+ const nonce = ${safeJsonStringify(nonce)};
1465
+ const path = ${safeJsonStringify(safePath)};
1466
+ const clientSecret = ${safeJsonStringify(clientSecret)};
1243
1467
  const queries = ${JSON.stringify(queries)};
1244
1468
  const sizeMb = ${sizeMb};
1245
1469
 
@@ -1600,6 +1824,41 @@ function getBehaviorScore(context) {
1600
1824
  if (metrics.keystrokeLatency > 0 && metrics.keystrokeLatency < 40) score += 25; // Frappe trop rapide pour un humain.
1601
1825
  if (metrics.keystrokeLatency > 1000) score += 15; // Latence très élevée, peut être un script lent.
1602
1826
 
1827
+ // NOUVEAU: Analyse de digraphie/trigraphie (dwell & flight times)
1828
+ const dwellTimes = metrics.keystrokeDwellTimes || [];
1829
+ const flightTimes = metrics.keystrokeFlightTimes || [];
1830
+
1831
+ if (dwellTimes.length >= 5) {
1832
+ const meanDwell = dwellTimes.reduce((a, b) => a + b, 0) / dwellTimes.length;
1833
+ const varDwell = dwellTimes.reduce((a, b) => a + Math.pow(b - meanDwell, 2), 0) / dwellTimes.length;
1834
+ const stdDevDwell = Math.sqrt(varDwell);
1835
+
1836
+ if (stdDevDwell < 2.0) {
1837
+ score += 35; // Suspicion d'automatisation (pas de variation humaine de pression)
1838
+ }
1839
+ if (meanDwell < 15.0) {
1840
+ score += 25; // Dwell time irréaliste
1841
+ }
1842
+ }
1843
+
1844
+ if (flightTimes.length >= 5) {
1845
+ const times = flightTimes.map(f => f.time);
1846
+ const meanFlight = times.reduce((a, b) => a + b, 0) / times.length;
1847
+ const varFlight = times.reduce((a, b) => a + Math.pow(b - meanFlight, 2), 0) / times.length;
1848
+ const stdDevFlight = Math.sqrt(varFlight);
1849
+
1850
+ if (stdDevFlight < 3.0) {
1851
+ score += 35; // Pas de variation de transition (flight time robotique)
1852
+ }
1853
+ if (meanFlight < 25.0) {
1854
+ score += 25; // Transitions trop rapides
1855
+ }
1856
+ const benfordDev = Optimization.Operators.benfordTest(times);
1857
+ if (benfordDev > 0.18) {
1858
+ score += 30; // Les intervalles ne suivent pas la loi de Benford
1859
+ }
1860
+ }
1861
+
1603
1862
  // 4. Analyse de la distribution avec la loi de Benford (si les valeurs sont non nulles).
1604
1863
  if (segments.length > 10) {
1605
1864
  const benfordDeviation = Optimization.Operators.benfordTest(segments);
@@ -2766,8 +3025,9 @@ export const getSuspicionVector = async (context, securityConfig) => {
2766
3025
  const stableFp = extractStablePart(currentDeviceHash);
2767
3026
  const stableFpHash = cyrb53(stableFp).toString();
2768
3027
  const { botnetClusterScore } = await getBotnetClusterScore(context, stableFpHash);
2769
-
2770
- const { tcpAnomalyScore } = getTcpAnomalyScore(context);
3028
+ const { tcpAnomalyScore } = getTcpAnomalyScore(context);
3029
+ const { quicAnomalyScore } = getQuicAnomalyScore(context);
3030
+ const { renderingAnomalyScore } = getRenderingAnomalyScore(context);
2771
3031
 
2772
3032
  // Save the updated device state to the store
2773
3033
  // 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.
@@ -2779,7 +3039,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
2779
3039
  deviceData.ips = new Set(deviceData.ips);
2780
3040
  }
2781
3041
  // Le vecteur de suspicion est maintenant complet.
2782
- return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore, tcpAnomalyScore };
3042
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore, tcpAnomalyScore, quicAnomalyScore, renderingAnomalyScore };
2783
3043
  };
2784
3044
 
2785
3045
  // A residential user can change networks (home, 4G, public wifi).
@@ -2848,8 +3108,8 @@ function calculateTarget(suspicionFactor, securityConfig = {}) {
2848
3108
  // MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
2849
3109
  // NOUVEAU: La difficulté est maintenant configurable.
2850
3110
  const { cpu: cpuConfig = {} } = securityConfig;
2851
- const MIN_DIFFICULTY_BITS = cpuConfig.minDifficultyBits ?? 8;
2852
- const MAX_DIFFICULTY_BITS = cpuConfig.maxDifficultyBits ?? 16;
3111
+ const MIN_DIFFICULTY_BITS = cpuConfig.minDifficultyBits ?? 8;
3112
+ const MAX_DIFFICULTY_BITS = cpuConfig.maxDifficultyBits ?? 22;
2853
3113
 
2854
3114
  // Use linear interpolation between min and max difficulty.
2855
3115
  const totalDifficultyBits =
@@ -2903,6 +3163,8 @@ export function generateCpuTargetChallenge(
2903
3163
  };
2904
3164
  }
2905
3165
 
3166
+ const htmlTemplateCache = new Map();
3167
+
2906
3168
  /**
2907
3169
  * Generates the HTML page for the CPU target challenge.
2908
3170
  * @param {object} challengeDetails - The details from generateCpuTargetChallenge.
@@ -2911,6 +3173,7 @@ export function generateCpuTargetChallenge(
2911
3173
  */
2912
3174
  function generateCpuTargetChallengePage(challengeDetails, clientIp) {
2913
3175
  const { nonce, target, path } = challengeDetails;
3176
+ const safePath = sanitizeRedirectPath(path);
2914
3177
  const solverCode = getPowSolverCode();
2915
3178
  return `
2916
3179
  <html><head><title>Security Check</title></head>
@@ -2922,14 +3185,14 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
2922
3185
  <script>
2923
3186
  async function solve() {
2924
3187
  const clientIp = ${JSON.stringify(clientIp)};
2925
- const nonce = ${JSON.stringify(nonce)};
3188
+ const nonce = ${safeJsonStringify(nonce)};
2926
3189
  const cpuTarget = BigInt("0x" + "${target}");
2927
3190
  // La nouvelle version de solveCpuChallengeInline n'a plus besoin de l'IP ou du secret,
2928
3191
  // car tout est dans le baseBlock. Pour la compatibilité de ce challenge simple, on passe null.
2929
3192
  const baseBlockBytes = new TextEncoder().encode(nonce + ":");
2930
3193
  const solution = await window.solveCpuChallengeInline(baseBlockBytes, cpuTarget, (progress) => {});
2931
3194
  window.location.href = ${JSON.stringify(path)} + "?pow_type=cpu_target&pow_nonce=" + nonce + "&pow_solution=" + solution;
2932
- }
3195
+ }
2933
3196
  solve();
2934
3197
  </script>
2935
3198
  </body></html>`;
@@ -2944,6 +3207,7 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
2944
3207
  */
2945
3208
  function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret, securityConfig, trapUrls, originalFingerprint) { // eslint-disable-line max-len
2946
3209
  const { nonce, target, path } = cpuChallengeDetails;
3210
+ const safePath = sanitizeRedirectPath(path);
2947
3211
  const solverCode = getPowSolverCode();
2948
3212
  // On prépare le baseBlock pour le client. Il sera envoyé sous forme de tableau d'octets.
2949
3213
  // Le fingerprint est maintenant passé directement en paramètre.
@@ -2960,10 +3224,10 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
2960
3224
 
2961
3225
  const challengeScript = `
2962
3226
  async function solve() {
2963
- const nonce = ${JSON.stringify(nonce)};
2964
- const path = ${JSON.stringify(path)};
2965
- const clientSecret = ${JSON.stringify(clientSecret)};
2966
- const clientIp = ${JSON.stringify(clientIp)};
3227
+ const nonce = ${safeJsonStringify(nonce)};
3228
+ const path = ${JSON.stringify(path)};
3229
+ const clientSecret = ${safeJsonStringify(clientSecret)};
3230
+ const clientIp = ${JSON.stringify(clientIp)};
2967
3231
  const cpuTarget = BigInt("0x" + "${target}");
2968
3232
  const memDifficulty = ${memoryDifficulty};
2969
3233
  // Le client reçoit directement le 'baseBlock' sous forme de tableau d'octets.
@@ -3002,10 +3266,15 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
3002
3266
  const customTemplatePath = securityConfig?.challengePagePath;
3003
3267
 
3004
3268
  if (customTemplatePath) {
3005
- try {
3006
- htmlTemplate = readFileSync(customTemplatePath, 'utf-8');
3007
- } catch (error) {
3008
- console.warn(`[Fingerprint] Could not load custom challenge page at '${customTemplatePath}'. Falling back to default. Error: ${error.message}`);
3269
+ if (htmlTemplateCache.has(customTemplatePath)) {
3270
+ htmlTemplate = htmlTemplateCache.get(customTemplatePath);
3271
+ } else {
3272
+ try {
3273
+ htmlTemplate = readFileSync(customTemplatePath, 'utf-8');
3274
+ htmlTemplateCache.set(customTemplatePath, htmlTemplate);
3275
+ } catch (error) {
3276
+ console.warn(`[Fingerprint] Could not load custom challenge page at '${customTemplatePath}'. Falling back to default. Error: ${error.message}`);
3277
+ }
3009
3278
  }
3010
3279
  }
3011
3280
 
@@ -3144,6 +3413,18 @@ export class FingerprintEngine {
3144
3413
  this.dryRun = finalConfig.dryRun || false;
3145
3414
  }
3146
3415
 
3416
+ /**
3417
+ * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
3418
+ * sans nécessiter de redémarrage.
3419
+ * @param {object} newConfig - La nouvelle configuration partielle ou complète.
3420
+ */
3421
+ updateConfig(newConfig) {
3422
+ this._validateConfig(newConfig);
3423
+ this.securityConfig = deepMerge(this.securityConfig, newConfig);
3424
+ this.dryRun = this.securityConfig.dryRun || false;
3425
+ this._log('Configuration mise à jour à chaud (Hot-Reloaded)', this.securityConfig);
3426
+ }
3427
+
3147
3428
  /**
3148
3429
  * Validates the security configuration object to detect potential typos or missing essential keys.
3149
3430
  * @private
@@ -3208,7 +3489,9 @@ export class FingerprintEngine {
3208
3489
  (suspicionVector.clientHintsInconsistencyScore || 0) * (weights.clientHintsInconsistencyScore || 0) +
3209
3490
  (suspicionVector.subnetScore || 0) * (weights.subnetScore || 0) +
3210
3491
  (suspicionVector.ipReputationScore || 0) * (weights.ipReputationScore || 0) +
3211
- (suspicionVector.tcpAnomalyScore || 0) * (weights.tcpAnomalyScore || 0);
3492
+ (suspicionVector.tcpAnomalyScore || 0) * (weights.tcpAnomalyScore || 0) +
3493
+ (suspicionVector.quicAnomalyScore || 0) * (weights.quicAnomalyScore || 0) + // NOUVEAU: QUIC Anomaly
3494
+ (suspicionVector.renderingAnomalyScore || 0) * (weights.renderingAnomalyScore || 0); // NOUVEAU: Rendering Anomaly
3212
3495
 
3213
3496
  return Math.min(100, score);
3214
3497
  }
@@ -3652,8 +3935,9 @@ export class FingerprintEngine {
3652
3935
  // lors de l'émission du challenge.
3653
3936
  // --- FIX: Use submitted fingerprint, but fallback to current request's fingerprint ---
3654
3937
  // This handles API clients that might not use the full client-side library but still solve the challenge.
3655
- const solverFingerprint = pow_fp || getCompositeDeviceHash(requestContext);
3656
- const originalFingerprint = challengeContext.fingerprint; // This is the fingerprint of the request that *triggered* the challenge
3938
+ const safe_pow_fp = typeof pow_fp === 'string' ? pow_fp : (Array.isArray(pow_fp) ? String(pow_fp[0]) : '');
3939
+ const solverFingerprint = safe_pow_fp || getCompositeDeviceHash(requestContext);
3940
+ const originalFingerprint = typeof challengeContext.fingerprint === 'string' ? challengeContext.fingerprint : '';
3657
3941
 
3658
3942
  let similarity;
3659
3943
  const similarityThreshold = this.securityConfig.similarityThreshold ?? 0.95;
@@ -4115,9 +4399,8 @@ export class FingerprintEngine {
4115
4399
  // On passe la configuration pour que la difficulté soit calculée correctement.
4116
4400
  const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path, this.securityConfig);
4117
4401
 
4118
- // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
4119
- // Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
4120
- const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
4402
+ // La difficulté mémoire augmente désormais en parfaite synergie avec le facteur de suspicion (ratio constant)
4403
+ const memActivationFactor = suspicionFactor;
4121
4404
 
4122
4405
  const minMemDifficulty = 0; // Peut être 0 Mo !
4123
4406
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
@@ -4846,6 +5129,9 @@ export const modsecurity_analyzer = (rulesPath) => {
4846
5129
  * @returns {Array<{userAgent: string, hostnameSuffix: string}>}
4847
5130
  */
4848
5131
  export const default_whitelist = () => [
5132
+ googlebot_whitelist(),
5133
+ bingbot_whitelist(),
5134
+ yandex_whitelist(),
4849
5135
  // === Moteurs de recherche majeurs ===
4850
5136
  { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
4851
5137
  { userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
@@ -4943,6 +5229,26 @@ export const default_whitelist = () => [
4943
5229
  { userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
4944
5230
  ];
4945
5231
 
5232
+ /**
5233
+ * Retourne la liste officielle des préfixes IP/CIDR (IPv4 et IPv6) utilisés par Googlebot
5234
+ * enveloppée dans un objet de type 'allowlist' prêt à être injecté.
5235
+ * @returns {{type: string, entries: string[]}} Règle d'allowlist de sécurité.
5236
+ */
5237
+ export const googlebot_whitelist = () => ({
5238
+ type: 'allowlist',
5239
+ entries: googlebotEntries
5240
+ });
5241
+
5242
+ export const yandex_whitelist = () => ({
5243
+ type: 'allowlist',
5244
+ entries: yandexEntries
5245
+ });
5246
+
5247
+ export const bingbot_whitelist = () => ({
5248
+ type: 'allowlist',
5249
+ entries: bingbotEntries
5250
+ });
5251
+
4946
5252
 
4947
5253
  /**
4948
5254
  * Extracts the TLS Session ID or ticket hash from the request context.
@@ -4972,6 +5278,8 @@ function getTlsSessionId(context) {
4972
5278
  }
4973
5279
 
4974
5280
  // --- Proof-of-Work Middleware (The Tollbooth) ---
5281
+ const staticFileCache = new Map();
5282
+
4975
5283
  export const powMiddleware = (securityConfig) => {
4976
5284
  const engine = new FingerprintEngine(securityConfig);
4977
5285
 
@@ -5052,7 +5360,11 @@ export const powMiddleware = (securityConfig) => {
5052
5360
  }
5053
5361
  }
5054
5362
  if (jsFile && existsSync(jsFile)) {
5055
- const fileContent = readFileSync(jsFile);
5363
+ let fileContent = staticFileCache.get(jsFile);
5364
+ if (!fileContent) {
5365
+ fileContent = readFileSync(jsFile);
5366
+ staticFileCache.set(jsFile, fileContent);
5367
+ }
5056
5368
  res.setHeader('Content-Type', 'application/javascript');
5057
5369
  return res.send(fileContent);
5058
5370
  }
@@ -5069,7 +5381,11 @@ export const powMiddleware = (securityConfig) => {
5069
5381
  }
5070
5382
  }
5071
5383
  if (wasmFile && existsSync(wasmFile)) {
5072
- const fileContent = readFileSync(wasmFile);
5384
+ let fileContent = staticFileCache.get(wasmFile);
5385
+ if (!fileContent) {
5386
+ fileContent = readFileSync(wasmFile);
5387
+ staticFileCache.set(wasmFile, fileContent);
5388
+ }
5073
5389
  res.setHeader('Content-Type', 'application/wasm');
5074
5390
  return res.send(fileContent);
5075
5391
  }
@@ -5079,7 +5395,7 @@ export const powMiddleware = (securityConfig) => {
5079
5395
 
5080
5396
  const requestContext = {
5081
5397
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
5082
- path: req.path,
5398
+ path: sanitizeRedirectPath(req.path),
5083
5399
  cookies: req.cookies,
5084
5400
  query: req.query,
5085
5401
  body: req.body,
@@ -5132,7 +5448,7 @@ export const powMiddleware = (securityConfig) => {
5132
5448
  if (decision.cookie) {
5133
5449
  res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
5134
5450
  }
5135
- return res.redirect(decision.path);
5451
+ return res.redirect(sanitizeRedirectPath(decision.path));
5136
5452
 
5137
5453
  case 'next':
5138
5454
  default:
@@ -5141,6 +5457,7 @@ export const powMiddleware = (securityConfig) => {
5141
5457
  };
5142
5458
  };
5143
5459
 
5460
+
5144
5461
  /**
5145
5462
  * @internal
5146
5463
  * Exporting an object containing the functions to make them mockable in tests.
@@ -5186,6 +5503,8 @@ export const __internal = {
5186
5503
  parseTcpSyn, // Expose for testing
5187
5504
  classifyTcpOs, // Expose for testing
5188
5505
  getTcpAnomalyScore, // Expose for testing,
5506
+ getQuicAnomalyScore, // NOUVEAU: Expose pour les tests
5507
+ getRenderingAnomalyScore, // NOUVEAU: Expose pour les tests
5189
5508
  registerCooperativeNode,
5190
5509
  findPeerInSubnet,
5191
5510
  handleCooperativeRequest
@@ -5584,7 +5903,7 @@ export async function handleMetricsRequest(req, res, securityConfig) {
5584
5903
  if (typeof authorizationCallback === 'function') {
5585
5904
  const context = new RequestContext(
5586
5905
  req.ip,
5587
- req.path,
5906
+ sanitizeRedirectPath(req.path),
5588
5907
  req.headers,
5589
5908
  req.query,
5590
5909
  req.body,