@anonympins/fingerprint 0.0.9 → 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 +26 -10
- package/fingerprint.client.js +51 -2
- package/fingerprint.js +267 -132
- package/library.js +1577 -1446
- package/package.json +2 -2
package/fingerprint.js
CHANGED
|
@@ -383,6 +383,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
383
383
|
return `${expiry}:${signature}`;
|
|
384
384
|
};
|
|
385
385
|
|
|
386
|
+
|
|
387
|
+
|
|
386
388
|
/**
|
|
387
389
|
* Verifies a memory PoW solution.
|
|
388
390
|
* The server performs the same calculation to validate.
|
|
@@ -559,6 +561,28 @@ function getHoneypotScore(context, honeypotConfig = {}) {
|
|
|
559
561
|
return { honeypotScore: 0 };
|
|
560
562
|
}
|
|
561
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
|
+
|
|
562
586
|
/**
|
|
563
587
|
* Analyzes server-side request patterns for a given device to detect bot-like behavior.
|
|
564
588
|
* This is a stateful check that looks for repetitive or unnaturally fast requests.
|
|
@@ -752,7 +776,7 @@ export const configureStore = (externalStore) => {
|
|
|
752
776
|
* @param {object} context - The request context.
|
|
753
777
|
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
754
778
|
*/
|
|
755
|
-
async function resolveRequestIdentity(context) {
|
|
779
|
+
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
756
780
|
const existingDeviceId = context.cookies?.device_id;
|
|
757
781
|
const currentDeviceHash = getDeviceHash(context);
|
|
758
782
|
let deviceId = existingDeviceId;
|
|
@@ -781,7 +805,9 @@ async function resolveRequestIdentity(context) {
|
|
|
781
805
|
name: "device_id",
|
|
782
806
|
value: deviceId,
|
|
783
807
|
options: {
|
|
784
|
-
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 }),
|
|
785
811
|
}
|
|
786
812
|
};
|
|
787
813
|
|
|
@@ -866,7 +892,7 @@ async function getBehavioralIndicators(context, deviceData) {
|
|
|
866
892
|
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
|
|
867
893
|
*/
|
|
868
894
|
export const getSuspicionVector = async (context, securityConfig) => {
|
|
869
|
-
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
|
|
895
|
+
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
|
|
870
896
|
|
|
871
897
|
const clientIp = context.clientIp;
|
|
872
898
|
|
|
@@ -888,8 +914,16 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
888
914
|
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
889
915
|
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
890
916
|
// Calculate the inconsistency score here, separately.
|
|
891
|
-
|
|
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
|
|
892
925
|
|
|
926
|
+
const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
|
|
893
927
|
|
|
894
928
|
// Save the updated device state to the store
|
|
895
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.
|
|
@@ -900,7 +934,8 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
900
934
|
if (Array.isArray(deviceData.ips)) {
|
|
901
935
|
deviceData.ips = new Set(deviceData.ips);
|
|
902
936
|
}
|
|
903
|
-
|
|
937
|
+
// Correction : Ajouter behaviorScore à l'objet retourné
|
|
938
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, requestPatternScore };
|
|
904
939
|
};
|
|
905
940
|
|
|
906
941
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -1100,6 +1135,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
|
|
|
1100
1135
|
*/
|
|
1101
1136
|
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
1102
1137
|
clientIp, // This parameter is crucial and must be the actual client IP
|
|
1138
|
+
ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
|
|
1103
1139
|
nonce,
|
|
1104
1140
|
solution,
|
|
1105
1141
|
suspicionFactor,
|
|
@@ -1118,8 +1154,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
1118
1154
|
if (hashAsInt < target) {
|
|
1119
1155
|
// The comparison is direct with native BigInts
|
|
1120
1156
|
// The proof is valid, generate the ticket
|
|
1121
|
-
|
|
1122
|
-
|
|
1157
|
+
const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
|
|
1158
|
+
const signature = crypto
|
|
1123
1159
|
.createHmac("sha256", getPowSecret())
|
|
1124
1160
|
.update(`${clientIp}:${expiry}`)
|
|
1125
1161
|
.digest("hex");
|
|
@@ -1135,6 +1171,72 @@ const staticExtensions = new RegExp(
|
|
|
1135
1171
|
);
|
|
1136
1172
|
const isStaticResource = (path) => staticExtensions.test(path);
|
|
1137
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
|
+
|
|
1138
1240
|
// --- Middleware Proof-of-Work (Le péage) ---
|
|
1139
1241
|
export class FingerprintEngine {
|
|
1140
1242
|
constructor(securityConfig) {
|
|
@@ -1143,7 +1245,21 @@ export class FingerprintEngine {
|
|
|
1143
1245
|
this.isProduction = isProduction;
|
|
1144
1246
|
this._allowlist = this._buildAllowlist();
|
|
1145
1247
|
}
|
|
1146
|
-
|
|
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
|
+
}
|
|
1147
1263
|
/**
|
|
1148
1264
|
* Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
|
|
1149
1265
|
* This is the fastest check and should be performed first.
|
|
@@ -1242,6 +1358,7 @@ export class FingerprintEngine {
|
|
|
1242
1358
|
}
|
|
1243
1359
|
|
|
1244
1360
|
async processRequest(requestContext) {
|
|
1361
|
+
|
|
1245
1362
|
const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
|
|
1246
1363
|
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
1247
1364
|
if (isStatic) {
|
|
@@ -1256,13 +1373,13 @@ export class FingerprintEngine {
|
|
|
1256
1373
|
const { pow_nonce } = query;
|
|
1257
1374
|
|
|
1258
1375
|
// Honeypot: Direct probing of challenge endpoints is highly suspicious.
|
|
1259
|
-
// A legitimate user only hits these endpoints via the challenge page itself
|
|
1260
|
-
// If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot.
|
|
1261
|
-
// 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.
|
|
1262
1378
|
if (pow_nonce) {
|
|
1263
1379
|
const powCookie = cookies?.pow_clearance;
|
|
1264
1380
|
if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
|
|
1265
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.
|
|
1266
1383
|
}
|
|
1267
1384
|
}
|
|
1268
1385
|
|
|
@@ -1271,8 +1388,72 @@ export class FingerprintEngine {
|
|
|
1271
1388
|
return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
|
|
1272
1389
|
}
|
|
1273
1390
|
|
|
1274
|
-
//
|
|
1275
|
-
|
|
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
|
+
|
|
1276
1457
|
if (deviceData?.condemned) {
|
|
1277
1458
|
if (onDeviceCompromised) {
|
|
1278
1459
|
onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
|
|
@@ -1280,30 +1461,25 @@ export class FingerprintEngine {
|
|
|
1280
1461
|
return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1281
1462
|
}
|
|
1282
1463
|
|
|
1283
|
-
// (NOUVEAU) Vérifier si un nouveau device_id a été créé lors de cette requête
|
|
1284
|
-
const isNewDevice = requestContext._newCookies?.some(c => c.name === 'device_id');
|
|
1285
|
-
|
|
1286
1464
|
// The engine now works with the context directly, no more rawReq dependency here.
|
|
1287
1465
|
const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
1288
1466
|
const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
|
|
1289
|
-
const { requestPatternScore } = getRequestPatternScore(requestContext, deviceData, this.securityConfig.patterns);
|
|
1290
1467
|
suspicionVector.honeypotScore = honeypotScore;
|
|
1291
|
-
|
|
1468
|
+
// Le behaviorScore est déjà dans le vecteur de suspicion via getSuspicionVector
|
|
1292
1469
|
|
|
1293
|
-
|
|
1294
|
-
suspicionVector.historyScore * (weights.historyScore || 0) +
|
|
1295
|
-
suspicionVector.rotationScore * (weights.rotationScore || 0) +
|
|
1296
|
-
suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) + suspicionVector.requestPatternScore * (weights.requestPatternScore || 0) +
|
|
1297
|
-
suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
|
|
1298
|
-
honeypotScore * (weights.honeypotScore || 0);
|
|
1470
|
+
let finalScore = this.calculateFinalScore(suspicionVector);
|
|
1299
1471
|
|
|
1300
1472
|
// Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
|
|
1301
1473
|
// Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
+
}
|
|
1304
1479
|
|
|
1305
1480
|
const isBlocked = finalScore >= (thresholds.block || 95);
|
|
1306
1481
|
|
|
1482
|
+
|
|
1307
1483
|
const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
|
|
1308
1484
|
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1309
1485
|
const isSuspicious = finalScore >= thresholds.low;
|
|
@@ -1311,24 +1487,12 @@ export class FingerprintEngine {
|
|
|
1311
1487
|
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1312
1488
|
const suspicionFactor = isSuspicious
|
|
1313
1489
|
? Math.min(
|
|
1314
|
-
1,
|
|
1490
|
+
1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
|
|
1315
1491
|
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
1316
1492
|
)
|
|
1317
1493
|
: 0;
|
|
1318
|
-
const powCookie = cookies?.pow_clearance;
|
|
1319
|
-
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1320
1494
|
|
|
1321
|
-
|
|
1322
|
-
// A legitimate user only hits these endpoints via the challenge page itself.
|
|
1323
|
-
if (pow_nonce && !isSuspicious) {
|
|
1324
|
-
if (logger) {
|
|
1325
|
-
logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
|
|
1326
|
-
}
|
|
1327
|
-
suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
|
|
1328
|
-
// Recalculate score and block immediately.
|
|
1329
|
-
const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
|
|
1330
|
-
return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
|
|
1331
|
-
}
|
|
1495
|
+
const powCookie = cookies?.pow_clearance;
|
|
1332
1496
|
|
|
1333
1497
|
// If the action is to block, we should still include the score and vector for logging/testing.
|
|
1334
1498
|
if (isBlocked) {
|
|
@@ -1353,103 +1517,32 @@ export class FingerprintEngine {
|
|
|
1353
1517
|
return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
|
|
1354
1518
|
}
|
|
1355
1519
|
|
|
1356
|
-
if (
|
|
1357
|
-
//
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1365
|
-
ticket = verifyCpuTargetPoWAndGenerateTicket(
|
|
1366
|
-
clientIp,
|
|
1367
|
-
pow_nonce,
|
|
1368
|
-
pow_solution,
|
|
1369
|
-
suspicionFactor, // Pass the analog factor
|
|
1370
|
-
clientSecret,
|
|
1371
|
-
);
|
|
1372
|
-
isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
|
|
1373
|
-
// Verify combined challenge
|
|
1374
|
-
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
|
|
1375
|
-
clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
|
|
1376
|
-
);
|
|
1377
|
-
const minDifficulty = 16; // 16Mo
|
|
1378
|
-
const maxDifficulty = 48; // 48Mo
|
|
1379
|
-
const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
|
|
1380
|
-
const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
|
|
1381
|
-
|
|
1382
|
-
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
|
|
1383
|
-
|
|
1384
|
-
isValid = cpuTicket !== null && isMemValid;
|
|
1385
|
-
if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
|
|
1386
|
-
} else if (pow_type === "tsp") {
|
|
1387
|
-
// Logic for TSP remains the same
|
|
1388
|
-
// ...
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
if (isValid) {
|
|
1392
|
-
// The secret has been used, delete it to prevent replay.
|
|
1393
|
-
if (clientSecret) {
|
|
1394
|
-
await store.delete(`secret:${pow_nonce}`);
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
if (!ticket) {
|
|
1398
|
-
// If the ticket has not already been generated (CPU case)
|
|
1399
|
-
const expiry = Date.now() + 3600000; // 1 heure
|
|
1400
|
-
const signature = crypto
|
|
1401
|
-
.createHmac("sha256", getPowSecret())
|
|
1402
|
-
.update(`${clientIp}:${expiry}`)
|
|
1403
|
-
.digest("hex");
|
|
1404
|
-
ticket = `${expiry}:${signature}`;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
if (logger) {
|
|
1408
|
-
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
return {
|
|
1412
|
-
action: 'redirect',
|
|
1413
|
-
path: path,
|
|
1414
|
-
score: finalScore,
|
|
1415
|
-
vector: suspicionVector,
|
|
1416
|
-
cookie: {
|
|
1417
|
-
name: 'pow_clearance',
|
|
1418
|
-
value: ticket,
|
|
1419
|
-
options: {
|
|
1420
|
-
httpOnly: true,
|
|
1421
|
-
secure: this.isProduction,
|
|
1422
|
-
maxAge: 3600000,
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
};
|
|
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() });
|
|
1426
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 };
|
|
1427
1533
|
}
|
|
1428
1534
|
|
|
1429
1535
|
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
1430
1536
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1431
1537
|
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
1432
1538
|
|
|
1433
|
-
// Store the secret with a short TTL (e.g., 5 minutes)
|
|
1434
|
-
await store.set(`secret:${nonce}`, clientSecret, 300);
|
|
1435
|
-
|
|
1436
|
-
// Associate the current challenge nonce with the device for trap URL verification later.
|
|
1437
|
-
if (deviceData) {
|
|
1438
|
-
deviceData.lastChallengeNonce = nonce; // No TTL, part of the main device object
|
|
1439
|
-
await store.set(`device:${cookies.device_id}`, deviceData);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
if (logger) {
|
|
1443
|
-
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
1539
|
// LEVEL 3: CAPTCHA (the highest)
|
|
1447
1540
|
if (isSuspiciousHigh) {
|
|
1448
1541
|
// ... logic for TSP/Captcha challenge
|
|
1449
1542
|
}
|
|
1450
1543
|
|
|
1451
1544
|
// UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
|
|
1452
|
-
if (isSuspicious
|
|
1545
|
+
if (isSuspicious) { // Couvre à la fois low et medium
|
|
1453
1546
|
// Generate some trap URLs to embed in the challenge page.
|
|
1454
1547
|
// These links are visually hidden but present in the DOM to trap bots.
|
|
1455
1548
|
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
|
|
@@ -1466,10 +1559,44 @@ export class FingerprintEngine {
|
|
|
1466
1559
|
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
1467
1560
|
const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
|
|
1468
1561
|
|
|
1469
|
-
//
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
+
}
|
|
1473
1600
|
}
|
|
1474
1601
|
}
|
|
1475
1602
|
|
|
@@ -1521,8 +1648,9 @@ export class FingerprintEngine {
|
|
|
1521
1648
|
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
1522
1649
|
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
1523
1650
|
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
|
|
1524
|
-
honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
|
|
1525
|
-
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);
|
|
1526
1654
|
|
|
1527
1655
|
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
1528
1656
|
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
@@ -1661,10 +1789,11 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1661
1789
|
body: req.body,
|
|
1662
1790
|
headers: req.headers,
|
|
1663
1791
|
isStatic: isStaticResource(req.path),
|
|
1792
|
+
// Pass the original request object for the isApiRequest function
|
|
1793
|
+
rawReq: req,
|
|
1664
1794
|
// Add the newly required properties for full decoupling
|
|
1665
1795
|
rawHeaders: req.rawHeaders,
|
|
1666
1796
|
// Pass the raw request object for advanced inspection (e.g., JA3)
|
|
1667
|
-
rawReq: req,
|
|
1668
1797
|
httpVersion: req.httpVersion,
|
|
1669
1798
|
};
|
|
1670
1799
|
|
|
@@ -1685,7 +1814,11 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1685
1814
|
case 'block':
|
|
1686
1815
|
return res.status(decision.status).send(decision.body);
|
|
1687
1816
|
|
|
1688
|
-
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
|
|
1689
1822
|
return res.status(decision.status).send(decision.body);
|
|
1690
1823
|
|
|
1691
1824
|
case 'redirect':
|
|
@@ -1712,7 +1845,9 @@ export const __internal = {
|
|
|
1712
1845
|
cyrb53, // Export for testing
|
|
1713
1846
|
FingerprintBuilder, // Export for testing
|
|
1714
1847
|
calculateTarget,
|
|
1848
|
+
determineOptimalTicketTtl,
|
|
1715
1849
|
getRequestPatternScore, // Expose for testing
|
|
1850
|
+
getBehaviorScore, // Expose for testing
|
|
1716
1851
|
};
|
|
1717
1852
|
|
|
1718
1853
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|