@anonympins/fingerprint 0.0.2 → 0.0.3
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 +45 -28
- package/fingerprint.js +151 -362
- package/library.js +1 -1
- package/package.json +3 -6
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# fingerprint
|
|
2
|
-

|
|
3
|
-

|
|
4
|
-
](https://github.com/anonympins/fingerprint/actions/workflows/ci.yml)
|
|
3
|
+
[](https://github.com/anonympins/fingerprint/releases)
|
|
4
|
+
[](https://github.com/anonympins/fingerprint/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/anonympins/fingerprint/releases)
|
|
6
|
+
[](https://github.com/anonympins/fingerprint/watchers)
|
|
7
7
|
|
|
8
8
|
An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.
|
|
9
9
|
|
|
10
10
|
## How It Works
|
|
11
11
|
|
|
12
|
-
This system
|
|
12
|
+
This system identifies and slows down bots and automated scripts by evaluating the "suspicion" level of each incoming request. Instead of outright blocking, it imposes challenges with a difficulty proportional to the suspicion score, penalizing bots without significantly impacting legitimate users.
|
|
13
13
|
|
|
14
14
|
The process unfolds in three steps:
|
|
15
15
|
|
|
@@ -20,25 +20,19 @@ The process unfolds in three steps:
|
|
|
20
20
|
* **IP Behavior**: An excessive number of different devices seen from the same IP, or a single device using a large number of IPs (proxy rotation).
|
|
21
21
|
* **Inconsistency**: A low similarity score between the current fingerprint and the initial one associated with the `device_id` (cookie theft detection).
|
|
22
22
|
3. **Dynamic Challenge**: If the suspicion score exceeds a certain threshold, a challenge is presented to the user. The difficulty and type of challenge depend on the score:
|
|
23
|
-
* **
|
|
24
|
-
* **
|
|
25
|
-
* **Level 3 (High Suspicion)**: Complex challenge (e.g., TSP - Traveling Salesperson Problem) or a CAPTCHA.
|
|
23
|
+
* **Low to Medium Suspicion**: A combined CPU and Memory Proof-of-Work (PoW) challenge is issued. The difficulty of both the CPU (hash calculation) and Memory (allocation and computation) components scales progressively with the suspicion score. For low scores, the memory challenge is negligible, making it primarily a CPU task.
|
|
24
|
+
* **High Suspicion**: For the most suspicious requests, the system issues a high-difficulty combined CPU/Memory challenge. The architecture allows for plugging in more complex challenges like CAPTCHAs if needed.
|
|
26
25
|
|
|
27
|
-
Once the challenge is solved, a clearance "ticket" is issued via a cookie, exempting the user from new challenges for a set period.
|
|
26
|
+
Once the challenge is solved, a clearance "ticket" is issued via a secure cookie, exempting the user from new challenges for a set period.
|
|
28
27
|
|
|
29
28
|
## Features
|
|
30
29
|
|
|
31
30
|
- **Multi-Factor Fingerprinting**: Combines client-side data (`hardwareConcurrency`, `deviceMemory`, `screen`, `canvas`, `webgl`) and server-side data (`User-Agent`, `Client-Hints`).
|
|
32
|
-
- **Weighted Suspicion Engine**: Calculates a score based on behavioral and technical indicators.
|
|
33
|
-
- **Variable-Difficulty Proof-of-Work Challenges**:
|
|
34
|
-
- `cpu_target`: An "analog" CPU challenge where difficulty is finely tuned to the suspicion score.
|
|
35
|
-
- `memory`: A challenge that allocates an amount of memory proportional to the suspicion level.
|
|
36
|
-
- `tsp`: An optimization challenge (Traveling Salesperson Problem) for the most suspicious cases.
|
|
37
31
|
- **Secure Ticket System**: Uses HMAC-SHA256 signatures to validate clearances and prevent tampering.
|
|
38
32
|
- **Pluggable Datastore**: Supports external datastores like Redis for state persistence and scalability across multiple server instances.
|
|
39
33
|
- **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`.
|
|
40
34
|
- **Timing Attack Protection**: Uses `crypto.timingSafeEqual` for secure ticket validation.
|
|
41
|
-
- **Automatic Threshold Tuning**:
|
|
35
|
+
- **Automatic Threshold Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust suspicion thresholds (`low`, `medium`, `high`), improving bot detection accuracy and reducing false positives over time.
|
|
42
36
|
|
|
43
37
|
## Installation and Usage
|
|
44
38
|
|
|
@@ -58,13 +52,11 @@ export POW_SECRET="your_secret_key_of_at_least_32_characters"
|
|
|
58
52
|
|
|
59
53
|
### Integration Example
|
|
60
54
|
|
|
61
|
-
|
|
55
|
+
The `powMiddleware` requires a configuration object defining the weights of suspicion indicators and the challenge trigger thresholds.
|
|
62
56
|
|
|
63
57
|
```javascript
|
|
64
58
|
import express from 'express';
|
|
65
59
|
import cookieParser from 'cookie-parser';
|
|
66
|
-
// The `configurePow` function is a conceptual example. In the actual implementation,
|
|
67
|
-
// you would pass the configuration to the middleware, for example, via a factory function.
|
|
68
60
|
import { powMiddleware /*, configurePow */ } from './fingerprint.js'; // Adjust the path
|
|
69
61
|
|
|
70
62
|
const app = express();
|
|
@@ -81,27 +73,33 @@ const securityConfig = {
|
|
|
81
73
|
},
|
|
82
74
|
thresholds: {
|
|
83
75
|
low: 20, // Score from which a CPU challenge is issued
|
|
84
|
-
medium: 45, // Score for a Memory challenge
|
|
85
|
-
high: 75
|
|
76
|
+
medium: 45, // Score for a more difficult combined CPU/Memory challenge
|
|
77
|
+
high: 75, // Score for a very difficult challenge
|
|
78
|
+
block: 95 // Score above which the request is blocked outright (HTTP 403)
|
|
86
79
|
}
|
|
87
80
|
};
|
|
88
81
|
|
|
89
|
-
//
|
|
90
|
-
// For example: const configuredPowMiddleware = createPowMiddleware(securityConfig);
|
|
82
|
+
// Create an instance of the middleware with your security configuration.
|
|
91
83
|
const powMiddlewareInstance = powMiddleware(securityConfig);
|
|
92
84
|
|
|
93
85
|
// Enable trust proxy if your app is behind a reverse proxy (Nginx, etc.)
|
|
94
86
|
// to correctly retrieve the client's IP.
|
|
95
87
|
app.set('trust proxy', 1);
|
|
96
88
|
|
|
97
|
-
// Apply the protection middleware to all routes or to specific
|
|
98
|
-
// You would use the configured middleware here.
|
|
89
|
+
// Apply the protection middleware to all routes or to specific ones.
|
|
99
90
|
app.use(powMiddlewareInstance);
|
|
100
91
|
|
|
101
92
|
app.get('/', (req, res) => {
|
|
102
93
|
res.send('Welcome to the protected page!');
|
|
103
94
|
});
|
|
104
95
|
|
|
96
|
+
// Example of accessing the suspicion score in a subsequent middleware or route.
|
|
97
|
+
// The `fingerprint` object is attached to the request object by the middleware.
|
|
98
|
+
app.use((req, res, next) => {
|
|
99
|
+
console.log(`Request from ${req.ip} has a suspicion score of: ${req.fingerprint?.score}`);
|
|
100
|
+
next();
|
|
101
|
+
});
|
|
102
|
+
|
|
105
103
|
app.listen(3000, () => console.log('Server started on port 3000'));
|
|
106
104
|
```
|
|
107
105
|
|
|
@@ -111,7 +109,7 @@ In addition to the main middleware, several functions are exported to allow for
|
|
|
111
109
|
|
|
112
110
|
### Main Functions
|
|
113
111
|
|
|
114
|
-
#### `powMiddleware(
|
|
112
|
+
#### `powMiddleware(securityConfig)`
|
|
115
113
|
The main Express middleware. It orchestrates identification, suspicion calculation, and challenge issuance. It is the main entry point of the library.
|
|
116
114
|
|
|
117
115
|
#### `configureStore(store)`
|
|
@@ -119,7 +117,7 @@ Allows replacing the in-memory store with an external datastore (like Redis) for
|
|
|
119
117
|
|
|
120
118
|
```javascript
|
|
121
119
|
import { configureStore } from './fingerprint.js';
|
|
122
|
-
import { createRedisStore } from './redis-store.js'; // Assuming
|
|
120
|
+
import { createRedisStore } from './redis-store.js'; // Assuming a redis store implementation exists
|
|
123
121
|
|
|
124
122
|
const redisStore = createRedisStore(process.env.REDIS_URL);
|
|
125
123
|
configureStore(redisStore);
|
|
@@ -154,7 +152,7 @@ app.use(async (req, res, next) => {
|
|
|
154
152
|
#### `isTicketValid(ip, ticket)`
|
|
155
153
|
Checks the validity of a `pow_clearance` cookie. Returns `true` if the ticket is present, not expired, and correctly signed for the given IP.
|
|
156
154
|
|
|
157
|
-
#### `FingerprintBuilder`
|
|
155
|
+
#### `FingerprintBuilder`
|
|
158
156
|
A class for building granular server-side fingerprints.
|
|
159
157
|
|
|
160
158
|
```javascript
|
|
@@ -166,7 +164,22 @@ const fp = builder.toString(); // "os:hash1|ua:hash2"
|
|
|
166
164
|
|
|
167
165
|
#### `getDeviceFingerprint()`
|
|
168
166
|
*Client-side function only.* Generates a detailed browser fingerprint using APIs like Canvas, WebGL, etc.
|
|
167
|
+
This is the primary function for client-side identification.
|
|
168
|
+
|
|
169
|
+
#### `generateRequestSignature(payload)`
|
|
170
|
+
*Client-side function only.* Creates a signature for an outgoing request. It combines the device fingerprint with a hash of the request's `payload`. This can be used on the server-side to verify that a request comes from a recognized device and that its payload has not been trivially altered.
|
|
169
171
|
|
|
172
|
+
```javascript
|
|
173
|
+
// On the client
|
|
174
|
+
const signature = generateRequestSignature({ action: 'update', id: 123 });
|
|
175
|
+
// Send signature in headers...
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### `generateClientSideSignature(payload, secret)`
|
|
179
|
+
*Client-side function only.* Generates a secure HMAC-SHA256 signature for a given `payload` using a `secret`.
|
|
180
|
+
**Security Note:** This function is powerful but should be used with caution. The `secret` must be managed securely. It is typically used with a temporary, single-use secret provided by the server for a specific action, rather than a long-lived shared secret embedded in the client-side code.
|
|
181
|
+
|
|
182
|
+
---
|
|
170
183
|
## Advanced Features
|
|
171
184
|
|
|
172
185
|
### Architecture: `FingerprintEngine`
|
|
@@ -219,6 +232,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
219
232
|
// 2. Process and get a decision
|
|
220
233
|
const decision = await engine.processRequest(requestContext);
|
|
221
234
|
|
|
235
|
+
// The decision object now contains the score and the raw suspicion vector.
|
|
236
|
+
// You can use it for logging or custom logic.
|
|
237
|
+
console.log(`Request from ${requestContext.clientIp} processed with score: ${decision.score}`);
|
|
238
|
+
|
|
222
239
|
// 3. Act on the decision
|
|
223
240
|
if (decision.action === 'challenge') {
|
|
224
241
|
res.writeHead(decision.status, { 'Content-Type': 'text/html' });
|
package/fingerprint.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { Optimization } from "./library.js";
|
|
4
|
+
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
5
|
+
import { getDeviceHash } from "./fingerprint.server.js";
|
|
4
6
|
|
|
5
7
|
const POW_SECRET = process.env.POW_SECRET;
|
|
6
8
|
|
|
@@ -9,250 +11,6 @@ if (!POW_SECRET && process.env.NODE_ENV === 'production') {
|
|
|
9
11
|
} else if (!POW_SECRET) {
|
|
10
12
|
console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
|
|
11
13
|
}
|
|
12
|
-
/**
|
|
13
|
-
* cyrb53 hash algorithm (fast with a low collision rate). Exported for reuse.
|
|
14
|
-
*/
|
|
15
|
-
export const cyrb53 = (str, seed = 0) => {
|
|
16
|
-
let h1 = 0xdeadbeef ^ seed,
|
|
17
|
-
h2 = 0x41c6ce57 ^ seed;
|
|
18
|
-
for (let i = 0, ch; i < str.length; i++) {
|
|
19
|
-
ch = str.charCodeAt(i);
|
|
20
|
-
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
21
|
-
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
22
|
-
}
|
|
23
|
-
h1 =
|
|
24
|
-
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
|
|
25
|
-
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
26
|
-
h2 =
|
|
27
|
-
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
|
|
28
|
-
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
29
|
-
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Class to build a composite fingerprint (Multi-Hash).
|
|
34
|
-
* Output format: "grp1:hash1|grp2:hash2|grp3:hash3"
|
|
35
|
-
*/
|
|
36
|
-
export class FingerprintBuilder {
|
|
37
|
-
constructor() {
|
|
38
|
-
this.components = new Map();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Adds a component to the global hash.
|
|
43
|
-
* @param {string} group - The group name (e.g., 'hw', 'screen', 'geo')
|
|
44
|
-
* @param {string|number|boolean} value - The raw value to be hashed
|
|
45
|
-
*/
|
|
46
|
-
add(group, value) {
|
|
47
|
-
if (value === undefined || value === null) return this;
|
|
48
|
-
// Hash the value individually to anonymize it and reduce its size
|
|
49
|
-
this.components.set(group, cyrb53(String(value)));
|
|
50
|
-
return this;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Generates the final signature string.
|
|
55
|
-
* Sorts keys to ensure a deterministic order.
|
|
56
|
-
*/
|
|
57
|
-
toString() {
|
|
58
|
-
return Array.from(this.components.entries())
|
|
59
|
-
.sort((a, b) => a[0].localeCompare(b[0])) // Tri alphabétique des clés
|
|
60
|
-
.map(([key, hash]) => `${key}:${hash}`)
|
|
61
|
-
.join("|");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Compares two fingerprints and returns a similarity score (0 to 1).
|
|
66
|
-
* Uses weights to give more importance to strong invariants (Canvas, GPU).
|
|
67
|
-
* @param {string} fpString1 - Fingerprint A
|
|
68
|
-
* @param {string} fpString2 - Fingerprint B
|
|
69
|
-
*/
|
|
70
|
-
static compare(fpString1, fpString2) {
|
|
71
|
-
if (!fpString1 || !fpString2) return 0;
|
|
72
|
-
|
|
73
|
-
const parse = (str) => {
|
|
74
|
-
const map = new Map();
|
|
75
|
-
str.split("|").forEach((part) => {
|
|
76
|
-
const [k, v] = part.split(":");
|
|
77
|
-
if (k && v) map.set(k, v);
|
|
78
|
-
});
|
|
79
|
-
return map;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const map1 = parse(fpString1);
|
|
83
|
-
const map2 = parse(fpString2);
|
|
84
|
-
|
|
85
|
-
// "Veracity" weights (Entropy/Stability)
|
|
86
|
-
const weights = {
|
|
87
|
-
cvs: 4.0, // Canvas: Very high entropy (Unique rendering)
|
|
88
|
-
gpu: 3.0, // GPU: High entropy (Specific hardware)
|
|
89
|
-
hw: 1.5, // Hardware: Medium entropy
|
|
90
|
-
scr: 1.0, // Screen: Medium
|
|
91
|
-
geo: 0.5, // Geo: Low (VPN/Travel)
|
|
92
|
-
os: 0.5, // OS: Low (Generic)
|
|
93
|
-
bot: 0.0, // Bot: Informational
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
let weightedMatches = 0;
|
|
97
|
-
let totalWeight = 0;
|
|
98
|
-
|
|
99
|
-
const allKeys = new Set([...map1.keys(), ...map2.keys()]);
|
|
100
|
-
|
|
101
|
-
allKeys.forEach((key) => {
|
|
102
|
-
if (map1.has(key) && map2.has(key)) {
|
|
103
|
-
const weight = weights[key] || 1.0;
|
|
104
|
-
totalWeight += weight;
|
|
105
|
-
|
|
106
|
-
if (map1.get(key) === map2.get(key)) {
|
|
107
|
-
weightedMatches += weight;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Cache to avoid recalculating constants (Hardware, etc.)
|
|
117
|
-
let cachedBuilder = null;
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Generates the fingerprint of the current device.
|
|
121
|
-
*/
|
|
122
|
-
export const getDeviceFingerprint = () => {
|
|
123
|
-
// NOTE: This is client-side code and should be in a separate file.
|
|
124
|
-
// It will not work in a Node.js environment.
|
|
125
|
-
// The presence of `window` and `document` confirms this.
|
|
126
|
-
|
|
127
|
-
if (typeof window === "undefined") return "server-side";
|
|
128
|
-
|
|
129
|
-
if (!cachedBuilder) {
|
|
130
|
-
const nav = window.navigator;
|
|
131
|
-
const screen = window.screen;
|
|
132
|
-
|
|
133
|
-
cachedBuilder = new FingerprintBuilder();
|
|
134
|
-
|
|
135
|
-
// 1. Hardware (Very stable): Cores, RAM, GPU (if available via canvas), Touch
|
|
136
|
-
cachedBuilder.add(
|
|
137
|
-
"hw",
|
|
138
|
-
`${nav.hardwareConcurrency}_${nav.deviceMemory}_${nav.maxTouchPoints}`,
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
// 2. Geo/Locale (Stable except for travel/VPN): Timezone, Language
|
|
142
|
-
cachedBuilder.add(
|
|
143
|
-
"geo",
|
|
144
|
-
`${Intl.DateTimeFormat().resolvedOptions().timeZone}_${nav.language}_${new Date().getTimezoneOffset()}`,
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
// 3. Screen (Stable except for monitor/zoom changes): Dimensions, ColorDepth
|
|
148
|
-
// Note: We use availWidth/Height which excludes the taskbar, sometimes more unique
|
|
149
|
-
cachedBuilder.add(
|
|
150
|
-
"scr",
|
|
151
|
-
`${screen.width}x${screen.height}_${screen.colorDepth}`,
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
// 4. Platform (Stable): OS, Engine
|
|
155
|
-
cachedBuilder.add("os", nav.platform);
|
|
156
|
-
|
|
157
|
-
// 5. Graphics (WebGL Vendor/Renderer) - Strong hardware invariant
|
|
158
|
-
try {
|
|
159
|
-
const canvas = document.createElement("canvas");
|
|
160
|
-
const gl =
|
|
161
|
-
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
162
|
-
if (gl) {
|
|
163
|
-
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
|
|
164
|
-
if (debugInfo) {
|
|
165
|
-
const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
|
|
166
|
-
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
|
|
167
|
-
cachedBuilder.add("gpu", `${vendor}_${renderer}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} catch (e) {}
|
|
171
|
-
|
|
172
|
-
// 6. Canvas Fingerprinting (Rendering quirks) - Adds ~5-10% uniqueness
|
|
173
|
-
// Exploits micro-differences in anti-aliasing and font rendering
|
|
174
|
-
try {
|
|
175
|
-
const canvas = document.createElement("canvas");
|
|
176
|
-
const ctx = canvas.getContext("2d");
|
|
177
|
-
if (ctx) {
|
|
178
|
-
canvas.width = 200;
|
|
179
|
-
canvas.height = 50;
|
|
180
|
-
ctx.textBaseline = "alphabetic";
|
|
181
|
-
ctx.font = "14px 'Arial'";
|
|
182
|
-
ctx.fillStyle = "#f60";
|
|
183
|
-
ctx.fillRect(125, 1, 62, 20);
|
|
184
|
-
ctx.fillStyle = "#069";
|
|
185
|
-
ctx.fillText("Primals", 2, 15);
|
|
186
|
-
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
|
|
187
|
-
ctx.fillText("Primals", 4, 17);
|
|
188
|
-
cachedBuilder.add("cvs", canvas.toDataURL());
|
|
189
|
-
}
|
|
190
|
-
} catch (e) {}
|
|
191
|
-
|
|
192
|
-
// 7. Bot Detection (Hidden indicator)
|
|
193
|
-
if (nav.webdriver) cachedBuilder.add("bot", "true");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Return a copy to allow adding dynamic fields if needed without polluting the cache
|
|
197
|
-
return cachedBuilder.toString();
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Generates a request signature including the context.
|
|
202
|
-
* @param {object} payload
|
|
203
|
-
*/
|
|
204
|
-
export const generateRequestSignature = (payload = {}) => {
|
|
205
|
-
const deviceFp = getDeviceFingerprint();
|
|
206
|
-
|
|
207
|
-
// Create a temporary builder that inherits from deviceFp
|
|
208
|
-
// Note: Here we keep it simple, just concatenating the payload hash
|
|
209
|
-
const sortedPayload = Object.keys(payload)
|
|
210
|
-
.sort()
|
|
211
|
-
.map((k) => `${k}=${payload[k]}`)
|
|
212
|
-
.join("&");
|
|
213
|
-
const payloadHash = cyrb53(sortedPayload);
|
|
214
|
-
|
|
215
|
-
return `${deviceFp}|req:${payloadHash}`;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Generates an HMAC-SHA256 signature for combat data.
|
|
220
|
-
* @param {object} payload - The data to sign (e.g., { opponentId, victory, damageDealt }).
|
|
221
|
-
* @param {string} secret - The shared secret key.
|
|
222
|
-
* @returns {Promise<string>} The hexadecimal signature.
|
|
223
|
-
*/
|
|
224
|
-
export const generateCombatSignature = async (payload, secret) => {
|
|
225
|
-
// NOTE: This is client-side code using the Web Crypto API (`window.crypto`).
|
|
226
|
-
// It should be moved to a client-side script file.
|
|
227
|
-
|
|
228
|
-
// 1. Create a stable string from the payload.
|
|
229
|
-
const sortedPayload = Object.keys(payload)
|
|
230
|
-
.sort()
|
|
231
|
-
.map((k) => `${k}=${payload[k]}`)
|
|
232
|
-
.join("&");
|
|
233
|
-
|
|
234
|
-
// 2. Use the Web Crypto API for HMAC
|
|
235
|
-
const encoder = new TextEncoder();
|
|
236
|
-
const key = await window.crypto.subtle.importKey(
|
|
237
|
-
"raw",
|
|
238
|
-
encoder.encode(secret),
|
|
239
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
240
|
-
false,
|
|
241
|
-
["sign"],
|
|
242
|
-
);
|
|
243
|
-
const signatureBuffer = await window.crypto.subtle.sign(
|
|
244
|
-
"HMAC",
|
|
245
|
-
key,
|
|
246
|
-
encoder.encode(sortedPayload),
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
// 3. Convert the signature to a hexadecimal string.
|
|
250
|
-
const hashArray = Array.from(new Uint8Array(signatureBuffer));
|
|
251
|
-
const hexString = hashArray
|
|
252
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
253
|
-
.join("");
|
|
254
|
-
return hexString;
|
|
255
|
-
};
|
|
256
14
|
|
|
257
15
|
/**
|
|
258
16
|
* Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
|
|
@@ -567,53 +325,37 @@ export const isTicketValid = (ip, ticket) => {
|
|
|
567
325
|
.digest("hex");
|
|
568
326
|
|
|
569
327
|
// Use timingSafeEqual to prevent timing attacks
|
|
570
|
-
|
|
328
|
+
try {
|
|
329
|
+
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// This can happen if the buffers have different lengths, which is a failure case.
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
571
334
|
};
|
|
572
335
|
|
|
573
|
-
/**
|
|
574
|
-
* Creates a stable hash based on device characteristics, independent of the IP.
|
|
575
|
-
* This is our "level 2 fingerprint".
|
|
576
|
-
* @param {object} req - The Express request object.
|
|
577
|
-
* @returns {string} A hash representing the device.
|
|
578
|
-
*/
|
|
579
|
-
function getDeviceHash(req) {
|
|
580
|
-
const srv = new FingerprintBuilder();
|
|
581
|
-
srv.add("ua", req.headers["user-agent"]);
|
|
582
|
-
if (req.headers["sec-ch-ua-platform"])
|
|
583
|
-
srv.add("os", req.headers["sec-ch-ua-platform"]);
|
|
584
|
-
if (req.headers["sec-ch-ua"]) srv.add("ch", req.headers["sec-ch-ua"]);
|
|
585
|
-
return srv.toString(); // Returns the full fingerprint string for detailed comparison.
|
|
586
|
-
}
|
|
587
336
|
|
|
588
337
|
/**
|
|
589
338
|
* Calculates suspicion indicators related to HTTP header anomalies.
|
|
590
|
-
* @param {object}
|
|
339
|
+
* @param {object} context - The request context.
|
|
591
340
|
* @returns {{headerAnomalyScore: number}}
|
|
592
341
|
*/
|
|
593
|
-
function getHeaderAnomalies(
|
|
594
|
-
// FIX: consistencyScore est maintenant passé
|
|
342
|
+
function getHeaderAnomalies(context) {
|
|
595
343
|
let anomalyScore = 0;
|
|
596
344
|
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
597
|
-
if (!
|
|
345
|
+
if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
|
|
598
346
|
anomalyScore += 60;
|
|
599
347
|
}
|
|
600
348
|
// Penalty if Accept-Language header is missing
|
|
601
|
-
if (!
|
|
349
|
+
if (!context.headers["accept-language"]) {
|
|
602
350
|
anomalyScore += 25;
|
|
603
351
|
}
|
|
604
352
|
// Penalty for HTTP/1.0 requests, often used by old tools or bots
|
|
605
|
-
if (
|
|
353
|
+
if (context.httpVersion === "1.0") {
|
|
606
354
|
anomalyScore += 15;
|
|
607
355
|
}
|
|
608
356
|
|
|
609
|
-
// NEW: Inconsistency score (stolen cookie?)
|
|
610
|
-
// If the consistency score is low, add a massive penalty.
|
|
611
|
-
// A score of 0.2 means a huge difference.
|
|
612
|
-
const inconsistencyScore = Math.max(0, (1 - consistencyScore) * 200);
|
|
613
|
-
|
|
614
357
|
return {
|
|
615
358
|
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
616
|
-
inconsistencyScore: Math.min(100, inconsistencyScore),
|
|
617
359
|
};
|
|
618
360
|
}
|
|
619
361
|
|
|
@@ -652,16 +394,16 @@ export const configureStore = (externalStore) => {
|
|
|
652
394
|
/**
|
|
653
395
|
* Orchestrates request identification using a persistent anchor (cookie)
|
|
654
396
|
* and fingerprint verification.
|
|
655
|
-
* @param {object}
|
|
656
|
-
* @
|
|
657
|
-
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number}>}
|
|
397
|
+
* @param {object} context - The request context.
|
|
398
|
+
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
658
399
|
*/
|
|
659
|
-
async function resolveRequestIdentity(
|
|
660
|
-
const existingDeviceId =
|
|
661
|
-
const currentDeviceHash = getDeviceHash(
|
|
400
|
+
async function resolveRequestIdentity(context) {
|
|
401
|
+
const existingDeviceId = context.cookies?.device_id;
|
|
402
|
+
const currentDeviceHash = getDeviceHash(context);
|
|
662
403
|
let deviceId = existingDeviceId;
|
|
663
404
|
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
664
405
|
let deviceData = null;
|
|
406
|
+
let newCookie = null;
|
|
665
407
|
|
|
666
408
|
if (deviceId) {
|
|
667
409
|
deviceData = await store.get(`device:${deviceId}`);
|
|
@@ -680,13 +422,14 @@ async function resolveRequestIdentity(req, res) {
|
|
|
680
422
|
// Case 2: New user or lost/invalid cookie.
|
|
681
423
|
deviceId = crypto.randomUUID(); // Generate a new "passport".
|
|
682
424
|
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
425
|
+
// Return the intention to set a cookie.
|
|
426
|
+
newCookie = {
|
|
427
|
+
name: "device_id",
|
|
428
|
+
value: deviceId,
|
|
429
|
+
options: {
|
|
430
|
+
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 31536000000, // 1 year
|
|
431
|
+
}
|
|
432
|
+
};
|
|
690
433
|
|
|
691
434
|
// Initialize tracking for this new device.
|
|
692
435
|
deviceData = {
|
|
@@ -700,24 +443,24 @@ async function resolveRequestIdentity(req, res) {
|
|
|
700
443
|
// The write will happen in getSuspicionVector after all modifications.
|
|
701
444
|
}
|
|
702
445
|
|
|
703
|
-
return { deviceId, deviceData, consistencyScore };
|
|
446
|
+
return { deviceId, deviceData, consistencyScore, newCookie };
|
|
704
447
|
}
|
|
705
448
|
|
|
706
449
|
/*
|
|
707
450
|
* Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
|
|
708
|
-
* @param {object}
|
|
451
|
+
* @param {object} context - The request context.
|
|
709
452
|
* @param {object} deviceData - The device's activity data.
|
|
710
453
|
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
711
454
|
*/
|
|
712
|
-
async function getBehavioralIndicators(
|
|
455
|
+
async function getBehavioralIndicators(context, deviceData) {
|
|
713
456
|
const now = Date.now();
|
|
714
|
-
const clientIp =
|
|
457
|
+
const clientIp = context.clientIp;
|
|
715
458
|
|
|
716
459
|
// Get the IP type to modulate the score
|
|
717
460
|
const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
|
|
718
461
|
const isSharedIp = ipProfile.type === "shared";
|
|
719
462
|
|
|
720
|
-
const currentFpHash = getDeviceHash(
|
|
463
|
+
const currentFpHash = getDeviceHash(context); // Use the device hash
|
|
721
464
|
|
|
722
465
|
// --- Behavior analysis (Change frequency) ---
|
|
723
466
|
if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
|
|
@@ -762,13 +505,20 @@ async function getBehavioralIndicators(req, deviceData) {
|
|
|
762
505
|
|
|
763
506
|
/**
|
|
764
507
|
* Returns a vector of raw (unweighted) suspicion scores.
|
|
765
|
-
* @param {object}
|
|
508
|
+
* @param {object} context - The request context object.
|
|
766
509
|
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
|
|
767
510
|
*/
|
|
768
|
-
export const getSuspicionVector = async (
|
|
769
|
-
const { deviceId, deviceData, consistencyScore } = await resolveRequestIdentity(
|
|
511
|
+
export const getSuspicionVector = async (context) => {
|
|
512
|
+
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
|
|
770
513
|
|
|
771
|
-
const clientIp =
|
|
514
|
+
const clientIp = context.clientIp;
|
|
515
|
+
|
|
516
|
+
// If a new cookie needs to be set, attach it to the request object
|
|
517
|
+
// so the middleware can handle it. This is a temporary state holder.
|
|
518
|
+
if (newCookie) {
|
|
519
|
+
context._newCookies = context._newCookies || [];
|
|
520
|
+
context._newCookies.push(newCookie);
|
|
521
|
+
}
|
|
772
522
|
await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
|
|
773
523
|
|
|
774
524
|
// Periodically clean up device data
|
|
@@ -778,13 +528,16 @@ export const getSuspicionVector = async (req, res) => {
|
|
|
778
528
|
}
|
|
779
529
|
deviceData.lastUpdate = Date.now();
|
|
780
530
|
|
|
781
|
-
const behavioral = await getBehavioralIndicators(
|
|
782
|
-
const
|
|
531
|
+
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
532
|
+
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
533
|
+
// Calculate the inconsistency score here, separately.
|
|
534
|
+
const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200));
|
|
535
|
+
|
|
783
536
|
|
|
784
537
|
// Save the updated device state to the store
|
|
785
538
|
await store.set(`device:${deviceId}`, deviceData);
|
|
786
539
|
|
|
787
|
-
return { ...behavioral,
|
|
540
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore };
|
|
788
541
|
};
|
|
789
542
|
|
|
790
543
|
// A residential user can change networks (home, 4G, public wifi).
|
|
@@ -804,61 +557,29 @@ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes a
|
|
|
804
557
|
* and IP, making spoofing more complex (requires changing the entire stack).
|
|
805
558
|
*/
|
|
806
559
|
export const identifyRequest = async (req, res) => {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
type: "residential",
|
|
813
|
-
deviceIds: new Set(),
|
|
814
|
-
statelessCount: 0,
|
|
815
|
-
lastSeen: 0,
|
|
560
|
+
// This function now acts as a lightweight wrapper around the engine's identifyRequest method.
|
|
561
|
+
// It requires a default configuration to work.
|
|
562
|
+
const defaultConfig = {
|
|
563
|
+
weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8 },
|
|
564
|
+
thresholds: { low: 20, medium: 40, high: 75 }
|
|
816
565
|
};
|
|
817
|
-
|
|
818
|
-
if (deviceId) {
|
|
819
|
-
ipProfile.deviceIds.add(deviceId);
|
|
820
|
-
} else {
|
|
821
|
-
// Improved anti-"Amnesiac Bot" logic
|
|
822
|
-
ipProfile.statelessCount++;
|
|
823
|
-
}
|
|
566
|
+
const engine = new FingerprintEngine(defaultConfig);
|
|
824
567
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
568
|
+
const requestContext = {
|
|
569
|
+
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
570
|
+
cookies: req.cookies,
|
|
571
|
+
headers: req.headers,
|
|
572
|
+
rawHeaders: req.rawHeaders,
|
|
573
|
+
httpVersion: req.httpVersion,
|
|
574
|
+
};
|
|
829
575
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
return `suspicious_high:${clientIp}`;
|
|
835
|
-
}
|
|
836
|
-
await store.set(`ip:${clientIp}`, ipProfile);
|
|
837
|
-
|
|
838
|
-
// For compatibility with the rate-limiter, calculate a simple score.
|
|
839
|
-
// The PoW will use the more complex weighted system.
|
|
840
|
-
const vector = await getSuspicionVector(req, res);
|
|
841
|
-
const score =
|
|
842
|
-
vector.historyScore * 0.3 +
|
|
843
|
-
vector.rotationScore * 0.5 +
|
|
844
|
-
vector.headerAnomalyScore * 0.1 +
|
|
845
|
-
vector.inconsistencyScore * 0.8; // Inconsistency is a very strong signal
|
|
846
|
-
|
|
847
|
-
// Return a string for compatibility with rate limiters,
|
|
848
|
-
// but based on suspicion thresholds.
|
|
849
|
-
// NOTE: These thresholds are fixed here, but the PoW will use dynamic thresholds.
|
|
850
|
-
if (score >= 75) {
|
|
851
|
-
return `suspicious_high:${clientIp}`;
|
|
852
|
-
}
|
|
853
|
-
if (score >= 40) {
|
|
854
|
-
return `suspicious_medium:${clientIp}`;
|
|
576
|
+
const key = await engine.identifyRequest(requestContext);
|
|
577
|
+
|
|
578
|
+
if (requestContext._newCookies && res) {
|
|
579
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
855
580
|
}
|
|
856
581
|
|
|
857
|
-
|
|
858
|
-
// Use the device hash so the rate-limit follows the device, not the IP.
|
|
859
|
-
const deviceIdForIp = await store.get(`ip-device:${clientIp}`);
|
|
860
|
-
const finalDeviceId = deviceId || deviceIdForIp || clientIp;
|
|
861
|
-
return `device:${finalDeviceId}`;
|
|
582
|
+
return key;
|
|
862
583
|
};
|
|
863
584
|
// --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
|
|
864
585
|
|
|
@@ -1052,12 +773,11 @@ class FingerprintEngine {
|
|
|
1052
773
|
const { weights, thresholds, logger } = this.securityConfig;
|
|
1053
774
|
|
|
1054
775
|
if (isStatic) {
|
|
1055
|
-
return { action: 'next' };
|
|
776
|
+
return { action: 'next', score: 0, vector: {} };
|
|
1056
777
|
}
|
|
1057
778
|
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
const suspicionVector = await __internal.getSuspicionVector(requestContext.rawReq, requestContext.rawRes);
|
|
779
|
+
// The engine now works with the context directly, no more rawReq dependency here.
|
|
780
|
+
const suspicionVector = await __internal.getSuspicionVector(requestContext);
|
|
1061
781
|
|
|
1062
782
|
const finalScore =
|
|
1063
783
|
suspicionVector.historyScore * (weights.historyScore || 0) +
|
|
@@ -1079,11 +799,6 @@ class FingerprintEngine {
|
|
|
1079
799
|
const powCookie = cookies?.pow_clearance;
|
|
1080
800
|
const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1081
801
|
|
|
1082
|
-
// Basic log for each non-static request
|
|
1083
|
-
if (logger && !isSuspicious) {
|
|
1084
|
-
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
802
|
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1088
803
|
// --- CHALLENGE SOLUTION HANDLING ---
|
|
1089
804
|
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
@@ -1140,6 +855,8 @@ class FingerprintEngine {
|
|
|
1140
855
|
return {
|
|
1141
856
|
action: 'redirect',
|
|
1142
857
|
path: path,
|
|
858
|
+
score: finalScore,
|
|
859
|
+
vector: suspicionVector,
|
|
1143
860
|
cookie: {
|
|
1144
861
|
name: 'pow_clearance',
|
|
1145
862
|
value: ticket,
|
|
@@ -1176,7 +893,7 @@ class FingerprintEngine {
|
|
|
1176
893
|
|
|
1177
894
|
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
|
|
1178
895
|
return {
|
|
1179
|
-
action: 'challenge',
|
|
896
|
+
action: 'challenge', score: finalScore, vector: suspicionVector,
|
|
1180
897
|
status: 429, body: page
|
|
1181
898
|
};
|
|
1182
899
|
}
|
|
@@ -1195,11 +912,66 @@ class FingerprintEngine {
|
|
|
1195
912
|
|
|
1196
913
|
// On utilise toujours la page combinée, même si la difficulté mémoire est 0 (le calcul sera quasi instantané).
|
|
1197
914
|
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
|
|
1198
|
-
return { action: 'challenge', status: 429, body: page };
|
|
915
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
|
|
1199
916
|
}
|
|
1200
917
|
}
|
|
1201
918
|
|
|
1202
|
-
|
|
919
|
+
// Basic log for each non-static request that passed without a challenge
|
|
920
|
+
if (logger) {
|
|
921
|
+
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return { action: 'next', score: finalScore, vector: suspicionVector };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Identifies a request in a granular way for non-Express environments.
|
|
929
|
+
* @param {object} requestContext - The request context object.
|
|
930
|
+
* @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
|
|
931
|
+
*/
|
|
932
|
+
async identifyRequest(requestContext) {
|
|
933
|
+
const { clientIp, cookies, rawReq, rawRes } = requestContext;
|
|
934
|
+
|
|
935
|
+
// --- Update IP reputation ---
|
|
936
|
+
const ipProfile = (await store.get(`ip:${clientIp}`)) || {
|
|
937
|
+
type: "residential",
|
|
938
|
+
deviceIds: new Set(),
|
|
939
|
+
statelessCount: 0,
|
|
940
|
+
lastSeen: 0,
|
|
941
|
+
};
|
|
942
|
+
ipProfile.lastSeen = Date.now();
|
|
943
|
+
if (cookies?.device_id) {
|
|
944
|
+
ipProfile.deviceIds.add(cookies.device_id);
|
|
945
|
+
} else {
|
|
946
|
+
ipProfile.statelessCount++;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
|
|
950
|
+
ipProfile.type = "shared";
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
|
|
954
|
+
if (ipProfile.statelessCount > statelessLimit) {
|
|
955
|
+
return `suspicious_high:${clientIp}`;
|
|
956
|
+
}
|
|
957
|
+
await store.set(`ip:${clientIp}`, ipProfile);
|
|
958
|
+
|
|
959
|
+
const vector = await __internal.getSuspicionVector(requestContext);
|
|
960
|
+
const score =
|
|
961
|
+
vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
|
|
962
|
+
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
963
|
+
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
964
|
+
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8);
|
|
965
|
+
|
|
966
|
+
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
967
|
+
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
968
|
+
if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
|
|
969
|
+
|
|
970
|
+
// If a new device_id was created, it's in the context.
|
|
971
|
+
const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
|
|
972
|
+
const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
|
|
973
|
+
|
|
974
|
+
return `device:${finalDeviceId}`;
|
|
1203
975
|
}
|
|
1204
976
|
}
|
|
1205
977
|
|
|
@@ -1215,14 +987,28 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1215
987
|
query: req.query,
|
|
1216
988
|
headers: req.headers,
|
|
1217
989
|
isStatic: isStaticResource(req),
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
990
|
+
// Add the newly required properties for full decoupling
|
|
991
|
+
rawHeaders: req.rawHeaders,
|
|
992
|
+
httpVersion: req.httpVersion,
|
|
1221
993
|
};
|
|
1222
994
|
|
|
1223
995
|
const decision = await engine.processRequest(requestContext);
|
|
1224
996
|
|
|
997
|
+
// Attach the fingerprinting result to the request object for downstream middlewares.
|
|
998
|
+
req.fingerprint = {
|
|
999
|
+
score: decision.score,
|
|
1000
|
+
vector: decision.vector,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
// After getSuspicionVector runs, it might have attached cookies to be set.
|
|
1004
|
+
if (requestContext._newCookies) {
|
|
1005
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1225
1008
|
switch (decision.action) {
|
|
1009
|
+
case 'block':
|
|
1010
|
+
return res.status(decision.status).send(decision.body);
|
|
1011
|
+
|
|
1226
1012
|
case 'challenge':
|
|
1227
1013
|
return res.status(decision.status).send(decision.body);
|
|
1228
1014
|
|
|
@@ -1245,7 +1031,10 @@ export const powMiddleware = (securityConfig) => {
|
|
|
1245
1031
|
* This is a common pattern to allow mocking of ES module functions.
|
|
1246
1032
|
*/
|
|
1247
1033
|
export const __internal = {
|
|
1034
|
+
getDeviceHash,
|
|
1248
1035
|
getSuspicionVector,
|
|
1036
|
+
cyrb53, // Export for testing
|
|
1037
|
+
FingerprintBuilder, // Export for testing
|
|
1249
1038
|
calculateTarget,
|
|
1250
1039
|
FingerprintEngine, // Expose for advanced testing
|
|
1251
1040
|
};
|
|
@@ -1284,7 +1073,7 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
|
|
|
1284
1073
|
const fitnessFunction = (solution) => {
|
|
1285
1074
|
const [low, medium, high] = solution;
|
|
1286
1075
|
// Constraints: thresholds must be ordered and within a reasonable range.
|
|
1287
|
-
if (low >= medium || medium >= high || low
|
|
1076
|
+
if (low >= medium || medium >= high || low <= 10 || high >= 90) return Infinity;
|
|
1288
1077
|
|
|
1289
1078
|
let falsePositives = 0; // Humans challenged unnecessarily.
|
|
1290
1079
|
let falseNegatives = 0; // Undetected bots.
|
package/library.js
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anonympins/fingerprint",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
|
|
5
5
|
"main": "fingerprint.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=20.0.0"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "vitest run"
|
|
@@ -42,9 +42,6 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"cookie-parser": "^1.4.6",
|
|
44
44
|
"express": "^4.18.2",
|
|
45
|
-
"vitest": "^1.
|
|
46
|
-
},
|
|
47
|
-
"dependencies": {
|
|
48
|
-
|
|
45
|
+
"vitest": "^4.1.11"
|
|
49
46
|
}
|
|
50
47
|
}
|