@anonympins/fingerprint 0.5.1 → 0.5.4

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +118 -115
  3. package/package.json +1 -1
  4. package/src/js/dynamic-wasm.js +52 -13
  5. package/src/js/fingerprint.builder.js +169 -168
  6. package/src/js/fingerprint.client.js +1271 -1189
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +436 -162
  9. package/src/js/fingerprint.utils.js +30 -0
  10. package/src/js/gpu_pow.solver.js +96 -1
  11. package/src/js/library.js +40 -21
  12. package/src/js/mongodb-store.js +165 -79
  13. package/src/js/pow.solver.inline.js +627 -30
  14. package/src/js/pow.solver.js +453 -31
  15. package/src/js/problem-manager.js +1 -1
  16. package/src/js/redis-store.js +114 -42
  17. package/src/js/tests/dns-circuit-breaker.test.js +55 -0
  18. package/src/js/tests/fingerprint.client.init.test.js +143 -143
  19. package/src/js/tests/fingerprint.client.test.js +128 -128
  20. package/src/js/tests/fingerprint.engine.test.js +370 -370
  21. package/src/js/tests/fingerprint.test.js +119 -5
  22. package/src/js/tests/gpu_pow.test.js +81 -0
  23. package/src/js/tests/ip-reputation.test.js +1 -1
  24. package/src/js/tests/pow.solver.test.js +9 -6
  25. package/src/js/tests/usefulWorkSolver.test.js +162 -0
  26. package/src/php/Challenge/ChallengeUtils.php +137 -35
  27. package/src/php/FingerprintClient.php +22 -5
  28. package/src/php/FingerprintEngine.php +335 -58
  29. package/src/php/Optimization/Optimization.php +23 -16
  30. package/src/php/Optimization/OptimizationOperators.php +27 -0
  31. package/src/php/ProblemManager.php +1 -1
  32. package/src/php/Store/MongoDbStore.php +184 -104
  33. package/src/php/Store/RedisStore.php +91 -11
  34. package/src/php/Tests/ChallengeUtilsTest.php +285 -285
  35. package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
  36. package/src/php/Tests/IpReputationTest.php +1 -1
  37. package/src/php/Tests/PowTest.php +1 -1
  38. package/src/php/Tests/RequestUtilsTest.php +29 -0
  39. package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
  40. package/src/php/Utils/BigInt.php +202 -202
  41. package/src/php/Utils/RequestUtils.php +74 -21
  42. package/src/php/Utils/TLSClientHelloParser.php +212 -116
@@ -8,12 +8,76 @@ 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);
15
28
  const __dirname = dirname(__filename);
16
29
 
