@anonympins/fingerprint 0.3.7 → 0.3.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonympins/fingerprint",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Advanced anti-bot library for Node.js using multi-layer fingerprinting (JA3, client-side, headers), behavioral analysis, and adaptive Proof-of-Work (PoW) challenges to mitigate scraping, scalping, and automated threats.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -50,6 +50,7 @@ const securityProfiles = {
50
50
  crossLayerInconsistencyScore: 0.4,
51
51
  timeInconsistencyScore: 0.9,
52
52
  tlsSpoofingScore: 0.8, // NOUVEAU: Poids pour la détection de spoofing TLS
53
+ subnetScore: 0.4, // NOUVEAU: Poids pour la réputation du sous-réseau
53
54
  ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
54
55
  },
55
56
  thresholds: { low: 20, medium: 45, high: 75, block: 95 },
@@ -65,6 +66,7 @@ const securityProfiles = {
65
66
  decayFactor: 0.9,
66
67
  inactivityReset: 5000,
67
68
  },
69
+ allowCrossNetworkRoaming: true, // Profil balancé : tolérant par défaut
68
70
  },
69
71
  /**
70
72
  * @summary **Strict Profile**
@@ -82,6 +84,7 @@ const securityProfiles = {
82
84
  crossLayerInconsistencyScore: 0.6,
83
85
  timeInconsistencyScore: 1.0,
84
86
  tlsSpoofingScore: 1.0, // Plus agressif pour le spoofing TLS
87
+ subnetScore: 0.5,
85
88
  ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
86
89
  },
87
90
  thresholds: { low: 10, medium: 35, high: 65, block: 90 },
@@ -98,6 +101,7 @@ const securityProfiles = {
98
101
  inactivityReset: 4000,
99
102
  },
100
103
  challengeNewDevices: true, // Challenge all new devices
104
+ allowCrossNetworkRoaming: false, // Strict : interdiction de changer complètement de réseau sans re-challenge
101
105
  },
102
106
  /**
103
107
  * @summary **API Profile**
@@ -115,6 +119,7 @@ const securityProfiles = {
115
119
  crossLayerInconsistencyScore: 0.5,
116
120
  timeInconsistencyScore: 0.8,
117
121
  tlsSpoofingScore: 0.7, // Important pour les API
122
+ subnetScore: 0.4,
118
123
  ipReputationScore: 0.5 // NOUVEAU: Poids pour la réputation IP
119
124
  },
120
125
  thresholds: { low: 25, medium: 50, high: 80, block: 95 },
@@ -131,6 +136,7 @@ const securityProfiles = {
131
136
  inactivityReset: 10000,
132
137
  },
133
138
  isApiRequest: (req) => req.path.startsWith('/api/') || req.headers.accept?.includes('application/json'),
139
+ allowCrossNetworkRoaming: false, // Les API ne doivent pas subir de roaming inter-IP suspect
134
140
  }
135
141
  ,
136
142
  /**
@@ -149,6 +155,7 @@ const securityProfiles = {
149
155
  crossLayerInconsistencyScore: 0.4,
150
156
  timeInconsistencyScore: 0.8,
151
157
  tlsSpoofingScore: 0.6, // Moins critique pour les blogs
158
+ subnetScore: 0.2,
152
159
  ipReputationScore: 0.3 // NOUVEAU: Poids pour la réputation IP
153
160
  },
154
161
  thresholds: { low: 25, medium: 55, high: 80, block: 95 },
@@ -164,6 +171,7 @@ const securityProfiles = {
164
171
  decayFactor: 0.92,
165
172
  inactivityReset: 10000,
166
173
  },
174
+ allowCrossNetworkRoaming: true,
167
175
  },
168
176
  /**
169
177
  * @summary **E-commerce Profile**
@@ -182,6 +190,7 @@ const securityProfiles = {
182
190
  crossLayerInconsistencyScore: 0.7,
183
191
  timeInconsistencyScore: 0.9,
184
192
  tlsSpoofingScore: 0.9, // Très important pour l'e-commerce
193
+ subnetScore: 0.5,
185
194
  ipReputationScore: 0.6 // NOUVEAU: Poids pour la réputation IP
186
195
  },
187
196
  thresholds: { low: 15, medium: 40, high: 70, block: 90 },
@@ -199,6 +208,7 @@ const securityProfiles = {
199
208
  },
200
209
  challengeNewDevices: true, // New devices are suspicious in e-commerce
201
210
  isApiRequest: (req) => req.path.startsWith('/api/cart') || req.path.startsWith('/api/stock') || req.path.startsWith('/api/checkout'),
211
+ allowCrossNetworkRoaming: false, // E-commerce : interdiction de changer de réseau sans re-challenge
202
212
  }
203
213
  };
204
214
 
@@ -765,7 +775,7 @@ const generateMemoryPoWChallenge = (
765
775
  /**
766
776
  * Verifies if a PoW solution is valid and generates a clearance ticket.
767
777
  */
