@anonympins/fingerprint 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -41
- package/fingerprint.client.js +470 -458
- package/fingerprint.js +608 -267
- package/library.js +1619 -1577
- package/package.json +1 -1
- package/pow.solver.js +177 -2
package/fingerprint.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { BlockList } from "node:net";
|
|
3
3
|
import dns from "node:dns/promises";
|
|
4
|
+
import { problemManager } from "./problem-manager.js";
|
|
4
5
|
import { Optimization } from "./library.js";
|
|
5
6
|
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
@@ -35,8 +36,8 @@ const getPowSolverCode = () => {
|
|
|
35
36
|
console.warn('Could not load pow.solver.js for inlining, using fallback inline code');
|
|
36
37
|
// Fallback inline code if file cannot be loaded
|
|
37
38
|
return `(function(global){
|
|
38
|
-
async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
|
|
39
|
-
const cpuTarget = BigInt(target);
|
|
39
|
+
async function solveCpuTargetInline(clientIp, nonce, target, clientSecret, progressCallback){
|
|
40
|
+
const cpuTarget = typeof target === 'bigint' ? target : BigInt('0x' + target);
|
|
40
41
|
let cpuSolution = 0;
|
|
41
42
|
const ipPart = clientIp || '';
|
|
42
43
|
while(true){
|
|
@@ -191,15 +192,37 @@ function getHeaderSignature(context) {
|
|
|
191
192
|
}
|
|
192
193
|
return cyrb53(headerKeys.join(','));
|
|
193
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Returns the client-side fingerprint if available, otherwise computes a server-side hash.
|
|
198
|
+
* This aligns with the test's expectation for prioritization.
|
|
199
|
+
* @param {object} context The request context.
|
|
200
|
+
* @returns {string} The device fingerprint.
|
|
201
|
+
*/
|
|
194
202
|
export function getDeviceHash(context) {
|
|
195
|
-
// Prioritize the rich client-side fingerprint if provided.
|
|
196
203
|
const clientFp = context.headers['x-device-fingerprint'];
|
|
197
|
-
if (clientFp && typeof clientFp === 'string'
|
|
204
|
+
if (clientFp && typeof clientFp === 'string') {
|
|
198
205
|
return clientFp;
|
|
199
206
|
}
|
|
207
|
+
// Fallback to composite hash if client fingerprint is not available
|
|
208
|
+
return getCompositeDeviceHash(context);
|
|
209
|
+
}
|
|
200
210
|
|
|
211
|
+
export function getCompositeDeviceHash(context) {
|
|
201
212
|
const srv = new FingerprintBuilder();
|
|
202
213
|
|
|
214
|
+
// Si un fingerprint client est fourni, on l'intègre comme un signal fort,
|
|
215
|
+
// mais on ne lui fait pas aveuglément confiance. On continue de construire
|
|
216
|
+
// notre propre fingerprint serveur pour le comparer.
|
|
217
|
+
// Un attaquant qui forge un `clientFp` mais oublie de forger les en-têtes
|
|
218
|
+
// correspondants sera détecté par l'incohérence.
|
|
219
|
+
const clientFp = context.headers['x-device-fingerprint'];
|
|
220
|
+
if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
|
|
221
|
+
// On ajoute le hash du fingerprint client comme un composant du fingerprint serveur.
|
|
222
|
+
// Si le clientFp change, le hash serveur changera aussi.
|
|
223
|
+
srv.add("client_fp_hash", clientFp);
|
|
224
|
+
}
|
|
225
|
+
|
|
203
226
|
// 1. SIGNAL FORT: User Agent (poids élevé)
|
|
204
227
|
const ua = context.headers["user-agent"];
|
|
205
228
|
if (ua) {
|
|
@@ -376,15 +399,15 @@ const generateTspChallenge = (
|
|
|
376
399
|
<div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
|
|
377
400
|
<script>${solverCode}</script>
|
|
378
401
|
<script>
|
|
379
|
-
const cities = ${citiesJson};
|
|
380
|
-
const nonce =
|
|
402
|
+
const cities = ${citiesJson}; // Safe, as it's JSON
|
|
403
|
+
const nonce = ${JSON.stringify(nonce)}; // Safe
|
|
381
404
|
const targetMaxDistance = ${targetMaxDistance};
|
|
382
405
|
|
|
383
406
|
async function solve() {
|
|
384
407
|
const result = await window.solveTspChallenge(cities, targetMaxDistance);
|
|
385
408
|
|
|
386
409
|
if (result.distance <= targetMaxDistance) {
|
|
387
|
-
window.location.href =
|
|
410
|
+
window.location.href = ${JSON.stringify(path)} + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(result.path);
|
|
388
411
|
} else {
|
|
389
412
|
document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
|
|
390
413
|
}
|
|
@@ -740,25 +763,159 @@ function getHoneypotScore(context, honeypotConfig = {}) {
|
|
|
740
763
|
return { honeypotScore: 0 };
|
|
741
764
|
}
|
|
742
765
|
|
|
766
|
+
/**
|
|
767
|
+
* @private
|
|
768
|
+
* Map of malicious patterns grouped by type.
|
|
769
|
+
*/
|
|
770
|
+
const injectionPatterns = {
|
|
771
|
+
// SQL/NoSQL injections, including time-based attacks
|
|
772
|
+
sql: /(\$ne|' *OR *'1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i,
|
|
773
|
+
// Log4Shell (JNDI injection)
|
|
774
|
+
log4shell: /\$\{jndi:(ldap|rmi|dns):/i,
|
|
775
|
+
// Server-Side Template Injection (SSTI) for engines like Jinja2, Twig, etc.
|
|
776
|
+
ssti: /\{\{.*\}\}|\{%.*%\}/,
|
|
777
|
+
// XML External Entity (XXE) injection
|
|
778
|
+
xxe: /<!ENTITY\s+.*SYSTEM/i,
|
|
779
|
+
// Path Traversal
|
|
780
|
+
traversal: /(\.\.\/|\.\.\\)/,
|
|
781
|
+
// Remote Command Execution (RCE)
|
|
782
|
+
rce: /`.*`|(^|[\n;&|]\s*)(ping|ls|whoami|cat|rm|ncat|nc|bash|sh|powershell|cmd)\b/i,
|
|
783
|
+
};
|
|
784
|
+
|
|
743
785
|
/**
|
|
744
786
|
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
745
787
|
* @param {object} context - Le contexte de la requête, contenant les en-têtes.
|
|
746
788
|
* @returns {{behaviorScore: number}}
|
|
747
789
|
*/
|
|
748
790
|
function getBehaviorScore(context) {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
791
|
+
const behaviorHeader = context.headers["x-behavior-metrics"];
|
|
792
|
+
if (!behaviorHeader) {
|
|
793
|
+
return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
const metrics = JSON.parse(behaviorHeader);
|
|
798
|
+
let score = 0;
|
|
799
|
+
|
|
800
|
+
// 1. Pénalité maximale si un honeypot client a été déclenché.
|
|
801
|
+
if (metrics.honeypotInteraction) {
|
|
802
|
+
return { behaviorScore: 100 };
|
|
752
803
|
}
|
|
753
804
|
|
|
805
|
+
// 2. Pénalité pour absence totale d'interaction.
|
|
806
|
+
if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) {
|
|
807
|
+
score += 40;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 3. Vérification de la plausibilité et de la distribution des métriques.
|
|
811
|
+
// Un bot pourrait envoyer des valeurs aléatoires, mais elles ne suivront probablement pas
|
|
812
|
+
// des distributions naturelles (comme la loi de Benford pour les premiers chiffres).
|
|
813
|
+
|
|
814
|
+
// Plausibilité de l'entropie de la souris
|
|
815
|
+
if (metrics.mouseEntropy > 0 && metrics.mouseEntropy < 0.1) score += 20; // Entropie très faible, suspect.
|
|
816
|
+
if (metrics.mouseEntropy > 500) score += 30; // Entropie irréalistement élevée.
|
|
817
|
+
|
|
818
|
+
// Plausibilité de la latence de frappe
|
|
819
|
+
if (metrics.keystrokeLatency > 0 && metrics.keystrokeLatency < 40) score += 25; // Frappe trop rapide pour un humain.
|
|
820
|
+
if (metrics.keystrokeLatency > 1000) score += 15; // Latence très élevée, peut être un script lent.
|
|
821
|
+
|
|
822
|
+
// 4. Analyse de la distribution avec la loi de Benford (si les valeurs sont non nulles).
|
|
823
|
+
// On utilise les décimales pour avoir plus de chiffres à analyser.
|
|
824
|
+
const mouseEntropyStr = String(metrics.mouseEntropy).replace(".", "");
|
|
825
|
+
const keystrokeLatencyStr = String(metrics.keystrokeLatency).replace(".", "");
|
|
826
|
+
|
|
827
|
+
if (mouseEntropyStr.length > 2) {
|
|
828
|
+
const mouseDeviation = Optimization.Operators.benfordTest(mouseEntropyStr);
|
|
829
|
+
// Une déviation > 0.15 est fortement suspecte.
|
|
830
|
+
if (mouseDeviation > 0.15) score += 40;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (keystrokeLatencyStr.length > 2) {
|
|
834
|
+
const keystrokeDeviation = Optimization.Operators.benfordTest(keystrokeLatencyStr);
|
|
835
|
+
if (keystrokeDeviation > 0.15) score += 40;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return { behaviorScore: Math.min(100, score) };
|
|
839
|
+
} catch (e) {
|
|
840
|
+
return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Calcule un score basé sur l'incohérence temporelle entre le client et le serveur pour détecter les attaques par rejeu.
|
|
846
|
+
* @param {object} context - Le contexte de la requête, contenant le timestamp de la requête.
|
|
847
|
+
* @param {object} metrics - Les métriques comportementales parsées depuis le client.
|
|
848
|
+
* @returns {{timeInconsistencyScore: number}}
|
|
849
|
+
*/
|
|
850
|
+
function getTimeInconsistencyScore(context, metrics) {
|
|
851
|
+
const REPLAY_THRESHOLD_MS = 5000; // 5 secondes
|
|
852
|
+
let score = 0;
|
|
853
|
+
|
|
854
|
+
if (metrics.clientTimestamp && context.requestTimestamp) {
|
|
855
|
+
const timeDelta = context.requestTimestamp - metrics.clientTimestamp;
|
|
856
|
+
|
|
857
|
+
// Un delta très grand est un signal fort d'attaque par rejeu.
|
|
858
|
+
// Un delta négatif peut arriver si l'horloge du client est en avance, on l'ignore.
|
|
859
|
+
if (timeDelta > REPLAY_THRESHOLD_MS) {
|
|
860
|
+
// La pénalité est proportionnelle au dépassement du seuil.
|
|
861
|
+
score = Math.min(100, (timeDelta / REPLAY_THRESHOLD_MS - 1) * 50);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { timeInconsistencyScore: score };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Calcule un score d'incohérence entre les données du fingerprint client et les en-têtes serveur.
|
|
869
|
+
* @param {object} context - Le contexte de la requête.
|
|
870
|
+
* @returns {{crossLayerInconsistencyScore: number}}
|
|
871
|
+
*/
|
|
872
|
+
function getCrossLayerInconsistency(context) {
|
|
754
873
|
try {
|
|
755
|
-
const
|
|
874
|
+
const clientFpString = context.headers['x-device-fingerprint'];
|
|
875
|
+
if (!clientFpString) return { crossLayerInconsistencyScore: 0 };
|
|
876
|
+
|
|
877
|
+
const clientFpMap = new Map(clientFpString.split("|").map(part => part.split(":")));
|
|
878
|
+
const ua = context.headers["user-agent"] || '';
|
|
756
879
|
let score = 0;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
880
|
+
|
|
881
|
+
// 1. Incohérence de l'OS
|
|
882
|
+
const clientOsHash = clientFpMap.get('os');
|
|
883
|
+
if (clientOsHash) {
|
|
884
|
+
const serverOsParts = parseUserAgent(ua);
|
|
885
|
+
if (serverOsParts.os && clientOsHash !== cyrb53(serverOsParts.os)) {
|
|
886
|
+
// Exemple: le client prétend être 'Windows' mais le UA est 'macOS'.
|
|
887
|
+
score += 50;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// 2. Incohérence de l'écran (si les Client Hints sont disponibles)
|
|
892
|
+
const clientScreenHash = clientFpMap.get('scr');
|
|
893
|
+
const viewportWidth = context.headers['sec-ch-viewport-width'];
|
|
894
|
+
if (clientScreenHash && viewportWidth) {
|
|
895
|
+
const clientWidth = clientFpMap.get('scr')?.split('x')[0];
|
|
896
|
+
// Ce n'est pas une comparaison directe, mais un bot pourrait oublier de forger les CH.
|
|
897
|
+
// Si le client FP a une largeur et que le CH en a une autre, c'est suspect.
|
|
898
|
+
// Cette vérification est basique et pourrait être affinée.
|
|
899
|
+
if (clientWidth && clientWidth !== viewportWidth) {
|
|
900
|
+
score += 20;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 3. Incohérence du GPU/Canvas et JA3
|
|
905
|
+
// Un attaquant sophistiqué peut forger le canvas, mais il est très difficile de forger
|
|
906
|
+
// le JA3 qui dépend de la librairie TLS. Une forte incohérence ici est un signal fort.
|
|
907
|
+
const clientGpuHash = clientFpMap.get('gpu');
|
|
908
|
+
const ja3 = getJa3Hash(context);
|
|
909
|
+
if (clientGpuHash && ja3) {
|
|
910
|
+
// Une vraie implémentation nécessiterait une base de données mappant les GPU connus
|
|
911
|
+
// à des signatures JA3 typiques. Pour l'exemple, on simule une pénalité si les deux
|
|
912
|
+
// sont présents mais que le score de cohérence global est déjà faible.
|
|
913
|
+
// (Cette logique est déjà en partie couverte par le `consistencyScore`).
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { crossLayerInconsistencyScore: Math.min(100, score) };
|
|
760
917
|
} catch (e) {
|
|
761
|
-
return {
|
|
918
|
+
return { crossLayerInconsistencyScore: 10 }; // Erreur de parsing = suspect.
|
|
762
919
|
}
|
|
763
920
|
}
|
|
764
921
|
|
|
@@ -779,7 +936,9 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
779
936
|
scrapeThreshold = 1000, scrapeWeight = 20, scrapeBurstWeight = 40,
|
|
780
937
|
historySize = 10,
|
|
781
938
|
decayFactor = 0.9,
|
|
782
|
-
inactivityReset = 30000,
|
|
939
|
+
inactivityReset = 30000, // 30 secondes
|
|
940
|
+
// Nouveaux paramètres pour l'analyse de distribution
|
|
941
|
+
benfordMinSamples = 15, benfordWeight = 50,
|
|
783
942
|
// Nouveau paramètre pour la détection de séquences
|
|
784
943
|
sequenceLength = 3, sequenceWeight = 60
|
|
785
944
|
} = patternConfig;
|
|
@@ -793,9 +952,10 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
793
952
|
const currentQueryString = params.toString();
|
|
794
953
|
|
|
795
954
|
// Initialize request history if it doesn't exist
|
|
796
|
-
if (!deviceData.requestHistory)
|
|
797
|
-
|
|
798
|
-
|
|
955
|
+
if (!deviceData.requestHistory) deviceData.requestHistory = [];
|
|
956
|
+
// NOUVEAU: S'assurer que timingHistory est toujours initialisé.
|
|
957
|
+
// Cette vérification est séparée car deviceData peut exister avec requestHistory mais sans timingHistory.
|
|
958
|
+
if (!deviceData.timingHistory) deviceData.timingHistory = [];
|
|
799
959
|
|
|
800
960
|
const history = deviceData.requestHistory;
|
|
801
961
|
let score = 0;
|
|
@@ -803,7 +963,10 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
803
963
|
// --- Analyze patterns based on the last few requests ---
|
|
804
964
|
if (history.length > 0) {
|
|
805
965
|
const lastRequest = history[history.length - 1];
|
|
806
|
-
const timeSinceLast = now - lastRequest.timestamp;
|
|
966
|
+
const timeSinceLast = now - lastRequest.timestamp;
|
|
967
|
+
|
|
968
|
+
// Stocker le délai pour l'analyse de distribution
|
|
969
|
+
deviceData.timingHistory.push(timeSinceLast);
|
|
807
970
|
|
|
808
971
|
// 1. Velocity Check: Penalize requests that are too fast to be human.
|
|
809
972
|
if (timeSinceLast < velocityThreshold) { // 150 < 200 -> true
|
|
@@ -836,6 +999,16 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
836
999
|
);
|
|
837
1000
|
if (isRepeating) score += sequenceWeight;
|
|
838
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
// 5. (NOUVEAU) Analyse de la distribution des délais avec la loi de Benford
|
|
1004
|
+
if (deviceData.timingHistory.length >= benfordMinSamples) {
|
|
1005
|
+
// On concatène tous les délais en une seule chaîne de chiffres.
|
|
1006
|
+
const benfordDeviation = Optimization.Operators.benfordTest(deviceData.timingHistory);
|
|
1007
|
+
|
|
1008
|
+
// Une déviation > 0.15 est suspecte. On peut pondérer la pénalité.
|
|
1009
|
+
// Une déviation de 0.3 (très suspecte) donnerait un score de 100 (0.3 / 0.3 * 100).
|
|
1010
|
+
score += Math.min(100, (benfordDeviation / 0.3) * benfordWeight);
|
|
1011
|
+
}
|
|
839
1012
|
}
|
|
840
1013
|
|
|
841
1014
|
// --- Update history ---
|
|
@@ -849,17 +1022,20 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
|
|
|
849
1022
|
if (history.length > historySize) {
|
|
850
1023
|
history.shift();
|
|
851
1024
|
}
|
|
1025
|
+
if (deviceData.timingHistory.length > benfordMinSamples * 2) { // Garder un historique plus long pour Benford
|
|
1026
|
+
deviceData.timingHistory.shift();
|
|
1027
|
+
}
|
|
852
1028
|
|
|
853
1029
|
// Decay the score over time if behavior becomes normal again.
|
|
854
1030
|
// We can store the score in deviceData and decay it.
|
|
855
|
-
deviceData.lastPatternScore = (deviceData.lastPatternScore || 0) * decayFactor + score; // Decay old score and add new
|
|
1031
|
+
deviceData.lastPatternScore = Math.min(100, (deviceData.lastPatternScore || 0) * decayFactor + score); // Decay old score and add new, plafonné à 100
|
|
856
1032
|
|
|
857
1033
|
// If there hasn't been a request in a while, reset the pattern score.
|
|
858
1034
|
if (history.length > 1 && (now - history[history.length - 2].timestamp > inactivityReset)) { // X ms inactivity
|
|
859
1035
|
deviceData.lastPatternScore = 0;
|
|
860
1036
|
}
|
|
861
1037
|
|
|
862
|
-
return { requestPatternScore:
|
|
1038
|
+
return { requestPatternScore: deviceData.lastPatternScore };
|
|
863
1039
|
}
|
|
864
1040
|
|
|
865
1041
|
const trapUrlTemplates = [
|
|
@@ -957,7 +1133,7 @@ export const configureStore = (externalStore) => {
|
|
|
957
1133
|
*/
|
|
958
1134
|
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
959
1135
|
const existingDeviceId = context.cookies?.device_id;
|
|
960
|
-
const currentDeviceHash =
|
|
1136
|
+
const currentDeviceHash = getCompositeDeviceHash(context); // Use the composite hash for consistency checks
|
|
961
1137
|
let deviceId = existingDeviceId;
|
|
962
1138
|
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
963
1139
|
let deviceData = null;
|
|
@@ -1022,7 +1198,7 @@ async function getBehavioralIndicators(context, deviceData) {
|
|
|
1022
1198
|
const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
|
|
1023
1199
|
const isSharedIp = ipProfile.type === "shared";
|
|
1024
1200
|
|
|
1025
|
-
const currentFpHash =
|
|
1201
|
+
const currentFpHash = getCompositeDeviceHash(context); // Use the composite hash for behavioral indicators
|
|
1026
1202
|
|
|
1027
1203
|
// --- Behavior analysis (Change frequency) ---
|
|
1028
1204
|
if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
|
|
@@ -1108,6 +1284,12 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1108
1284
|
// On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
|
|
1109
1285
|
const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
|
|
1110
1286
|
|
|
1287
|
+
// NOUVEAU: On calcule le score d'incohérence temporelle.
|
|
1288
|
+
const { timeInconsistencyScore } = getTimeInconsistencyScore(context, JSON.parse(context.headers['x-behavior-metrics'] || '{}'));
|
|
1289
|
+
|
|
1290
|
+
// NOUVEAU: On calcule le score d'incohérence entre les couches.
|
|
1291
|
+
const { crossLayerInconsistencyScore } = getCrossLayerInconsistency(context);
|
|
1292
|
+
|
|
1111
1293
|
const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
|
|
1112
1294
|
|
|
1113
1295
|
// Save the updated device state to the store
|
|
@@ -1120,7 +1302,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
1120
1302
|
deviceData.ips = new Set(deviceData.ips);
|
|
1121
1303
|
}
|
|
1122
1304
|
// Le vecteur de suspicion est maintenant complet.
|
|
1123
|
-
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore };
|
|
1305
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore };
|
|
1124
1306
|
};
|
|
1125
1307
|
|
|
1126
1308
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -1235,13 +1417,13 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
|
|
|
1235
1417
|
<script>${solverCode}</script>
|
|
1236
1418
|
<script>
|
|
1237
1419
|
async function solve() {
|
|
1238
|
-
const clientIp =
|
|
1239
|
-
const nonce =
|
|
1420
|
+
const clientIp = ${JSON.stringify(clientIp)};
|
|
1421
|
+
const nonce = ${JSON.stringify(nonce)};
|
|
1240
1422
|
const cpuTarget = BigInt("0x${target}");
|
|
1241
1423
|
const solution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, null, (progress) => {
|
|
1242
1424
|
// Optional progress callback
|
|
1243
1425
|
});
|
|
1244
|
-
window.location.href =
|
|
1426
|
+
window.location.href = ${JSON.stringify(path)} + "?pow_type=cpu_target&pow_nonce=" + nonce + "&pow_solution=" + solution;
|
|
1245
1427
|
}
|
|
1246
1428
|
solve();
|
|
1247
1429
|
</script>
|
|
@@ -1261,23 +1443,26 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1261
1443
|
|
|
1262
1444
|
const challengeScript = `
|
|
1263
1445
|
async function solve() {
|
|
1264
|
-
const nonce =
|
|
1265
|
-
const path =
|
|
1266
|
-
const clientSecret =
|
|
1267
|
-
const clientIp =
|
|
1446
|
+
const nonce = ${JSON.stringify(nonce)};
|
|
1447
|
+
const path = ${JSON.stringify(path)};
|
|
1448
|
+
const clientSecret = ${JSON.stringify(clientSecret)};
|
|
1449
|
+
const clientIp = ${JSON.stringify(clientIp)};
|
|
1268
1450
|
const cpuTarget = BigInt("0x${target}");
|
|
1269
1451
|
const memDifficulty = ${memoryDifficulty};
|
|
1452
|
+
// The client-side fingerprint library must be available to generate the fingerprint
|
|
1453
|
+
// of the machine solving the challenge. This assumes a client library is loaded.
|
|
1454
|
+
// We need a function to get the client fingerprint. Let's assume it's available on window.
|
|
1455
|
+
const getClientFingerprint = () => (window.ClientLibrary && typeof window.ClientLibrary.getDeviceFingerprint === 'function') ? window.ClientLibrary.getDeviceFingerprint() : '';
|
|
1456
|
+
const fingerprint = getClientFingerprint();
|
|
1270
1457
|
|
|
1271
1458
|
// --- CPU Challenge ---
|
|
1272
1459
|
document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
|
|
1273
|
-
const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, (progress) => {
|
|
1460
|
+
const cpuSolution = await window.solveCpuChallengeInline(clientIp, nonce, cpuTarget, clientSecret, fingerprint, (progress) => {
|
|
1274
1461
|
// Optional progress callback
|
|
1275
1462
|
});
|
|
1276
|
-
|
|
1277
1463
|
// --- Memory Challenge ---
|
|
1278
1464
|
document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
|
|
1279
1465
|
await new Promise(r => setTimeout(r, 10)); // Yield to update UI
|
|
1280
|
-
|
|
1281
1466
|
let memSolution = 0;
|
|
1282
1467
|
try {
|
|
1283
1468
|
const memSeed = nonce + ":" + clientSecret;
|
|
@@ -1286,7 +1471,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1286
1471
|
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
1287
1472
|
return;
|
|
1288
1473
|
}
|
|
1289
|
-
window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
|
|
1474
|
+
window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + ${JSON.stringify(nonce)} + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution + "&pow_fp=" + encodeURIComponent(fingerprint);
|
|
1290
1475
|
}
|
|
1291
1476
|
solve();
|
|
1292
1477
|
`;
|
|
@@ -1317,14 +1502,15 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1317
1502
|
*/
|
|
1318
1503
|
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1319
1504
|
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1320
|
-
|
|
1505
|
+
ticketTtl, // NOUVEAU: Durée de validité du ticket (TTL en ms) configurable
|
|
1321
1506
|
nonce,
|
|
1322
1507
|
solution,
|
|
1323
1508
|
clientSecret, // Le secret est maintenant requis
|
|
1324
|
-
target, // La cible est maintenant passée directement
|
|
1509
|
+
target, // La cible est maintenant passée directement en hexadécimal,
|
|
1510
|
+
fingerprint, // Le fingerprint du SOLVER, soumis par le client
|
|
1325
1511
|
) {
|
|
1326
1512
|
const message = clientSecret
|
|
1327
|
-
? `${nonce}:${solution}:${clientSecret}`
|
|
1513
|
+
? `${nonce}:${solution}:${clientSecret}:${fingerprint}`
|
|
1328
1514
|
: `${clientIp}:${nonce}:${solution}`; // L'IP est utilisée uniquement pour les challenges sans secret (plus anciens/simples)
|
|
1329
1515
|
const hash = crypto
|
|
1330
1516
|
.createHash("sha256")
|
|
@@ -1335,7 +1521,7 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
1335
1521
|
if (hashAsInt < BigInt("0x" + target)) {
|
|
1336
1522
|
// The comparison is direct with native BigInts
|
|
1337
1523
|
// The proof is valid, generate the ticket
|
|
1338
|
-
const expiry = Date.now() + (
|
|
1524
|
+
const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
|
|
1339
1525
|
const signature = crypto
|
|
1340
1526
|
.createHmac("sha256", getPowSecret())
|
|
1341
1527
|
.update(`${clientIp}:${expiry}`)
|
|
@@ -1346,106 +1532,6 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
1346
1532
|
return null;
|
|
1347
1533
|
}
|
|
1348
1534
|
|
|
1349
|
-
const staticExtensions = new RegExp(
|
|
1350
|
-
"\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest|webmanifest)$",
|
|
1351
|
-
"i",
|
|
1352
|
-
);
|
|
1353
|
-
const isStaticResource = (path) => staticExtensions.test(path);
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
/**
|
|
1357
|
-
* Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
|
|
1358
|
-
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
1359
|
-
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
1360
|
-
*/
|
|
1361
|
-
function determineOptimalTicketTtl(suspicionScore) {
|
|
1362
|
-
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
1363
|
-
const MIN_TTL = 300000;
|
|
1364
|
-
const MAX_TTL = 86400000;
|
|
1365
|
-
|
|
1366
|
-
const solverFunction = () => {
|
|
1367
|
-
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
1368
|
-
|
|
1369
|
-
// Un "individu" est simplement une valeur de TTL en millisecondes.
|
|
1370
|
-
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
1371
|
-
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
1372
|
-
const mutate = (ttl) => {
|
|
1373
|
-
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
|
|
1374
|
-
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
1375
|
-
};
|
|
1376
|
-
|
|
1377
|
-
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
1378
|
-
createIndividual,
|
|
1379
|
-
fitnessFunction,
|
|
1380
|
-
crossover,
|
|
1381
|
-
mutate,
|
|
1382
|
-
{
|
|
1383
|
-
generations: 40,
|
|
1384
|
-
populationSize: 30,
|
|
1385
|
-
}
|
|
1386
|
-
);
|
|
1387
|
-
|
|
1388
|
-
// Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
|
|
1389
|
-
// Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
|
|
1390
|
-
// en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
|
|
1391
|
-
if (!paretoFront || paretoFront.length === 0) {
|
|
1392
|
-
return { solution: null, fitness: Infinity };
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// Stratégie de sélection :
|
|
1396
|
-
// Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
|
|
1397
|
-
// Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
|
|
1398
|
-
let bestSolutionInFront;
|
|
1399
|
-
if (suspicionScore < 50) {
|
|
1400
|
-
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
1401
|
-
} else {
|
|
1402
|
-
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
1403
|
-
}
|
|
1404
|
-
return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
|
|
1405
|
-
};
|
|
1406
|
-
|
|
1407
|
-
// On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
|
|
1408
|
-
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
1409
|
-
|
|
1410
|
-
if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
|
|
1411
|
-
// Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
|
|
1412
|
-
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
|
|
1416
|
-
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
1417
|
-
return bestResult.solution;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
/**
|
|
1421
|
-
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
1422
|
-
* @param {string} str - La chaîne à vérifier.
|
|
1423
|
-
* @returns {boolean} - True si un pattern malveillant est détecté.
|
|
1424
|
-
* @private
|
|
1425
|
-
*/
|
|
1426
|
-
function isMalicious(str) {
|
|
1427
|
-
if (typeof str !== 'string') return false;
|
|
1428
|
-
// Regex pour les injections SQL et NoSQL de base
|
|
1429
|
-
// Ajout de la détection des injections basées sur le temps (SLEEP, BENCHMARK, WAITFOR) et d'autres commandes dangereuses.
|
|
1430
|
-
const injectionRegex = /(\$ne|' *OR *'1'='1|['";]\s*--|; ?(DROP|TRUNCATE|DELETE)|UNION SELECT|SLEEP\(|BENCHMARK\(|WAITFOR DELAY)/i;
|
|
1431
|
-
// Regex pour les injections plus avancées
|
|
1432
|
-
const log4ShellRegex = /\$\{jndi:(ldap|rmi|dns):/i;
|
|
1433
|
-
const sstiRegex = /\{\{.*\}\}|\{%.*%\}/; // Détecte les syntaxes de type Jinja2, Twig, etc.
|
|
1434
|
-
const xxeRegex = /<!ENTITY\s+.*SYSTEM/i;
|
|
1435
|
-
const pathTraversalRegex = /(\.\.\/|\.\.\\)/;
|
|
1436
|
-
// Regex pour les injections de commandes.
|
|
1437
|
-
// Elle détecte deux cas :
|
|
1438
|
-
// 1. L'utilisation de backticks `` pour l'exécution de commandes.
|
|
1439
|
-
// 2. Des commandes dangereuses (comme rm, whoami) précédées par un séparateur de commande (;, &&, ||, |)
|
|
1440
|
-
// pour éviter les faux positifs sur des phrases comme "A normal command like ls -la".
|
|
1441
|
-
const commandInjectionRegex = /`.*`|[\n;&|]\s*(ping|ls|whoami|cat|rm|ncat|nc|bash|sh|powershell|cmd)\b/i;
|
|
1442
|
-
|
|
1443
|
-
return injectionRegex.test(str) || log4ShellRegex.test(str) || sstiRegex.test(str) || xxeRegex.test(str) || pathTraversalRegex.test(str) || commandInjectionRegex.test(str);
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
// --- Middleware Proof-of-Work (Le péage) ---
|
|
1447
|
-
export { isMalicious };
|
|
1448
|
-
|
|
1449
1535
|
export class FingerprintEngine {
|
|
1450
1536
|
constructor(securityConfig) {
|
|
1451
1537
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -1471,7 +1557,9 @@ export class FingerprintEngine {
|
|
|
1471
1557
|
(suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
|
|
1472
1558
|
(suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
|
|
1473
1559
|
(suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
|
|
1474
|
-
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0)
|
|
1560
|
+
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0) +
|
|
1561
|
+
(suspicionVector.crossLayerInconsistencyScore || 0) * (weights.crossLayerInconsistencyScore || 0) +
|
|
1562
|
+
(suspicionVector.timeInconsistencyScore || 0) * (weights.timeInconsistencyScore || 0);
|
|
1475
1563
|
|
|
1476
1564
|
return Math.min(100, score);
|
|
1477
1565
|
}
|
|
@@ -1608,114 +1696,7 @@ export class FingerprintEngine {
|
|
|
1608
1696
|
this._log('Whitelisted bot verified - allowing request', { clientIp });
|
|
1609
1697
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
1610
1698
|
}
|
|
1611
|
-
|
|
1612
|
-
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
1613
|
-
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
1614
|
-
// avant même de recalculer le score de suspicion.
|
|
1615
|
-
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1616
|
-
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1617
|
-
this._log('Challenge solution submitted', { pow_type, pow_nonce });
|
|
1618
|
-
|
|
1619
|
-
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
1620
|
-
// car le TTL optimal en dépend.
|
|
1621
|
-
const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1622
|
-
const preliminaryScore = this.calculateFinalScore(preliminaryVector);
|
|
1623
|
-
|
|
1624
|
-
this._log('Preliminary suspicion vector calculated', {
|
|
1625
|
-
vector: preliminaryVector,
|
|
1626
|
-
score: preliminaryScore
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
|
-
let isValid = false;
|
|
1630
|
-
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1631
|
-
let ticket = null;
|
|
1632
|
-
// Déclarer optimalTtl ici avec une valeur par défaut
|
|
1633
|
-
let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
|
|
1634
|
-
|
|
1635
|
-
if (challengeContext) {
|
|
1636
|
-
optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
1637
|
-
this._log('Challenge context found, verifying solution', { optimalTtl });
|
|
1638
|
-
|
|
1639
|
-
if (pow_type === "cpu_target") {
|
|
1640
|
-
// On passe la durée de vie du ticket configurée
|
|
1641
|
-
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1642
|
-
isValid = ticket !== null;
|
|
1643
|
-
this._log('CPU target challenge verification', { isValid });
|
|
1644
|
-
} else if (pow_type === "cpu_mem") {
|
|
1645
|
-
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1646
|
-
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
|
|
1647
|
-
isValid = cpuTicket !== null && isMemValid;
|
|
1648
|
-
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
1649
|
-
this._log('Combined CPU+Memory challenge verification', {
|
|
1650
|
-
cpuValid: cpuTicket !== null,
|
|
1651
|
-
memValid: isMemValid,
|
|
1652
|
-
isValid
|
|
1653
|
-
});
|
|
1654
|
-
}
|
|
1655
|
-
} else {
|
|
1656
|
-
this._log('Challenge context not found or expired', { pow_nonce });
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
console.log({isValid})
|
|
1660
|
-
if (isValid) {
|
|
1661
|
-
// La solution est valide. On supprime le secret et on redirige.
|
|
1662
|
-
await store.delete(`secret:${pow_nonce}`);
|
|
1663
|
-
this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: optimalTtl });
|
|
1664
|
-
|
|
1665
|
-
if (logger) {
|
|
1666
|
-
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
// NOUVELLE LOGIQUE DE REDIRECTION (plus robuste)
|
|
1670
|
-
// 1. On part du chemin original stocké, qui peut contenir des query params.
|
|
1671
|
-
const originalUrl = new URL(challengeContext?.originalPath || path, `http://${requestContext.headers.host || 'localhost'}`);
|
|
1672
|
-
|
|
1673
|
-
console.log({originalUrl})
|
|
1674
|
-
// 2. On crée un nouvel objet de paramètres à partir de la requête entrante (qui contient les solutions ET les params originaux).
|
|
1675
|
-
const finalSearchParams = new URLSearchParams(requestContext.query);
|
|
1676
|
-
|
|
1677
|
-
// 3. On supprime uniquement les paramètres liés au challenge.
|
|
1678
|
-
finalSearchParams.delete('pow_type');
|
|
1679
|
-
finalSearchParams.delete('pow_nonce');
|
|
1680
|
-
finalSearchParams.delete('pow_solution');
|
|
1681
|
-
finalSearchParams.delete('pow_solution_cpu');
|
|
1682
|
-
finalSearchParams.delete('pow_solution_mem');
|
|
1683
|
-
|
|
1684
|
-
// 4. On reconstruit le chemin final.
|
|
1685
|
-
const finalQueryString = finalSearchParams.toString();
|
|
1686
|
-
const finalRedirectPath = finalQueryString ? `${originalUrl.pathname}?${finalQueryString}` : originalUrl.pathname;
|
|
1687
|
-
this._log('Redirecting to clean path', { finalRedirectPath });
|
|
1688
|
-
|
|
1689
|
-
console.log({finalRedirectPath})
|
|
1690
|
-
return {
|
|
1691
|
-
action: 'redirect',
|
|
1692
|
-
path: finalRedirectPath,
|
|
1693
|
-
score: 0, // Le score n'est pas pertinent ici, on a passé le test.
|
|
1694
|
-
vector: { challenge_solved: 100 },
|
|
1695
|
-
cookie: {
|
|
1696
|
-
name: 'pow_clearance',
|
|
1697
|
-
value: ticket,
|
|
1698
|
-
options: {
|
|
1699
|
-
httpOnly: true,
|
|
1700
|
-
secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
|
|
1701
|
-
maxAge: optimalTtl,
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1706
|
-
// Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
|
|
1707
|
-
// sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
|
|
1708
|
-
// On pourrait même ajouter une pénalité ici si on le voulait.
|
|
1709
|
-
this._log('Challenge solution invalid', { reason: challengeContext ? 'Invalid solution' : 'Nonce not found or expired' });
|
|
1710
|
-
|
|
1711
|
-
if (logger && challengeContext) {
|
|
1712
|
-
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
|
|
1713
|
-
} else if (logger && !challengeContext) {
|
|
1714
|
-
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
1718
|
-
|
|
1699
|
+
|
|
1719
1700
|
// Resolve identity and check for persisted "condemned" status early.
|
|
1720
1701
|
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
1721
1702
|
const isNewDevice = !!newCookie;
|
|
@@ -1761,6 +1742,7 @@ export class FingerprintEngine {
|
|
|
1761
1742
|
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
1762
1743
|
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1763
1744
|
const isSuspicious = finalScore >= thresholds.low;
|
|
1745
|
+
const isVerySuspicious = finalScore >= thresholds.medium; // Seuil pour le challenge d'optimisation
|
|
1764
1746
|
|
|
1765
1747
|
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1766
1748
|
const suspicionFactor = isSuspicious
|
|
@@ -1782,6 +1764,173 @@ export class FingerprintEngine {
|
|
|
1782
1764
|
|
|
1783
1765
|
const powCookie = cookies?.pow_clearance;
|
|
1784
1766
|
|
|
1767
|
+
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
1768
|
+
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
1769
|
+
// avant même de recalculer le score de suspicion.
|
|
1770
|
+
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem, pow_fp, pow_solution_population, pow_solution_work_result, pow_problem_id } = query;
|
|
1771
|
+
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1772
|
+
this._log('Challenge solution submitted', { pow_type, pow_nonce });
|
|
1773
|
+
|
|
1774
|
+
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
1775
|
+
// car le TTL optimal en dépend.
|
|
1776
|
+
const preliminaryVector = suspicionVector; // Use the already calculated vector
|
|
1777
|
+
const preliminaryScore = finalScore; // Use the already calculated score
|
|
1778
|
+
|
|
1779
|
+
this._log('Preliminary suspicion vector calculated', {
|
|
1780
|
+
vector: preliminaryVector,
|
|
1781
|
+
score: preliminaryScore
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
let isValid = false;
|
|
1785
|
+
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1786
|
+
let ticket = null;
|
|
1787
|
+
// Déclarer optimalTtl ici avec une valeur par défaut
|
|
1788
|
+
let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
|
|
1789
|
+
// NOUVEAU: Logique de ticket probatoire
|
|
1790
|
+
|
|
1791
|
+
let finalTtl; // Déclarer finalTtl ici pour qu'il soit accessible dans la portée
|
|
1792
|
+
const isProbationary = preliminaryScore >= thresholds.low;
|
|
1793
|
+
const probationaryTtl = 30000; // 30 secondes
|
|
1794
|
+
|
|
1795
|
+
if (challengeContext) {
|
|
1796
|
+
// *** NOUVELLE VÉRIFICATION CRUCIALE ***
|
|
1797
|
+
// On compare le fingerprint soumis par le solver (`pow_fp`) avec celui stocké
|
|
1798
|
+
// lors de l'émission du challenge (`challengeContext.fingerprint`).
|
|
1799
|
+
const solverFingerprint = pow_fp;
|
|
1800
|
+
const originalFingerprint = challengeContext.fingerprint;
|
|
1801
|
+
|
|
1802
|
+
if (solverFingerprint !== originalFingerprint) {
|
|
1803
|
+
this._log('Fingerprint mismatch - challenge solved on a different machine!', {
|
|
1804
|
+
original: originalFingerprint,
|
|
1805
|
+
solver: solverFingerprint,
|
|
1806
|
+
});
|
|
1807
|
+
isValid = false;
|
|
1808
|
+
} else {
|
|
1809
|
+
optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
1810
|
+
finalTtl = isProbationary ? probationaryTtl : optimalTtl;
|
|
1811
|
+
this._log('Challenge context found, verifying solution', { optimalTtl, finalTtl });
|
|
1812
|
+
|
|
1813
|
+
if (pow_type === "cpu_target" && pow_solution) {
|
|
1814
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution, challengeContext.clientSecret, challengeContext.cpuTarget, solverFingerprint);
|
|
1815
|
+
isValid = ticket !== null;
|
|
1816
|
+
this._log('CPU target challenge verification', { isValid });
|
|
1817
|
+
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
1818
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext.clientSecret, challengeContext.cpuTarget, solverFingerprint);
|
|
1819
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
|
|
1820
|
+
isValid = cpuTicket !== null && isMemValid;
|
|
1821
|
+
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
1822
|
+
this._log('Combined CPU+Memory challenge verification', {
|
|
1823
|
+
cpuValid: cpuTicket !== null,
|
|
1824
|
+
memValid: isMemValid,
|
|
1825
|
+
isValid
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
} else {
|
|
1830
|
+
this._log('Challenge context not found or expired', { pow_nonce });
|
|
1831
|
+
}
|
|
1832
|
+
if (isValid) {
|
|
1833
|
+
// La solution est valide. On supprime le secret et on redirige.
|
|
1834
|
+
await store.delete(`secret:${pow_nonce}`);
|
|
1835
|
+
this._log('Challenge solution valid - issuing ticket', { ticketMaxAge: finalTtl, isProbationary });
|
|
1836
|
+
|
|
1837
|
+
if (logger) {
|
|
1838
|
+
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// NOUVELLE LOGIQUE DE REDIRECTION (plus robuste)
|
|
1842
|
+
// 1. On part du chemin original stocké, qui peut contenir des query params.
|
|
1843
|
+
const originalUrl = new URL(challengeContext?.originalPath || path, `http://${requestContext.headers.host || 'localhost'}`);
|
|
1844
|
+
// 2. On crée un nouvel objet de paramètres à partir de la requête entrante (qui contient les solutions ET les params originaux).
|
|
1845
|
+
const finalSearchParams = new URLSearchParams(requestContext.query);
|
|
1846
|
+
|
|
1847
|
+
// 3. On supprime uniquement les paramètres liés au challenge.
|
|
1848
|
+
finalSearchParams.delete('pow_type');
|
|
1849
|
+
finalSearchParams.delete('pow_nonce');
|
|
1850
|
+
finalSearchParams.delete('pow_solution');
|
|
1851
|
+
finalSearchParams.delete('pow_solution_cpu');
|
|
1852
|
+
finalSearchParams.delete('pow_solution_mem');
|
|
1853
|
+
finalSearchParams.delete('pow_fp'); // Ne pas oublier de nettoyer le fingerprint
|
|
1854
|
+
|
|
1855
|
+
// 4. On reconstruit le chemin final.
|
|
1856
|
+
const finalQueryString = finalSearchParams.toString();
|
|
1857
|
+
const finalRedirectPath = finalQueryString ? `${originalUrl.pathname}?${finalQueryString}` : originalUrl.pathname;
|
|
1858
|
+
this._log('Redirecting to clean path', { finalRedirectPath, cookieMaxAge: finalTtl });
|
|
1859
|
+
return {
|
|
1860
|
+
action: 'redirect',
|
|
1861
|
+
path: finalRedirectPath,
|
|
1862
|
+
score: 0, // Le score n'est pas pertinent ici, on a passé le test.
|
|
1863
|
+
vector: { challenge_solved: 100 },
|
|
1864
|
+
cookie: {
|
|
1865
|
+
name: 'pow_clearance',
|
|
1866
|
+
value: ticket, // The ticket itself
|
|
1867
|
+
options: { httpOnly: true, secure: this.isProduction, maxAge: finalTtl } // Options for setting the cookie
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
} else {
|
|
1871
|
+
// If the solution is invalid, we should treat it as a high-suspicion event.
|
|
1872
|
+
// This prevents the request from proceeding and forces a new, likely harder, challenge.
|
|
1873
|
+
this._log('Challenge solution invalid', { pow_nonce });
|
|
1874
|
+
suspicionVector.honeypotScore = 100; // Invalid solution is a strong bot signal.
|
|
1875
|
+
finalScore = this.calculateFinalScore(suspicionVector);
|
|
1876
|
+
}
|
|
1877
|
+
} else if (pow_nonce && pow_type === 'optimization_task' && pow_solution_population) {
|
|
1878
|
+
this._log('Optimization task solution submitted', { pow_nonce });
|
|
1879
|
+
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1880
|
+
let isValid = false;
|
|
1881
|
+
|
|
1882
|
+
if (challengeContext?.optimizationProblem) {
|
|
1883
|
+
try {
|
|
1884
|
+
const submittedChromosomes = JSON.parse(pow_solution_population);
|
|
1885
|
+
// Vérification simple : le client a-t-il renvoyé le bon nombre de solutions ?
|
|
1886
|
+
if (Array.isArray(submittedChromosomes) && submittedChromosomes.length === challengeContext.optimizationProblem.population.length) {
|
|
1887
|
+
// Le serveur recalcule la fitness pour la nouvelle population.
|
|
1888
|
+
const fitnessFunction = Optimization.Operators.createFullSecurityConfigEvaluator({ trafficData: challengeContext.optimizationProblem.trafficData });
|
|
1889
|
+
const newPopulation = submittedChromosomes.map(chromosome => ({ chromosome, fitness: fitnessFunction(chromosome) }));
|
|
1890
|
+
|
|
1891
|
+
// On met à jour le problème principal avec la nouvelle population.
|
|
1892
|
+
challengeContext.optimizationProblem.population = newPopulation;
|
|
1893
|
+
await store.set(`device:${deviceId}`, deviceData); // Sauvegarde l'état mis à jour
|
|
1894
|
+
isValid = true;
|
|
1895
|
+
}
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
this._log('Error parsing optimization solution', { error: e.message });
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
if (isValid) {
|
|
1902
|
+
await store.delete(`secret:${pow_nonce}`);
|
|
1903
|
+
// La solution est valide, on accorde un ticket et on redirige.
|
|
1904
|
+
const ticket = "valid_ticket_placeholder"; // Générer un vrai ticket ici
|
|
1905
|
+
return { action: 'redirect', path: path, score: 0, vector: { challenge_solved: 100 }, cookie: { name: 'pow_clearance', value: ticket, options: { httpOnly: true, secure: this.isProduction, maxAge: 60000 } } };
|
|
1906
|
+
} else {
|
|
1907
|
+
this._log('Optimization task solution invalid', { pow_nonce });
|
|
1908
|
+
suspicionVector.honeypotScore = 100;
|
|
1909
|
+
finalScore = this.calculateFinalScore(suspicionVector);
|
|
1910
|
+
}
|
|
1911
|
+
} else if (pow_nonce && pow_type === 'useful_work_task' && pow_solution_work_result && pow_problem_id) {
|
|
1912
|
+
this._log('Useful work solution submitted', { problemId: pow_problem_id });
|
|
1913
|
+
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1914
|
+
if (challengeContext) {
|
|
1915
|
+
try {
|
|
1916
|
+
const workResult = JSON.parse(pow_solution_work_result);
|
|
1917
|
+
problemManager.integrateSolution(pow_problem_id, workResult);
|
|
1918
|
+
|
|
1919
|
+
await store.delete(`secret:${pow_nonce}`);
|
|
1920
|
+
// Accorder un ticket de passage comme pour un PoW normal
|
|
1921
|
+
const ticket = "valid_ticket_placeholder"; // Générer un vrai ticket ici
|
|
1922
|
+
return { action: 'redirect', path: path, score: 0, vector: { challenge_solved: 100 }, cookie: { name: 'pow_clearance', value: ticket, options: { httpOnly: true, secure: this.isProduction, maxAge: 60000 } } };
|
|
1923
|
+
|
|
1924
|
+
} catch (e) {
|
|
1925
|
+
this._log('Error parsing useful work solution', { error: e.message });
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
// Si la validation échoue, on pénalise fortement
|
|
1929
|
+
suspicionVector.honeypotScore = 100;
|
|
1930
|
+
finalScore = this.calculateFinalScore(suspicionVector);
|
|
1931
|
+
}
|
|
1932
|
+
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
1933
|
+
|
|
1785
1934
|
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
1786
1935
|
if (isBlocked) {
|
|
1787
1936
|
this._log('Request blocked - score exceeded block threshold', { finalScore, blockThreshold });
|
|
@@ -1807,9 +1956,8 @@ export class FingerprintEngine {
|
|
|
1807
1956
|
return { action: 'block', status: 404, score: 100, vector: { honeypotScore: 100 } };
|
|
1808
1957
|
}
|
|
1809
1958
|
|
|
1810
|
-
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1959
|
+
if (isSuspicious && !isTicketValid(clientIp, powCookie)) { // The powCookie check is now after the PoW solution check
|
|
1811
1960
|
this._log('Suspicious request without valid ticket - issuing challenge', { finalScore, hasPowCookie: !!powCookie });
|
|
1812
|
-
|
|
1813
1961
|
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1814
1962
|
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1815
1963
|
// If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
|
|
@@ -1830,13 +1978,25 @@ export class FingerprintEngine {
|
|
|
1830
1978
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1831
1979
|
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
1832
1980
|
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1981
|
+
// Pour les scores élevés, on choisit aléatoirement entre un challenge de travail utile et un PoW classique.
|
|
1982
|
+
// Cela rend l'automatisation plus difficile pour un attaquant.
|
|
1983
|
+
if (isSuspicious && this.securityConfig.enableUsefulWork && Math.random() > 0.5) {
|
|
1984
|
+
this._log('Issuing a useful work challenge', { finalScore });
|
|
1985
|
+
|
|
1986
|
+
const { problemId, task } = problemManager.dispatchWork(suspicionFactor);
|
|
1837
1987
|
|
|
1838
|
-
|
|
1839
|
-
|
|
1988
|
+
await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
|
|
1989
|
+
|
|
1990
|
+
const challengePayload = {
|
|
1991
|
+
challenge: {
|
|
1992
|
+
type: 'useful_work_task',
|
|
1993
|
+
nonce: nonce,
|
|
1994
|
+
clientSecret: clientSecret,
|
|
1995
|
+
usefulWorkTask: { problemId, task }
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
|
|
1999
|
+
} else if (isSuspicious) { // Pour les scores bas/moyens ou si le travail utile n'est pas choisi
|
|
1840
2000
|
// Generate some trap URLs to embed in the challenge page.
|
|
1841
2001
|
// These links are visually hidden but present in the DOM to trap bots.
|
|
1842
2002
|
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
|
|
@@ -1853,17 +2013,21 @@ export class FingerprintEngine {
|
|
|
1853
2013
|
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
1854
2014
|
const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
|
|
1855
2015
|
|
|
1856
|
-
this._log('Challenge parameters calculated', {
|
|
1857
|
-
suspicionFactor,
|
|
1858
|
-
memActivationFactor,
|
|
1859
|
-
memDifficulty,
|
|
1860
|
-
cpuTarget: cpuChallengeDetails.target
|
|
2016
|
+
this._log('Challenge parameters calculated', {
|
|
2017
|
+
suspicionFactor,
|
|
2018
|
+
memActivationFactor,
|
|
2019
|
+
memDifficulty,
|
|
2020
|
+
cpuTarget: cpuChallengeDetails.target
|
|
1861
2021
|
});
|
|
2022
|
+
// (NOUVEAU) On stocke le fingerprint de la requête qui a déclenché le challenge.
|
|
2023
|
+
const originalFingerprint = requestContext.headers['x-device-fingerprint'] || getCompositeDeviceHash(requestContext);
|
|
1862
2024
|
|
|
1863
2025
|
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
1864
2026
|
await store.set(`secret:${nonce}`, {
|
|
1865
2027
|
clientSecret,
|
|
1866
2028
|
cpuTarget: cpuChallengeDetails.target,
|
|
2029
|
+
suspicionScore: finalScore, // *** FIX: Store the score that triggered the challenge ***
|
|
2030
|
+
fingerprint: originalFingerprint, // *** NOUVEAU ***
|
|
1867
2031
|
memDifficulty: memDifficulty,
|
|
1868
2032
|
originalPath: path, // *** FIX: Store the original path ***
|
|
1869
2033
|
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
@@ -1885,7 +2049,7 @@ export class FingerprintEngine {
|
|
|
1885
2049
|
}
|
|
1886
2050
|
|
|
1887
2051
|
// Check if the request is an API request to return a JSON challenge
|
|
1888
|
-
const isApi = requestContext.rawReq && this.securityConfig
|
|
2052
|
+
const isApi = requestContext.rawReq && this.securityConfig?.isApiRequest?.(requestContext.rawReq);
|
|
1889
2053
|
|
|
1890
2054
|
if (isApi) {
|
|
1891
2055
|
// For API clients, send a JSON response with challenge details.
|
|
@@ -1978,6 +2142,176 @@ export class FingerprintEngine {
|
|
|
1978
2142
|
}
|
|
1979
2143
|
}
|
|
1980
2144
|
|
|
2145
|
+
const staticExtensions = new RegExp(
|
|
2146
|
+
"\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map|json|manifest|webmanifest)$",
|
|
2147
|
+
"i",
|
|
2148
|
+
);
|
|
2149
|
+
const isStaticResource = (path) => staticExtensions.test(path);
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
|
|
2154
|
+
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
2155
|
+
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
2156
|
+
*/
|
|
2157
|
+
function determineOptimalTicketTtl(suspicionScore) {
|
|
2158
|
+
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
2159
|
+
const MIN_TTL = 300000;
|
|
2160
|
+
const MAX_TTL = 86400000;
|
|
2161
|
+
|
|
2162
|
+
const solverFunction = () => {
|
|
2163
|
+
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
2164
|
+
|
|
2165
|
+
// Un "individu" est simplement une valeur de TTL en millisecondes.
|
|
2166
|
+
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
2167
|
+
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
2168
|
+
const mutate = (ttl) => {
|
|
2169
|
+
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
|
|
2170
|
+
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
2171
|
+
};
|
|
2172
|
+
|
|
2173
|
+
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
2174
|
+
createIndividual,
|
|
2175
|
+
fitnessFunction,
|
|
2176
|
+
crossover,
|
|
2177
|
+
mutate,
|
|
2178
|
+
{
|
|
2179
|
+
generations: 40,
|
|
2180
|
+
populationSize: 30,
|
|
2181
|
+
}
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
// Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
|
|
2185
|
+
// Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
|
|
2186
|
+
// en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
|
|
2187
|
+
if (!paretoFront || paretoFront.length === 0) {
|
|
2188
|
+
return { solution: null, fitness: Infinity };
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// Stratégie de sélection :
|
|
2192
|
+
// Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
|
|
2193
|
+
// Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
|
|
2194
|
+
let bestSolutionInFront;
|
|
2195
|
+
if (suspicionScore < 50) {
|
|
2196
|
+
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
2197
|
+
} else {
|
|
2198
|
+
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
2199
|
+
}
|
|
2200
|
+
return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
// On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
|
|
2204
|
+
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
2205
|
+
|
|
2206
|
+
if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
|
|
2207
|
+
// Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
|
|
2208
|
+
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
|
|
2212
|
+
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
2213
|
+
return Math.round(bestResult.solution);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
2218
|
+
* @private
|
|
2219
|
+
* @param {string} str - La chaîne à vérifier.
|
|
2220
|
+
* @param {string[]} [typesToDetect=['sql', 'log4shell', 'ssti', 'xxe', 'traversal', 'rce']] - Les types d'injections à détecter.
|
|
2221
|
+
* @returns {boolean} - True si un pattern malveillant est détecté.
|
|
2222
|
+
*/
|
|
2223
|
+
function isMalicious(str, typesToDetect = Object.keys(injectionPatterns)) {
|
|
2224
|
+
if (typeof str !== 'string') return false;
|
|
2225
|
+
|
|
2226
|
+
for (const type of typesToDetect) {
|
|
2227
|
+
const regex = injectionPatterns[type];
|
|
2228
|
+
if (regex && regex.test(str)) {
|
|
2229
|
+
return true;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
return false;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// --- Middleware Proof-of-Work (Le péage) ---
|
|
2237
|
+
export { isMalicious };
|
|
2238
|
+
|
|
2239
|
+
/**
|
|
2240
|
+
* Returns a default list of security analyzers for honeypot detection.
|
|
2241
|
+
* This list can be used as a base and extended with custom rules.
|
|
2242
|
+
* Currently includes an XSS detection analyzer.
|
|
2243
|
+
* @returns {Array<Function>}
|
|
2244
|
+
*/
|
|
2245
|
+
export const default_analyzers = () => [
|
|
2246
|
+
// Analyzer for Cross-Site Scripting (XSS) detection.
|
|
2247
|
+
// It uses the 'xss' library, which should be installed by the user (`npm install xss`).
|
|
2248
|
+
// If 'xss' is not available, this analyzer will be safely ignored.
|
|
2249
|
+
xss_analyzer
|
|
2250
|
+
];
|
|
2251
|
+
|
|
2252
|
+
export const xss_analyzer = async (data) => {
|
|
2253
|
+
try {
|
|
2254
|
+
// Dynamically import the 'xss' library.
|
|
2255
|
+
// The module is loaded only once by Node's cache.
|
|
2256
|
+
const xss = (await import('xss')).default;
|
|
2257
|
+
const originalData = JSON.stringify(data);
|
|
2258
|
+
// If the sanitized string is different, it means malicious HTML/JS was found and removed.
|
|
2259
|
+
return xss(originalData) !== originalData;
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
// This catch block handles the case where the 'xss' module is not installed.
|
|
2262
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
2263
|
+
console.warn('[Fingerprint] Warning: The "xss" package is not installed. The default XSS analyzer is disabled. Run "npm install xss" to enable it.');
|
|
2264
|
+
// To avoid repeated warnings, we can replace this function with a no-op.
|
|
2265
|
+
this.isXssAnalyzerAvailable = false; // A flag to prevent future attempts.
|
|
2266
|
+
}
|
|
2267
|
+
return false; // In case of any error, we assume the data is not malicious.
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Returns a powerful WAF (Web Application Firewall) analyzer based on ModSecurity.
|
|
2272
|
+
* This analyzer is highly effective against a wide range of attacks (SQLi, XSS, RCE, etc.)
|
|
2273
|
+
* by using the OWASP Core Rule Set.
|
|
2274
|
+
*
|
|
2275
|
+
* **Note:** This is an optional and advanced feature.
|
|
2276
|
+
* 1. The user must install the package: `npm install modsecurity-nodejs`
|
|
2277
|
+
* 2. ModSecurity rules (like the OWASP CRS) must be available on the server.
|
|
2278
|
+
*
|
|
2279
|
+
* If the package is not installed, the analyzer will be safely ignored.
|
|
2280
|
+
*
|
|
2281
|
+
* @param {string} rulesPath - The path to the ModSecurity rules configuration file (e.g., `crs-setup.conf`).
|
|
2282
|
+
* @returns {Function} An analyzer function to be used in the `honeypot.analyzers` array.
|
|
2283
|
+
*/
|
|
2284
|
+
export const modsecurity_analyzer = (rulesPath) => {
|
|
2285
|
+
let wafInstance = null; // Singleton instance for the WAF
|
|
2286
|
+
|
|
2287
|
+
return async (data) => {
|
|
2288
|
+
if (!rulesPath) {
|
|
2289
|
+
console.warn('[Fingerprint] ModSecurity analyzer disabled: `rulesPath` is not provided.');
|
|
2290
|
+
return false;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
try {
|
|
2294
|
+
if (!wafInstance) {
|
|
2295
|
+
// Dynamically import the library only when needed.
|
|
2296
|
+
const { ModSecurity } = await import('modsecurity-nodejs');
|
|
2297
|
+
wafInstance = new ModSecurity();
|
|
2298
|
+
wafInstance.init();
|
|
2299
|
+
wafInstance.addRules(rulesPath);
|
|
2300
|
+
console.log('[Fingerprint] ModSecurity WAF analyzer initialized successfully.');
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// The `transaction` method checks the data against the loaded rules.
|
|
2304
|
+
// It returns `null` if no rules are matched, or an object with intervention details if a threat is found.
|
|
2305
|
+
const result = wafInstance.transaction(data);
|
|
2306
|
+
return result !== null; // A non-null result means a threat was detected.
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
2309
|
+
console.warn('[Fingerprint] Warning: "modsecurity-nodejs" is not installed. The WAF analyzer is disabled. Run "npm install modsecurity-nodejs" to enable it.');
|
|
2310
|
+
}
|
|
2311
|
+
return false; // Assume data is safe if any error occurs.
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
};
|
|
1981
2315
|
/**
|
|
1982
2316
|
* Returns a default list of whitelisting rules for common and legitimate web crawlers.
|
|
1983
2317
|
* This list can be used as a base and extended with custom rules.
|
|
@@ -2096,9 +2430,8 @@ export const powMiddleware = (securityConfig) => {
|
|
|
2096
2430
|
|
|
2097
2431
|
// Provide a default for isApiRequest if not specified by the user.
|
|
2098
2432
|
// This makes API challenge handling work more seamlessly out-of-the-box.
|
|
2099
|
-
if (!securityConfig
|
|
2100
|
-
|
|
2101
|
-
securityConfig.thresholds.isApiRequest = (req) =>
|
|
2433
|
+
if (!securityConfig?.isApiRequest) {
|
|
2434
|
+
securityConfig.isApiRequest = (req) =>
|
|
2102
2435
|
req.headers?.accept?.includes('application/json');
|
|
2103
2436
|
}
|
|
2104
2437
|
|
|
@@ -2110,9 +2443,10 @@ export const powMiddleware = (securityConfig) => {
|
|
|
2110
2443
|
query: req.query,
|
|
2111
2444
|
body: req.body,
|
|
2112
2445
|
headers: req.headers,
|
|
2113
|
-
isStatic: isStaticResource(req.path),
|
|
2446
|
+
isStatic: securityConfig?.isStaticResource?.(req.path) || isStaticResource(req.path),
|
|
2114
2447
|
// Pass the original request object for the isApiRequest function
|
|
2115
2448
|
rawReq: req,
|
|
2449
|
+
requestTimestamp: Date.now(), // Timestamp de début de requête
|
|
2116
2450
|
// Add the newly required properties for full decoupling
|
|
2117
2451
|
rawHeaders: req.rawHeaders,
|
|
2118
2452
|
// Pass the raw request object for advanced inspection (e.g., JA3)
|
|
@@ -2162,7 +2496,9 @@ export const powMiddleware = (securityConfig) => {
|
|
|
2162
2496
|
* This is a common pattern to allow mocking of ES module functions.
|
|
2163
2497
|
*/
|
|
2164
2498
|
export const __internal = {
|
|
2499
|
+
store, // Export the store for testing
|
|
2165
2500
|
getDeviceHash,
|
|
2501
|
+
getCompositeDeviceHash,
|
|
2166
2502
|
getSuspicionVector,
|
|
2167
2503
|
cyrb53, // Export for testing
|
|
2168
2504
|
FingerprintBuilder, // Export for testing
|
|
@@ -2170,6 +2506,11 @@ export const __internal = {
|
|
|
2170
2506
|
determineOptimalTicketTtl,
|
|
2171
2507
|
getRequestPatternScore, // Expose for testing
|
|
2172
2508
|
getBehaviorScore, // Expose for testing
|
|
2509
|
+
getCrossLayerInconsistency, // Expose for testing
|
|
2510
|
+
// Expose page generators for security testing
|
|
2511
|
+
getTimeInconsistencyScore,
|
|
2512
|
+
generateCpuTargetChallengePage,
|
|
2513
|
+
generateCombinedPoWChallengePage,
|
|
2173
2514
|
};
|
|
2174
2515
|
|
|
2175
2516
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|