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