@anonympins/fingerprint 0.0.8 → 0.1.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 +80 -43
- package/fingerprint.builder.js +104 -0
- package/fingerprint.client.js +51 -2
- package/fingerprint.js +311 -177
- package/library.js +1577 -1446
- package/mongodb-store.js +53 -0
- package/package.json +23 -9
- package/redis-store.js +43 -0
- package/sql-store.js +78 -0
package/fingerprint.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { BlockList } from "node:net";
|
|
4
4
|
import dns from "node:dns/promises";
|
|
5
5
|
import { Optimization } from "./library.js";
|
|
6
6
|
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
7
|
+
export { createRedisStore } from "./redis-store.js";
|
|
8
|
+
export { createMongoDbStore } from "./mongodb-store.js";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Retrieves the POW_SECRET from environment variables with appropriate checks.
|
|
@@ -381,6 +383,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
381
383
|
return `${expiry}:${signature}`;
|
|
382
384
|
};
|
|
383
385
|
|
|
386
|
+
|
|
387
|
+
|
|
384
388
|
/**
|
|
385
389
|
* Verifies a memory PoW solution.
|
|
386
390
|
* The server performs the same calculation to validate.
|
|
@@ -557,6 +561,28 @@ function getHoneypotScore(context, honeypotConfig = {}) {
|
|
|
557
561
|
return { honeypotScore: 0 };
|
|
558
562
|
}
|
|
559
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Calcule un score basé sur les métriques comportementales envoyées par le client.
|
|
566
|
+
* @param {object} context - Le contexte de la requête, contenant les en-têtes.
|
|
567
|
+
* @returns {{behaviorScore: number}}
|
|
568
|
+
*/
|
|
569
|
+
function getBehaviorScore(context) {
|
|
570
|
+
const behaviorHeader = context.headers['x-behavior-metrics'];
|
|
571
|
+
if (!behaviorHeader) {
|
|
572
|
+
return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
const metrics = JSON.parse(behaviorHeader);
|
|
577
|
+
let score = 0;
|
|
578
|
+
if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
|
|
579
|
+
if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
|
|
580
|
+
return { behaviorScore: Math.min(100, score) };
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
560
586
|
/**
|
|
561
587
|
* Analyzes server-side request patterns for a given device to detect bot-like behavior.
|
|
562
588
|
* This is a stateful check that looks for repetitive or unnaturally fast requests.
|
|
@@ -702,7 +728,7 @@ function verifyTrapUrl(path, signature, nonce) {
|
|
|
702
728
|
/**
|
|
703
729
|
* @typedef {object} IStore
|
|
704
730
|
* @property {(key: string) => Promise<any>} get
|
|
705
|
-
* @property {(key: string, value: any) => Promise<void>} set
|
|
731
|
+
* @property {(key: string, value: any, ttl?: number) => Promise<void>} set
|
|
706
732
|
* @property {(key: string) => Promise<boolean>} has
|
|
707
733
|
* @property {(key: string) => Promise<void>} delete
|
|
708
734
|
*/
|
|
@@ -713,8 +739,21 @@ function verifyTrapUrl(path, signature, nonce) {
|
|
|
713
739
|
*/
|
|
714
740
|
const inMemoryStore = {
|
|
715
741
|
_map: new Map(),
|
|
742
|
+
_timeouts: new Map(),
|
|
716
743
|
async get(key) { return this._map.get(key); },
|
|
717
|
-
async set(key, value
|
|
744
|
+
async set(key, value, ttl) {
|
|
745
|
+
this._map.set(key, value);
|
|
746
|
+
// If a timeout already exists for this key, clear it.
|
|
747
|
+
if (this._timeouts.has(key)) {
|
|
748
|
+
clearTimeout(this._timeouts.get(key));
|
|
749
|
+
this._timeouts.delete(key);
|
|
750
|
+
}
|
|
751
|
+
// If a TTL is provided, set a timeout to delete the key.
|
|
752
|
+
if (ttl && ttl > 0) {
|
|
753
|
+
const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
|
|
754
|
+
this._timeouts.set(key, timeoutId);
|
|
755
|
+
}
|
|
756
|
+
},
|
|
718
757
|
async has(key) { return this._map.has(key); },
|
|
719
758
|
async delete(key) { this._map.delete(key); },
|
|
720
759
|
};
|
|
@@ -737,7 +776,7 @@ export const configureStore = (externalStore) => {
|
|
|
737
776
|
* @param {object} context - The request context.
|
|
738
777
|
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
739
778
|
*/
|
|
740
|
-
async function resolveRequestIdentity(context) {
|
|
779
|
+
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
741
780
|
const existingDeviceId = context.cookies?.device_id;
|
|
742
781
|
const currentDeviceHash = getDeviceHash(context);
|
|
743
782
|
let deviceId = existingDeviceId;
|
|
@@ -766,7 +805,9 @@ async function resolveRequestIdentity(context) {
|
|
|
766
805
|
name: "device_id",
|
|
767
806
|
value: deviceId,
|
|
768
807
|
options: {
|
|
769
|
-
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
|
|
808
|
+
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
|
|
809
|
+
// Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
|
|
810
|
+
...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
|
|
770
811
|
}
|
|
771
812
|
};
|
|
772
813
|
|
|
@@ -851,7 +892,7 @@ async function getBehavioralIndicators(context, deviceData) {
|
|
|
851
892
|
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
|
|
852
893
|
*/
|
|
853
894
|
export const getSuspicionVector = async (context, securityConfig) => {
|
|
854
|
-
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
|
|
895
|
+
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
|
|
855
896
|
|
|
856
897
|
const clientIp = context.clientIp;
|
|
857
898
|
|
|
@@ -861,7 +902,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
861
902
|
context._newCookies = context._newCookies || [];
|
|
862
903
|
context._newCookies.push(newCookie);
|
|
863
904
|
}
|
|
864
|
-
await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
|
|
905
|
+
await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
|
|
865
906
|
|
|
866
907
|
// Periodically clean up device data
|
|
867
908
|
if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
|
|
@@ -873,8 +914,16 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
873
914
|
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
874
915
|
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
875
916
|
// Calculate the inconsistency score here, separately.
|
|
876
|
-
|
|
917
|
+
let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
|
|
918
|
+
|
|
919
|
+
// NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
|
|
920
|
+
if (consistencyScore < 0.7) { // Seuil de rupture
|
|
921
|
+
inconsistencyScore = 100;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
|
|
877
925
|
|
|
926
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
|
|
878
927
|
|
|
879
928
|
// Save the updated device state to the store
|
|
880
929
|
// Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
|
|
@@ -885,7 +934,8 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
885
934
|
if (Array.isArray(deviceData.ips)) {
|
|
886
935
|
deviceData.ips = new Set(deviceData.ips);
|
|
887
936
|
}
|
|
888
|
-
|
|
937
|
+
// Correction : Ajouter behaviorScore à l'objet retourné
|
|
938
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, requestPatternScore };
|
|
889
939
|
};
|
|
890
940
|
|
|
891
941
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -1084,14 +1134,17 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1084
1134
|
* Verifies a PoW solution based on a target and generates a ticket.
|
|
1085
1135
|
*/
|
|
1086
1136
|
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1087
|
-
clientIp,
|
|
1137
|
+
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1138
|
+
ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
|
|
1088
1139
|
nonce,
|
|
1089
1140
|
solution,
|
|
1090
1141
|
suspicionFactor,
|
|
1091
1142
|
clientSecret, // Le secret est maintenant requis
|
|
1092
1143
|
) {
|
|
1093
1144
|
const target = calculateTarget(suspicionFactor);
|
|
1094
|
-
const message = clientSecret
|
|
1145
|
+
const message = clientSecret
|
|
1146
|
+
? `${clientIp}:${nonce}:${solution}:${clientSecret}`
|
|
1147
|
+
: `${clientIp}:${nonce}:${solution}`;
|
|
1095
1148
|
const hash = crypto
|
|
1096
1149
|
.createHash("sha256")
|
|
1097
1150
|
.update(message)
|
|
@@ -1101,8 +1154,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
1101
1154
|
if (hashAsInt < target) {
|
|
1102
1155
|
// The comparison is direct with native BigInts
|
|
1103
1156
|
// The proof is valid, generate the ticket
|
|
1104
|
-
|
|
1105
|
-
|
|
1157
|
+
const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
|
|
1158
|
+
const signature = crypto
|
|
1106
1159
|
.createHmac("sha256", getPowSecret())
|
|
1107
1160
|
.update(`${clientIp}:${expiry}`)
|
|
1108
1161
|
.digest("hex");
|
|
@@ -1118,14 +1171,95 @@ const staticExtensions = new RegExp(
|
|
|
1118
1171
|
);
|
|
1119
1172
|
const isStaticResource = (path) => staticExtensions.test(path);
|
|
1120
1173
|
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
|
|
1177
|
+
* @param {number} suspicionScore - Le score de suspicion de la requête.
|
|
1178
|
+
* @returns {number} Le TTL optimal calculé en millisecondes.
|
|
1179
|
+
*/
|
|
1180
|
+
function determineOptimalTicketTtl(suspicionScore) {
|
|
1181
|
+
// Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
|
|
1182
|
+
const MIN_TTL = 300000;
|
|
1183
|
+
const MAX_TTL = 86400000;
|
|
1184
|
+
|
|
1185
|
+
const solverFunction = () => {
|
|
1186
|
+
const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
|
|
1187
|
+
|
|
1188
|
+
// Un "individu" est simplement une valeur de TTL en millisecondes.
|
|
1189
|
+
const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
|
|
1190
|
+
const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
|
|
1191
|
+
const mutate = (ttl) => {
|
|
1192
|
+
const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
|
|
1193
|
+
return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
const paretoFront = Optimization.geneticAlgorithmMultiObjective(
|
|
1197
|
+
createIndividual,
|
|
1198
|
+
fitnessFunction,
|
|
1199
|
+
crossover,
|
|
1200
|
+
mutate,
|
|
1201
|
+
{
|
|
1202
|
+
generations: 40,
|
|
1203
|
+
populationSize: 30,
|
|
1204
|
+
}
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
// Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
|
|
1208
|
+
// Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
|
|
1209
|
+
// en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
|
|
1210
|
+
if (!paretoFront || paretoFront.length === 0) {
|
|
1211
|
+
return { solution: null, fitness: Infinity };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Stratégie de sélection :
|
|
1215
|
+
// Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
|
|
1216
|
+
// Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
|
|
1217
|
+
let bestSolutionInFront;
|
|
1218
|
+
if (suspicionScore < 50) {
|
|
1219
|
+
bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
|
|
1220
|
+
} else {
|
|
1221
|
+
bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
|
|
1222
|
+
}
|
|
1223
|
+
return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
// On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
|
|
1227
|
+
const { bestResult } = Optimization.runMultiple(solverFunction, 20);
|
|
1228
|
+
|
|
1229
|
+
if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
|
|
1230
|
+
// Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
|
|
1231
|
+
return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
|
|
1235
|
+
// La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
|
|
1236
|
+
return bestResult.solution;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
|
|
1121
1240
|
// --- Middleware Proof-of-Work (Le péage) ---
|
|
1122
1241
|
export class FingerprintEngine {
|
|
1123
1242
|
constructor(securityConfig) {
|
|
1124
1243
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
1125
1244
|
this.securityConfig = securityConfig;
|
|
1126
1245
|
this.isProduction = isProduction;
|
|
1246
|
+
this._allowlist = this._buildAllowlist();
|
|
1127
1247
|
}
|
|
1128
|
-
|
|
1248
|
+
calculateFinalScore = function(suspicionVector) {
|
|
1249
|
+
const { weights } = this.securityConfig;
|
|
1250
|
+
if (!weights) return 0;
|
|
1251
|
+
|
|
1252
|
+
const score =
|
|
1253
|
+
(suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
|
|
1254
|
+
(suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
|
|
1255
|
+
(suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
|
|
1256
|
+
(suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
|
|
1257
|
+
(suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
|
|
1258
|
+
(suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
|
|
1259
|
+
(suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
|
|
1260
|
+
|
|
1261
|
+
return Math.min(100, score);
|
|
1262
|
+
}
|
|
1129
1263
|
/**
|
|
1130
1264
|
* Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
|
|
1131
1265
|
* This is the fastest check and should be performed first.
|
|
@@ -1133,50 +1267,31 @@ export class FingerprintEngine {
|
|
|
1133
1267
|
* @param {string} clientIp - The IP address of the client.
|
|
1134
1268
|
* @returns {boolean} True if the IP is in the allowlist.
|
|
1135
1269
|
*/
|
|
1136
|
-
|
|
1270
|
+
_buildAllowlist() {
|
|
1271
|
+
const blockList = new BlockList();
|
|
1137
1272
|
const { whitelist = [] } = this.securityConfig;
|
|
1138
1273
|
const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
|
|
1139
1274
|
|
|
1140
1275
|
if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
|
|
1141
|
-
return
|
|
1276
|
+
return blockList; // Retourne une liste vide
|
|
1142
1277
|
}
|
|
1143
1278
|
|
|
1144
|
-
const ip = parse(clientIp);
|
|
1145
|
-
const ipVersion = ip.family;
|
|
1146
|
-
|
|
1147
1279
|
for (const entry of allowlistRule.entries) {
|
|
1148
1280
|
if (entry.includes('/')) { // CIDR range
|
|
1149
1281
|
try {
|
|
1150
|
-
const [
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const rangeBytes = rangeIp.toBuffer();
|
|
1158
|
-
const mask = Buffer.alloc(ipBytes.length, 0xff);
|
|
1159
|
-
|
|
1160
|
-
for (let i = 0; i < Math.floor(prefix / 8); i++) {
|
|
1161
|
-
if (ipBytes[i] !== rangeBytes[i]) {
|
|
1162
|
-
break; // Mismatch in full byte, move to next entry
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
const remainingBits = prefix % 8;
|
|
1166
|
-
if (remainingBits > 0) {
|
|
1167
|
-
const byteIndex = Math.floor(prefix / 8);
|
|
1168
|
-
const bitmask = (0xff << (8 - remainingBits)) & 0xff;
|
|
1169
|
-
if ((ipBytes[byteIndex] & bitmask) !== (rangeBytes[byteIndex] & bitmask)) {
|
|
1170
|
-
continue; // Mismatch in partial byte
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
return true; // IP is in CIDR range
|
|
1174
|
-
} catch (e) { continue; /* Ignore invalid CIDR entries */ }
|
|
1175
|
-
} else if (entry === clientIp) { // Direct IP match
|
|
1176
|
-
return true;
|
|
1282
|
+
const [address, prefix] = entry.split('/');
|
|
1283
|
+
blockList.addSubnet(address, parseInt(prefix, 10));
|
|
1284
|
+
} catch (e) {
|
|
1285
|
+
// Ignore les entrées CIDR invalides
|
|
1286
|
+
}
|
|
1287
|
+
} else { // Direct IP match
|
|
1288
|
+
blockList.addAddress(entry);
|
|
1177
1289
|
}
|
|
1178
1290
|
}
|
|
1179
|
-
return
|
|
1291
|
+
return blockList;
|
|
1292
|
+
}
|
|
1293
|
+
_isIpInAllowlist(clientIp) {
|
|
1294
|
+
return this._allowlist.check(clientIp);
|
|
1180
1295
|
}
|
|
1181
1296
|
/**
|
|
1182
1297
|
* Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
|
|
@@ -1224,26 +1339,27 @@ export class FingerprintEngine {
|
|
|
1224
1339
|
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
1225
1340
|
|
|
1226
1341
|
if (!validHostname) {
|
|
1227
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
|
|
1342
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1228
1343
|
return false;
|
|
1229
1344
|
}
|
|
1230
1345
|
|
|
1231
1346
|
// 2. Forward DNS lookup
|
|
1232
1347
|
const addresses = await dns.resolve(validHostname);
|
|
1233
1348
|
if (addresses.includes(clientIp)) {
|
|
1234
|
-
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h
|
|
1349
|
+
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
1235
1350
|
return true;
|
|
1236
1351
|
}
|
|
1237
1352
|
} catch (error) {
|
|
1238
1353
|
// DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
|
|
1239
1354
|
}
|
|
1240
1355
|
|
|
1241
|
-
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
|
|
1356
|
+
await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
|
|
1242
1357
|
return false;
|
|
1243
1358
|
}
|
|
1244
1359
|
|
|
1245
1360
|
async processRequest(requestContext) {
|
|
1246
|
-
|
|
1361
|
+
|
|
1362
|
+
const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
|
|
1247
1363
|
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
1248
1364
|
if (isStatic) {
|
|
1249
1365
|
return { action: 'next', score: 0, vector: {} };
|
|
@@ -1257,13 +1373,13 @@ export class FingerprintEngine {
|
|
|
1257
1373
|
const { pow_nonce } = query;
|
|
1258
1374
|
|
|
1259
1375
|
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1260
|
-
// A legitimate user only hits these endpoints via the challenge page itself
|
|
1261
|
-
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot.
|
|
1262
|
-
// We check this early, before the main suspicion calculation.
|
|
1376
|
+
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1377
|
+
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
|
|
1263
1378
|
if (pow_nonce) {
|
|
1264
1379
|
const powCookie = cookies?.pow_clearance;
|
|
1265
1380
|
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
1266
1381
|
// This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
|
|
1382
|
+
// The final decision is made later, after calculating the score.
|
|
1267
1383
|
}
|
|
1268
1384
|
}
|
|
1269
1385
|
|
|
@@ -1272,8 +1388,72 @@ export class FingerprintEngine {
|
|
|
1272
1388
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
1273
1389
|
}
|
|
1274
1390
|
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1391
|
+
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
1392
|
+
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
1393
|
+
// avant même de recalculer le score de suspicion.
|
|
1394
|
+
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1395
|
+
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1396
|
+
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
1397
|
+
// car le TTL optimal en dépend.
|
|
1398
|
+
const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1399
|
+
const preliminaryScore = this.calculateFinalScore(preliminaryVector);
|
|
1400
|
+
let isValid = false;
|
|
1401
|
+
const challengeContext = await store.get(`secret:${pow_nonce}`);
|
|
1402
|
+
let ticket = null;
|
|
1403
|
+
|
|
1404
|
+
if (challengeContext) {
|
|
1405
|
+
const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
1406
|
+
if (pow_type === "cpu_target") {
|
|
1407
|
+
// On passe la durée de vie du ticket configurée
|
|
1408
|
+
ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1409
|
+
isValid = ticket !== null;
|
|
1410
|
+
} else if (pow_type === "cpu_mem") {
|
|
1411
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
|
|
1412
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
|
|
1413
|
+
isValid = cpuTicket !== null && isMemValid;
|
|
1414
|
+
if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (isValid) {
|
|
1419
|
+
// La solution est valide. On supprime le secret et on redirige.
|
|
1420
|
+
await store.delete(`secret:${pow_nonce}`);
|
|
1421
|
+
|
|
1422
|
+
if (logger) {
|
|
1423
|
+
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
return {
|
|
1427
|
+
action: 'redirect',
|
|
1428
|
+
path: path,
|
|
1429
|
+
score: 0, // Le score n'est pas pertinent ici, on a passé le test.
|
|
1430
|
+
vector: { challenge_solved: 100 },
|
|
1431
|
+
cookie: {
|
|
1432
|
+
name: 'pow_clearance',
|
|
1433
|
+
value: ticket,
|
|
1434
|
+
options: {
|
|
1435
|
+
httpOnly: true,
|
|
1436
|
+
secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
|
|
1437
|
+
maxAge: this.securityConfig.ticketMaxAge || 3600000,
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
// Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
|
|
1443
|
+
// sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
|
|
1444
|
+
// On pourrait même ajouter une pénalité ici si on le voulait.
|
|
1445
|
+
if (logger && challengeContext) {
|
|
1446
|
+
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
|
|
1447
|
+
} else if (logger && !challengeContext) {
|
|
1448
|
+
logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// --- FIN DE LA LOGIQUE DE PRIORITÉ ---
|
|
1452
|
+
|
|
1453
|
+
// Resolve identity and check for persisted "condemned" status early.
|
|
1454
|
+
const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
|
|
1455
|
+
const isNewDevice = !!newCookie;
|
|
1456
|
+
|
|
1277
1457
|
if (deviceData?.condemned) {
|
|
1278
1458
|
if (onDeviceCompromised) {
|
|
1279
1459
|
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
|
|
@@ -1281,30 +1461,25 @@ export class FingerprintEngine {
|
|
|
1281
1461
|
return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1282
1462
|
}
|
|
1283
1463
|
|
|
1284
|
-
// (NOUVEAU) Vérifier si un nouveau device_id a été créé lors de cette requête
|
|
1285
|
-
const isNewDevice = requestContext._newCookies?.some(c => c.name === 'device_id');
|
|
1286
|
-
|
|
1287
1464
|
// The engine now works with the context directly, no more rawReq dependency here.
|
|
1288
1465
|
const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1289
1466
|
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
|
1290
|
-
const { requestPatternScore } = getRequestPatternScore(requestContext, deviceData, this.securityConfig.patterns);
|
|
1291
1467
|
suspicionVector.honeypotScore = honeypotScore;
|
|
1292
|
-
|
|
1468
|
+
// Le behaviorScore est déjà dans le vecteur de suspicion via getSuspicionVector
|
|
1293
1469
|
|
|
1294
|
-
|
|
1295
|
-
suspicionVector.historyScore * (weights.historyScore || 0) +
|
|
1296
|
-
suspicionVector.rotationScore * (weights.rotationScore || 0) +
|
|
1297
|
-
suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) + suspicionVector.requestPatternScore * (weights.requestPatternScore || 0) +
|
|
1298
|
-
suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
|
|
1299
|
-
honeypotScore * (weights.honeypotScore || 0);
|
|
1470
|
+
let finalScore = this.calculateFinalScore(suspicionVector);
|
|
1300
1471
|
|
|
1301
1472
|
// Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
|
|
1302
1473
|
// Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
|
|
1303
|
-
|
|
1304
|
-
|
|
1474
|
+
// NOUVEAU : Cette logique est maintenant configurable.
|
|
1475
|
+
const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
|
|
1476
|
+
if (isNewDevice && finalScore < thresholds.low) {
|
|
1477
|
+
finalScore = thresholds.low;
|
|
1478
|
+
}
|
|
1305
1479
|
|
|
1306
1480
|
const isBlocked = finalScore >= (thresholds.block || 95);
|
|
1307
1481
|
|
|
1482
|
+
|
|
1308
1483
|
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
1309
1484
|
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1310
1485
|
const isSuspicious = finalScore >= thresholds.low;
|
|
@@ -1312,24 +1487,12 @@ export class FingerprintEngine {
|
|
|
1312
1487
|
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1313
1488
|
const suspicionFactor = isSuspicious
|
|
1314
1489
|
? Math.min(
|
|
1315
|
-
1,
|
|
1490
|
+
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
1316
1491
|
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
1317
1492
|
)
|
|
1318
1493
|
: 0;
|
|
1319
|
-
const powCookie = cookies?.pow_clearance;
|
|
1320
|
-
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1321
1494
|
|
|
1322
|
-
|
|
1323
|
-
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1324
|
-
if (pow_nonce && !isSuspicious) {
|
|
1325
|
-
if (logger) {
|
|
1326
|
-
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
|
|
1327
|
-
}
|
|
1328
|
-
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
1329
|
-
// Recalculate score and block immediately.
|
|
1330
|
-
const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
|
|
1331
|
-
return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
1332
|
-
}
|
|
1495
|
+
const powCookie = cookies?.pow_clearance;
|
|
1333
1496
|
|
|
1334
1497
|
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
1335
1498
|
if (isBlocked) {
|
|
@@ -1350,107 +1513,36 @@ export class FingerprintEngine {
|
|
|
1350
1513
|
if (logger) {
|
|
1351
1514
|
logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
|
|
1352
1515
|
}
|
|
1353
|
-
await store.set(`device:${cookies.device_id}`, deviceData);
|
|
1516
|
+
await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
|
|
1354
1517
|
return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1355
1518
|
}
|
|
1356
1519
|
|
|
1357
|
-
if (
|
|
1358
|
-
//
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
if (
|
|
1365
|
-
|
|
1366
|
-
ticket = verifyCpuTargetPoWAndGenerateTicket(
|
|
1367
|
-
clientIp,
|
|
1368
|
-
pow_nonce,
|
|
1369
|
-
pow_solution,
|
|
1370
|
-
suspicionFactor, // Pass the analog factor
|
|
1371
|
-
clientSecret,
|
|
1372
|
-
);
|
|
1373
|
-
isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
|
|
1374
|
-
// Verify combined challenge
|
|
1375
|
-
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
|
|
1376
|
-
clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
|
|
1377
|
-
);
|
|
1378
|
-
const minDifficulty = 16; // 16Mo
|
|
1379
|
-
const maxDifficulty = 48; // 48Mo
|
|
1380
|
-
const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
|
|
1381
|
-
const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
|
|
1382
|
-
|
|
1383
|
-
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
|
|
1384
|
-
|
|
1385
|
-
isValid = cpuTicket !== null && isMemValid;
|
|
1386
|
-
if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
|
|
1387
|
-
} else if (pow_type === "tsp") {
|
|
1388
|
-
// Logic for TSP remains the same
|
|
1389
|
-
// ...
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (isValid) {
|
|
1393
|
-
// The secret has been used, delete it to prevent replay.
|
|
1394
|
-
if (clientSecret) {
|
|
1395
|
-
await store.delete(`secret:${pow_nonce}`);
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
if (!ticket) {
|
|
1399
|
-
// If the ticket has not already been generated (CPU case)
|
|
1400
|
-
const expiry = Date.now() + 3600000; // 1 heure
|
|
1401
|
-
const signature = crypto
|
|
1402
|
-
.createHmac("sha256", getPowSecret())
|
|
1403
|
-
.update(`${clientIp}:${expiry}`)
|
|
1404
|
-
.digest("hex");
|
|
1405
|
-
ticket = `${expiry}:${signature}`;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
if (logger) {
|
|
1409
|
-
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
return {
|
|
1413
|
-
action: 'redirect',
|
|
1414
|
-
path: path,
|
|
1415
|
-
score: finalScore,
|
|
1416
|
-
vector: suspicionVector,
|
|
1417
|
-
cookie: {
|
|
1418
|
-
name: 'pow_clearance',
|
|
1419
|
-
value: ticket,
|
|
1420
|
-
options: {
|
|
1421
|
-
httpOnly: true,
|
|
1422
|
-
secure: this.isProduction,
|
|
1423
|
-
maxAge: 3600000,
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
};
|
|
1520
|
+
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1521
|
+
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1522
|
+
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1523
|
+
// If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
|
|
1524
|
+
// AND it's not a legitimate response to a challenge we issued, it's a probe.
|
|
1525
|
+
const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
|
|
1526
|
+
if (pow_nonce && !isChallengeResponse) {
|
|
1527
|
+
if (logger) {
|
|
1528
|
+
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
|
|
1427
1529
|
}
|
|
1530
|
+
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
1531
|
+
const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
|
|
1532
|
+
return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
1428
1533
|
}
|
|
1429
1534
|
|
|
1430
1535
|
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
1431
1536
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1432
1537
|
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
1433
1538
|
|
|
1434
|
-
// Store the secret with a short TTL (e.g., 5 minutes)
|
|
1435
|
-
await store.set(`secret:${nonce}`, clientSecret, 300);
|
|
1436
|
-
|
|
1437
|
-
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1438
|
-
if (deviceData) {
|
|
1439
|
-
deviceData.lastChallengeNonce = nonce;
|
|
1440
|
-
await store.set(`device:${cookies.device_id}`, deviceData);
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
if (logger) {
|
|
1444
|
-
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
1539
|
// LEVEL 3: CAPTCHA (the highest)
|
|
1448
1540
|
if (isSuspiciousHigh) {
|
|
1449
1541
|
// ... logic for TSP/Captcha challenge
|
|
1450
1542
|
}
|
|
1451
1543
|
|
|
1452
1544
|
// UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
|
|
1453
|
-
if (isSuspicious
|
|
1545
|
+
if (isSuspicious) { // Couvre à la fois low et medium
|
|
1454
1546
|
// Generate some trap URLs to embed in the challenge page.
|
|
1455
1547
|
// These links are visually hidden but present in the DOM to trap bots.
|
|
1456
1548
|
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
|
|
@@ -1467,10 +1559,44 @@ export class FingerprintEngine {
|
|
|
1467
1559
|
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
1468
1560
|
const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
|
|
1469
1561
|
|
|
1470
|
-
//
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1562
|
+
// Store the entire challenge context with a short TTL (e.g., 5 minutes)
|
|
1563
|
+
await store.set(`secret:${nonce}`, {
|
|
1564
|
+
clientSecret,
|
|
1565
|
+
cpuTarget: cpuChallengeDetails.target,
|
|
1566
|
+
memDifficulty: memDifficulty
|
|
1567
|
+
}, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
|
|
1568
|
+
|
|
1569
|
+
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1570
|
+
if (deviceData) {
|
|
1571
|
+
deviceData.lastChallengeNonce = nonce;
|
|
1572
|
+
await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (logger) {
|
|
1576
|
+
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Check if the request is an API request to return a JSON challenge
|
|
1580
|
+
const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
|
|
1581
|
+
|
|
1582
|
+
if (isApi) {
|
|
1583
|
+
// For API clients, send a JSON response with challenge details.
|
|
1584
|
+
const challengePayload = {
|
|
1585
|
+
challenge: {
|
|
1586
|
+
type: 'cpu_mem',
|
|
1587
|
+
nonce: nonce,
|
|
1588
|
+
clientSecret: clientSecret, // The client needs this to solve the challenge
|
|
1589
|
+
cpuTarget: cpuChallengeDetails.target,
|
|
1590
|
+
memDifficulty: memDifficulty,
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: challengePayload };
|
|
1594
|
+
} else {
|
|
1595
|
+
// For browsers, send the HTML page.
|
|
1596
|
+
const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
|
|
1597
|
+
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
|
|
1598
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
|
|
1599
|
+
}
|
|
1474
1600
|
}
|
|
1475
1601
|
}
|
|
1476
1602
|
|
|
@@ -1512,7 +1638,7 @@ export class FingerprintEngine {
|
|
|
1512
1638
|
if (ipProfile.statelessCount > statelessLimit) {
|
|
1513
1639
|
return `suspicious_high:${clientIp}`;
|
|
1514
1640
|
}
|
|
1515
|
-
await store.set(`ip:${clientIp}`, ipProfile);
|
|
1641
|
+
await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
|
|
1516
1642
|
|
|
1517
1643
|
const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
|
|
1518
1644
|
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
|
@@ -1522,8 +1648,9 @@ export class FingerprintEngine {
|
|
|
1522
1648
|
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
1523
1649
|
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
1524
1650
|
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
|
|
1525
|
-
honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
|
|
1526
|
-
requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0)
|
|
1651
|
+
honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
|
|
1652
|
+
requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
|
|
1653
|
+
vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
|
|
1527
1654
|
|
|
1528
1655
|
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
1529
1656
|
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
@@ -1662,10 +1789,11 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1662
1789
|
body: req.body,
|
|
1663
1790
|
headers: req.headers,
|
|
1664
1791
|
isStatic: isStaticResource(req.path),
|
|
1792
|
+
// Pass the original request object for the isApiRequest function
|
|
1793
|
+
rawReq: req,
|
|
1665
1794
|
// Add the newly required properties for full decoupling
|
|
1666
1795
|
rawHeaders: req.rawHeaders,
|
|
1667
1796
|
// Pass the raw request object for advanced inspection (e.g., JA3)
|
|
1668
|
-
rawReq: req,
|
|
1669
1797
|
httpVersion: req.httpVersion,
|
|
1670
1798
|
};
|
|
1671
1799
|
|
|
@@ -1686,7 +1814,11 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1686
1814
|
case 'block':
|
|
1687
1815
|
return res.status(decision.status).send(decision.body);
|
|
1688
1816
|
|
|
1689
|
-
case 'challenge':
|
|
1817
|
+
case 'challenge': // Gère à la fois les réponses HTML et JSON
|
|
1818
|
+
if (typeof decision.body === 'object' && decision.body !== null) {
|
|
1819
|
+
return res.status(decision.status).json(decision.body);
|
|
1820
|
+
}
|
|
1821
|
+
// Par défaut, envoie du HTML
|
|
1690
1822
|
return res.status(decision.status).send(decision.body);
|
|
1691
1823
|
|
|
1692
1824
|
case 'redirect':
|
|
@@ -1713,7 +1845,9 @@ export const __internal = {
|
|
|
1713
1845
|
cyrb53, // Export for testing
|
|
1714
1846
|
FingerprintBuilder, // Export for testing
|
|
1715
1847
|
calculateTarget,
|
|
1848
|
+
determineOptimalTicketTtl,
|
|
1716
1849
|
getRequestPatternScore, // Expose for testing
|
|
1850
|
+
getBehaviorScore, // Expose for testing
|
|
1717
1851
|
};
|
|
1718
1852
|
|
|
1719
1853
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|