@anonympins/fingerprint 0.4.4 → 0.4.6
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 +40 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/js/dynamic-wasm.js +180 -0
- package/src/js/fingerprint.client.js +21 -0
- package/src/js/fingerprint.js +763 -56
- package/src/js/library.js +1740 -1729
- package/src/js/pow.solver.inline.js +381 -285
- package/src/js/pow.solver.js +97 -1
- package/src/js/tests/dynamic-wasm.test.js +78 -0
- package/src/js/tests/fingerprint.client.test.js +128 -104
- package/src/js/tests/fingerprint.test.js +79 -1
- package/src/js/tests/ip-reputation.test.js +141 -131
- package/src/js/tests/tcpFingerprint.test.js +78 -0
- package/src/php/AutoTuner.php +1 -1
- package/src/php/Challenge/ChallengeUtils.php +174 -12
- package/src/php/Config/SecurityProfiles.php +10 -0
- package/src/php/FingerprintEngine.php +140 -8
- package/src/php/Optimization/Optimization.php +257 -255
- package/src/php/Optimization/OptimizationOperators.php +41 -35
- package/src/php/RequestContext.php +2 -0
- package/src/php/Tests/FingerprintEngineTest.php +132 -1
- package/src/php/Tests/IpReputationTest.php +175 -156
- package/src/php/Tests/RequestUtilsTest.php +13 -0
- package/src/php/Utils/RequestUtils.php +319 -5
package/src/js/fingerprint.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as dns from "node:dns/promises";
|
|
|
4
4
|
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
5
5
|
import {Optimization} from "./library.js";
|
|
6
6
|
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
7
|
+
import {DynamicWasmGenerator} from "./dynamic-wasm.js";
|
|
7
8
|
import {readFileSync, existsSync} from "node:fs";
|
|
8
9
|
import {fileURLToPath} from "node:url";
|
|
9
10
|
import {dirname, join, resolve} from "node:path";
|
|
@@ -14,6 +15,198 @@ const __dirname = dirname(__filename);
|
|
|
14
15
|
export { createRedisStore } from "./redis-store.js";
|
|
15
16
|
export { createMongoDbStore } from "./mongodb-store.js";
|
|
16
17
|
|
|
18
|
+
let activeMappings = [];
|
|
19
|
+
let lastMappingTime = 0;
|
|
20
|
+
let isCompilingMapping = false;
|
|
21
|
+
const MAPPING_ROTATION_INTERVAL = 60000; // 60 seconds
|
|
22
|
+
|
|
23
|
+
function generateSessionMapping() {
|
|
24
|
+
const randomStr = (len = 6) => crypto.randomBytes(len).toString('hex').replace(/[0-9]/g, 'g').substring(0, len);
|
|
25
|
+
const randomHeader = () => `X-Sess-${crypto.randomBytes(4).toString('hex')}`;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
headers: {
|
|
29
|
+
'x-device-fingerprint': randomHeader(),
|
|
30
|
+
'x-behavior-metrics': randomHeader(),
|
|
31
|
+
},
|
|
32
|
+
globals: {
|
|
33
|
+
'ClientLibrary': `ClientLib_${randomStr(6)}`,
|
|
34
|
+
'getDeviceFingerprint': `getFP_${randomStr(6)}`,
|
|
35
|
+
'getClientBehaviorMetrics': `getMetrics_${randomStr(6)}`,
|
|
36
|
+
},
|
|
37
|
+
keys: {
|
|
38
|
+
'ua': randomStr(4),
|
|
39
|
+
'hw': randomStr(4),
|
|
40
|
+
'geo': randomStr(4),
|
|
41
|
+
'scr': randomStr(4),
|
|
42
|
+
'os': randomStr(4),
|
|
43
|
+
'gpu': randomStr(4),
|
|
44
|
+
'cvs': randomStr(4),
|
|
45
|
+
'cdp': randomStr(4),
|
|
46
|
+
'bot': randomStr(4),
|
|
47
|
+
'wasm': randomStr(4),
|
|
48
|
+
},
|
|
49
|
+
wasmConstants: {
|
|
50
|
+
seed: crypto.randomBytes(4).readInt32LE(0),
|
|
51
|
+
multiplier: crypto.randomBytes(4).readInt32LE(0) | 1,
|
|
52
|
+
adder: crypto.randomBytes(4).readInt32LE(0)
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function compilePolymorphicJs(mapping) {
|
|
58
|
+
const clientScriptPath = join(__dirname, 'fingerprint.client.js');
|
|
59
|
+
let jsCode = '';
|
|
60
|
+
try {
|
|
61
|
+
jsCode = readFileSync(clientScriptPath, 'utf-8');
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error('[Fingerprint] Could not read fingerprint.client.js for dynamic obfuscation. Fallback to obfuscated build.');
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync(join(__dirname, 'fingerprint.client.obfuscated.js'), 'utf-8');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
jsCode = jsCode.replace(/X-Device-Fingerprint/g, mapping.headers['x-device-fingerprint']);
|
|
72
|
+
jsCode = jsCode.replace(/X-Behavior-Metrics/g, mapping.headers['x-behavior-metrics']);
|
|
73
|
+
jsCode = jsCode.replace(/ClientLibrary/g, mapping.globals['ClientLibrary']);
|
|
74
|
+
jsCode = jsCode.replace(/getDeviceFingerprint/g, mapping.globals['getDeviceFingerprint']);
|
|
75
|
+
jsCode = jsCode.replace(/getClientBehaviorMetrics/g, mapping.globals['getClientBehaviorMetrics']);
|
|
76
|
+
|
|
77
|
+
for (const [origKey, randKey] of Object.entries(mapping.keys)) {
|
|
78
|
+
const regex1 = new RegExp(`add\\(["']${origKey}["']`, 'g');
|
|
79
|
+
jsCode = jsCode.replace(regex1, `add("${randKey}"`);
|
|
80
|
+
|
|
81
|
+
const regex2 = new RegExp(`addRaw\\(["']${origKey}["']`, 'g');
|
|
82
|
+
jsCode = jsCode.replace(regex2, `addRaw("${randKey}"`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const obfuscationResult = JavaScriptObfuscator.obfuscate(jsCode, {
|
|
86
|
+
compact: true,
|
|
87
|
+
controlFlowFlattening: true,
|
|
88
|
+
deadCodeInjection: true,
|
|
89
|
+
stringArray: true,
|
|
90
|
+
stringArrayRotate: true,
|
|
91
|
+
stringArrayShuffle: true,
|
|
92
|
+
seed: Math.abs(mapping.wasmConstants.seed),
|
|
93
|
+
selfDefending: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return obfuscationResult.getObfuscatedCode();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function ensureLatestMapping() {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if ((now - lastMappingTime > MAPPING_ROTATION_INTERVAL || activeMappings.length === 0) && !isCompilingMapping) {
|
|
102
|
+
isCompilingMapping = true;
|
|
103
|
+
try {
|
|
104
|
+
const mapping = generateSessionMapping();
|
|
105
|
+
const polymorphicJs = await compilePolymorphicJs(mapping);
|
|
106
|
+
const polymorphicWasm = DynamicWasmGenerator.generate(mapping.wasmConstants);
|
|
107
|
+
|
|
108
|
+
mapping.jsBuffer = Buffer.from(polymorphicJs, 'utf8');
|
|
109
|
+
mapping.wasmBuffer = polymorphicWasm;
|
|
110
|
+
mapping.timestamp = now;
|
|
111
|
+
|
|
112
|
+
activeMappings.unshift(mapping);
|
|
113
|
+
if (activeMappings.length > 5) {
|
|
114
|
+
activeMappings.pop();
|
|
115
|
+
}
|
|
116
|
+
lastMappingTime = now;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await store.set('active-polymorphic-mappings', activeMappings.map(m => ({
|
|
120
|
+
headers: m.headers,
|
|
121
|
+
keys: m.keys
|
|
122
|
+
})));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Ignore
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
isCompilingMapping = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getActiveMappingForRequest(headers) {
|
|
133
|
+
if (!headers) return null;
|
|
134
|
+
for (const mapping of activeMappings) {
|
|
135
|
+
const headerName = mapping.headers['x-device-fingerprint'].toLowerCase();
|
|
136
|
+
if (headers[headerName]) {
|
|
137
|
+
return mapping;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function decodePolymorphicFingerprint(fpString, mapping) {
|
|
144
|
+
if (!fpString || !mapping || !mapping.keys) return fpString;
|
|
145
|
+
const reverseKeys = {};
|
|
146
|
+
for (const [orig, rand] of Object.entries(mapping.keys)) {
|
|
147
|
+
reverseKeys[rand] = orig;
|
|
148
|
+
}
|
|
149
|
+
const parts = fpString.split('|');
|
|
150
|
+
const mappedParts = parts.map(part => {
|
|
151
|
+
const pair = part.split(':');
|
|
152
|
+
if (pair.length === 2) {
|
|
153
|
+
const origKey = reverseKeys[pair[0]] || pair[0];
|
|
154
|
+
return `${origKey}:${pair[1]}`;
|
|
155
|
+
}
|
|
156
|
+
return part;
|
|
157
|
+
});
|
|
158
|
+
return mappedParts.join('|');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const base64UrlEncode = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
162
|
+
const base64UrlDecode = (str) => {
|
|
163
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
164
|
+
while (base64.length % 4) {
|
|
165
|
+
base64 += '=';
|
|
166
|
+
}
|
|
167
|
+
return Buffer.from(base64, 'base64');
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export function generateStatelessTicket(payload) {
|
|
171
|
+
const secret = getPowSecret();
|
|
172
|
+
const key = crypto.createHash('sha256').update(secret).digest();
|
|
173
|
+
const iv = crypto.randomBytes(16);
|
|
174
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
175
|
+
let encrypted = cipher.update(JSON.stringify(payload));
|
|
176
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
177
|
+
|
|
178
|
+
const signature = crypto.createHmac('sha256', key).update(Buffer.concat([iv, encrypted])).digest();
|
|
179
|
+
return `${base64UrlEncode(iv)}.${base64UrlEncode(encrypted)}.${base64UrlEncode(signature)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseStatelessTicket(ticket) {
|
|
183
|
+
try {
|
|
184
|
+
const parts = ticket.split('.');
|
|
185
|
+
if (parts.length !== 3) return null;
|
|
186
|
+
|
|
187
|
+
const iv = base64UrlDecode(parts[0]);
|
|
188
|
+
const encrypted = base64UrlDecode(parts[1]);
|
|
189
|
+
const signature = base64UrlDecode(parts[2]);
|
|
190
|
+
|
|
191
|
+
if (iv.length !== 16) return null;
|
|
192
|
+
|
|
193
|
+
const secret = getPowSecret();
|
|
194
|
+
const key = crypto.createHash('sha256').update(secret).digest();
|
|
195
|
+
|
|
196
|
+
const expectedSignature = crypto.createHmac('sha256', key).update(Buffer.concat([iv, encrypted])).digest();
|
|
197
|
+
if (signature.length !== expectedSignature.length || !crypto.timingSafeEqual(signature, expectedSignature)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
202
|
+
let decrypted = decipher.update(encrypted);
|
|
203
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
204
|
+
|
|
205
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
17
210
|
|
|
18
211
|
/**
|
|
19
212
|
* Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
|
|
@@ -493,6 +686,7 @@ function getCompositeDeviceHash(context) {
|
|
|
493
686
|
"ch_model": "sec-ch-ua-model",
|
|
494
687
|
"ch_arch": "sec-ch-ua-arch",
|
|
495
688
|
"ch_bitness": "sec-ch-ua-bitness",
|
|
689
|
+
"ch_full_version_list": "sec-ch-ua-full-version-list",
|
|
496
690
|
"upgrade_req": "upgrade-insecure-requests",
|
|
497
691
|
"accept_lang": "accept-language",
|
|
498
692
|
"accept_enc": "accept-encoding",
|
|
@@ -835,18 +1029,16 @@ export const verifyPoWAndGenerateTicket = async (
|
|
|
835
1029
|
return null;
|
|
836
1030
|
}
|
|
837
1031
|
|
|
838
|
-
// 2. Generate
|
|
839
|
-
const ticketId = crypto.randomUUID();
|
|
1032
|
+
// 2. Generate a signed and encrypted stateless ticket
|
|
840
1033
|
const expiry = Date.now() + 3600000; // 1 hour
|
|
841
|
-
|
|
842
|
-
await store.set(`ticket:${ticketId}`, {
|
|
1034
|
+
const payload = {
|
|
843
1035
|
expiry,
|
|
844
1036
|
originalIp: ip,
|
|
845
1037
|
deviceId,
|
|
846
1038
|
deviceHash
|
|
847
|
-
}
|
|
1039
|
+
};
|
|
848
1040
|
|
|
849
|
-
return
|
|
1041
|
+
return generateStatelessTicket(payload);
|
|
850
1042
|
};
|
|
851
1043
|
|
|
852
1044
|
|
|
@@ -898,11 +1090,56 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
|
|
|
898
1090
|
}
|
|
899
1091
|
return finalHash === parseInt(solution, 10);
|
|
900
1092
|
};
|
|
1093
|
+
|
|
1094
|
+
export function verifySpacePoW(nonce, solution, queries, seed, clientSecret) {
|
|
1095
|
+
const combined = new Uint8Array(queries.length * 1024);
|
|
1096
|
+
for (let i = 0; i < queries.length; i++) {
|
|
1097
|
+
const idx = queries[i];
|
|
1098
|
+
const block = generateBlock(seed, idx);
|
|
1099
|
+
combined.set(block, i * 1024);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const nonceBytes = Buffer.from(nonce + ":" + clientSecret, "utf8");
|
|
1103
|
+
const finalBlock = Buffer.concat([Buffer.from(combined), nonceBytes]);
|
|
1104
|
+
|
|
1105
|
+
const hash = crypto.createHash("sha256").update(finalBlock).digest("hex");
|
|
1106
|
+
return hash === solution;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function generateBlock(seed, blockIndex, blockSize = 1024) {
|
|
1110
|
+
const block = new Uint8Array(blockSize);
|
|
1111
|
+
let h = cyrb53(seed + ":" + blockIndex);
|
|
1112
|
+
for (let i = 0; i < blockSize; i++) {
|
|
1113
|
+
h = Math.imul(h ^ i, 1597334677);
|
|
1114
|
+
block[i] = h & 0xff;
|
|
1115
|
+
}
|
|
1116
|
+
return block;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
901
1119
|
export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '', allowCrossNetworkRoaming = false) => {
|
|
902
1120
|
// Input validation: ensure the ticket is a non-empty string with the correct format.
|
|
903
1121
|
if (typeof ticket !== 'string' || ticket.length === 0) return false;
|
|
904
1122
|
|
|
905
|
-
// 1. Resolve
|
|
1123
|
+
// 1. Resolve stateless ticket first (zero database I/O cost)
|
|
1124
|
+
const statelessData = parseStatelessTicket(ticket);
|
|
1125
|
+
if (statelessData) {
|
|
1126
|
+
const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = statelessData;
|
|
1127
|
+
|
|
1128
|
+
if (!expiry || Date.now() > expiry) {
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (ip === originalIp) return true;
|
|
1133
|
+
const currentSubnet = getIpSubnet(ip);
|
|
1134
|
+
const originalSubnet = getIpSubnet(originalIp);
|
|
1135
|
+
if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
|
|
1136
|
+
|
|
1137
|
+
if (!allowCrossNetworkRoaming) return false;
|
|
1138
|
+
|
|
1139
|
+
return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// 2. Resolve opaque ticket session from server-side store
|
|
906
1143
|
const ticketData = await store.get(`ticket:${ticket}`);
|
|
907
1144
|
if (ticketData) {
|
|
908
1145
|
const { expiry, originalIp, deviceId: storedDeviceId, deviceHash: storedDeviceHash } = ticketData;
|
|
@@ -922,7 +1159,7 @@ export const isTicketValid = async (ip, ticket, deviceId = '', deviceHash = '',
|
|
|
922
1159
|
return !!(deviceId && deviceId === storedDeviceId && deviceHash && deviceHash === storedDeviceHash);
|
|
923
1160
|
}
|
|
924
1161
|
|
|
925
|
-
//
|
|
1162
|
+
// 3. Legacy fallback verification (backward compatibility for old client tokens)
|
|
926
1163
|
let expiry, originalIp, sig;
|
|
927
1164
|
if (ticket.includes('|')) {
|
|
928
1165
|
const parts = ticket.split('|');
|
|
@@ -1018,6 +1255,66 @@ function getHeaderAnomalies(context) {
|
|
|
1018
1255
|
};
|
|
1019
1256
|
}
|
|
1020
1257
|
|
|
1258
|
+
export function generateSpaceChallenge(clientIp, nonce, suspicionFactor, originalUrl, securityConfig) {
|
|
1259
|
+
const sizeMb = securityConfig?.pospace?.sizeMb || 100;
|
|
1260
|
+
const numQueries = securityConfig?.pospace?.numQueries || 10;
|
|
1261
|
+
|
|
1262
|
+
const queries = [];
|
|
1263
|
+
const maxBlocks = sizeMb * 1024;
|
|
1264
|
+
while (queries.length < numQueries) {
|
|
1265
|
+
const idx = Math.floor(Math.random() * maxBlocks);
|
|
1266
|
+
if (!queries.includes(idx)) {
|
|
1267
|
+
queries.push(idx);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return {
|
|
1272
|
+
type: "pospace",
|
|
1273
|
+
nonce: nonce,
|
|
1274
|
+
sizeMb,
|
|
1275
|
+
queries,
|
|
1276
|
+
path: originalUrl
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function generateSpaceChallengePage(challengeDetails, clientSecret, securityConfig) {
|
|
1281
|
+
const { nonce, sizeMb, queries, path } = challengeDetails;
|
|
1282
|
+
const solverCode = getPowSolverCode();
|
|
1283
|
+
|
|
1284
|
+
const challengeScript = `
|
|
1285
|
+
async function solve() {
|
|
1286
|
+
const nonce = ${JSON.stringify(nonce)};
|
|
1287
|
+
const path = ${JSON.stringify(path)};
|
|
1288
|
+
const clientSecret = ${JSON.stringify(clientSecret)};
|
|
1289
|
+
const queries = ${JSON.stringify(queries)};
|
|
1290
|
+
const sizeMb = ${sizeMb};
|
|
1291
|
+
|
|
1292
|
+
document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
|
|
1293
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1294
|
+
|
|
1295
|
+
try {
|
|
1296
|
+
await window.initializeSpace(nonce + ":" + clientSecret, sizeMb);
|
|
1297
|
+
document.getElementById('loader').innerText = '⚙️ Generating Proof of Space...';
|
|
1298
|
+
const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret);
|
|
1299
|
+
|
|
1300
|
+
window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash;
|
|
1301
|
+
} catch(e) {
|
|
1302
|
+
document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
solve();
|
|
1306
|
+
`;
|
|
1307
|
+
|
|
1308
|
+
return `<html><head><title>Security Check</title></head>
|
|
1309
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
1310
|
+
<h1>Security Check (Level 2)</h1>
|
|
1311
|
+
<p>We are verifying your storage allocation. This may take a few seconds on first load.</p>
|
|
1312
|
+
<div id="loader" style="margin:20px;">⚙️ Initializing storage space...</div>
|
|
1313
|
+
<script>${solverCode}</script>
|
|
1314
|
+
<script>${challengeScript}</script>
|
|
1315
|
+
</body></html>`;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1021
1318
|
/**
|
|
1022
1319
|
* Checks for submitted honeypot fields to detect bots.
|
|
1023
1320
|
* @param {object} context - The request context.
|
|
@@ -1400,11 +1697,27 @@ function getCrossLayerInconsistency(context) {
|
|
|
1400
1697
|
const clientScreenHash = clientFpMap.get('scr');
|
|
1401
1698
|
const viewportWidth = context.headers['sec-ch-viewport-width'];
|
|
1402
1699
|
if (clientScreenHash && viewportWidth) {
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1700
|
+
const viewportWidthInt = parseInt(viewportWidth, 10);
|
|
1701
|
+
let matchedScreenWidth = null;
|
|
1702
|
+
const commonWidths = [320, 360, 375, 390, 412, 414, 768, 1024, 1280, 1366, 1440, 1536, 1600, 1920, 2560, 3840];
|
|
1703
|
+
const commonHeights = [480, 568, 640, 667, 736, 800, 812, 844, 896, 900, 1024, 1080, 1200, 1440, 1600, 2160];
|
|
1704
|
+
const commonDepths = [24, 30, 32];
|
|
1705
|
+
|
|
1706
|
+
for (const w of commonWidths) {
|
|
1707
|
+
for (const h of commonHeights) {
|
|
1708
|
+
for (const d of commonDepths) {
|
|
1709
|
+
const candidate = `${w}x${h}_${d}`;
|
|
1710
|
+
if (clientScreenHash === String(cyrb53(candidate))) {
|
|
1711
|
+
matchedScreenWidth = w;
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
if (matchedScreenWidth !== null) break;
|
|
1716
|
+
}
|
|
1717
|
+
if (matchedScreenWidth !== null) break;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (matchedScreenWidth !== null && viewportWidthInt > matchedScreenWidth) {
|
|
1408
1721
|
score += 20;
|
|
1409
1722
|
}
|
|
1410
1723
|
}
|
|
@@ -1415,10 +1728,17 @@ function getCrossLayerInconsistency(context) {
|
|
|
1415
1728
|
const clientGpuHash = clientFpMap.get('gpu');
|
|
1416
1729
|
const ja3 = getTlsFingerprint(context)?.ja3;
|
|
1417
1730
|
if (clientGpuHash && ja3) {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1731
|
+
let expectedBrowsers = tlsFingerprintDb[ja3];
|
|
1732
|
+
if (expectedBrowsers) {
|
|
1733
|
+
if (!Array.isArray(expectedBrowsers)) {
|
|
1734
|
+
expectedBrowsers = [expectedBrowsers];
|
|
1735
|
+
}
|
|
1736
|
+
const nonBrowserLibraries = ['Python', 'Go', 'Java', 'curl'];
|
|
1737
|
+
const isLibrary = expectedBrowsers.some(lib => nonBrowserLibraries.includes(lib));
|
|
1738
|
+
if (isLibrary) {
|
|
1739
|
+
score += 30;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1422
1742
|
}
|
|
1423
1743
|
|
|
1424
1744
|
return { crossLayerInconsistencyScore: Math.min(100, score) };
|
|
@@ -1647,6 +1967,48 @@ function getClientHintsInconsistencyScore(context) {
|
|
|
1647
1967
|
return { clientHintsInconsistencyScore: 0 };
|
|
1648
1968
|
}
|
|
1649
1969
|
|
|
1970
|
+
const fullVersionList = context.headers['sec-ch-ua-full-version-list'];
|
|
1971
|
+
if (fullVersionList) {
|
|
1972
|
+
let chFullVersion = null;
|
|
1973
|
+
let chFullBrowser = null;
|
|
1974
|
+
const matches = [...fullVersionList.matchAll(/"([^"]+)";v="([^"]+)"/g)];
|
|
1975
|
+
for (const match of matches) {
|
|
1976
|
+
const brand = match[1];
|
|
1977
|
+
const version = match[2];
|
|
1978
|
+
if (brand === 'Google Chrome' || brand === 'Chromium' || brand === 'Microsoft Edge') {
|
|
1979
|
+
chFullVersion = version;
|
|
1980
|
+
chFullBrowser = brand === 'Microsoft Edge' ? 'Edge' : 'Chrome';
|
|
1981
|
+
if (brand === 'Google Chrome' || brand === 'Microsoft Edge') {
|
|
1982
|
+
break;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
if (chFullVersion && chFullBrowser) {
|
|
1987
|
+
let uaFullVersion = null;
|
|
1988
|
+
const uaFullMatch = ua.match(/(Chrome|Edg)\/([\d\.]+)/);
|
|
1989
|
+
if (uaFullMatch) {
|
|
1990
|
+
const uaBrowserMapped = uaFullMatch[1] === 'Edg' ? 'Edge' : 'Chrome';
|
|
1991
|
+
uaFullVersion = uaFullMatch[2];
|
|
1992
|
+
if (uaBrowserMapped === chFullBrowser && uaFullVersion !== chFullVersion) {
|
|
1993
|
+
const parts1 = uaFullVersion.split('.').map(Number);
|
|
1994
|
+
const parts2 = chFullVersion.split('.').map(Number);
|
|
1995
|
+
let diffIndex = -1;
|
|
1996
|
+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
1997
|
+
if ((parts1[i] || 0) !== (parts2[i] || 0)) {
|
|
1998
|
+
diffIndex = i;
|
|
1999
|
+
break;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const baseScores = [95, 90, 85, 80];
|
|
2003
|
+
const baseScore = baseScores[diffIndex] || 80;
|
|
2004
|
+
const delta = Math.abs((parts1[diffIndex] || 0) - (parts2[diffIndex] || 0));
|
|
2005
|
+
const finalFullScore = Math.min(100, baseScore + Math.min(5, delta * 5));
|
|
2006
|
+
return { clientHintsInconsistencyScore: finalFullScore };
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1650
2012
|
// 1. Extract browser and version from User-Agent
|
|
1651
2013
|
let uaVersion = null;
|
|
1652
2014
|
let uaBrowser = null;
|
|
@@ -1681,10 +2043,19 @@ function getClientHintsInconsistencyScore(context) {
|
|
|
1681
2043
|
|
|
1682
2044
|
const versionDifference = Math.abs(parseInt(uaVersion, 10) - parseInt(chVersion, 10));
|
|
1683
2045
|
|
|
1684
|
-
|
|
1685
|
-
|
|
2046
|
+
let clientHintsInconsistencyScore = 0;
|
|
2047
|
+
if (versionDifference > 0) {
|
|
2048
|
+
if (versionDifference <= 2) {
|
|
2049
|
+
clientHintsInconsistencyScore = versionDifference * 20;
|
|
2050
|
+
} else if (versionDifference <= 7) {
|
|
2051
|
+
clientHintsInconsistencyScore = 40 + (versionDifference - 2) * 8;
|
|
2052
|
+
} else {
|
|
2053
|
+
clientHintsInconsistencyScore = Math.min(100, 80 + (versionDifference - 7) * 3.33);
|
|
2054
|
+
}
|
|
2055
|
+
clientHintsInconsistencyScore = Math.round(clientHintsInconsistencyScore);
|
|
2056
|
+
}
|
|
1686
2057
|
|
|
1687
|
-
|
|
2058
|
+
return { clientHintsInconsistencyScore };
|
|
1688
2059
|
}
|
|
1689
2060
|
|
|
1690
2061
|
/**
|
|
@@ -2225,11 +2596,20 @@ export const configureStore = (externalStore) => {
|
|
|
2225
2596
|
async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
2226
2597
|
const existingDeviceId = context.cookies?.device_id;
|
|
2227
2598
|
const currentDeviceHash = getCompositeDeviceHash(context); // Use the composite hash for consistency checks
|
|
2228
|
-
|
|
2599
|
+
const tlsSessionId = getTlsSessionId(context);
|
|
2600
|
+
let deviceId = existingDeviceId;
|
|
2229
2601
|
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
2230
2602
|
let deviceData = null;
|
|
2231
2603
|
let newCookie = null;
|
|
2232
|
-
|
|
2604
|
+
|
|
2605
|
+
if (!deviceId && tlsSessionId) {
|
|
2606
|
+
const resumedDeviceId = await store.get(`tls-session:${tlsSessionId}`);
|
|
2607
|
+
if (resumedDeviceId) {
|
|
2608
|
+
deviceId = resumedDeviceId;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
if (deviceId) {
|
|
2233
2613
|
deviceData = await store.get(`device:${deviceId}`);
|
|
2234
2614
|
}
|
|
2235
2615
|
|
|
@@ -2272,6 +2652,9 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
|
2272
2652
|
// The write will happen in getSuspicionVector after all modifications.
|
|
2273
2653
|
}
|
|
2274
2654
|
|
|
2655
|
+
if (deviceId && tlsSessionId) {
|
|
2656
|
+
await store.set(`tls-session:${tlsSessionId}`, deviceId, 3600); // Bind TLS session for 1 hour
|
|
2657
|
+
}
|
|
2275
2658
|
return { deviceId, deviceData, consistencyScore, newCookie };
|
|
2276
2659
|
}
|
|
2277
2660
|
|
|
@@ -2416,6 +2799,8 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
2416
2799
|
const stableFpHash = cyrb53(stableFp).toString();
|
|
2417
2800
|
const { botnetClusterScore } = await getBotnetClusterScore(context, stableFpHash);
|
|
2418
2801
|
|
|
2802
|
+
const { tcpAnomalyScore } = getTcpAnomalyScore(context);
|
|
2803
|
+
|
|
2419
2804
|
// Save the updated device state to the store
|
|
2420
2805
|
// 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.
|
|
2421
2806
|
await store.set(`device:${deviceId}`, deviceData);
|
|
@@ -2426,7 +2811,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
2426
2811
|
deviceData.ips = new Set(deviceData.ips);
|
|
2427
2812
|
}
|
|
2428
2813
|
// Le vecteur de suspicion est maintenant complet.
|
|
2429
|
-
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore };
|
|
2814
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, honeypotScore, botScore, requestPatternScore, crossLayerInconsistencyScore, timeInconsistencyScore, tlsSpoofingScore, clickVarianceScore, clientHintsInconsistencyScore, subnetScore, ipReputationScore, botnetClusterScore, tcpAnomalyScore };
|
|
2430
2815
|
};
|
|
2431
2816
|
|
|
2432
2817
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -2722,19 +3107,15 @@ export async function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
2722
3107
|
if (isValid) {
|
|
2723
3108
|
console.log('[FP Server Verify] CPU PoW verification PASSED. Details:', {
|
|
2724
3109
|
});
|
|
2725
|
-
// Generate an opaque ticket ID and store session metadata securely on the server
|
|
2726
|
-
const ticketId = crypto.randomUUID();
|
|
2727
3110
|
const ttl = ticketTtl || 3600000; // Calculates expiration from TTL
|
|
2728
3111
|
const expiry = Date.now() + ttl;
|
|
2729
3112
|
|
|
2730
|
-
|
|
3113
|
+
return generateStatelessTicket({
|
|
2731
3114
|
expiry,
|
|
2732
3115
|
originalIp: clientIp,
|
|
2733
3116
|
deviceId,
|
|
2734
3117
|
deviceHash
|
|
2735
3118
|
}, Math.ceil(ttl / 1000));
|
|
2736
|
-
|
|
2737
|
-
return ticketId;
|
|
2738
3119
|
}
|
|
2739
3120
|
|
|
2740
3121
|
return null;
|
|
@@ -2836,7 +3217,8 @@ export class FingerprintEngine {
|
|
|
2836
3217
|
(suspicionVector.clickVarianceScore || 0) * (weights.clickVarianceScore || 0) +
|
|
2837
3218
|
(suspicionVector.clientHintsInconsistencyScore || 0) * (weights.clientHintsInconsistencyScore || 0) +
|
|
2838
3219
|
(suspicionVector.subnetScore || 0) * (weights.subnetScore || 0) +
|
|
2839
|
-
(suspicionVector.ipReputationScore || 0) * (weights.ipReputationScore || 0)
|
|
3220
|
+
(suspicionVector.ipReputationScore || 0) * (weights.ipReputationScore || 0) +
|
|
3221
|
+
(suspicionVector.tcpAnomalyScore || 0) * (weights.tcpAnomalyScore || 0);
|
|
2840
3222
|
|
|
2841
3223
|
return Math.min(100, score);
|
|
2842
3224
|
}
|
|
@@ -3094,7 +3476,7 @@ export class FingerprintEngine {
|
|
|
3094
3476
|
async processRequest(requestContext) {
|
|
3095
3477
|
sanitizeProxyHeaders(requestContext, this.securityConfig);
|
|
3096
3478
|
|
|
3097
|
-
const { clientIp = "unknown", path, cookies, query, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
|
|
3479
|
+
const { clientIp = "unknown", path, cookies = {}, query = {}, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
|
|
3098
3480
|
const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
|
|
3099
3481
|
|
|
3100
3482
|
this._log('Processing request', { clientIp, path, isStatic });
|
|
@@ -3174,6 +3556,13 @@ export class FingerprintEngine {
|
|
|
3174
3556
|
decision.action = 'next';
|
|
3175
3557
|
delete decision.status;
|
|
3176
3558
|
delete decision.body;
|
|
3559
|
+
|
|
3560
|
+
if (requestContext._newCookies) {
|
|
3561
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
3562
|
+
if (deviceCookie) {
|
|
3563
|
+
decision.newCookieForResponse = deviceCookie;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3177
3566
|
}
|
|
3178
3567
|
return decision;
|
|
3179
3568
|
}
|
|
@@ -3213,8 +3602,8 @@ export class FingerprintEngine {
|
|
|
3213
3602
|
// --- NOUVELLE LOGIQUE DE PRIORITÉ ---
|
|
3214
3603
|
// Si une solution de challenge est soumise, on la traite en priorité absolue,
|
|
3215
3604
|
// avant même de recalculer le score de suspicion.
|
|
3216
|
-
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem, pow_fp, pow_solution_population, pow_solution_work_result, pow_problem_id } = query;
|
|
3217
|
-
if (pow_nonce && (pow_solution || pow_solution_cpu)) { // Vérifie pow_solution pour la compatibilité ascendante
|
|
3605
|
+
const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem, pow_fp, pow_solution_population, pow_solution_work_result, pow_problem_id, pow_solution_space } = query;
|
|
3606
|
+
if (pow_nonce && (pow_solution || pow_solution_cpu || pow_solution_space)) { // Vérifie pow_solution pour la compatibilité ascendante
|
|
3218
3607
|
this._log('Challenge solution submitted', { pow_type, pow_nonce });
|
|
3219
3608
|
|
|
3220
3609
|
// On doit calculer le score de suspicion *avant* de valider le ticket,
|
|
@@ -3292,13 +3681,13 @@ export class FingerprintEngine {
|
|
|
3292
3681
|
} else {
|
|
3293
3682
|
optimalTtl = determineOptimalTicketTtl(preliminaryScore);
|
|
3294
3683
|
finalTtl = isProbationary ? probationaryTtl : optimalTtl;
|
|
3295
|
-
this._log('Challenge context found, verifying solution', {
|
|
3684
|
+
this._log('Challenge context found, verifying solution', {optimalTtl, finalTtl});
|
|
3296
3685
|
|
|
3297
3686
|
if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
|
|
3298
3687
|
const cpuSolution = pow_solution_cpu || pow_solution;
|
|
3299
3688
|
ticket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
|
|
3300
3689
|
isValid = ticket !== null;
|
|
3301
|
-
this._log('CPU target challenge verification', {
|
|
3690
|
+
this._log('CPU target challenge verification', {isValid});
|
|
3302
3691
|
} else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
|
|
3303
3692
|
const cpuTicket = await verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
|
|
3304
3693
|
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
|
|
@@ -3309,6 +3698,18 @@ export class FingerprintEngine {
|
|
|
3309
3698
|
memValid: isMemValid,
|
|
3310
3699
|
isValid
|
|
3311
3700
|
});
|
|
3701
|
+
} else if (pow_type === "pospace" && pow_solution_space) {
|
|
3702
|
+
const isSpaceValid = verifySpacePoW(pow_nonce, pow_solution_space, challengeContext.queries, pow_nonce + ":" + challengeContext.clientSecret, challengeContext.clientSecret);
|
|
3703
|
+
isValid = isSpaceValid;
|
|
3704
|
+
if (isValid) {
|
|
3705
|
+
const ttl = finalTtl || 3600000;
|
|
3706
|
+
ticket = generateStatelessTicket({
|
|
3707
|
+
expiry: Date.now() + ttl,
|
|
3708
|
+
originalIp: clientIp,
|
|
3709
|
+
deviceId,
|
|
3710
|
+
deviceHash
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3312
3713
|
}
|
|
3313
3714
|
}
|
|
3314
3715
|
} else {
|
|
@@ -3345,6 +3746,7 @@ export class FingerprintEngine {
|
|
|
3345
3746
|
finalSearchParams.delete('pow_solution_cpu');
|
|
3346
3747
|
finalSearchParams.delete('pow_solution_mem');
|
|
3347
3748
|
finalSearchParams.delete('pow_fp'); // Ne pas oublier de nettoyer le fingerprint
|
|
3749
|
+
finalSearchParams.delete('pow_solution_space');
|
|
3348
3750
|
// NOUVEAU: Nettoyer aussi les paramètres des challenges d'optimisation et de travail utile
|
|
3349
3751
|
finalSearchParams.delete('pow_solution_population');
|
|
3350
3752
|
finalSearchParams.delete('pow_solution_work_result');
|
|
@@ -3382,6 +3784,12 @@ export class FingerprintEngine {
|
|
|
3382
3784
|
decision.action = 'next';
|
|
3383
3785
|
delete decision.status;
|
|
3384
3786
|
delete decision.body;
|
|
3787
|
+
if (requestContext._newCookies) {
|
|
3788
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
3789
|
+
if (deviceCookie) {
|
|
3790
|
+
decision.newCookieForResponse = deviceCookie;
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3385
3793
|
}
|
|
3386
3794
|
return decision;
|
|
3387
3795
|
}
|
|
@@ -3474,6 +3882,12 @@ export class FingerprintEngine {
|
|
|
3474
3882
|
decision.action = 'next';
|
|
3475
3883
|
delete decision.status;
|
|
3476
3884
|
delete decision.body;
|
|
3885
|
+
if (requestContext._newCookies) {
|
|
3886
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
3887
|
+
if (deviceCookie) {
|
|
3888
|
+
decision.newCookieForResponse = deviceCookie;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3477
3891
|
}
|
|
3478
3892
|
return decision;
|
|
3479
3893
|
}
|
|
@@ -3521,6 +3935,12 @@ export class FingerprintEngine {
|
|
|
3521
3935
|
decision.action = 'next';
|
|
3522
3936
|
delete decision.status;
|
|
3523
3937
|
delete decision.body;
|
|
3938
|
+
if (requestContext._newCookies) {
|
|
3939
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
3940
|
+
if (deviceCookie) {
|
|
3941
|
+
decision.newCookieForResponse = deviceCookie;
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3524
3944
|
}
|
|
3525
3945
|
return decision;
|
|
3526
3946
|
}
|
|
@@ -3545,6 +3965,12 @@ export class FingerprintEngine {
|
|
|
3545
3965
|
decision.action = 'next';
|
|
3546
3966
|
delete decision.status;
|
|
3547
3967
|
delete decision.body;
|
|
3968
|
+
if (requestContext._newCookies) {
|
|
3969
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
3970
|
+
if (deviceCookie) {
|
|
3971
|
+
decision.newCookieForResponse = deviceCookie;
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3548
3974
|
}
|
|
3549
3975
|
return decision;
|
|
3550
3976
|
}
|
|
@@ -3579,6 +4005,12 @@ export class FingerprintEngine {
|
|
|
3579
4005
|
decision.action = 'next';
|
|
3580
4006
|
delete decision.status;
|
|
3581
4007
|
delete decision.body;
|
|
4008
|
+
if (requestContext._newCookies) {
|
|
4009
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
4010
|
+
if (deviceCookie) {
|
|
4011
|
+
decision.newCookieForResponse = deviceCookie;
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
3582
4014
|
}
|
|
3583
4015
|
return decision;
|
|
3584
4016
|
}
|
|
@@ -3588,6 +4020,7 @@ export class FingerprintEngine {
|
|
|
3588
4020
|
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
3589
4021
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
3590
4022
|
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
4023
|
+
const isApi = requestContext.rawReq && this.securityConfig?.isApiRequest?.(requestContext.rawReq);
|
|
3591
4024
|
|
|
3592
4025
|
// Pour les scores élevés, on choisit aléatoirement entre un challenge de travail utile et un PoW classique.
|
|
3593
4026
|
// Cela rend l'automatisation plus difficile pour un attaquant.
|
|
@@ -3630,7 +4063,6 @@ export class FingerprintEngine {
|
|
|
3630
4063
|
}
|
|
3631
4064
|
|
|
3632
4065
|
if (isSuspicious && usefulWorkDispatched) {
|
|
3633
|
-
const isApi = requestContext.rawReq && this.securityConfig?.isApiRequest?.(requestContext.rawReq);
|
|
3634
4066
|
if (isApi) {
|
|
3635
4067
|
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 404, body: challengePayload };
|
|
3636
4068
|
} else {
|
|
@@ -3648,6 +4080,33 @@ export class FingerprintEngine {
|
|
|
3648
4080
|
delete decision.status;
|
|
3649
4081
|
return decision;
|
|
3650
4082
|
}
|
|
4083
|
+
if (this.securityConfig.enableProofOfSpace) {
|
|
4084
|
+
const spaceChallenge = generateSpaceChallenge(clientIp, nonce, suspicionFactor, path, this.securityConfig);
|
|
4085
|
+
const clientSecret = crypto.randomBytes(16).toString("hex");
|
|
4086
|
+
await store.set(`secret:${nonce}`, {
|
|
4087
|
+
clientSecret,
|
|
4088
|
+
suspicionScore: finalScore,
|
|
4089
|
+
queries: spaceChallenge.queries,
|
|
4090
|
+
sizeMb: spaceChallenge.sizeMb,
|
|
4091
|
+
originalPath: path,
|
|
4092
|
+
}, this.securityConfig.challengeTtl || 300);
|
|
4093
|
+
|
|
4094
|
+
if (isApi) {
|
|
4095
|
+
decision.body = {
|
|
4096
|
+
challenge: {
|
|
4097
|
+
type: 'pospace',
|
|
4098
|
+
nonce: nonce,
|
|
4099
|
+
clientSecret,
|
|
4100
|
+
queries: spaceChallenge.queries,
|
|
4101
|
+
sizeMb: spaceChallenge.sizeMb,
|
|
4102
|
+
}
|
|
4103
|
+
};
|
|
4104
|
+
} else {
|
|
4105
|
+
const page = generateSpaceChallengePage(spaceChallenge, clientSecret, this.securityConfig);
|
|
4106
|
+
decision.body = page;
|
|
4107
|
+
}
|
|
4108
|
+
return decision;
|
|
4109
|
+
}
|
|
3651
4110
|
// Generate some trap URLs to embed in the challenge page.
|
|
3652
4111
|
// These links are visually hidden but present in the DOM to trap bots.
|
|
3653
4112
|
const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce)); // Génère les URL
|
|
@@ -3706,9 +4165,6 @@ export class FingerprintEngine {
|
|
|
3706
4165
|
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
3707
4166
|
}
|
|
3708
4167
|
|
|
3709
|
-
// Check if the request is an API request to return a JSON challenge
|
|
3710
|
-
const isApi = requestContext.rawReq && this.securityConfig?.isApiRequest?.(requestContext.rawReq);
|
|
3711
|
-
|
|
3712
4168
|
if (isApi) {
|
|
3713
4169
|
// For API clients, send a JSON response with challenge details.
|
|
3714
4170
|
const challengePayload = {
|
|
@@ -3742,8 +4198,14 @@ export class FingerprintEngine {
|
|
|
3742
4198
|
if (logger) {
|
|
3743
4199
|
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
|
|
3744
4200
|
}
|
|
3745
|
-
|
|
3746
|
-
|
|
4201
|
+
const response = { action: 'next', score: finalScore, vector: suspicionVector, intendedAction: 'next' };
|
|
4202
|
+
if (requestContext._newCookies) {
|
|
4203
|
+
const deviceCookie = requestContext._newCookies.find(c => c.name === 'device_id');
|
|
4204
|
+
if (deviceCookie) {
|
|
4205
|
+
response.newCookieForResponse = deviceCookie;
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
return response;
|
|
3747
4209
|
}
|
|
3748
4210
|
|
|
3749
4211
|
/**
|
|
@@ -3982,6 +4444,185 @@ function determineOptimalTicketTtl(suspicionScore) {
|
|
|
3982
4444
|
return ttl;
|
|
3983
4445
|
}
|
|
3984
4446
|
|
|
4447
|
+
|
|
4448
|
+
/**
|
|
4449
|
+
* Parse une trame TCP SYN brute (IPv4 ou IPv6).
|
|
4450
|
+
* @private
|
|
4451
|
+
* @param {Buffer|Uint8Array} binary - Le paquet binaire.
|
|
4452
|
+
* @returns {object|null}
|
|
4453
|
+
*/
|
|
4454
|
+
function parseTcpSyn(binary) {
|
|
4455
|
+
if (!binary || binary.length < 40) return null;
|
|
4456
|
+
let ttl = 64;
|
|
4457
|
+
let tcpOffset = 20;
|
|
4458
|
+
const version = binary[0] >> 4;
|
|
4459
|
+
|
|
4460
|
+
if (version === 4) {
|
|
4461
|
+
ttl = binary[8];
|
|
4462
|
+
const ihl = binary[0] & 0x0f;
|
|
4463
|
+
tcpOffset = ihl * 4;
|
|
4464
|
+
} else if (version === 6) {
|
|
4465
|
+
ttl = binary[7]; // Hop Limit
|
|
4466
|
+
tcpOffset = 40;
|
|
4467
|
+
} else {
|
|
4468
|
+
tcpOffset = 0;
|
|
4469
|
+
ttl = 64;
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
if (binary.length < tcpOffset + 20) return null;
|
|
4473
|
+
|
|
4474
|
+
const windowSize = (binary[tcpOffset + 14] << 8) | binary[tcpOffset + 15];
|
|
4475
|
+
const dataOffset = (binary[tcpOffset + 12] >> 4) * 4;
|
|
4476
|
+
const optionsEnd = tcpOffset + dataOffset;
|
|
4477
|
+
|
|
4478
|
+
let mss = null;
|
|
4479
|
+
let ws = null;
|
|
4480
|
+
let sack = false;
|
|
4481
|
+
|
|
4482
|
+
let i = tcpOffset + 20;
|
|
4483
|
+
while (i < optionsEnd && i < binary.length) {
|
|
4484
|
+
const optType = binary[i];
|
|
4485
|
+
if (optType === 0) break;
|
|
4486
|
+
if (optType === 1) {
|
|
4487
|
+
i++;
|
|
4488
|
+
continue;
|
|
4489
|
+
}
|
|
4490
|
+
if (i + 1 >= binary.length) break;
|
|
4491
|
+
const optLen = binary[i + 1];
|
|
4492
|
+
if (optLen < 2 || i + optLen > binary.length) break;
|
|
4493
|
+
|
|
4494
|
+
if (optType === 2 && optLen === 4) {
|
|
4495
|
+
mss = (binary[i + 2] << 8) | binary[i + 3];
|
|
4496
|
+
} else if (optType === 3 && optLen === 3) {
|
|
4497
|
+
ws = binary[i + 2];
|
|
4498
|
+
} else if (optType === 4 && optLen === 2) {
|
|
4499
|
+
sack = true;
|
|
4500
|
+
}
|
|
4501
|
+
i += optLen;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
return { ttl, windowSize, mss, ws, sack };
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
/**
|
|
4508
|
+
* Classifie l'OS à partir du fingerprint de la pile TCP/IP.
|
|
4509
|
+
* @private
|
|
4510
|
+
* @param {object|null} fingerprint
|
|
4511
|
+
* @returns {string}
|
|
4512
|
+
*/
|
|
4513
|
+
function classifyTcpOs(fingerprint) {
|
|
4514
|
+
if (!fingerprint) return 'unknown';
|
|
4515
|
+
const ttl = fingerprint.ttl ?? 64;
|
|
4516
|
+
const windowSize = fingerprint.windowSize ?? 0;
|
|
4517
|
+
const ws = fingerprint.ws ?? null;
|
|
4518
|
+
|
|
4519
|
+
if (ttl > 64 && ttl <= 128) {
|
|
4520
|
+
return 'Windows';
|
|
4521
|
+
}
|
|
4522
|
+
if (ttl > 32 && ttl <= 64) {
|
|
4523
|
+
if (windowSize === 29200 || windowSize === 14600 || windowSize === 5840) {
|
|
4524
|
+
return 'Linux';
|
|
4525
|
+
}
|
|
4526
|
+
return 'Linux';
|
|
4527
|
+
}
|
|
4528
|
+
if (ttl <= 64) {
|
|
4529
|
+
if (windowSize === 65535 && (ws === 6 || ws === 8 || ws === 5)) {
|
|
4530
|
+
return 'macOS/iOS';
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
if (ttl > 64) return 'Windows';
|
|
4534
|
+
if (ttl > 0) return 'Linux';
|
|
4535
|
+
return 'unknown';
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
/**
|
|
4539
|
+
* Détecte les anomalies de pile réseau par rapport au User-Agent.
|
|
4540
|
+
* @private
|
|
4541
|
+
* @param {object} context - Le contexte de la requête.
|
|
4542
|
+
* @returns {{tcpAnomalyScore: number}}
|
|
4543
|
+
*/
|
|
4544
|
+
function getTcpAnomalyScore(context) {
|
|
4545
|
+
let fp = null;
|
|
4546
|
+
const rawTcpBinary = context.headers?.['x-raw-tcp-binary'] || context.rawTcpBinary || null;
|
|
4547
|
+
if (rawTcpBinary) {
|
|
4548
|
+
const binary = Buffer.isBuffer(rawTcpBinary) ? rawTcpBinary : (typeof rawTcpBinary === 'string' ? Buffer.from(rawTcpBinary, 'hex') : rawTcpBinary);
|
|
4549
|
+
fp = parseTcpSyn(binary);
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
if (!fp) {
|
|
4553
|
+
const tcpHeader = context.headers?.['x-tcp-fingerprint'] || context.tcpFingerprint || null;
|
|
4554
|
+
if (tcpHeader && typeof tcpHeader === 'string') {
|
|
4555
|
+
const parts = tcpHeader.split(':');
|
|
4556
|
+
if (parts.length >= 2) {
|
|
4557
|
+
fp = {
|
|
4558
|
+
ttl: parseInt(parts[0], 10),
|
|
4559
|
+
windowSize: parseInt(parts[1], 10),
|
|
4560
|
+
mss: parts[2] ? parseInt(parts[2], 10) : null,
|
|
4561
|
+
ws: parts[3] ? parseInt(parts[3], 10) : null,
|
|
4562
|
+
sack: parts[4] === '1' || parts[4] === 'true'
|
|
4563
|
+
};
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
if (!fp) {
|
|
4569
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
const tcpOs = classifyTcpOs(fp);
|
|
4573
|
+
const ua = context.headers?.['user-agent'] || '';
|
|
4574
|
+
const uaParts = parseUserAgent(ua);
|
|
4575
|
+
const uaOs = uaParts.os;
|
|
4576
|
+
|
|
4577
|
+
if (!uaOs || tcpOs === 'unknown') {
|
|
4578
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
let mappedOs = null;
|
|
4582
|
+
if (uaOs.startsWith('Windows')) mappedOs = 'Windows';
|
|
4583
|
+
else if (uaOs.startsWith('Mac') || uaOs.startsWith('macOS')) mappedOs = 'macOS';
|
|
4584
|
+
else if (uaOs.startsWith('iOS')) mappedOs = 'iOS';
|
|
4585
|
+
else if (uaOs.startsWith('Linux')) mappedOs = 'Linux';
|
|
4586
|
+
|
|
4587
|
+
if (!mappedOs) {
|
|
4588
|
+
return { tcpAnomalyScore: 0.0 };
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4591
|
+
const OS_EXPECTED_TCP = {
|
|
4592
|
+
'Windows': { ttl: 128, windowSize: 64240, ws: 8, mss: 1460, sack: true },
|
|
4593
|
+
'Linux': { ttl: 64, windowSize: 29200, ws: 7, mss: 1460, sack: true },
|
|
4594
|
+
'macOS': { ttl: 64, windowSize: 65535, ws: 6, mss: 1460, sack: true },
|
|
4595
|
+
'iOS': { ttl: 64, windowSize: 65535, ws: 6, mss: 1460, sack: true }
|
|
4596
|
+
};
|
|
4597
|
+
|
|
4598
|
+
const expected = OS_EXPECTED_TCP[mappedOs];
|
|
4599
|
+
const ttlDiff = Math.abs(fp.ttl - expected.ttl) / expected.ttl;
|
|
4600
|
+
const winDiff = Math.abs(fp.windowSize - expected.windowSize) / expected.windowSize;
|
|
4601
|
+
const wsDiff = expected.ws !== null && fp.ws !== null ? Math.abs(fp.ws - expected.ws) / expected.ws : 0.0;
|
|
4602
|
+
const mssDiff = expected.mss !== null && fp.mss !== null ? Math.abs(fp.mss - expected.mss) / expected.mss : 0.0;
|
|
4603
|
+
const sackDiff = (fp.sack ?? true) === (expected.sack ?? true) ? 0.0 : 1.0;
|
|
4604
|
+
|
|
4605
|
+
const deviation = (
|
|
4606
|
+
Math.min(1.0, ttlDiff) * 0.50 +
|
|
4607
|
+
Math.min(1.0, winDiff) * 0.25 +
|
|
4608
|
+
Math.min(1.0, wsDiff) * 0.15 +
|
|
4609
|
+
Math.min(1.0, mssDiff) * 0.05 +
|
|
4610
|
+
sackDiff * 0.05
|
|
4611
|
+
);
|
|
4612
|
+
|
|
4613
|
+
let tcpAnomalyScore = 0.0;
|
|
4614
|
+
if (tcpOs !== mappedOs && tcpOs !== 'unknown') {
|
|
4615
|
+
const baseAnomaly = mappedOs === 'Windows' ? 80.0 :
|
|
4616
|
+
(mappedOs === 'macOS' || mappedOs === 'iOS' ? 85.0 : 75.0);
|
|
4617
|
+
tcpAnomalyScore = baseAnomaly + (deviation - 0.4) * 10.0;
|
|
4618
|
+
} else {
|
|
4619
|
+
tcpAnomalyScore = deviation * 40.0;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
tcpAnomalyScore = Math.max(0.0, Math.min(100.0, Math.round(tcpAnomalyScore * 10) / 10));
|
|
4623
|
+
return { tcpAnomalyScore };
|
|
4624
|
+
}
|
|
4625
|
+
|
|
3985
4626
|
/**
|
|
3986
4627
|
* Vérifie si une chaîne de caractères contient des patterns d'injection connus.
|
|
3987
4628
|
* @private
|
|
@@ -4191,6 +4832,32 @@ export const default_whitelist = () => [
|
|
|
4191
4832
|
];
|
|
4192
4833
|
|
|
4193
4834
|
|
|
4835
|
+
/**
|
|
4836
|
+
* Extracts the TLS Session ID or ticket hash from the request context.
|
|
4837
|
+
* Prioritizes proxy-provided headers and falls back to Node's native socket session.
|
|
4838
|
+
* @private
|
|
4839
|
+
* @param {object} context - The request context.
|
|
4840
|
+
* @returns {string|null}
|
|
4841
|
+
*/
|
|
4842
|
+
function getTlsSessionId(context) {
|
|
4843
|
+
if (!context) return null;
|
|
4844
|
+
const fromHeader = context.headers ? (context.headers['x-tls-session-id'] || context.headers['x-ssl-session-id']) : null;
|
|
4845
|
+
if (fromHeader) return fromHeader;
|
|
4846
|
+
|
|
4847
|
+
const socket = context.rawReq?.socket;
|
|
4848
|
+
if (socket) {
|
|
4849
|
+
if (socket.sessionId) {
|
|
4850
|
+
return socket.sessionId.toString('hex');
|
|
4851
|
+
}
|
|
4852
|
+
if (typeof socket.getSession === 'function') {
|
|
4853
|
+
const session = socket.getSession();
|
|
4854
|
+
if (session) {
|
|
4855
|
+
return crypto.createHash('sha256').update(session).digest('hex');
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
}
|
|
4859
|
+
return null;
|
|
4860
|
+
}
|
|
4194
4861
|
|
|
4195
4862
|
// --- Proof-of-Work Middleware (The Tollbooth) ---
|
|
4196
4863
|
export const powMiddleware = (securityConfig) => {
|
|
@@ -4223,6 +4890,24 @@ export const powMiddleware = (securityConfig) => {
|
|
|
4223
4890
|
}
|
|
4224
4891
|
|
|
4225
4892
|
return async (req, res, next) => {
|
|
4893
|
+
if (!req.headers_translated) {
|
|
4894
|
+
req.headers_translated = true;
|
|
4895
|
+
const matchedMapping = getActiveMappingForRequest(req.headers);
|
|
4896
|
+
if (matchedMapping) {
|
|
4897
|
+
const devFpHeader = matchedMapping.headers['x-device-fingerprint'].toLowerCase();
|
|
4898
|
+
const behaviorHeader = matchedMapping.headers['x-behavior-metrics'].toLowerCase();
|
|
4899
|
+
|
|
4900
|
+
if (req.headers[devFpHeader]) {
|
|
4901
|
+
req.headers['x-device-fingerprint'] = req.headers[devFpHeader];
|
|
4902
|
+
}
|
|
4903
|
+
if (req.headers[behaviorHeader]) {
|
|
4904
|
+
req.headers['x-behavior-metrics'] = req.headers[behaviorHeader];
|
|
4905
|
+
}
|
|
4906
|
+
if (req.headers['x-device-fingerprint']) {
|
|
4907
|
+
req.headers['x-device-fingerprint'] = decodePolymorphicFingerprint(req.headers['x-device-fingerprint'], matchedMapping);
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
}
|
|
4226
4911
|
if (securityConfig?.wasm) {
|
|
4227
4912
|
const wasmConfig = securityConfig.wasm;
|
|
4228
4913
|
let jsPath = '/fp.js';
|
|
@@ -4244,24 +4929,40 @@ export const powMiddleware = (securityConfig) => {
|
|
|
4244
4929
|
wasmFile = wasmConfig.wasmFile ? resolve(wasmConfig.wasmFile) : resolve(__dirname, '..', '..', 'public', 'fp.wasm');
|
|
4245
4930
|
}
|
|
4246
4931
|
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4932
|
+
if (req.path === jsPath) {
|
|
4933
|
+
try {
|
|
4934
|
+
if (wasmConfig === 'dynamic' || wasmConfig.dynamic || wasmConfig.polymorphic) {
|
|
4935
|
+
await ensureLatestMapping();
|
|
4936
|
+
const latest = activeMappings[0];
|
|
4937
|
+
if (latest && latest.jsBuffer) {
|
|
4938
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
4939
|
+
return res.send(latest.jsBuffer);
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
if (jsFile && existsSync(jsFile)) {
|
|
4943
|
+
const fileContent = readFileSync(jsFile);
|
|
4944
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
4945
|
+
return res.send(fileContent);
|
|
4946
|
+
}
|
|
4947
|
+
} catch (e) {}
|
|
4254
4948
|
}
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4949
|
+
if (req.path === wasmPath) {
|
|
4950
|
+
try {
|
|
4951
|
+
if (wasmConfig === 'dynamic' || wasmConfig.dynamic || wasmConfig.polymorphic) {
|
|
4952
|
+
await ensureLatestMapping();
|
|
4953
|
+
const latest = activeMappings[0];
|
|
4954
|
+
if (latest && latest.wasmBuffer) {
|
|
4955
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
4956
|
+
return res.send(latest.wasmBuffer);
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
if (wasmFile && existsSync(wasmFile)) {
|
|
4960
|
+
const fileContent = readFileSync(wasmFile);
|
|
4961
|
+
res.setHeader('Content-Type', 'application/wasm');
|
|
4962
|
+
return res.send(fileContent);
|
|
4963
|
+
}
|
|
4964
|
+
} catch (e) {}
|
|
4263
4965
|
}
|
|
4264
|
-
}
|
|
4265
4966
|
}
|
|
4266
4967
|
|
|
4267
4968
|
const requestContext = {
|
|
@@ -4338,6 +5039,7 @@ export const __internal = {
|
|
|
4338
5039
|
getDeviceHash,
|
|
4339
5040
|
getCompositeDeviceHash,
|
|
4340
5041
|
getSuspicionVector,
|
|
5042
|
+
getTlsSessionId,
|
|
4341
5043
|
cyrb53, // Export for testing
|
|
4342
5044
|
FingerprintBuilder, // Export for testing
|
|
4343
5045
|
calculateTarget,
|
|
@@ -4352,6 +5054,8 @@ export const __internal = {
|
|
|
4352
5054
|
getTlsFingerprint, // NOUVEAU: Expose pour les tests
|
|
4353
5055
|
sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
|
|
4354
5056
|
getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
|
|
5057
|
+
generateStatelessTicket,
|
|
5058
|
+
parseStatelessTicket,
|
|
4355
5059
|
parseJa3,
|
|
4356
5060
|
getBotnetClusterScore, // NOUVEAU: Expose pour les tests
|
|
4357
5061
|
generateCpuTargetChallengePage,
|
|
@@ -4364,6 +5068,9 @@ export const __internal = {
|
|
|
4364
5068
|
getIpReputationScore, // Expose for testing
|
|
4365
5069
|
updateIpReputationScore, // Expose for testing
|
|
4366
5070
|
setLastBestSolution: (val) => { lastBestSolution = val; }, // Expose to test auto-tuning metrics
|
|
5071
|
+
parseTcpSyn, // Expose for testing
|
|
5072
|
+
classifyTcpOs, // Expose for testing
|
|
5073
|
+
getTcpAnomalyScore // Expose for testing
|
|
4367
5074
|
};
|
|
4368
5075
|
|
|
4369
5076
|
// --- THRESHOLD AUTO-TUNING SECTION ---
|