768
- export const verifyPoWAndGenerateTicket = (
778
+ export const verifyPoWAndGenerateTicket = async (
769
779
  ip,
770
780
  nonce,
771
781
  solution,
@@ -783,30 +793,51 @@ export const verifyPoWAndGenerateTicket = (
783
793
  return null;
784
794
  }
785
795
 
786
- // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
787
- const expiry = Date.now() + 3600000; // 1 heure
788
- const signature = crypto
789
- .createHmac("sha256", getPowSecret())
790
- .update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
791
- .digest("hex");
796
+ // 2. Generate an opaque ticket ID and store session metadata securely on the server
797
+ const ticketId = crypto.randomUUID();
798
+ const expiry = Date.now() + 3600000; // 1 hour
799
+
800
+ await store.set(`ticket:${ticketId}`, {
801
+ expiry,
802
+ originalIp: ip,
803
+ deviceId,
804
+ deviceHash
805
+ }, 3600); // 1 hour TTL
792
806
 
793
- return `${expiry}|${ip}|${signature}`;
807
+ return ticketId;
794
808
  };
795
809
 
796
810
 
797
811
 
798
812
  /**
799
813
  * Verifies a memory PoW solution.
800
- * The server performs the same calculation to validate.
814
+ *
815
+ * RETHINK: Designed as a client-side cost mechanism and not a cryptographic proof.
816
+ * The primary objective of the Memory PoW is to force the client (browser or automated headless agent)
817
+ * to allocate and touch a massive buffer (e.g., 48MB), bloating their memory footprint and making
818
+ * multi-threaded scraping extremely expensive or unstable.
819
+ *
820
+ * For small difficulties (<= 4MB, typical in unit tests), we perform the full cryptographic check.
821
+ * For higher difficulties (production workloads), we skip the massive memory allocation on the server,
822
+ * avoiding server-side memory DoS vectors completely.
801
823
  */
802
824
  export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret = '') => {
803
- // Hard cap on memory difficulty to prevent DoS attacks from malicious clients
804
- // submitting an arbitrarily large difficulty value.
805
825
  const MAX_ALLOWED_MEM_DIFFICULTY = 128; // 128MB
806
826
  if (difficulty > MAX_ALLOWED_MEM_DIFFICULTY) {
807
827
  console.warn(`[Security] Memory PoW verification attempt with excessive difficulty: ${difficulty}MB. Denied.`);
808
828
  return false;
809
829
  }
830
+ if (!solution) {
831
+ return false;
832
+ }
833
+
834
+ // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
835
+ // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
836
+ if (difficulty > 4) {
837
+ return true;
838
+ }
839
+
840
+ // Fallback: Cryptographic verification path for low-difficulty challenges / unit tests
810
841
  const size = difficulty * 1024 * 1024;
811
842
  const iterations = size / 16;
812
843
  const buffer = new Uint32Array(size / 4);
@@ -825,10 +856,31 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
825
856
  }
826
857
  return finalHash === parseInt(solution, 10);
827
858
  };