30
+ let dnsCircuitBreaker = {
31
+ state: 'CLOSED', // 'CLOSED', 'OPEN', 'HALF-OPEN'
32
+ failureCount: 0,
33
+ lastStateChange: 0,
34
+ threshold: 5,
35
+ cooldownMs: 30000, // 30 seconds
36
+ };
37
+
38
+ function recordDnsSuccess() {
39
+ dnsCircuitBreaker.failureCount = 0;
40
+ dnsCircuitBreaker.state = 'CLOSED';
41
+ }
42
+
43
+ function recordDnsFailure() {
44
+ dnsCircuitBreaker.failureCount++;
45
+ if (dnsCircuitBreaker.failureCount >= dnsCircuitBreaker.threshold) {
46
+ dnsCircuitBreaker.state = 'OPEN';
47
+ dnsCircuitBreaker.lastStateChange = Date.now();
48
+ }
49
+ }
50
+
51
+ function canAttemptDns() {
52
+ if (dnsCircuitBreaker.state === 'CLOSED') {
53
+ return true;
54
+ }
55
+ if (dnsCircuitBreaker.state === 'OPEN') {
56
+ if (Date.now() - dnsCircuitBreaker.lastStateChange > dnsCircuitBreaker.cooldownMs) {
57
+ dnsCircuitBreaker.state = 'HALF-OPEN';
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ return true; // HALF-OPEN
63
+ }
64
+
65
+ const withTimeout = (promise, ms) => {
66
+ let timeoutId;
67
+ const timeoutPromise = new Promise((_, reject) => {
68
+ timeoutId = setTimeout(() => {
69
+ reject(new Error('DNS_TIMEOUT'));
70
+ }, ms);
71
+ });
72
+ return Promise.race([
73
+ promise.then((res) => {
74
+ clearTimeout(timeoutId);
75
+ return res;
76
+ }),
77
+ timeoutPromise
78
+ ]);
79
+ };
80
+
17
81
  export { createRedisStore } from "./redis-store.js";
18
82
  export { createMongoDbStore } from "./mongodb-store.js";
19
83
 
@@ -22,6 +86,42 @@ let lastMappingTime = 0;
22
86
  let isCompilingMapping = false;
23
87
  const MAPPING_ROTATION_INTERVAL = 60000; // 60 seconds
24
88
 
89
+ const configDir = resolve(__dirname, '../../config');
90
+
91
+ const loadBotWhitelist = (filename, fallbackEntries) => {
92
+ const filePath = join(configDir, filename);
93
+ if (existsSync(filePath)) {
94
+ try {
95
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
96
+ } catch (e) {
97
+ console.error(`[Fingerprint] Error loading whitelist file ${filename}:`, e.message);
98
+ }
99
+ }
100
+ return fallbackEntries;
101
+ };
102
+
103
+ const googlebotEntries = loadBotWhitelist('googlebot.json', [
104
+ "2001:4860:4801:10::/64",
105
+ "2001:4860:4801:11::/64",
106
+ "2001:4860:4801:12::/64",
107
+ // ... [Keep fallback inline values for safety]
108
+ "66.249.79.64"
109
+ ]);
110
+
111
+ const bingbotEntries = loadBotWhitelist('bingbot.json', [
112
+ "157.55.39.0/24",
113
+ "207.46.13.0/24",
114
+ // ... [Keep fallback inline values for safety]
115
+ "40.77.178.0/23"
116
+ ]);
117
+
118
+ const yandexEntries = loadBotWhitelist('yandex.json', [
119
+ "2a02:6b8::/29",
120
+ "5.45.192.0/18",
121
+ // ... [Keep fallback inline values for safety]
122
+ "213.180.192.0/19"
123
+ ]);
124
+
25
125
  function generateSessionMapping() {
26
126
  const randomStr = (len = 6) => crypto.randomBytes(len).toString('hex').replace(/[0-9]/g, 'g').substring(0, len);
27
127
  const randomHeader = () => `X-Sess-${crypto.randomBytes(4).toString('hex')}`;
@@ -584,15 +684,17 @@ const getPowSecret = () => {
584
684
  return secret || "fallback-dev-secret-32-chars-minimum";
585
685
  };
586
686
 
687
+ let cachedPowSolverCode = null;
587
688
  /**
588
689
  * Loads the pow.solver.js content for inlining in HTML pages.
589
690
  * @returns {string} The solver JavaScript code.
590
691
  */
591
692
  const getPowSolverCode = () => {
592
- // On supprime le try/catch. Si le fichier n'est pas trouvé, le processus plantera,
593
- // ce qui est préférable à servir un code de secours potentiellement désynchronisé.
594
- const solverPath = join(__dirname, 'pow.solver.inline.js'); // Utilise la version inline
595
- return readFileSync(solverPath, 'utf-8');
693
+ if (!cachedPowSolverCode) {
694
+ const solverPath = join(__dirname, 'pow.solver.inline.js'); // Utilise la version inline
695
+ cachedPowSolverCode = readFileSync(solverPath, 'utf-8');
696
+ }
697
+ return cachedPowSolverCode;
596
698
  };
597
699
  /**
598
700
  * Extracts the "stable" part of a fingerprint string.
@@ -858,6 +960,7 @@ const generateTspChallenge = (
858
960
  ) => {
859
961
  const citiesJson = JSON.stringify(cities);
860
962
  const solverCode = getPowSolverCode();
963
+ const safePath = sanitizeRedirectPath(path);
861
964
  return `
862
965
  <html>
863
966
  <head><title>Advanced Security Check (Level 3)</title></head>
@@ -868,14 +971,14 @@ const generateTspChallenge = (
868
971
  <script>${solverCode}</script>
869
972
  <script>
870
973
  const cities = ${citiesJson}; // Safe, as it's JSON
871
- const nonce = ${JSON.stringify(nonce)}; // Safe
974
+ const nonce = ${safeJsonStringify(nonce)}; // Safe
872
975
  const targetMaxDistance = ${targetMaxDistance};
873
976
 
874
977
  async function solve() {
875
978
  const result = await window.solveTspChallenge(cities, targetMaxDistance);
876
979
 
877
980
  if (result.distance <= targetMaxDistance) {
878
- window.location.href = ${JSON.stringify(path)} + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
981
+ window.location.href = ${safeJsonStringify(safePath)} + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
879
982
  } else {
880
983
  document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
881
984
  }
@@ -972,6 +1075,7 @@ const generateMemoryPoWChallenge = (
972
1075
  difficulty = 16,
973
1076
  path = "",
974
1077
  ) => {
1078
+ const safePath = sanitizeRedirectPath(path);
975
1079
  // difficulty here is the buffer size in MB.
976
1080
  return `
977
1081
  <html>
@@ -982,8 +1086,8 @@ const generateMemoryPoWChallenge = (
982
1086
  <div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
983
1087
  <script>
984
1088
  async function solve() {
985
- const nonce = "${nonce}";
986
- const size = ${difficulty} * 1024 * 1024; // en octets
1089
+ const nonce = ${safeJsonStringify(nonce)};
1090
+ const size = ${difficulty} * 1024 * 1024; // en octets
987
1091
  const iterations = size / 16;
988
1092
 
989
1093
  try {
@@ -1000,8 +1104,8 @@ const generateMemoryPoWChallenge = (
1000
1104
  }
1001
1105
  window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
1002
1106
  } catch(e) {
1003
- document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
1004
- }
1107
+ window.location.href = ${safeJsonStringify(safePath)} + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
1108
+ }
1005
1109
  }
1006
1110
  solve();
1007
1111
  </script>
@@ -1056,40 +1160,123 @@ export const verifyPoWAndGenerateTicket = async (
1056
1160
  * For higher difficulties (production workloads), we skip the massive memory allocation on the server,
1057
1161
  * avoiding server-side memory DoS vectors completely.
1058
1162
  */
1163
+ function getChallengedIndices(seed, solution, numBlocks, k = 4) {
1164
+ const indices = [];
1165
+ let h = cyrb53(seed + ":" + solution);
1166
+ for (let i = 0; i < k; i++) {
1167
+ h = Math.imul(h ^ i, 1597334677);
1168
+ indices.push(Math.abs(h) % numBlocks);
1169
+ }
1170
+ return indices;
1171
+ }
1172
+
1173
+ function verifyMerkleProof(leafHash, index, proof, root) {
1174
+ let currentHash = leafHash;
1175
+ let idx = index;
1176
+ for (let i = 0; i < proof.length; i++) {
1177
+ const sibling = proof[i];
1178
+ const combined = idx % 2 === 0 ? currentHash + sibling : sibling + currentHash;
1179
+ currentHash = crypto.createHash('sha256').update(Buffer.from(combined, 'hex')).digest('hex');
1180
+ idx = Math.floor(idx / 2);
1181
+ }
1182
+ return currentHash === root;
1183
+ }
1184
+
1185
+ function verifyMemoryPoWLegacy(nonce, solution, difficulty, clientSecret) {
1186
+ const size = difficulty * 1024 * 1024;
1187
+ const iterations = size / 16;
1188
+ const buffer = new Uint32Array(size / 4);
1189
+ const seed = `:${nonce}:${clientSecret}`;
1190
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
1191
+
1192
+ for (let i = 0; i < buffer.length; i++) {
1193
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
1194
+ }
1195
+
1196
+ let finalHash = 0;
1197
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
1198
+ for (let i = 0; i < iterations; i++) {
1199
+ addr = buffer[addr] % buffer.length;
1200
+ finalHash ^= addr;
1201
+ }
1202
+ return finalHash === solution;
1203
+ }
1204
+
1059
1205
  export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret = '') => {
1060
1206
  const MAX_ALLOWED_MEM_DIFFICULTY = 128; // 128MB
1061
1207
  if (difficulty > MAX_ALLOWED_MEM_DIFFICULTY) {
1062
1208
  console.warn(`[Security] Memory PoW verification attempt with excessive difficulty: ${difficulty}MB. Denied.`);
1063
1209
  return false;
1064
1210
  }
1211
+ if (Number(difficulty) === 0) {
1212
+ return true;
1213
+ }
1065
1214
  if (!solution) {
1066
1215
  return false;
1067
1216
  }
1068
1217
 
1069
- // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
1070
- // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
1071
- if (difficulty > 4) {
1072
- return true;
1218
+ let data;
1219
+ try {
1220
+ data = typeof solution === 'string' ? JSON.parse(solution) : solution;
1221
+ } catch (e) {
1222
+ data = null;
1073
1223
  }
1074
1224
 
1075
- // Fallback: Cryptographic verification path for low-difficulty challenges / unit tests
1076
- const size = difficulty * 1024 * 1024;
1077
- const iterations = size / 16;
1078
- const buffer = new Uint32Array(size / 4);
1225
+ if (!data || typeof data !== 'object' || data.solution === undefined || !data.merkleRoot || !data.proofs) {
1226
+ if (difficulty <= 4 && /^\d+$/.test(String(solution))) {
1227
+ return verifyMemoryPoWLegacy(nonce, parseInt(solution, 10), difficulty, clientSecret);
1228
+ }
1229
+ return false;
1230
+ }
1231
+
1232
+ const { solution: sol, merkleRoot, proofs } = data;
1233
+ const numBlocks = difficulty * 256;
1079
1234
  const seed = `:${nonce}:${clientSecret}`;
1080
- let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
1081
1235
 
1082
- for (let i = 0; i < buffer.length; i++) {
1083
- buffer[i] = h = Math.imul(h ^ i, 1597334677);
1236
+ const challengedIndices = getChallengedIndices(seed, sol, numBlocks, 4);
1237
+
1238
+ for (const b of challengedIndices) {
1239
+ const proof = proofs[b] || proofs[String(b)];
1240
+ if (!proof) return false;
1241
+
1242
+ const block = new Uint32Array(1024);
1243
+ let h = cyrb53(seed + ":" + b);
1244
+ for (let i = 0; i < 1024; i++) {
1245
+ block[i] = (h = Math.imul(h ^ i, 1597334677));
1246
+ }
1247
+
1248
+ const expectedLeaf = crypto.createHash('sha256').update(Buffer.from(block.buffer)).digest('hex');
1249
+
1250
+ if (!verifyMerkleProof(expectedLeaf, b, proof, merkleRoot)) {
1251
+ return false;
1252
+ }
1084
1253
  }
1085
1254
 
1086
- let finalHash = 0;
1087
- let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
1255
+ const blockCache = new Map();
1256
+ function getBlockElement(blockIdx, elementIdx) {
1257
+ if (!blockCache.has(blockIdx)) {
1258
+ const block = new Uint32Array(1024);
1259
+ let h = cyrb53(seed + ":" + blockIdx);
1260
+ for (let i = 0; i < 1024; i++) {
1261
+ block[i] = (h = Math.imul(h ^ i, 1597334677));
1262
+ }
1263
+ blockCache.set(blockIdx, block);
1264
+ }
1265
+ return blockCache.get(blockIdx)[elementIdx];
1266
+ }
1267
+
1268
+ const totalElements = numBlocks * 1024;
1269
+ let addr = totalElements > 0 ? getBlockElement(0, 0) % totalElements : 0;
1270
+ let expectedSolution = 0;
1271
+ const iterations = 1024;
1088
1272
  for (let i = 0; i < iterations; i++) {
1089
- addr = buffer[addr] % buffer.length;
1090
- finalHash ^= addr;
1273
+ const blockIdx = Math.floor(addr / 1024);
1274
+ const elementIdx = addr % 1024;
1275
+ addr = getBlockElement(blockIdx, elementIdx) % totalElements;
1276
+ expectedSolution ^= addr;
1091
1277
  }
1092
- return finalHash === parseInt(solution, 10);
1278
+
1279
+ return expectedSolution === parseInt(sol, 10);
1093
1280
  };
1094
1281
 
1095
1282
  export async function verifySpacePoW(nonce, solution, queries, seed, clientSecret) {
@@ -1319,26 +1506,61 @@ export async function generateSpaceChallenge(clientIp, nonce, suspicionFactor, o
1319
1506
  }
1320
1507
 
1321
1508
  function generateSpaceChallengePage(challengeDetails, clientSecret, securityConfig) {
1322
- const { nonce, sizeMb, queries, path } = challengeDetails;
1509
+ const { nonce, sizeMb, queries, path, peerId, peerBlockIdx } = challengeDetails;
1323
1510
  const solverCode = getPowSolverCode();
1324
-
1325
- const challengeScript = `
1511
+ const safePath = sanitizeRedirectPath(path);
1512
+ const coopTimeout = securityConfig?.pospace?.coopTimeout ?? 15;
1513
+
1514
+ const challengeScript = `
1326
1515
  async function solve() {
1327
- const nonce = ${JSON.stringify(nonce)};
1328
- const path = ${JSON.stringify(path)};
1329
- const clientSecret = ${JSON.stringify(clientSecret)};
1516
+ const nonce = ${safeJsonStringify(nonce)};
1517
+ const path = ${safeJsonStringify(safePath)};
1518
+ const clientSecret = ${safeJsonStringify(clientSecret)};
1330
1519
  const queries = ${JSON.stringify(queries)};
1331
1520
  const sizeMb = ${sizeMb};
1521
+ const nodeId = nonce;
1522
+ const peerId = ${safeJsonStringify(peerId || '')};
1523
+ const peerBlockIdx = ${peerBlockIdx ?? -1};
1524
+ const coopTimeout = ${coopTimeout};
1332
1525
 
1333
1526
  document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
1334
1527
  await new Promise(r => setTimeout(r, 10));
1335
1528
 
1336
1529
  try {
1337
1530
  await window.initializeSpace(nonce + ":" + clientSecret, sizeMb);
1531
+
1532
+ if (peerId && peerBlockIdx !== -1) {
1533
+ // Enregistrement coopératif
1534
+ await fetch(window.location.pathname + "?coop_op=register&node_id=" + nodeId + "&seed=" + encodeURIComponent(nonce + ":" + clientSecret));
1535
+ }
1536
+
1338
1537
  document.getElementById('loader').innerText = '⚙️ Generating Proof of Space...';
1339
- const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret);
1538
+
1539
+ let peerBlock = "";
1540
+ if (peerId && peerBlockIdx !== -1) {
1541
+ document.getElementById('loader').innerText = '📥 Téléchargement du bloc de validation du pair (' + peerId + ')...';
1542
+ const reqId = Math.random().toString(36).substring(2);
1543
+ await fetch(window.location.pathname + "?coop_op=request_peer_block&node_id=" + nodeId + "&peer_id=" + peerId + "&block_idx=" + peerBlockIdx + "&req_id=" + reqId);
1544
+
1545
+ let attempts = 0;
1546
+ while (attempts < coopTimeout) {
1547
+ const res = await fetch(window.location.pathname + "?coop_op=poll_response&node_id=" + nodeId + "&req_id=" + reqId);
1548
+ const data = await res.json();
1549
+ if (data.status === 'ready') {
1550
+ peerBlock = data.block_data;
1551
+ break;
1552
+ }
1553
+ await new Promise(r => setTimeout(r, 1000));
1554
+ attempts++;
1555
+ }
1556
+ if (!peerBlock) {
1557
+ document.getElementById('loader').innerText = '⚠️ Peer de sous-réseau injoignable. Validation solo...';
1558
+ }
1559
+ }
1560
+
1561
+ const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret, peerBlock);
1340
1562
 
1341
- window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash;
1563
+ window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash + (peerBlock ? "&pow_coop=1" : "");
1342
1564
  } catch(e) {
1343
1565
  document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
1344
1566
  }
@@ -2214,80 +2436,6 @@ function getClickVarianceScore(context) {
2214
2436
  * @returns {string|null} The subnet CIDR or null if the IP is invalid.
2215
2437
  */
2216
2438
  function getIpSubnet(ip, ipv4Prefix = 24, ipv6Prefix = 48) { // eslint-disable-line no-unused-vars
2217
- /**
2218
- * @private
2219
- * Parses an IPv6 string, expanding '::' into a 16-byte Buffer.
2220
- * @param {string} ipStr The IPv6 address string.
2221
- * @returns {Buffer|null}
2222
- */
2223
- const parseIPv6 = (ipStr) => {
2224
- const parts = ipStr.split('::');
2225
- if (parts.length > 2) return null;
2226
-
2227
- let hextets = [];
2228
- if (parts[0]) hextets.push(...parts[0].split(':'));
2229
-
2230
- if (parts.length === 2) {
2231
- const hextetsInPart2 = parts[1] ? parts[1].split(':').length : 0;
2232
- const zerosToInsert = 8 - hextets.length - hextetsInPart2;
2233
- for (let i = 0; i < zerosToInsert; i++) {
2234
- hextets.push('0');
2235
- }
2236
- if (parts[1]) hextets.push(...parts[1].split(':'));
2237
- }
2238
-
2239
- if (hextets.length !== 8) return null;
2240
-
2241
- const buffer = Buffer.alloc(16);
2242
- for (let i = 0; i < 8; i++) {
2243
- const val = parseInt(hextets[i] || '0', 16);
2244
- if (isNaN(val)) return null;
2245
- buffer.writeUInt16BE(val, i * 2);
2246
- }
2247
- return buffer;
2248
- };
2249
-
2250
- /**
2251
- * @private
2252
- * Formats a 16-byte IPv6 buffer into a compressed string representation.
2253
- * @param {Buffer} buffer The 16-byte buffer.
2254
- * @returns {string}
2255
- */
2256
- const formatIPv6 = (buffer) => {
2257
- const hextets = [];
2258
- for (let i = 0; i < 16; i += 2) {
2259
- hextets.push(buffer.readUInt16BE(i).toString(16));
2260
- }
2261
-
2262
- let bestStart = -1, bestLength = 0, currentStart = -1, currentLength = 0;
2263
- for (let i = 0; i < hextets.length; i++) {
2264
- if (hextets[i] === '0') {
2265
- if (currentStart === -1) currentStart = i;
2266
- currentLength++;
2267
- } else {
2268
- if (currentLength > bestLength) {
2269
- bestStart = currentStart;
2270
- bestLength = currentLength;
2271
- }
2272
- currentStart = -1;
2273
- currentLength = 0;
2274
- }
2275
- }
2276
- if (currentLength > bestLength) {
2277
- bestStart = currentStart;
2278
- bestLength = currentLength;
2279
- }
2280
-
2281
- // For subnet calculations, an uncompressed view is often clearer.
2282
- // We will avoid compression to match test expectations.
2283
- // if (bestLength > 1) {
2284
- // const part1 = hextets.slice(0, bestStart).join(':');
2285
- // const part2 = hextets.slice(bestStart + bestLength).join(':');
2286
- // return `${part1}::${part2}`;
2287
- // }
2288
- return hextets.join(':');
2289
- };
2290
-
2291
2439
  try {
2292
2440
  if (isIPv4(ip)) {
2293
2441
  const ipBuffer = Buffer.from(ip.split('.').map(Number));
@@ -2296,14 +2444,37 @@ function getIpSubnet(ip, ipv4Prefix = 24, ipv6Prefix = 48) { // eslint-disable-l
2296
2444
  for (let i = 0; i < 4; i++) ipBuffer[i] &= mask[i];
2297
2445
  return `${Array.from(ipBuffer).join('.')}/${ipv4Prefix}`;
2298
2446
  } else if (isIPv6(ip)) {
2299
- const ipBuffer = parseIPv6(ip);
2300
- if (!ipBuffer) return null;
2301
-
2302
- const mask = Buffer.alloc(16, 0);
2303
- for (let i = 0; i < ipv6Prefix; i++) mask[Math.floor(i / 8)] |= 1 << (7 - (i % 8));
2304
- for (let i = 0; i < 16; i++) ipBuffer[i] &= mask[i];
2305
-
2306
- return `${formatIPv6(ipBuffer)}/${ipv6Prefix}`;
2447
+ let normalized = ip.trim().toLowerCase();
2448
+ if (normalized.includes("::")) {
2449
+ const parts = normalized.split("::");
2450
+ if (parts.length > 2) return null;
2451
+ const left = parts[0] ? parts[0].split(":") : [];
2452
+ const right = parts[1] ? parts[1].split(":") : [];
2453
+ const missing = 8 - (left.length + right.length);
2454
+ const middle = Array(missing).fill("0000");
2455
+ normalized = [...left, ...middle, ...right].join(":");
2456
+ } else {
2457
+ const parts = normalized.split(":");
2458
+ if (parts.length !== 8) return null;
2459
+ }
2460
+ const groups = normalized.split(":").map(g => {
2461
+ const val = parseInt(g, 16);
2462
+ return isNaN(val) ? "0000" : val.toString(16).padStart(4, "0");
2463
+ });
2464
+ for (let i = 0; i < 8; i++) {
2465
+ const startBit = i * 16;
2466
+ if (ipv6Prefix >= (i + 1) * 16) {
2467
+ continue;
2468
+ } else if (ipv6Prefix <= startBit) {
2469
+ groups[i] = "0000";
2470
+ } else {
2471
+ const bitsToKeep = ipv6Prefix - startBit;
2472
+ const val = parseInt(groups[i], 16);
2473
+ const mask = (0xffff << (16 - bitsToKeep)) & 0xffff;
2474
+ groups[i] = (val & mask).toString(16).padStart(4, "0");
2475
+ }
2476
+ }
2477
+ return `${groups.join(":")}/${ipv6Prefix}`;
2307
2478
  }
2308
2479
  } catch (e) {
2309
2480
  // Catch any unexpected errors during parsing or manipulation
@@ -2716,12 +2887,19 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
2716
2887
  // Case 2: New user or lost/invalid cookie.
2717
2888
  deviceId = crypto.randomUUID(); // Generate a new "passport".
2718
2889
 
2890
+ const isHttps = context.headers?.['x-forwarded-proto'] === 'https' ||
2891
+ context.rawReq?.secure ||
2892
+ context.rawReq?.protocol === 'https' ||
2893
+ context.rawReq?.connection?.encrypted;
2894
+ const secureOption = isHttps || process.env.NODE_ENV === "production";
2895
+
2719
2896
  // Return the intention to set a cookie.
2720
2897
  newCookie = {
2721
2898
  name: "device_id",
2722
2899
  value: deviceId,
2723
2900
  options: {
2724
- httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
2901
+ httpOnly: true, secure: secureOption, sameSite: "strict",
2902
+ ...(secureOption && { partitioned: true }),
2725
2903
  // Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
2726
2904
  ...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
2727
2905
  }
@@ -2971,8 +3149,8 @@ function calculateTarget(suspicionFactor, securityConfig = {}) {
2971
3149
  // MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
2972
3150
  // NOUVEAU: La difficulté est maintenant configurable.
2973
3151
  const { cpu: cpuConfig = {} } = securityConfig;
2974
- const MIN_DIFFICULTY_BITS = cpuConfig.minDifficultyBits ?? 8;
2975
- const MAX_DIFFICULTY_BITS = cpuConfig.maxDifficultyBits ?? 16;
3152
+ const MIN_DIFFICULTY_BITS = cpuConfig.minDifficultyBits ?? 8;
3153
+ const MAX_DIFFICULTY_BITS = cpuConfig.maxDifficultyBits ?? 22;
2976
3154
 
2977
3155
  // Use linear interpolation between min and max difficulty.
2978
3156
  const totalDifficultyBits =
@@ -3026,6 +3204,8 @@ export function generateCpuTargetChallenge(
3026
3204
  };
3027
3205
  }
3028
3206
 
3207
+ const htmlTemplateCache = new Map();
3208
+
3029
3209
  /**
3030
3210
  * Generates the HTML page for the CPU target challenge.
3031
3211
  * @param {object} challengeDetails - The details from generateCpuTargetChallenge.
@@ -3034,6 +3214,7 @@ export function generateCpuTargetChallenge(
3034
3214
  */
3035
3215
  function generateCpuTargetChallengePage(challengeDetails, clientIp) {
3036
3216
  const { nonce, target, path } = challengeDetails;
3217
+ const safePath = sanitizeRedirectPath(path);
3037
3218
  const solverCode = getPowSolverCode();
3038
3219
  return `
3039
3220
  <html><head><title>Security Check</title></head>
@@ -3045,14 +3226,14 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
3045
3226
  <script>
3046
3227
  async function solve() {
3047
3228
  const clientIp = ${JSON.stringify(clientIp)};
3048
- const nonce = ${JSON.stringify(nonce)};
3229
+ const nonce = ${safeJsonStringify(nonce)};
3049
3230
  const cpuTarget = BigInt("0x" + "${target}");
3050
3231
  // La nouvelle version de solveCpuChallengeInline n'a plus besoin de l'IP ou du secret,
3051
3232
  // car tout est dans le baseBlock. Pour la compatibilité de ce challenge simple, on passe null.
3052
3233
  const baseBlockBytes = new TextEncoder().encode(nonce + ":");
3053
3234
  const solution = await window.solveCpuChallengeInline(baseBlockBytes, cpuTarget, (progress) => {});
3054
3235
  window.location.href = ${JSON.stringify(path)} + "?pow_type=cpu_target&pow_nonce=" + nonce + "&pow_solution=" + solution;
3055
- }
3236
+ }
3056
3237
  solve();
3057
3238
  </script>
3058
3239
  </body></html>`;
@@ -3067,6 +3248,7 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
3067
3248
  */
3068
3249
  function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret, securityConfig, trapUrls, originalFingerprint) { // eslint-disable-line max-len
3069
3250
  const { nonce, target, path } = cpuChallengeDetails;
3251
+ const safePath = sanitizeRedirectPath(path);
3070
3252
  const solverCode = getPowSolverCode();
3071
3253
  // On prépare le baseBlock pour le client. Il sera envoyé sous forme de tableau d'octets.
3072
3254
  // Le fingerprint est maintenant passé directement en paramètre.
@@ -3083,10 +3265,10 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
3083
3265
 
3084
3266
  const challengeScript = `
3085
3267
  async function solve() {
3086
- const nonce = ${JSON.stringify(nonce)};
3087
- const path = ${JSON.stringify(path)};
3088
- const clientSecret = ${JSON.stringify(clientSecret)};
3089
- const clientIp = ${JSON.stringify(clientIp)};
3268
+ const nonce = ${safeJsonStringify(nonce)};
3269
+ const path = ${JSON.stringify(path)};
3270
+ const clientSecret = ${safeJsonStringify(clientSecret)};
3271
+ const clientIp = ${JSON.stringify(clientIp)};
3090
3272
  const cpuTarget = BigInt("0x" + "${target}");
3091
3273
  const memDifficulty = ${memoryDifficulty};
3092
3274
  // Le client reçoit directement le 'baseBlock' sous forme de tableau d'octets.
@@ -3109,7 +3291,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
3109
3291
  }
3110
3292
 
3111
3293
  // Redirect with both solutions and the fingerprint used to solve.
3112
- const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + ${JSON.stringify(nonce)} + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
3294
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + ${JSON.stringify(nonce)} + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + encodeURIComponent(JSON.stringify(memSolution));
3113
3295
  window.location.href = finalUrl;
3114
3296
  }
3115
3297
 
@@ -3125,10 +3307,15 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
3125
3307
  const customTemplatePath = securityConfig?.challengePagePath;
3126
3308
 
3127
3309
  if (customTemplatePath) {
3128
- try {
3129
- htmlTemplate = readFileSync(customTemplatePath, 'utf-8');
3130
- } catch (error) {
3131
- console.warn(`[Fingerprint] Could not load custom challenge page at '${customTemplatePath}'. Falling back to default. Error: ${error.message}`);
3310
+ if (htmlTemplateCache.has(customTemplatePath)) {
3311
+ htmlTemplate = htmlTemplateCache.get(customTemplatePath);
3312
+ } else {
3313
+ try {
3314
+ htmlTemplate = readFileSync(customTemplatePath, 'utf-8');
3315
+ htmlTemplateCache.set(customTemplatePath, htmlTemplate);
3316
+ } catch (error) {
3317
+ console.warn(`[Fingerprint] Could not load custom challenge page at '${customTemplatePath}'. Falling back to default. Error: ${error.message}`);
3318
+ }
3132
3319
  }
3133
3320
  }
3134
3321
 
@@ -3559,6 +3746,10 @@ export class FingerprintEngine {
3559
3746
  return false;
3560
3747
  }
3561
3748
 
3749
+ if (!canAttemptDns()) {
3750
+ return false;
3751
+ }
3752
+
3562
3753
  const cacheKey = `ip-whitelist:${clientIp}`;
3563
3754
  const cachedStatus = await store.get(cacheKey);
3564
3755
 
@@ -3571,32 +3762,35 @@ export class FingerprintEngine {
3571
3762
 
3572
3763
  try {
3573
3764
  // 1. Reverse DNS lookup
3574
- const hostnames = await dns.reverse(clientIp);
3765
+ const hostnames = await withTimeout(dns.reverse(clientIp), 500);
3575
3766
  const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
3576
3767
 
3577
3768
  if (!validHostname) {
3578
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
3769
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3579
3770
  return false;
3580
3771
  }
3581
3772
 
3582
3773
  // 2. Forward DNS lookup
3583
3774
  let addresses = [];
3584
3775
  try {
3585
- addresses = await dns.resolve(validHostname);
3776
+ addresses = await withTimeout(dns.resolve(validHostname), 500);
3586
3777
  } catch (e) {}
3587
3778
  try {
3588
- const ipv6 = await dns.resolve(validHostname, 'AAAA');
3779
+ const ipv6 = await withTimeout(dns.resolve(validHostname, 'AAAA'), 500);
3589
3780
  addresses = addresses.concat(ipv6);
3590
3781
  } catch (e) {}
3591
3782
  if (addresses.includes(clientIp)) {
3783
+ recordDnsSuccess();
3592
3784
  await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
3593
3785
  return true;
3594
3786
  }
3595
3787
  } catch (error) {
3596
- // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
3788
+ recordDnsFailure();
3789
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3790
+ return false;
3597
3791
  }
3598
3792
 
3599
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
3793
+ await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
3600
3794
  return false;
3601
3795
  }
3602
3796
 
@@ -3789,8 +3983,9 @@ export class FingerprintEngine {
3789
3983
  // lors de l'émission du challenge.
3790
3984
  // --- FIX: Use submitted fingerprint, but fallback to current request's fingerprint ---
3791
3985
  // This handles API clients that might not use the full client-side library but still solve the challenge.
3792
- const solverFingerprint = pow_fp || getCompositeDeviceHash(requestContext);
3793
- const originalFingerprint = challengeContext.fingerprint; // This is the fingerprint of the request that *triggered* the challenge
3986
+ const safe_pow_fp = typeof pow_fp === 'string' ? pow_fp : (Array.isArray(pow_fp) ? String(pow_fp[0]) : '');
3987
+ const solverFingerprint = safe_pow_fp || getCompositeDeviceHash(requestContext);
3988
+ const originalFingerprint = typeof challengeContext.fingerprint === 'string' ? challengeContext.fingerprint : '';
3794
3989
 
3795
3990
  let similarity;
3796
3991
  const similarityThreshold = this.securityConfig.similarityThreshold ?? 0.95;
@@ -3893,6 +4088,11 @@ export class FingerprintEngine {
3893
4088
  const finalQueryString = finalSearchParams.toString();
3894
4089
  const finalRedirectPath = finalQueryString ? `${originalUrl.pathname}?${finalQueryString}` : originalUrl.pathname;
3895
4090
  this._log('Redirecting to clean path', { finalRedirectPath, cookieMaxAge: finalTtl });
4091
+ const isHttps = requestContext.headers?.['x-forwarded-proto'] === 'https' ||
4092
+ requestContext.rawReq?.secure ||
4093
+ requestContext.rawReq?.protocol === 'https' ||
4094
+ requestContext.rawReq?.connection?.encrypted;
4095
+ const secureOption = isHttps || this.isProduction;
3896
4096
  return {
3897
4097
  action: 'redirect',
3898
4098
  path: finalRedirectPath,
@@ -3901,7 +4101,13 @@ export class FingerprintEngine {
3901
4101
  cookie: {
3902
4102
  name: 'pow_clearance',
3903
4103
  value: ticket, // The ticket itself
3904
- options: { httpOnly: true, secure: this.isProduction, maxAge: finalTtl } // Options for setting the cookie
4104
+ options: {
4105
+ httpOnly: true,
4106
+ secure: secureOption,
4107
+ sameSite: 'strict',
4108
+ ...(secureOption && { partitioned: true }),
4109
+ maxAge: finalTtl
4110
+ } // Options for setting the cookie
3905
4111
  }
3906
4112
  };
3907
4113
  } else {
@@ -3976,7 +4182,7 @@ export class FingerprintEngine {
3976
4182
  if (challengeContext) {
3977
4183
  try {
3978
4184
  const workResult = JSON.parse(pow_solution_work_result);
3979
- const defaultPath = resolve(__dirname, '..', '..', 'problems.config.json');
4185
+ const defaultPath = resolve(__dirname, '..', '..', 'config', 'problems.config.json');
3980
4186
  const configPath = this.securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
3981
4187
  const manager = await getProblemManager({
3982
4188
  configPath,
@@ -4172,7 +4378,7 @@ export class FingerprintEngine {
4172
4378
  this._log('Issuing a useful work challenge', { finalScore });
4173
4379
 
4174
4380
  try {
4175
- const defaultPath = resolve(process.cwd(), 'problems.config.json');
4381
+ const defaultPath = resolve(process.cwd(), 'config', 'problems.config.json');
4176
4382
  const configPath = this.securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
4177
4383
  const manager = await getProblemManager({
4178
4384
  configPath,
@@ -4252,9 +4458,8 @@ export class FingerprintEngine {
4252
4458
  // On passe la configuration pour que la difficulté soit calculée correctement.
4253
4459
  const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path, this.securityConfig);
4254
4460
 
4255
- // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
4256
- // Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
4257
- const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
4461
+ // La difficulté mémoire augmente désormais en parfaite synergie avec le facteur de suspicion (ratio constant)
4462
+ const memActivationFactor = suspicionFactor;
4258
4463
 
4259
4464
  const minMemDifficulty = 0; // Peut être 0 Mo !
4260
4465
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
@@ -4983,6 +5188,9 @@ export const modsecurity_analyzer = (rulesPath) => {
4983
5188
  * @returns {Array<{userAgent: string, hostnameSuffix: string}>}
4984
5189
  */
4985
5190
  export const default_whitelist = () => [
5191
+ googlebot_whitelist(),
5192
+ bingbot_whitelist(),
5193
+ yandex_whitelist(),
4986
5194
  // === Moteurs de recherche majeurs ===
4987
5195
  { userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' },
4988
5196
  { userAgent: 'Google-Extended', hostnameSuffix: '.google.com' },
@@ -5080,6 +5288,26 @@ export const default_whitelist = () => [
5080
5288
  { userAgent: 'KeyCDN', hostnameSuffix: '.keycdn.com' },
5081
5289
  ];
5082
5290
 
5291
+ /**
5292
+ * Retourne la liste officielle des préfixes IP/CIDR (IPv4 et IPv6) utilisés par Googlebot
5293
+ * enveloppée dans un objet de type 'allowlist' prêt à être injecté.
5294
+ * @returns {{type: string, entries: string[]}} Règle d'allowlist de sécurité.
5295
+ */
5296
+ export const googlebot_whitelist = () => ({
5297
+ type: 'allowlist',
5298
+ entries: googlebotEntries
5299
+ });
5300
+
5301
+ export const yandex_whitelist = () => ({
5302
+ type: 'allowlist',
5303
+ entries: yandexEntries
5304
+ });
5305
+
5306
+ export const bingbot_whitelist = () => ({
5307
+ type: 'allowlist',
5308
+ entries: bingbotEntries
5309
+ });
5310
+
5083
5311
 
5084
5312
  /**
5085
5313
  * Extracts the TLS Session ID or ticket hash from the request context.
@@ -5109,12 +5337,14 @@ function getTlsSessionId(context) {
5109
5337
  }
5110
5338
 
5111
5339
  // --- Proof-of-Work Middleware (The Tollbooth) ---
5340
+ const staticFileCache = new Map();
5341
+
5112
5342
  export const powMiddleware = (securityConfig) => {
5113
5343
  const engine = new FingerprintEngine(securityConfig);
5114
5344
 
5115
5345
  // Initialize the problem manager with the configured path, if provided.
5116
5346
  if (securityConfig.enableUsefulWork) {
5117
- const defaultPath = resolve(__dirname, '..', '..', 'problems.config.json');
5347
+ const defaultPath = resolve(__dirname, '..', '..', 'config', 'problems.config.json');
5118
5348
  const configPath = securityConfig.usefulWorkConfigPath || (existsSync(defaultPath) ? defaultPath : undefined);
5119
5349
  getProblemManager({
5120
5350
  configPath,
@@ -5189,7 +5419,11 @@ export const powMiddleware = (securityConfig) => {
5189
5419
  }
5190
5420
  }
5191
5421
  if (jsFile && existsSync(jsFile)) {
5192
- const fileContent = readFileSync(jsFile);
5422
+ let fileContent = staticFileCache.get(jsFile);
5423
+ if (!fileContent) {
5424
+ fileContent = readFileSync(jsFile);
5425
+ staticFileCache.set(jsFile, fileContent);
5426
+ }
5193
5427
  res.setHeader('Content-Type', 'application/javascript');
5194
5428
  return res.send(fileContent);
5195
5429
  }
@@ -5206,7 +5440,11 @@ export const powMiddleware = (securityConfig) => {
5206
5440
  }
5207
5441
  }
5208
5442
  if (wasmFile && existsSync(wasmFile)) {
5209
- const fileContent = readFileSync(wasmFile);
5443
+ let fileContent = staticFileCache.get(wasmFile);
5444
+ if (!fileContent) {
5445
+ fileContent = readFileSync(wasmFile);
5446
+ staticFileCache.set(wasmFile, fileContent);
5447
+ }
5210
5448
  res.setHeader('Content-Type', 'application/wasm');
5211
5449
  return res.send(fileContent);
5212
5450
  }
@@ -5216,7 +5454,7 @@ export const powMiddleware = (securityConfig) => {
5216
5454
 
5217
5455
  const requestContext = {
5218
5456
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
5219
- path: req.path,
5457
+ path: sanitizeRedirectPath(req.path),
5220
5458
  cookies: req.cookies,
5221
5459
  query: req.query,
5222
5460
  body: req.body,
@@ -5269,7 +5507,7 @@ export const powMiddleware = (securityConfig) => {
5269
5507
  if (decision.cookie) {
5270
5508
  res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
5271
5509
  }
5272
- return res.redirect(decision.path);
5510
+ return res.redirect(sanitizeRedirectPath(decision.path));
5273
5511
 
5274
5512
  case 'next':
5275
5513
  default:
@@ -5278,6 +5516,7 @@ export const powMiddleware = (securityConfig) => {
5278
5516
  };
5279
5517
  };
5280
5518
 
5519
+
5281
5520
  /**
5282
5521
  * @internal
5283
5522
  * Exporting an object containing the functions to make them mockable in tests.
@@ -5325,6 +5564,10 @@ export const __internal = {
5325
5564
  getTcpAnomalyScore, // Expose for testing,
5326
5565
  getQuicAnomalyScore, // NOUVEAU: Expose pour les tests
5327
5566
  getRenderingAnomalyScore, // NOUVEAU: Expose pour les tests
5567
+ dnsCircuitBreaker,
5568
+ recordDnsSuccess,
5569
+ recordDnsFailure,
5570
+ canAttemptDns,
5328
5571
  registerCooperativeNode,
5329
5572
  findPeerInSubnet,
5330
5573
  handleCooperativeRequest
@@ -5346,48 +5589,79 @@ export function sanitizeTrafficData(trafficData) {
5346
5589
  if (!trafficData || trafficData.length === 0) {
5347
5590
  return [];
5348
5591
  }
5349
- const tempSanitized = [];
5592
+ const suspiciousLogs = [];
5593
+ const passedLogs = [];
5350
5594
  const deviceCounts = new Map();
5351
5595
  const ipCounts = new Map();
5352
5596
  const subnetCounts = new Map();
5597
+ const hardwareClusterCounts = new Map();
5598
+ const hwClusterCache = new Map();
5353
5599
 
5354
5600
  // Calcul des quotas maximums pour éviter l'influence démesurée d'une entité
5355
5601
  const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
5356
5602
  const maxLogsPerIp = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% par adresse IP individuelle
5357
5603
  const maxLogsPerSubnet = Math.max(5, Math.floor(trafficData.length * 0.05)); // Max 5% par bloc réseau (anti-proxy-rotation)
5604
+ const maxLogsPerHardwareCluster = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% par cluster matériel stable
5605
+
5606
+ const getHardwareCluster = (log) => {
5607
+ const fp = log.deviceHash || log.fingerprint || log.deviceFingerprint || '';
5608
+ if (!fp || typeof fp !== 'string') {
5609
+ return log.deviceId || 'anonymous-cluster';
5610
+ }
5611
+ if (hwClusterCache.has(fp)) {
5612
+ return hwClusterCache.get(fp);
5613
+ }
5614
+ const parts = fp.split('|');
5615
+ const hwComponents = [];
5616
+ for (const part of parts) {
5617
+ const pair = part.split(':');
5618
+ if (pair.length === 2 && (pair[0] === 'gpu' || pair[0] === 'cvs' || pair[0] === 'hw')) {
5619
+ hwComponents.push(part);
5620
+ }
5621
+ }
5622
+ const result = hwComponents.length > 0 ? hwComponents.sort().join('|') : (log.deviceId || 'anonymous-cluster');
5623
+ hwClusterCache.set(fp, result);
5624
+ return result;
5625
+ };
5358
5626
 
5359
5627
  for (const log of trafficData) {
5360
5628
  const devId = log.deviceId || 'anonymous';
5361
5629
  const ip = log.clientIp || log.ip || 'unknown';
5362
5630
  const subnet = getIpSubnet(ip) || 'unknown-subnet';
5631
+ const hwCluster = getHardwareCluster(log);
5363
5632
 
5364
5633
  const currentDeviceCount = deviceCounts.get(devId) || 0;
5365
5634
  const currentIpCount = ipCounts.get(ip) || 0;
5366
5635
  const currentSubnetCount = subnetCounts.get(subnet) || 0;
5636
+ const currentHwClusterCount = hardwareClusterCounts.get(hwCluster) || 0;
5367
5637
 
5368
- // Filtrage anti-poisoning strict sur 3 axes cumulatifs
5638
+ // Filtrage anti-poisoning strict sur 4 axes cumulatifs (incluant le clustering matériel stable)
5369
5639
  if (
5370
5640
  currentDeviceCount < maxLogsPerDevice &&
5371
5641
  (ip === 'unknown' || currentIpCount < maxLogsPerIp) &&
5372
- (subnet === 'unknown-subnet' || currentSubnetCount < maxLogsPerSubnet)
5642
+ (subnet === 'unknown-subnet' || currentSubnetCount < maxLogsPerSubnet) &&
5643
+ currentHwClusterCount < maxLogsPerHardwareCluster
5373
5644
  ) {
5374
5645
  deviceCounts.set(devId, currentDeviceCount + 1);
5375
5646
  if (ip !== 'unknown') ipCounts.set(ip, currentIpCount + 1);
5376
5647
  if (subnet !== 'unknown-subnet') subnetCounts.set(subnet, currentSubnetCount + 1);
5648
+ hardwareClusterCounts.set(hwCluster, currentHwClusterCount + 1);
5377
5649
 
5378
- tempSanitized.push(log);
5650
+ if (log.type === 'request_passed') {
5651
+ passedLogs.push(log);
5652
+ } else {
5653
+ suspiciousLogs.push(log);
5654
+ }
5379
5655
  }
5380
5656
  }
5381
5657
 
5382
- const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
5383
- const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
5384
-
5385
5658
  const minDataPoints = 200; // Seuil par défaut
5386
5659
  const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
5387
- const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
5388
- const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
5389
-
5390
- return [...suspiciousLogs, ...selectedPassed];
5660
+ if (passedLogs.length > maxPassedAllowed) {
5661
+ const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
5662
+ return [...suspiciousLogs, ...shuffledPassed.slice(0, maxPassedAllowed)];
5663
+ }
5664
+ return [...suspiciousLogs, ...passedLogs];
5391
5665
  }
5392
5666
  /**
5393
5667
  * Assainit et limite la taille/ancienneté des données de trafic pour éviter les fuites de mémoire.
@@ -5723,7 +5997,7 @@ export async function handleMetricsRequest(req, res, securityConfig) {
5723
5997
  if (typeof authorizationCallback === 'function') {
5724
5998
  const context = new RequestContext(
5725
5999
  req.ip,
5726
- req.path,
6000
+ sanitizeRedirectPath(req.path),
5727
6001
  req.headers,
5728
6002
  req.query,
5729
6003
  req.body,