@webdecoy/fcaptcha 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +52 -17
- package/package.json +4 -3
- package/server.js +49 -21
- package/suspicion.js +172 -0
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const crypto = require('crypto');
|
|
13
13
|
const detection = require('./detection');
|
|
14
14
|
const { ProxyTrust } = require('./clientip');
|
|
15
|
+
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// PoW Challenge Store (can be extended with Redis)
|
|
@@ -25,7 +26,7 @@ class PoWChallengeStore {
|
|
|
25
26
|
this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
generate(siteKey, ip, difficulty = 4) {
|
|
29
|
+
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
29
30
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
30
31
|
const timestamp = Date.now();
|
|
31
32
|
const expiresAt = timestamp + this.expirationMs;
|
|
@@ -36,14 +37,16 @@ class PoWChallengeStore {
|
|
|
36
37
|
timestamp,
|
|
37
38
|
expiresAt,
|
|
38
39
|
difficulty,
|
|
40
|
+
// How long the client must hold this challenge before submitting a
|
|
41
|
+
// solution. Inside the signed payload so it cannot be talked down.
|
|
42
|
+
minAgeMs,
|
|
39
43
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
// Sign the challenge
|
|
43
47
|
const sig = crypto.createHmac('sha256', this.secret)
|
|
44
48
|
.update(JSON.stringify(challengeData))
|
|
45
|
-
.digest('hex')
|
|
46
|
-
.slice(0, 16);
|
|
49
|
+
.digest('hex');
|
|
47
50
|
|
|
48
51
|
challengeData.sig = sig;
|
|
49
52
|
|
|
@@ -100,7 +103,13 @@ class PoWChallengeStore {
|
|
|
100
103
|
this.usedSolutions.add(solutionKey);
|
|
101
104
|
this.challenges.delete(challengeId);
|
|
102
105
|
|
|
103
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
valid: true,
|
|
108
|
+
difficulty: challenge.difficulty,
|
|
109
|
+
serverElapsed: Date.now() - challenge.timestamp,
|
|
110
|
+
// Fall back for challenges issued before adaptive cost existed.
|
|
111
|
+
minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
|
|
112
|
+
};
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
_cleanup() {
|
|
@@ -207,29 +216,31 @@ class ScoringEngine {
|
|
|
207
216
|
this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
|
|
208
217
|
this.rateLimiter = options.rateLimiter || new RateLimiter();
|
|
209
218
|
this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
|
|
219
|
+
this.suspicion = options.suspicion || new SuspicionLedger();
|
|
210
220
|
this.weights = options.weights || WEIGHTS;
|
|
211
221
|
}
|
|
212
222
|
|
|
213
223
|
// Generate a PoW challenge
|
|
214
224
|
generateChallenge(siteKey, ip, options = {}) {
|
|
225
|
+
// See suspicion.js for why the escalation lands almost entirely on
|
|
226
|
+
// minAgeMs rather than on difficulty.
|
|
215
227
|
let difficulty = options.difficulty || 4;
|
|
228
|
+
let minAgeMs = BASE_MIN_AGE_MS;
|
|
216
229
|
|
|
217
230
|
if (options.scaleByReputation !== false) {
|
|
218
|
-
if (detection.isDatacenterIP(ip)) {
|
|
219
|
-
difficulty = Math.max(difficulty, 5);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
231
|
const rateKey = `pow:${siteKey}:${ip}`;
|
|
223
232
|
const [exceeded, count] = this.rateLimiter.check(rateKey, 60, 20);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
const cost = computeChallengeCost(
|
|
234
|
+
this.suspicion.count(siteKey, ip),
|
|
235
|
+
detection.isDatacenterIP(ip),
|
|
236
|
+
count,
|
|
237
|
+
exceeded
|
|
238
|
+
);
|
|
239
|
+
difficulty = Math.max(difficulty, cost.difficulty);
|
|
240
|
+
minAgeMs = cost.minAgeMs;
|
|
230
241
|
}
|
|
231
242
|
|
|
232
|
-
return this.powStore.generate(siteKey, ip, difficulty);
|
|
243
|
+
return this.powStore.generate(siteKey, ip, difficulty, minAgeMs);
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
// Verify signals and return score
|
|
@@ -260,6 +271,26 @@ class ScoringEngine {
|
|
|
260
271
|
confidence: 0.8,
|
|
261
272
|
reason: `PoW verification failed: ${powResult.reason}`
|
|
262
273
|
});
|
|
274
|
+
} else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
|
|
275
|
+
// Under the universal baseline nothing legitimate can have happened —
|
|
276
|
+
// no human completes an interaction that fast.
|
|
277
|
+
detections.push({
|
|
278
|
+
category: 'bot',
|
|
279
|
+
score: 0.8,
|
|
280
|
+
confidence: 0.85,
|
|
281
|
+
reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
|
|
282
|
+
});
|
|
283
|
+
} else if (powResult.serverElapsed < powResult.minAgeMs) {
|
|
284
|
+
// Between the baseline and this source's own elevated floor is weaker
|
|
285
|
+
// evidence: a client predating adaptive cost, or one served from a
|
|
286
|
+
// stale cache, does not know to wait. It contributes rather than
|
|
287
|
+
// deciding.
|
|
288
|
+
detections.push({
|
|
289
|
+
category: 'bot',
|
|
290
|
+
score: 0.5,
|
|
291
|
+
confidence: 0.5,
|
|
292
|
+
reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
|
|
293
|
+
});
|
|
263
294
|
}
|
|
264
295
|
} else {
|
|
265
296
|
detections.push({
|
|
@@ -308,6 +339,10 @@ class ScoringEngine {
|
|
|
308
339
|
const success = finalScore < 0.5;
|
|
309
340
|
const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
|
|
310
341
|
|
|
342
|
+
// Feed the ledger so the next challenge this source asks for is priced on
|
|
343
|
+
// what it just did.
|
|
344
|
+
this.suspicion.record(siteKey, ip, finalScore);
|
|
345
|
+
|
|
311
346
|
return {
|
|
312
347
|
success,
|
|
313
348
|
score: finalScore,
|
|
@@ -332,7 +367,7 @@ class ScoringEngine {
|
|
|
332
367
|
delete decoded.sig;
|
|
333
368
|
|
|
334
369
|
const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
|
|
335
|
-
const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex')
|
|
370
|
+
const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
336
371
|
|
|
337
372
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
338
373
|
return { valid: false, reason: 'invalid_signature' };
|
|
@@ -707,7 +742,7 @@ class ScoringEngine {
|
|
|
707
742
|
};
|
|
708
743
|
|
|
709
744
|
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
710
|
-
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex')
|
|
745
|
+
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
711
746
|
data.sig = sig;
|
|
712
747
|
|
|
713
748
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
"test:clientip": "node clientip.test.js",
|
|
16
16
|
"test:limits": "node limits.test.js",
|
|
17
17
|
"test:detection": "node detection.test.js",
|
|
18
|
-
"test": "node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js",
|
|
19
|
-
"test:forensics": "node inputforensics.test.js"
|
|
18
|
+
"test": "node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node --test suspicion.test.js",
|
|
19
|
+
"test:forensics": "node inputforensics.test.js",
|
|
20
|
+
"test:suspicion": "node --test suspicion.test.js"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"cors": "^2.8.5",
|
package/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const detection = require('./detection');
|
|
|
12
12
|
const webbotauth = require('./webbotauth');
|
|
13
13
|
const { ProxyTrust } = require('./clientip');
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
|
+
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
15
16
|
const { detectInputForensics } = require('./inputforensics');
|
|
16
17
|
|
|
17
18
|
const app = express();
|
|
@@ -32,6 +33,10 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
|
|
|
32
33
|
// limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
|
|
33
34
|
const SITE_KEYS = SiteKeyGuard.fromEnv();
|
|
34
35
|
|
|
36
|
+
// Recent strong verdicts per source, used to price the next challenge that
|
|
37
|
+
// source asks for. Bounded and short-lived; see suspicion.js.
|
|
38
|
+
const suspicionLedger = new SuspicionLedger();
|
|
39
|
+
|
|
35
40
|
// Express's own `trust proxy` would re-derive req.ip from the same headers on
|
|
36
41
|
// its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
|
|
37
42
|
app.set('trust proxy', false);
|
|
@@ -97,7 +102,7 @@ const powChallengeStore = {
|
|
|
97
102
|
usedSolutions: new BoundedSet(),
|
|
98
103
|
|
|
99
104
|
// Generate a new challenge
|
|
100
|
-
generate(siteKey, ip, difficulty = 4) {
|
|
105
|
+
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
101
106
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
102
107
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
103
108
|
const timestamp = Date.now();
|
|
@@ -110,6 +115,9 @@ const powChallengeStore = {
|
|
|
110
115
|
timestamp,
|
|
111
116
|
expiresAt,
|
|
112
117
|
difficulty,
|
|
118
|
+
// How long the client must hold this challenge before submitting a
|
|
119
|
+
// solution. Inside the signed payload so it cannot be talked down.
|
|
120
|
+
minAgeMs,
|
|
113
121
|
nonce,
|
|
114
122
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
115
123
|
};
|
|
@@ -180,7 +188,14 @@ const powChallengeStore = {
|
|
|
180
188
|
// Calculate server-side elapsed time (un-spoofable)
|
|
181
189
|
const serverElapsed = Date.now() - challenge.createdAt;
|
|
182
190
|
|
|
183
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
valid: true,
|
|
193
|
+
difficulty: challenge.difficulty,
|
|
194
|
+
serverElapsed,
|
|
195
|
+
nonce: challenge.nonce,
|
|
196
|
+
// Fall back for challenges issued before adaptive cost existed.
|
|
197
|
+
minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
|
|
198
|
+
};
|
|
184
199
|
},
|
|
185
200
|
|
|
186
201
|
_cleanup() {
|
|
@@ -1311,14 +1326,26 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1311
1326
|
}
|
|
1312
1327
|
}
|
|
1313
1328
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1329
|
+
// Server-side timing, the one cost an attacker cannot buy their way out
|
|
1330
|
+
// of. Two thresholds, because they mean different things.
|
|
1331
|
+
if (powValid && powVerification.serverElapsed < BASE_MIN_AGE_MS) {
|
|
1332
|
+
// Under the universal baseline nothing legitimate can have happened.
|
|
1316
1333
|
detections.push({
|
|
1317
1334
|
category: 'bot',
|
|
1318
1335
|
score: 0.8,
|
|
1319
1336
|
confidence: 0.85,
|
|
1320
1337
|
reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
|
|
1321
1338
|
});
|
|
1339
|
+
} else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
|
|
1340
|
+
// Between the baseline and this source's own elevated floor is weaker
|
|
1341
|
+
// evidence: a client predating adaptive cost, or one served from a stale
|
|
1342
|
+
// cache, does not know to wait. It contributes rather than deciding.
|
|
1343
|
+
detections.push({
|
|
1344
|
+
category: 'bot',
|
|
1345
|
+
score: 0.5,
|
|
1346
|
+
confidence: 0.5,
|
|
1347
|
+
reason: `Challenge submitted before the required delay for this source (${powVerification.serverElapsed}ms of ${powVerification.minAgeMs}ms)`
|
|
1348
|
+
});
|
|
1322
1349
|
}
|
|
1323
1350
|
} else {
|
|
1324
1351
|
// No PoW solution provided - hard fail
|
|
@@ -1409,6 +1436,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1409
1436
|
const success = finalScore < 0.5;
|
|
1410
1437
|
const token = success ? generateToken(ip, siteKey, finalScore) : null;
|
|
1411
1438
|
|
|
1439
|
+
// Feed the ledger so the next challenge this source asks for is priced on
|
|
1440
|
+
// what it just did.
|
|
1441
|
+
suspicionLedger.record(siteKey, ip, finalScore);
|
|
1442
|
+
|
|
1412
1443
|
return {
|
|
1413
1444
|
success,
|
|
1414
1445
|
score: finalScore,
|
|
@@ -1506,25 +1537,18 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
1506
1537
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1507
1538
|
const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
|
|
1508
1539
|
|
|
1509
|
-
//
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
// Increase difficulty for suspicious IPs
|
|
1513
|
-
if (detection.isDatacenterIP(ip)) {
|
|
1514
|
-
difficulty = 5; // ~1-3 seconds
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
// Check rate - high request rate gets harder challenges
|
|
1540
|
+
// Cost scaling. See suspicion.js for why the escalation lands almost
|
|
1541
|
+
// entirely on minAgeMs rather than on difficulty.
|
|
1518
1542
|
const rateKey = `pow:${siteKey}:${ip}`;
|
|
1519
1543
|
const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1544
|
+
const cost = computeChallengeCost(
|
|
1545
|
+
suspicionLedger.count(siteKey, ip),
|
|
1546
|
+
detection.isDatacenterIP(ip),
|
|
1547
|
+
count,
|
|
1548
|
+
exceeded
|
|
1549
|
+
);
|
|
1526
1550
|
|
|
1527
|
-
const challenge = powChallengeStore.generate(siteKey, ip, difficulty);
|
|
1551
|
+
const challenge = powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
|
|
1528
1552
|
|
|
1529
1553
|
res.json({
|
|
1530
1554
|
challengeId: challenge.id,
|
|
@@ -1532,7 +1556,11 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
1532
1556
|
difficulty: challenge.difficulty,
|
|
1533
1557
|
expiresAt: challenge.expiresAt,
|
|
1534
1558
|
nonce: challenge.nonce,
|
|
1535
|
-
sig: challenge.sig
|
|
1559
|
+
sig: challenge.sig,
|
|
1560
|
+
// Tells the client how long to hold a solved challenge before submitting.
|
|
1561
|
+
// Honouring it is how an ordinary visitor pays an elevated cost as a short
|
|
1562
|
+
// wait instead of as a worse score.
|
|
1563
|
+
minAgeMs: challenge.minAgeMs
|
|
1536
1564
|
});
|
|
1537
1565
|
});
|
|
1538
1566
|
|
package/suspicion.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adaptive challenge cost: what a source pays is a function of what that source
|
|
5
|
+
* has recently been caught doing, rather than a constant.
|
|
6
|
+
*
|
|
7
|
+
* Why the cost is mostly time, not hashing
|
|
8
|
+
* ----------------------------------------
|
|
9
|
+
* A constant difficulty is strictly dominated: it either fails to inconvenience
|
|
10
|
+
* an attacker or it does hurt real users. The measurements behind that claim:
|
|
11
|
+
* browser JS runs 1-3M hash/s, native code 100-500M/s, so difficulty 6 costs a
|
|
12
|
+
* native solver about a millisecond and a budget Android phone about sixteen
|
|
13
|
+
* seconds. Raising difficulty is close to a pure tax on the slowest legitimate
|
|
14
|
+
* devices.
|
|
15
|
+
*
|
|
16
|
+
* Wall-clock is the knob that does not have that property. Nobody can make less
|
|
17
|
+
* time pass, so a minimum challenge age caps how fast one source can mint
|
|
18
|
+
* tokens no matter what hardware it brings. So suspicion moves the time floor
|
|
19
|
+
* first and difficulty barely at all.
|
|
20
|
+
*
|
|
21
|
+
* What this deliberately is not
|
|
22
|
+
* -----------------------------
|
|
23
|
+
* This is not Workstream F 10.1 (cross-session correlation). It stores strong-
|
|
24
|
+
* verdict timestamps per source, nothing else: no behavioral vectors, no
|
|
25
|
+
* per-fingerprint history, no traces that survive the window. It is the same
|
|
26
|
+
* shape and the same privacy class as the rate limiter sitting next to it, and
|
|
27
|
+
* it should stay that way — 10.1 has a privacy load this does not, and the two
|
|
28
|
+
* should not be conflated because they happen to both be "server-side memory".
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// The verdict score at or above which a verification counts as evidence.
|
|
32
|
+
// Deliberately high: a marginal verdict is exactly the case where the scoring
|
|
33
|
+
// might be wrong about a real person, and making the next person from that
|
|
34
|
+
// address wait is not worth the guess.
|
|
35
|
+
const STRONG_SCORE = 0.8;
|
|
36
|
+
|
|
37
|
+
// How long a strong verdict keeps counting. Short enough that a shared egress
|
|
38
|
+
// address recovers on its own within a coffee break.
|
|
39
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
// Only the count matters and every tier saturates well below this.
|
|
42
|
+
const MAX_HITS = 16;
|
|
43
|
+
|
|
44
|
+
// Bounds the table. Sources with no strong verdicts never get an entry at all,
|
|
45
|
+
// so this only has to cover addresses actively failing verification.
|
|
46
|
+
const MAX_SOURCES = 50000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Records recent strong verdicts per source.
|
|
50
|
+
*
|
|
51
|
+
* Entries are created only when a source produces a strong verdict, so the
|
|
52
|
+
* common case — a legitimate visitor — allocates nothing and looks up nothing
|
|
53
|
+
* but a miss.
|
|
54
|
+
*/
|
|
55
|
+
class SuspicionLedger {
|
|
56
|
+
constructor() {
|
|
57
|
+
this.hits = new Map();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static _key(siteKey, ip) {
|
|
61
|
+
return `${siteKey}|${ip}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Note a verdict. Scores below the strong threshold are ignored entirely
|
|
66
|
+
* rather than recorded and weighted, so a source that merely looks unusual
|
|
67
|
+
* never accumulates anything.
|
|
68
|
+
*/
|
|
69
|
+
record(siteKey, ip, score) {
|
|
70
|
+
if (!ip || typeof score !== 'number' || score < STRONG_SCORE) return;
|
|
71
|
+
|
|
72
|
+
const key = SuspicionLedger._key(siteKey, ip);
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const cutoff = now - WINDOW_MS;
|
|
75
|
+
|
|
76
|
+
const kept = (this.hits.get(key) || []).filter((t) => t > cutoff);
|
|
77
|
+
kept.push(now);
|
|
78
|
+
|
|
79
|
+
// Map preserves insertion order, so re-inserting makes this the most
|
|
80
|
+
// recently touched key and the eviction below drops the stalest source.
|
|
81
|
+
this.hits.delete(key);
|
|
82
|
+
this.hits.set(key, kept.length > MAX_HITS ? kept.slice(-MAX_HITS) : kept);
|
|
83
|
+
|
|
84
|
+
if (this.hits.size > MAX_SOURCES) {
|
|
85
|
+
this.hits.delete(this.hits.keys().next().value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How many strong verdicts this source produced inside the window. Counted
|
|
91
|
+
* from the timestamps rather than from the entry's existence, so an old hit
|
|
92
|
+
* actually decays while newer ones keep the entry alive.
|
|
93
|
+
*/
|
|
94
|
+
count(siteKey, ip) {
|
|
95
|
+
if (!ip) return 0;
|
|
96
|
+
const hits = this.hits.get(SuspicionLedger._key(siteKey, ip));
|
|
97
|
+
if (!hits) return 0;
|
|
98
|
+
|
|
99
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
100
|
+
const n = hits.filter((t) => t > cutoff).length;
|
|
101
|
+
if (n === 0) this.hits.delete(SuspicionLedger._key(siteKey, ip));
|
|
102
|
+
return n;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Baseline cost. A clean visitor pays exactly this, which is what the server
|
|
107
|
+
// has always charged everyone.
|
|
108
|
+
const BASE_DIFFICULTY = 4;
|
|
109
|
+
const BASE_MIN_AGE_MS = 1500;
|
|
110
|
+
|
|
111
|
+
// Caps the compute knob at 5, below the 6 this server used to reach. Difficulty
|
|
112
|
+
// 6 buys about a millisecond of attacker time and spends about sixteen seconds
|
|
113
|
+
// of a budget phone's; the escalation belongs in minAgeMs where an attacker
|
|
114
|
+
// cannot buy their way out of it.
|
|
115
|
+
const MAX_DIFFICULTY = 5;
|
|
116
|
+
|
|
117
|
+
// Caps the time knob at 15s. At the 1.5s baseline one address can mint roughly
|
|
118
|
+
// 40 tokens a minute; at 15s, four. Pushing further buys little and is felt by
|
|
119
|
+
// anyone sharing a poisoned egress address.
|
|
120
|
+
const MAX_MIN_AGE_MS = 15000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Maps accumulated suspicion onto a cost.
|
|
124
|
+
*
|
|
125
|
+
* Note what does NOT raise difficulty here: being on a datacenter address. That
|
|
126
|
+
* used to jump straight to difficulty 5, which charges a real person on a
|
|
127
|
+
* corporate VPN or iCloud Private Relay several seconds of blocked hashing on a
|
|
128
|
+
* slow phone for the offence of having a shared IP. It now moves the time floor
|
|
129
|
+
* instead, which a datacenter-hosted scraper feels as reduced throughput and a
|
|
130
|
+
* person filling in a form does not feel at all.
|
|
131
|
+
*
|
|
132
|
+
* @returns {{difficulty: number, minAgeMs: number}}
|
|
133
|
+
*/
|
|
134
|
+
function computeChallengeCost(strongHits, isDatacenter, requestCount, rateExceeded) {
|
|
135
|
+
let difficulty = BASE_DIFFICULTY;
|
|
136
|
+
let minAgeMs = BASE_MIN_AGE_MS;
|
|
137
|
+
|
|
138
|
+
if (strongHits >= 6) {
|
|
139
|
+
difficulty = 5;
|
|
140
|
+
minAgeMs = 15000;
|
|
141
|
+
} else if (strongHits >= 3) {
|
|
142
|
+
difficulty = 5;
|
|
143
|
+
minAgeMs = 8000;
|
|
144
|
+
} else if (strongHits >= 1) {
|
|
145
|
+
minAgeMs = 4000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Floors from signals that are suggestive rather than damning. They raise the
|
|
149
|
+
// time floor and never the difficulty.
|
|
150
|
+
const raiseTo = (ms) => {
|
|
151
|
+
if (minAgeMs < ms) minAgeMs = ms;
|
|
152
|
+
};
|
|
153
|
+
if (isDatacenter) raiseTo(3000);
|
|
154
|
+
if (requestCount > 10) raiseTo(6000);
|
|
155
|
+
if (rateExceeded) raiseTo(10000);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
difficulty: Math.min(difficulty, MAX_DIFFICULTY),
|
|
159
|
+
minAgeMs: Math.min(minAgeMs, MAX_MIN_AGE_MS)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
SuspicionLedger,
|
|
165
|
+
computeChallengeCost,
|
|
166
|
+
STRONG_SCORE,
|
|
167
|
+
WINDOW_MS,
|
|
168
|
+
BASE_DIFFICULTY,
|
|
169
|
+
BASE_MIN_AGE_MS,
|
|
170
|
+
MAX_DIFFICULTY,
|
|
171
|
+
MAX_MIN_AGE_MS
|
|
172
|
+
};
|