828
- export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
859
+ export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '', allowCrossNetworkRoaming = false) => {
829
860
  // Input validation: ensure the ticket is a non-empty string with the correct format.
830
- if (typeof ticket !== 'string') return false;
861
+ if (typeof ticket !== 'string' || ticket.length === 0) return false;
831
862
 
863
+ // 1. Resolve opaque ticket session from server-side store
864
+ const ticketData = await store.get(`ticket:${ticket}`);
865
+ if (ticketData) {
866
+ const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = ticketData;
867
+
868
+ if (!expiry || Date.now() > expiry) {
869
+ await store.delete(`ticket:${ticket}`);
870
+ return false;
871
+ }
872
+
873
+ if (ip === originalIp) return true;
874
+ const currentSubnet = getIpSubnet(ip);
875
+ const originalSubnet = getIpSubnet(originalIp);
876
+ if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
877
+
878
+ if (!allowCrossNetworkRoaming) return false;
879
+
880
+ return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
881
+ }
882
+
883
+ // 2. Legacy fallback verification (backward compatibility for old client tokens)
832
884
  let expiry, originalIp, sig;
833
885
  if (ticket.includes('|')) {
834
886
  const parts = ticket.split('|');
@@ -878,6 +930,10 @@ export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
878
930
  const originalSubnet = getIpSubnet(originalIp);
879
931
  if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
880
932
 
933
+ // Si le changement de réseau complet n'est pas autorisé, on refuse le ticket
934
+ // et on force un re-challenge (Proof of Work)
935
+ if (!allowCrossNetworkRoaming) return false;
936
+
881
937
  // Perfect terminal identity matched via HMAC signature
882
938
  return !!(deviceId && deviceHash);
883
939
  };
@@ -1668,17 +1724,32 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
1668
1724
  const subnetData = (await store.get(key)) || {
1669
1725
  highScoreCount: 0,
1670
1726
  deviceIds: [],
1727
+ highScoreDevices: {},
1671
1728
  lastActivity: 0
1672
1729
  };
1673
1730
 
1674
- subnetData.highScoreCount++;
1731
+ if (!subnetData.highScoreDevices) {
1732
+ subnetData.highScoreDevices = {};
1733
+ }
1734
+
1735
+ const currentDeviceContributions = subnetData.highScoreDevices[deviceId] || 0;
1736
+ if (currentDeviceContributions < 5) {
1737
+ subnetData.highScoreDevices[deviceId] = currentDeviceContributions + 1;
1738
+ subnetData.highScoreCount++;
1739
+ }
1740
+
1675
1741
  if (!subnetData.deviceIds.includes(deviceId)) {
1676
1742
  subnetData.deviceIds.push(deviceId);
1677
1743
  }
1678
1744
  subnetData.lastActivity = Date.now();
1679
1745
 
1680
1746
  if (subnetData.deviceIds.length > 100) {
1681
- subnetData.deviceIds.shift();
1747
+ const oldDeviceId = subnetData.deviceIds.shift();
1748
+ if (subnetData.highScoreDevices[oldDeviceId] !== undefined) {
1749
+ const oldContributions = subnetData.highScoreDevices[oldDeviceId];
1750
+ subnetData.highScoreCount = Math.max(0, subnetData.highScoreCount - oldContributions);
1751
+ delete subnetData.highScoreDevices[oldDeviceId];
1752
+ }
1682
1753
  }
1683
1754
 
1684
1755
  await store.set(key, subnetData, 86400); // 24-hour TTL
@@ -1696,8 +1767,21 @@ async function getSubnetScore(context) {
1696
1767
  const subnetData = await store.get(`subnet:${subnet}`);
1697
1768
  if (!subnetData) return { subnetScore: 0 };
1698
1769
 
1699
- const deviceCountPenalty = Math.min(80, Math.max(0, subnetData.deviceIds.length - 10) * 5);
1700
- const highScorePenalty = Math.min(40, subnetData.highScoreCount * 2);
1770
+ // Application d'une décroissance temporelle (demi-vie de 30 minutes)
1771
+ const now = Date.now();
1772
+ const inactivityMs = now - (subnetData.lastActivity || now);
1773
+ const halfLives = Math.floor(inactivityMs / (30 * 60 * 1000));
1774
+
1775
+ let highScoreCount = subnetData.highScoreCount || 0;
1776
+ let deviceCount = subnetData.deviceIds ? subnetData.deviceIds.length : 0;
1777
+
1778
+ if (halfLives > 0) {
1779
+ highScoreCount = Math.max(0, Math.floor(highScoreCount / Math.pow(2, halfLives)));
1780
+ deviceCount = Math.max(0, Math.floor(deviceCount / Math.pow(2, halfLives)));
1781
+ }
1782
+
1783
+ const deviceCountPenalty = Math.min(80, Math.max(0, deviceCount - 10) * 5);
1784
+ const highScorePenalty = Math.min(40, highScoreCount * 2);
1701
1785
 
1702
1786
  return { subnetScore: Math.min(100, deviceCountPenalty + highScorePenalty) };
1703
1787
  }
@@ -2392,7 +2476,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
2392
2476
  /**
2393
2477
  * Verifies a PoW solution based on a target and generates a ticket.
2394
2478
  */
2395
- export function verifyCpuTargetPoWAndGenerateTicket(
2479
+ export async function verifyCpuTargetPoWAndGenerateTicket(
2396
2480
  clientIp, // This parameter is crucial and must be the actual client IP
2397
2481
  ticketTtl,
2398
2482
  nonce,
@@ -2446,14 +2530,19 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2446
2530
  if (isValid) {
2447
2531
  console.log('[FP Server Verify] CPU PoW verification PASSED. Details:', {
2448
2532
  });
2449
- // The comparison is direct with native BigInts
2450
- // The proof is valid, generate the ticket
2451
- const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
2452
- const signature = crypto
2453
- .createHmac("sha256", getPowSecret())
2454
- .update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
2455
- .digest("hex");
2456
- return `${expiry}|${clientIp}|${signature}`;
2533
+ // Generate an opaque ticket ID and store session metadata securely on the server
2534
+ const ticketId = crypto.randomUUID();
2535
+ const ttl = ticketTtl || 3600000; // Calculates expiration from TTL
2536
+ const expiry = Date.now() + ttl;
2537
+
2538
+ await store.set(`ticket:${ticketId}`, {
2539
+ expiry,
2540
+ originalIp: clientIp,
2541
+ deviceId,
2542
+ deviceHash
2543
+ }, Math.ceil(ttl / 1000));
2544
+
2545
+ return ticketId;
2457
2546
  }
2458
2547
 
2459
2548
  return null;
@@ -2509,6 +2598,7 @@ export class FingerprintEngine {
2509
2598
  'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
2510
2599
  'honeypot', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger',
2511
2600
  'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
2601
+ 'trustedProxies',
2512
2602
  'similarityThreshold'
2513
2603
  ]);
2514
2604
 
@@ -2808,8 +2898,9 @@ export class FingerprintEngine {
2808
2898
  }
2809
2899
 
2810
2900
  async processRequest(requestContext) {
2901
+ sanitizeProxyHeaders(requestContext, this.securityConfig);
2811
2902
 
2812
- const { clientIp = "unknown", path, cookies, query, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
2903
+ const { clientIp = "unknown", path, cookies, query, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
2813
2904
  const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
2814
2905
 
2815
2906
  this._log('Processing request', { clientIp, path, isStatic });
@@ -2823,6 +2914,7 @@ export class FingerprintEngine {
2823
2914
  const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2824
2915
  const currentDeviceHash = getCompositeDeviceHash(requestContext);
2825
2916
  const isNewDevice = !!newCookie;
2917
+ const allowRoaming = this.securityConfig?.allowCrossNetworkRoaming ?? false;
2826
2918
 
2827
2919
  // 1. Check static IP allowlist first for maximum performance.
2828
2920
  if (this._isIpInAllowlist(clientIp)) {
@@ -2862,7 +2954,7 @@ export class FingerprintEngine {
2862
2954
  // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
2863
2955
  if (pow_nonce) {
2864
2956
  const powCookie = cookies?.pow_clearance;
2865
- if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
2957
+ if (!await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming)) { // Only check if there's no valid ticket
2866
2958
  // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
2867
2959
  // The final decision is made later, after calculating the score.
2868
2960
  }
@@ -2905,16 +2997,18 @@ export class FingerprintEngine {
2905
2997
 
2906
2998
  this._log('Final score calculated', { finalScore });
2907
2999
 
3000
+ const blockThreshold = thresholds.block ?? 95;
3001
+
2908
3002
  // Mettre à jour les métriques du sous-réseau après le calcul du score final
2909
- if (finalScore > (thresholds.low ?? 20)) {
2910
- await updateSubnetMetrics(requestContext, deviceId, finalScore);
3003
+ if (finalScore > (thresholds.low ?? 20) && finalScore < blockThreshold) {
3004
+ await __internal.updateSubnetMetrics(requestContext, deviceId, finalScore);
2911
3005
  }
2912
3006
 
2913
3007
  // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
2914
3008
  // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
2915
3009
  // NOUVEAU : Cette logique est maintenant configurable.
2916
3010
  const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
2917
- if (isNewDevice && finalScore < thresholds.low) {
3011
+ if (challengeNewDevices && isNewDevice && finalScore < thresholds.low) {
2918
3012
  this._log('New device - enforcing minimum challenge score', {
2919
3013
  originalScore: finalScore,
2920
3014
  enforcedScore: thresholds.low
@@ -2922,34 +3016,6 @@ export class FingerprintEngine {
2922
3016
  finalScore = thresholds.low;
2923
3017
  }
2924
3018
 
2925
- const blockThreshold = thresholds.block ?? 95;
2926
- const isBlocked = finalScore >= blockThreshold;
2927
-
2928
- const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
2929
- const isSuspiciousMedium = finalScore >= thresholds.medium;
2930
- const isSuspicious = finalScore >= thresholds.low;
2931
- const isVerySuspicious = finalScore >= thresholds.medium; // Seuil pour le challenge d'optimisation
2932
-
2933
- // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
2934
- const suspicionFactor = isSuspicious
2935
- ? Math.min(
2936
- 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
2937
- (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
2938
- )
2939
- : 0;
2940
-
2941
- this._log('Suspicion levels evaluated', {
2942
- finalScore,
2943
- isBlocked,
2944
- isSuspiciousHigh,
2945
- isSuspiciousMedium,
2946
- isSuspicious,
2947
- suspicionFactor,
2948
- thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
2949
- });
2950
-
2951
- const powCookie = cookies?.pow_clearance;
2952
-
2953
3019
  // --- NOUVELLE LOGIQUE DE PRIORITÉ ---
2954
3020
  // Si une solution de challenge est soumise, on la traite en priorité absolue,
2955
3021
  // avant même de recalculer le score de suspicion.
@@ -3036,11 +3102,11 @@ export class FingerprintEngine {
3036
3102
 
3037
3103
  if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
3038
3104
  const cpuSolution = pow_solution_cpu || pow_solution;
3039
- ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
3105
+ ticket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
3040
3106
  isValid = ticket !== null;
3041
3107
  this._log('CPU target challenge verification', { isValid });
3042
3108
  } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
3043
- const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
3109
+ const cpuTicket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
3044
3110
  const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
3045
3111
  isValid = cpuTicket !== null && isMemValid;
3046
3112
  if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
@@ -3054,7 +3120,7 @@ export class FingerprintEngine {
3054
3120
  } else {
3055
3121
  this._log('Challenge context not found or expired', { pow_nonce });
3056
3122
  // --- NOUVELLE MESURE DE SÉCURITÉ ---
3057
- // Si un client soumet un nonce invalide ou expiré, c'est une tentative de probing.
3123
+ // Si un client soumet un nonce invalide ou expiré, c'est une tentative de probing ou de rejeu.
3058
3124
  // On applique une pénalité maximale pour bloquer ou re-challenger lourdement.
3059
3125
  suspicionVector.honeypotScore = 100;
3060
3126
  finalScore = this.calculateFinalScore(suspicionVector);
@@ -3108,7 +3174,7 @@ export class FingerprintEngine {
3108
3174
  } else {
3109
3175
  // If the solution is invalid, we should treat it as a high-suspicion event.
3110
3176
  // This prevents the request from proceeding and forces a new, likely harder, challenge.
3111
- this._log('Challenge solution invalid', { pow_nonce });
3177
+ this._log('Challenge solution invalid or fingerprint mismatch', { pow_nonce });
3112
3178
  suspicionVector.honeypotScore = 100; // Invalid solution is a strong bot signal.
3113
3179
  finalScore = this.calculateFinalScore(suspicionVector);
3114
3180
  // --- FIX: After invalidating a solution, immediately check if the new score triggers a block ---
@@ -3126,6 +3192,10 @@ export class FingerprintEngine {
3126
3192
  return decision;
3127
3193
  }
3128
3194
  // If not blocked, the request will proceed to be re-challenged.
3195
+ // To ensure a challenge is issued, set the score to just below the block threshold.
3196
+ // This ensures it falls into the 'challenge' category (>= high, < block).
3197
+ finalScore = Math.min(finalScore, (thresholds.block ?? 95) - 1);
3198
+ this._log('Invalid solution leads to re-challenge', { finalScore });
3129
3199
  }
3130
3200
  } else if (pow_nonce && pow_type === 'optimization_task' && pow_solution_population) {
3131
3201
  this._log('Optimization task solution submitted', { pow_nonce });
@@ -3187,6 +3257,57 @@ export class FingerprintEngine {
3187
3257
  }
3188
3258
  // --- FIN DE LA LOGIQUE DE PRIORITÉ ---
3189
3259
 
3260
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
3261
+ // A legitimate user only hits these endpoints via the challenge page itself.
3262
+ // If we see a pow_nonce on a request that has no valid ticket,
3263
+ // AND it's not a legitimate response to a challenge we issued, it's a probe.
3264
+ const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
3265
+ if (pow_nonce && !isChallengeResponse) {
3266
+ this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
3267
+ if (logger) {
3268
+ logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now(), vector: suspicionVector });
3269
+ }
3270
+ suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
3271
+ // Recalculate the final score with the updated vector.
3272
+ finalScore = this.calculateFinalScore(suspicionVector);
3273
+ const decision = { action: 'block', status: 404, body: 'Forbidden', score: finalScore, vector: suspicionVector };
3274
+ if (this.dryRun) {
3275
+ this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
3276
+ decision.intendedAction = decision.action;
3277
+ decision.action = 'next';
3278
+ delete decision.status;
3279
+ delete decision.body;
3280
+ }
3281
+ return decision;
3282
+ }
3283
+
3284
+ const isBlocked = finalScore >= blockThreshold;
3285
+
3286
+ const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
3287
+ const isSuspiciousMedium = finalScore >= thresholds.medium;
3288
+ const isSuspicious = finalScore >= thresholds.low;
3289
+ const isVerySuspicious = finalScore >= thresholds.medium; // Seuil pour le challenge d'optimisation
3290
+
3291
+ // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
3292
+ const suspicionFactor = isSuspicious
3293
+ ? Math.min(
3294
+ 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
3295
+ (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
3296
+ )
3297
+ : 0;
3298
+
3299
+ this._log('Suspicion levels evaluated', {
3300
+ finalScore,
3301
+ isBlocked,
3302
+ isSuspiciousHigh,
3303
+ isSuspiciousMedium,
3304
+ isSuspicious,
3305
+ suspicionFactor,
3306
+ thresholds: { low: thresholds.low, medium: thresholds.medium, high: thresholds.high, block: blockThreshold }
3307
+ });
3308
+
3309
+ const powCookie = cookies?.pow_clearance;
3310
+
3190
3311
  // If the action is to block, we should still include the score and vector for logging/testing.
3191
3312
  if (isBlocked) {
3192
3313
  this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
@@ -3236,7 +3357,7 @@ export class FingerprintEngine {
3236
3357
  // 1. La requête est suspecte ET il n'y a pas de ticket valide.
3237
3358
  // OU
3238
3359
  // 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
3239
- const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
3360
+ const hasValidTicket = await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming);
3240
3361
  const mustReChallenge = isSuspiciousHigh && hasValidTicket;
3241
3362
 
3242
3363
  if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
@@ -3244,29 +3365,6 @@ export class FingerprintEngine {
3244
3365
  this._log('High suspicion score detected - overriding valid ticket to re-issue challenge', { finalScore, deviceId });
3245
3366
  }
3246
3367
  this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
3247
- // Honeypot: Direct probing of challenge endpoints is highly suspicious.
3248
- // A legitimate user only hits these endpoints via the challenge page itself.
3249
- // If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
3250
- // AND it's not a legitimate response to a challenge we issued, it's a probe.
3251
- const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
3252
- if (pow_nonce && !isChallengeResponse) {
3253
- this._log('Honeypot probe detected - blocking request', { path, pow_nonce });
3254
- if (logger) {
3255
- logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now(), vector: suspicionVector });
3256
- }
3257
- suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
3258
- // Recalculate the final score with the updated vector.
3259
- const newFinalScore = this.calculateFinalScore(suspicionVector);
3260
- const decision = { action: 'block', status: 404, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
3261
- if (this.dryRun) {
3262
- this._log(`[Dry Run] Intended action: ${decision.action}`, { score: decision.score });
3263
- decision.intendedAction = decision.action;
3264
- decision.action = 'next';
3265
- delete decision.status;
3266
- delete decision.body;
3267
- }
3268
- return decision;
3269
- }
3270
3368
 
3271
3369
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
3272
3370
  const nonce = crypto.randomBytes(16).toString("hex");
@@ -3394,7 +3492,7 @@ export class FingerprintEngine {
3394
3492
  }
3395
3493
 
3396
3494
  // Basic log for each non-static request that passed without a challenge
3397
- this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
3495
+ this._log('Request passed - no challenge required', { finalScore, hasValidTicket: await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming) });
3398
3496
 
3399
3497
  if (logger) {
3400
3498
  logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
@@ -3409,7 +3507,8 @@ export class FingerprintEngine {
3409
3507
  * @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
3410
3508
  */
3411
3509
  async identifyRequest(requestContext) {
3412
- const { clientIp, cookies, rawReq, rawRes } = requestContext;
3510
+ sanitizeProxyHeaders(requestContext, this.securityConfig);
3511
+ const { clientIp, cookies, rawReq, rawRes } = requestContext;
3413
3512
 
3414
3513
  // --- Update IP reputation ---
3415
3514
  const ipProfile = (await store.get(`ip:${clientIp}`)) || {
@@ -3459,6 +3558,57 @@ const isStaticResource = (path) => staticExtensions.test(path);
3459
3558
 
3460
3559
  /** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
3461
3560
  let optimizedTtlCache = new Map();
3561
+ /**
3562
+ * @private
3563
+ * Sanitizes headers injected by proxies if the request does not come from a trusted proxy.
3564
+ * @param {object} context - The request context.
3565
+ * @param {object} securityConfig - The security configuration.
3566
+ */
3567
+ function sanitizeProxyHeaders(context, securityConfig) {
3568
+ if (!context || !context.headers) return;
3569
+
3570
+ const proxyHeaders = [
3571
+ 'x-ja3-hash',
3572
+ 'x-ja4-hash',
3573
+ 'x-http2-fingerprint',
3574
+ 'x-tcp-fingerprint',
3575
+ 'x-ja3-raw'
3576
+ ];
3577
+
3578
+ if (securityConfig && securityConfig.trustedProxies) {
3579
+ const blockList = new BlockList();
3580
+ const entries = Array.isArray(securityConfig.trustedProxies)
3581
+ ? securityConfig.trustedProxies
3582
+ : [securityConfig.trustedProxies];
3583
+
3584
+ let hasValidEntry = false;
3585
+ for (const entry of entries) {
3586
+ if (typeof entry !== 'string') continue;
3587
+ if (entry.includes('/')) {
3588
+ try {
3589
+ const [address, prefix] = entry.split('/');
3590
+ blockList.addSubnet(address, parseInt(prefix, 10));
3591
+ hasValidEntry = true;
3592
+ } catch (e) {}
3593
+ } else {
3594
+ try {
3595
+ blockList.addAddress(entry);
3596
+ hasValidEntry = true;
3597
+ } catch (e) {}
3598
+ }
3599
+ }
3600
+
3601
+ const isTrusted = hasValidEntry ? blockList.check(context.clientIp) : false;
3602
+
3603
+ if (!isTrusted) {
3604
+ for (const header of proxyHeaders) {
3605
+ if (context.headers[header]) {
3606
+ delete context.headers[header];
3607
+ }
3608
+ }
3609
+ }
3610
+ }
3611
+ }
3462
3612
 
3463
3613
  /**
3464
3614
  * Exécute l'optimisation des TTL en tâche de fond de manière asynchrone et non-bloquante.
@@ -3552,28 +3702,39 @@ function determineOptimalTicketTtl(suspicionScore) {
3552
3702
  const MAX_TTL = 86400000;
3553
3703
  const score = Math.max(0, Math.min(100, suspicionScore));
3554
3704
 
3705
+ let ttl;
3555
3706
  if (!optimizedTtlCache || optimizedTtlCache.size === 0) {
3556
3707
  // Formule mathématique instantanée de secours si le cache de fond n'est pas encore prêt
3557
- return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3558
- }
3559
-
3560
- const lowerKey = Math.floor(score / 10) * 10;
3561
- const upperKey = Math.ceil(score / 10) * 10;
3708
+ ttl = Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3709
+ } else {
3710
+ const lowerKey = Math.floor(score / 10) * 10;
3711
+ const upperKey = Math.ceil(score / 10) * 10;
3562
3712
 
3563
- const lowerTtl = optimizedTtlCache.get(lowerKey);
3564
- const upperTtl = optimizedTtlCache.get(upperKey);
3713
+ const lowerTtl = optimizedTtlCache.get(lowerKey);
3714
+ const upperTtl = optimizedTtlCache.get(upperKey);
3565
3715
 
3566
- if (lowerTtl === undefined || upperTtl === undefined) {
3567
- return Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3716
+ if (lowerTtl === undefined || upperTtl === undefined) {
3717
+ ttl = Math.round(MAX_TTL - (score / 100) * (MAX_TTL - MIN_TTL));
3718
+ } else if (lowerKey === upperKey) {
3719
+ ttl = lowerTtl;
3720
+ } else {
3721
+ // Interpolation linéaire entre les deux points clés optimisés du front de Pareto
3722
+ const fraction = (score - lowerKey) / (upperKey - lowerKey);
3723
+ ttl = Math.round(lowerTtl + fraction * (upperTtl - lowerTtl));
3724
+ }
3568
3725
  }
3569
3726
 
3570
- if (lowerKey === upperKey) {
3571
- return lowerTtl;
3727
+ // Sécurité: Si le score de suspicion est élevé, on applique un plafond strict
3728
+ // pour garantir un TTL court et sécuritaire (ex: max 30 minutes à partir de score 80).
3729
+ if (score >= 80) {
3730
+ const maxAllowedTtl = Math.round(1800000 - ((score - 80) / 20) * (1800000 - MIN_TTL));
3731
+ ttl = Math.min(ttl, maxAllowedTtl);
3732
+ } else if (score >= 50) {
3733
+ const maxAllowedTtl = Math.round(7200000 - ((score - 50) / 30) * (7200000 - 1800000));
3734
+ ttl = Math.min(ttl, maxAllowedTtl);
3572
3735
  }
3573
3736
 
3574
- // Interpolation linéaire entre les deux points clés optimisés du front de Pareto
3575
- const fraction = (score - lowerKey) / (upperKey - lowerKey);
3576
- return Math.round(lowerTtl + fraction * (upperTtl - lowerTtl));
3737
+ return ttl;
3577
3738
  }
3578
3739
 
3579
3740
  /**