@webdecoy/fcaptcha 1.20.0 → 1.22.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 +5 -3
- package/server.js +176 -33
- package/siteverify.js +331 -0
- 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.22.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,10 @@
|
|
|
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:
|
|
18
|
+
"test": "node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node siteverify.test.js && node --test suspicion.test.js",
|
|
19
|
+
"test:siteverify": "node siteverify.test.js",
|
|
20
|
+
"test:forensics": "node inputforensics.test.js",
|
|
21
|
+
"test:suspicion": "node --test suspicion.test.js"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"cors": "^2.8.5",
|
package/server.js
CHANGED
|
@@ -12,13 +12,51 @@ 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');
|
|
17
|
+
const {
|
|
18
|
+
ERROR_CODES,
|
|
19
|
+
HostnameAllowlist,
|
|
20
|
+
IdempotencyStore,
|
|
21
|
+
requestHostname,
|
|
22
|
+
sanitizeAction,
|
|
23
|
+
sanitizeCdata,
|
|
24
|
+
secretMatches,
|
|
25
|
+
siteverify
|
|
26
|
+
} = require('./siteverify');
|
|
16
27
|
|
|
17
28
|
const app = express();
|
|
18
29
|
app.use(cors());
|
|
19
30
|
app.use(express.json());
|
|
31
|
+
// The siteverify contract accepts form-encoded bodies as well as JSON, and most
|
|
32
|
+
// PHP and Python integrations in the wild post form-encoded. Parsing both here
|
|
33
|
+
// costs nothing on the JSON path.
|
|
34
|
+
app.use(express.urlencoded({ extended: false }));
|
|
20
35
|
|
|
21
36
|
const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
|
|
37
|
+
|
|
38
|
+
// The credential a backend presents to validate a token. Defaults to the signing
|
|
39
|
+
// key, which is what the README has always documented, but can be separated:
|
|
40
|
+
// the signing key is a long-lived secret that must never leave the server, while
|
|
41
|
+
// this one is handed to every backend that verifies. Splitting them means a
|
|
42
|
+
// leaked verify credential does not let the holder mint tokens.
|
|
43
|
+
const VERIFY_SECRET = process.env.FCAPTCHA_VERIFY_SECRET || SECRET_KEY;
|
|
44
|
+
|
|
45
|
+
// Token verification used to accept anyone who could reach the endpoint: all
|
|
46
|
+
// three servers read `secret` out of the body and dropped it. Enforcing it is a
|
|
47
|
+
// breaking change for deployments that never sent one, so there is one release
|
|
48
|
+
// of escape hatch before the parameter becomes mandatory outright.
|
|
49
|
+
const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test(
|
|
50
|
+
process.env.FCAPTCHA_LEGACY_UNAUTH_VERIFY || ''
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Optional: restrict which page origins may mint tokens. Off by default so
|
|
54
|
+
// zero-config self-hosting keeps working. See siteverify.js.
|
|
55
|
+
const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv();
|
|
56
|
+
|
|
57
|
+
// Lets a caller retry a validation that timed out without burning the token.
|
|
58
|
+
const IDEMPOTENCY = new IdempotencyStore();
|
|
59
|
+
|
|
22
60
|
const PORT = process.env.PORT || 3000;
|
|
23
61
|
const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
|
|
24
62
|
|
|
@@ -32,6 +70,10 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
|
|
|
32
70
|
// limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
|
|
33
71
|
const SITE_KEYS = SiteKeyGuard.fromEnv();
|
|
34
72
|
|
|
73
|
+
// Recent strong verdicts per source, used to price the next challenge that
|
|
74
|
+
// source asks for. Bounded and short-lived; see suspicion.js.
|
|
75
|
+
const suspicionLedger = new SuspicionLedger();
|
|
76
|
+
|
|
35
77
|
// Express's own `trust proxy` would re-derive req.ip from the same headers on
|
|
36
78
|
// its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
|
|
37
79
|
app.set('trust proxy', false);
|
|
@@ -97,7 +139,7 @@ const powChallengeStore = {
|
|
|
97
139
|
usedSolutions: new BoundedSet(),
|
|
98
140
|
|
|
99
141
|
// Generate a new challenge
|
|
100
|
-
generate(siteKey, ip, difficulty = 4) {
|
|
142
|
+
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
101
143
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
102
144
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
103
145
|
const timestamp = Date.now();
|
|
@@ -110,6 +152,9 @@ const powChallengeStore = {
|
|
|
110
152
|
timestamp,
|
|
111
153
|
expiresAt,
|
|
112
154
|
difficulty,
|
|
155
|
+
// How long the client must hold this challenge before submitting a
|
|
156
|
+
// solution. Inside the signed payload so it cannot be talked down.
|
|
157
|
+
minAgeMs,
|
|
113
158
|
nonce,
|
|
114
159
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
115
160
|
};
|
|
@@ -180,7 +225,14 @@ const powChallengeStore = {
|
|
|
180
225
|
// Calculate server-side elapsed time (un-spoofable)
|
|
181
226
|
const serverElapsed = Date.now() - challenge.createdAt;
|
|
182
227
|
|
|
183
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
valid: true,
|
|
230
|
+
difficulty: challenge.difficulty,
|
|
231
|
+
serverElapsed,
|
|
232
|
+
nonce: challenge.nonce,
|
|
233
|
+
// Fall back for challenges issued before adaptive cost existed.
|
|
234
|
+
minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
|
|
235
|
+
};
|
|
184
236
|
},
|
|
185
237
|
|
|
186
238
|
_cleanup() {
|
|
@@ -1169,13 +1221,26 @@ function applyDispositiveFloor(score, detections) {
|
|
|
1169
1221
|
return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
|
|
1170
1222
|
}
|
|
1171
1223
|
|
|
1172
|
-
|
|
1224
|
+
// Bind the token to the page that minted it and to the action it was minted
|
|
1225
|
+
// for. All three ride inside the signed payload, so a token issued for
|
|
1226
|
+
// `action=login` on example.com cannot be replayed against a password-reset
|
|
1227
|
+
// endpoint or presented from a site that lifted the key — the backend compares
|
|
1228
|
+
// what siteverify reports against what it expected.
|
|
1229
|
+
//
|
|
1230
|
+
// Empty strings rather than omitted keys: the signing payload is a sorted-key
|
|
1231
|
+
// JSON serialisation, so a token whose key set varies with what the browser
|
|
1232
|
+
// happened to send would be a second payload shape to keep in sync across three
|
|
1233
|
+
// languages. Fixed shape, empty when unknown.
|
|
1234
|
+
function generateToken(ip, siteKey, score, binding = {}) {
|
|
1173
1235
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
1174
1236
|
const data = {
|
|
1175
1237
|
site_key: siteKey,
|
|
1176
1238
|
timestamp: Math.floor(Date.now() / 1000),
|
|
1177
1239
|
score: Math.round(score * 1000) / 1000,
|
|
1178
|
-
ip_hash: ipHash
|
|
1240
|
+
ip_hash: ipHash,
|
|
1241
|
+
hostname: binding.hostname || '',
|
|
1242
|
+
action: binding.action || '',
|
|
1243
|
+
cdata: binding.cdata || ''
|
|
1179
1244
|
};
|
|
1180
1245
|
|
|
1181
1246
|
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
@@ -1219,12 +1284,19 @@ function verifyToken(token, ip = null) {
|
|
|
1219
1284
|
// Mark token as used (prevents replay)
|
|
1220
1285
|
tokenStore.markUsed(sig);
|
|
1221
1286
|
|
|
1287
|
+
// hostname/action/cdata default to '' so a token minted before they existed
|
|
1288
|
+
// still verifies and reports the same shape. The signature covers whatever
|
|
1289
|
+
// keys the token actually carries, so old four-key tokens validate
|
|
1290
|
+
// unchanged — this is additive, not a format break.
|
|
1222
1291
|
return {
|
|
1223
1292
|
valid: true,
|
|
1224
1293
|
site_key: decoded.site_key,
|
|
1225
1294
|
timestamp: decoded.timestamp,
|
|
1226
1295
|
score: decoded.score,
|
|
1227
|
-
ip_hash: decoded.ip_hash
|
|
1296
|
+
ip_hash: decoded.ip_hash,
|
|
1297
|
+
hostname: decoded.hostname || '',
|
|
1298
|
+
action: decoded.action || '',
|
|
1299
|
+
cdata: decoded.cdata || ''
|
|
1228
1300
|
};
|
|
1229
1301
|
} catch (e) {
|
|
1230
1302
|
return { valid: false, reason: e.message };
|
|
@@ -1311,14 +1383,26 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1311
1383
|
}
|
|
1312
1384
|
}
|
|
1313
1385
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1386
|
+
// Server-side timing, the one cost an attacker cannot buy their way out
|
|
1387
|
+
// of. Two thresholds, because they mean different things.
|
|
1388
|
+
if (powValid && powVerification.serverElapsed < BASE_MIN_AGE_MS) {
|
|
1389
|
+
// Under the universal baseline nothing legitimate can have happened.
|
|
1316
1390
|
detections.push({
|
|
1317
1391
|
category: 'bot',
|
|
1318
1392
|
score: 0.8,
|
|
1319
1393
|
confidence: 0.85,
|
|
1320
1394
|
reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
|
|
1321
1395
|
});
|
|
1396
|
+
} else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
|
|
1397
|
+
// Between the baseline and this source's own elevated floor is weaker
|
|
1398
|
+
// evidence: a client predating adaptive cost, or one served from a stale
|
|
1399
|
+
// cache, does not know to wait. It contributes rather than deciding.
|
|
1400
|
+
detections.push({
|
|
1401
|
+
category: 'bot',
|
|
1402
|
+
score: 0.5,
|
|
1403
|
+
confidence: 0.5,
|
|
1404
|
+
reason: `Challenge submitted before the required delay for this source (${powVerification.serverElapsed}ms of ${powVerification.minAgeMs}ms)`
|
|
1405
|
+
});
|
|
1322
1406
|
}
|
|
1323
1407
|
} else {
|
|
1324
1408
|
// No PoW solution provided - hard fail
|
|
@@ -1406,8 +1490,29 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1406
1490
|
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
1407
1491
|
else recommendation = 'block';
|
|
1408
1492
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1493
|
+
// The hostname comes from the request headers rather than the request body:
|
|
1494
|
+
// it is what the browser reported about the page, not what the page claimed
|
|
1495
|
+
// about itself.
|
|
1496
|
+
const hostname = requestHostname(headers);
|
|
1497
|
+
|
|
1498
|
+
// An unlisted hostname withholds the token but does not touch the score. The
|
|
1499
|
+
// visitor is not the problem — a key registered to another site is — so the
|
|
1500
|
+
// detection layer has nothing to say about it and the refusal is reported as
|
|
1501
|
+
// its own reason rather than smuggled in as a bot verdict.
|
|
1502
|
+
const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
|
|
1503
|
+
const success = finalScore < 0.5 && hostnameAllowed;
|
|
1504
|
+
|
|
1505
|
+
const token = success
|
|
1506
|
+
? generateToken(ip, siteKey, finalScore, {
|
|
1507
|
+
hostname,
|
|
1508
|
+
action: sanitizeAction(opts.action),
|
|
1509
|
+
cdata: sanitizeCdata(opts.cdata)
|
|
1510
|
+
})
|
|
1511
|
+
: null;
|
|
1512
|
+
|
|
1513
|
+
// Feed the ledger so the next challenge this source asks for is priced on
|
|
1514
|
+
// what it just did.
|
|
1515
|
+
suspicionLedger.record(siteKey, ip, finalScore);
|
|
1411
1516
|
|
|
1412
1517
|
return {
|
|
1413
1518
|
success,
|
|
@@ -1416,7 +1521,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1416
1521
|
timestamp: Math.floor(Date.now() / 1000),
|
|
1417
1522
|
recommendation,
|
|
1418
1523
|
categoryScores,
|
|
1419
|
-
detections
|
|
1524
|
+
detections,
|
|
1525
|
+
...(hostnameAllowed ? {} : { reason: 'hostname_not_allowed', hostname })
|
|
1420
1526
|
};
|
|
1421
1527
|
}
|
|
1422
1528
|
|
|
@@ -1444,7 +1550,7 @@ function collectHeaders(req) {
|
|
|
1444
1550
|
}
|
|
1445
1551
|
|
|
1446
1552
|
app.post('/api/verify', async (req, res) => {
|
|
1447
|
-
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1553
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
1448
1554
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1449
1555
|
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
1450
1556
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
@@ -1460,13 +1566,13 @@ app.post('/api/verify', async (req, res) => {
|
|
|
1460
1566
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1461
1567
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1462
1568
|
|
|
1463
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1569
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1464
1570
|
logVerdict('verify', siteKey, result);
|
|
1465
1571
|
res.json(result);
|
|
1466
1572
|
});
|
|
1467
1573
|
|
|
1468
1574
|
app.post('/api/score', async (req, res) => {
|
|
1469
|
-
const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1575
|
+
const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
|
|
1470
1576
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1471
1577
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
1472
1578
|
const userAgent = req.headers['user-agent'] || '';
|
|
@@ -1481,19 +1587,35 @@ app.post('/api/score', async (req, res) => {
|
|
|
1481
1587
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1482
1588
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1483
1589
|
|
|
1484
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1590
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1485
1591
|
logVerdict('score', siteKey, result);
|
|
1486
1592
|
res.json({
|
|
1487
1593
|
success: result.success,
|
|
1488
1594
|
score: result.score,
|
|
1489
1595
|
token: result.token,
|
|
1490
|
-
|
|
1596
|
+
// Echo the sanitized form, not the raw input: this is what got signed into
|
|
1597
|
+
// the token, so a caller comparing the two sees the same value.
|
|
1598
|
+
action: sanitizeAction(action),
|
|
1599
|
+
cdata: sanitizeCdata(cdata),
|
|
1491
1600
|
recommendation: result.recommendation
|
|
1492
1601
|
});
|
|
1493
1602
|
});
|
|
1494
1603
|
|
|
1495
1604
|
app.post('/api/token/verify', (req, res) => {
|
|
1496
|
-
const { token } = req.body;
|
|
1605
|
+
const { token, secret } = req.body;
|
|
1606
|
+
|
|
1607
|
+
// The secret gate. This endpoint is the boundary between "a browser finished a
|
|
1608
|
+
// challenge" and "my backend believes it", so it is server-to-server and needs
|
|
1609
|
+
// a credential — without one, anyone who can reach the host can spend a token
|
|
1610
|
+
// they observed, and the single-use guard then denies the real user.
|
|
1611
|
+
if (!LEGACY_UNAUTH_VERIFY) {
|
|
1612
|
+
if (!secret) {
|
|
1613
|
+
return res.status(401).json({ valid: false, reason: 'missing_secret' });
|
|
1614
|
+
}
|
|
1615
|
+
if (!secretMatches(secret, VERIFY_SECRET)) {
|
|
1616
|
+
return res.status(401).json({ valid: false, reason: 'invalid_secret' });
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1497
1619
|
|
|
1498
1620
|
// Extract client IP for verification
|
|
1499
1621
|
const ip = PROXY_TRUST.clientIP(req);
|
|
@@ -1501,30 +1623,47 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
1501
1623
|
res.json(verifyToken(token, ip));
|
|
1502
1624
|
});
|
|
1503
1625
|
|
|
1626
|
+
// Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
|
|
1627
|
+
//
|
|
1628
|
+
// Same contract, three paths, because the path is hardcoded in the SDKs and
|
|
1629
|
+
// plugins we want to be usable against FCaptcha: pointing an existing
|
|
1630
|
+
// integration at this server should be a base-URL change and nothing else.
|
|
1631
|
+
// See siteverify.js for the adapter itself.
|
|
1632
|
+
function siteverifyHandler(req, res) {
|
|
1633
|
+
res.json(
|
|
1634
|
+
siteverify({
|
|
1635
|
+
body: req.body,
|
|
1636
|
+
// Bind to this server's token store, so replay state is shared with the
|
|
1637
|
+
// native endpoint rather than kept in a parallel universe.
|
|
1638
|
+
verifyToken,
|
|
1639
|
+
expectedSecret: VERIFY_SECRET,
|
|
1640
|
+
idempotencyStore: IDEMPOTENCY,
|
|
1641
|
+
requireSecret: !LEGACY_UNAUTH_VERIFY
|
|
1642
|
+
})
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
app.post('/turnstile/v0/siteverify', siteverifyHandler);
|
|
1647
|
+
app.post('/recaptcha/api/siteverify', siteverifyHandler);
|
|
1648
|
+
app.post('/siteverify', siteverifyHandler);
|
|
1649
|
+
|
|
1504
1650
|
// PoW Challenge endpoint - client fetches this on page load
|
|
1505
1651
|
app.get('/api/pow/challenge', (req, res) => {
|
|
1506
1652
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1507
1653
|
const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
|
|
1508
1654
|
|
|
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
|
|
1655
|
+
// Cost scaling. See suspicion.js for why the escalation lands almost
|
|
1656
|
+
// entirely on minAgeMs rather than on difficulty.
|
|
1518
1657
|
const rateKey = `pow:${siteKey}:${ip}`;
|
|
1519
1658
|
const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1659
|
+
const cost = computeChallengeCost(
|
|
1660
|
+
suspicionLedger.count(siteKey, ip),
|
|
1661
|
+
detection.isDatacenterIP(ip),
|
|
1662
|
+
count,
|
|
1663
|
+
exceeded
|
|
1664
|
+
);
|
|
1526
1665
|
|
|
1527
|
-
const challenge = powChallengeStore.generate(siteKey, ip, difficulty);
|
|
1666
|
+
const challenge = powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
|
|
1528
1667
|
|
|
1529
1668
|
res.json({
|
|
1530
1669
|
challengeId: challenge.id,
|
|
@@ -1532,7 +1671,11 @@ app.get('/api/pow/challenge', (req, res) => {
|
|
|
1532
1671
|
difficulty: challenge.difficulty,
|
|
1533
1672
|
expiresAt: challenge.expiresAt,
|
|
1534
1673
|
nonce: challenge.nonce,
|
|
1535
|
-
sig: challenge.sig
|
|
1674
|
+
sig: challenge.sig,
|
|
1675
|
+
// Tells the client how long to hold a solved challenge before submitting.
|
|
1676
|
+
// Honouring it is how an ordinary visitor pays an elevated cost as a short
|
|
1677
|
+
// wait instead of as a worse score.
|
|
1678
|
+
minAgeMs: challenge.minAgeMs
|
|
1536
1679
|
});
|
|
1537
1680
|
});
|
|
1538
1681
|
|
package/siteverify.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drop-in compatibility with the Turnstile / reCAPTCHA / hCaptcha siteverify
|
|
5
|
+
* contract.
|
|
6
|
+
*
|
|
7
|
+
* Every one of those services validates a token the same way: a server-to-server
|
|
8
|
+
* POST carrying `secret` and `response`, answered with a small JSON object whose
|
|
9
|
+
* shape has been stable for a decade. Every backend SDK, CMS plugin and Stack
|
|
10
|
+
* Overflow snippet in circulation speaks it:
|
|
11
|
+
*
|
|
12
|
+
* POST /turnstile/v0/siteverify (form-encoded or JSON)
|
|
13
|
+
* secret, response, remoteip, idempotency_key
|
|
14
|
+
* -> {success, challenge_ts, hostname, action, cdata, error-codes}
|
|
15
|
+
*
|
|
16
|
+
* FCaptcha's native endpoint (`POST /api/token/verify` -> `{valid, score, ...}`)
|
|
17
|
+
* is a different shape, which means none of that existing integration work
|
|
18
|
+
* applies. Serving the familiar contract alongside the native one turns a
|
|
19
|
+
* migration into a base-URL change, so this module is deliberately an *adapter*
|
|
20
|
+
* over `verifyToken` rather than a second verification path — there is one
|
|
21
|
+
* implementation of token validity and this translates its vocabulary.
|
|
22
|
+
*
|
|
23
|
+
* Three things the native endpoint did not do, which the contract requires:
|
|
24
|
+
*
|
|
25
|
+
* - `secret` is checked. All three servers previously accepted the parameter
|
|
26
|
+
* and ignored it, so anyone who could reach the endpoint could verify
|
|
27
|
+
* tokens. The README documented sending it, which made the omission worse:
|
|
28
|
+
* integrators believed they were authenticated.
|
|
29
|
+
* - `hostname` is reported, so the caller can confirm the token was minted on
|
|
30
|
+
* a page they actually serve. This is what stops a lifted site key from
|
|
31
|
+
* being used against someone else's deployment.
|
|
32
|
+
* - `idempotency_key` makes retries safe. Tokens are single-use, so a network
|
|
33
|
+
* timeout on the first validation would otherwise burn the token and fail
|
|
34
|
+
* the user's request on the retry.
|
|
35
|
+
*
|
|
36
|
+
* Mirrors server-go/siteverify.go and server-python/siteverify.py.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
const { BoundedMap } = require('./limits');
|
|
41
|
+
|
|
42
|
+
// How long a validation result stays replayable under its idempotency key.
|
|
43
|
+
// Matched to the token lifetime: past that the token is expired anyway, so a
|
|
44
|
+
// retry has nothing left to be idempotent about.
|
|
45
|
+
const IDEMPOTENCY_TTL_SECONDS = 300;
|
|
46
|
+
|
|
47
|
+
// Bounded because the key is caller-supplied. Same reasoning as limits.js:
|
|
48
|
+
// anything keyed on a value the client chooses needs a ceiling.
|
|
49
|
+
const MAX_IDEMPOTENCY_ENTRIES = 10000;
|
|
50
|
+
|
|
51
|
+
// `action` and `cdata` are echoed back verbatim, so they are attacker-controlled
|
|
52
|
+
// output. Length-capped to keep them from bloating the token, and in cdata's
|
|
53
|
+
// case to keep it a label rather than a smuggling channel.
|
|
54
|
+
const MAX_ACTION_LENGTH = 32;
|
|
55
|
+
const MAX_CDATA_LENGTH = 255;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The error-code vocabulary. These strings are the contract — integrators
|
|
59
|
+
* branch on them — so they match Cloudflare's spelling exactly, hyphens and all.
|
|
60
|
+
*/
|
|
61
|
+
const ERROR_CODES = {
|
|
62
|
+
MISSING_SECRET: 'missing-input-secret',
|
|
63
|
+
INVALID_SECRET: 'invalid-input-secret',
|
|
64
|
+
MISSING_RESPONSE: 'missing-input-response',
|
|
65
|
+
INVALID_RESPONSE: 'invalid-input-response',
|
|
66
|
+
BAD_REQUEST: 'bad-request',
|
|
67
|
+
TIMEOUT_OR_DUPLICATE: 'timeout-or-duplicate',
|
|
68
|
+
INTERNAL_ERROR: 'internal-error',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How `verifyToken`'s internal `reason` maps onto that vocabulary.
|
|
73
|
+
*
|
|
74
|
+
* The collapse is lossy on purpose. A caller learning *why* a token failed
|
|
75
|
+
* learns something about the signing key or the replay window, so every
|
|
76
|
+
* structural failure reports the same `invalid-input-response`, and expiry and
|
|
77
|
+
* replay share `timeout-or-duplicate` exactly as Cloudflare's do.
|
|
78
|
+
*/
|
|
79
|
+
const REASON_TO_ERROR_CODE = {
|
|
80
|
+
expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
81
|
+
token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
82
|
+
invalid_signature: ERROR_CODES.INVALID_RESPONSE,
|
|
83
|
+
invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
|
|
84
|
+
invalid_json: ERROR_CODES.INVALID_RESPONSE,
|
|
85
|
+
missing_signature: ERROR_CODES.INVALID_RESPONSE,
|
|
86
|
+
ip_mismatch: ERROR_CODES.INVALID_RESPONSE,
|
|
87
|
+
hostname_not_allowed: ERROR_CODES.INVALID_RESPONSE,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function reasonToErrorCode(reason) {
|
|
91
|
+
return REASON_TO_ERROR_CODE[reason] || ERROR_CODES.INVALID_RESPONSE;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pull the host out of a URL-shaped header value.
|
|
96
|
+
*
|
|
97
|
+
* Returns '' rather than throwing on anything unparseable, including the
|
|
98
|
+
* literal `null` that browsers send as Origin from a sandboxed iframe or a
|
|
99
|
+
* file:// page. An opaque origin is genuinely the absence of a hostname, not an
|
|
100
|
+
* error — the token simply carries no host binding.
|
|
101
|
+
*/
|
|
102
|
+
function hostFromUrl(value) {
|
|
103
|
+
if (!value || typeof value !== 'string') return '';
|
|
104
|
+
if (value === 'null') return '';
|
|
105
|
+
try {
|
|
106
|
+
// URL.hostname excludes the port and keeps IPv6 brackets, which is the
|
|
107
|
+
// normalisation we want: example.com:8443 and example.com are one host.
|
|
108
|
+
return new URL(value).hostname.toLowerCase();
|
|
109
|
+
} catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The hostname a token should be bound to, from the headers of the request that
|
|
116
|
+
* mints it.
|
|
117
|
+
*
|
|
118
|
+
* Origin first: the widget's verification call is a CORS POST, so browsers
|
|
119
|
+
* attach it, and unlike Referer it is not suppressed by referrer policy. Referer
|
|
120
|
+
* is the fallback for same-origin deployments where Origin may be absent on some
|
|
121
|
+
* request shapes. Neither is trustworthy against a non-browser caller — anything
|
|
122
|
+
* that can forge one can forge both — so this binds the *browser* case, which is
|
|
123
|
+
* the case that matters: it stops a site key lifted from your page from minting
|
|
124
|
+
* tokens that your own backend will accept.
|
|
125
|
+
*/
|
|
126
|
+
function requestHostname(headers) {
|
|
127
|
+
if (!headers) return '';
|
|
128
|
+
return hostFromUrl(headers['origin']) || hostFromUrl(headers['referer']) || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Trim a caller-supplied label to something safe to sign and echo. */
|
|
132
|
+
function sanitizeLabel(value, maxLength) {
|
|
133
|
+
if (typeof value !== 'string') return '';
|
|
134
|
+
// Control characters would ride through into the JSON response and any log
|
|
135
|
+
// line that records it.
|
|
136
|
+
return value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, maxLength);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sanitizeAction(value) {
|
|
140
|
+
return sanitizeLabel(value, MAX_ACTION_LENGTH);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sanitizeCdata(value) {
|
|
144
|
+
return sanitizeLabel(value, MAX_CDATA_LENGTH);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Constant-time secret comparison.
|
|
149
|
+
*
|
|
150
|
+
* timingSafeEqual demands equal-length buffers and throws otherwise, which would
|
|
151
|
+
* itself leak length, so both sides are hashed to a fixed width first.
|
|
152
|
+
*/
|
|
153
|
+
function secretMatches(provided, expected) {
|
|
154
|
+
if (typeof provided !== 'string' || typeof expected !== 'string') return false;
|
|
155
|
+
const a = crypto.createHash('sha256').update(provided).digest();
|
|
156
|
+
const b = crypto.createHash('sha256').update(expected).digest();
|
|
157
|
+
return crypto.timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional allowlist of hostnames permitted to mint tokens.
|
|
162
|
+
*
|
|
163
|
+
* Off by default so zero-config self-hosting keeps working, and reported rather
|
|
164
|
+
* than enforced when a request carries no derivable Origin — a native mobile
|
|
165
|
+
* client or a server-side integration legitimately has none, and refusing those
|
|
166
|
+
* would break them for no security gain.
|
|
167
|
+
*/
|
|
168
|
+
class HostnameAllowlist {
|
|
169
|
+
constructor(hostnames = []) {
|
|
170
|
+
this.hostnames = new Set(
|
|
171
|
+
hostnames
|
|
172
|
+
.map((h) => String(h).trim().toLowerCase())
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static fromEnv(env = process.env) {
|
|
178
|
+
const raw = env.FCAPTCHA_ALLOWED_HOSTNAMES || '';
|
|
179
|
+
return new HostnameAllowlist(raw.split(',').filter(Boolean));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get enabled() {
|
|
183
|
+
return this.hostnames.size > 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a hostname may mint tokens. An empty hostname passes: see the class
|
|
188
|
+
* comment — absence of an Origin is not evidence of a bad one.
|
|
189
|
+
*/
|
|
190
|
+
permits(hostname) {
|
|
191
|
+
if (!this.enabled) return true;
|
|
192
|
+
if (!hostname) return true;
|
|
193
|
+
return this.hostnames.has(hostname);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe() {
|
|
197
|
+
return this.enabled ? [...this.hostnames].join(', ') : 'any (unrestricted)';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Caches validation results so a retried siteverify returns the first answer
|
|
203
|
+
* instead of tripping the single-use guard.
|
|
204
|
+
*
|
|
205
|
+
* Keyed on the idempotency key *and* the token: reusing one key across different
|
|
206
|
+
* tokens is a caller bug, and answering the second one from the first one's
|
|
207
|
+
* cache entry would report success for a token nobody validated. Binding both
|
|
208
|
+
* degrades that case to an ordinary fresh verification.
|
|
209
|
+
*/
|
|
210
|
+
class IdempotencyStore {
|
|
211
|
+
constructor({ ttlSeconds = IDEMPOTENCY_TTL_SECONDS, maxEntries = MAX_IDEMPOTENCY_ENTRIES } = {}) {
|
|
212
|
+
this.ttlMs = ttlSeconds * 1000;
|
|
213
|
+
this.entries = new BoundedMap(maxEntries);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_key(idempotencyKey, token) {
|
|
217
|
+
const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
|
|
218
|
+
return `${idempotencyKey}:${tokenHash}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get(idempotencyKey, token) {
|
|
222
|
+
if (!idempotencyKey) return null;
|
|
223
|
+
const entry = this.entries.get(this._key(idempotencyKey, token));
|
|
224
|
+
if (!entry) return null;
|
|
225
|
+
// >= so that a zero TTL means "never replay" rather than "replay once".
|
|
226
|
+
if (Date.now() - entry.storedAt >= this.ttlMs) return null;
|
|
227
|
+
return entry.response;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
set(idempotencyKey, token, response) {
|
|
231
|
+
if (!idempotencyKey) return;
|
|
232
|
+
this.entries.set(this._key(idempotencyKey, token), {
|
|
233
|
+
storedAt: Date.now(),
|
|
234
|
+
response,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A failing siteverify response carrying the given codes. */
|
|
240
|
+
function failure(...codes) {
|
|
241
|
+
return { success: false, 'error-codes': codes };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the siteverify parameters out of a request body.
|
|
246
|
+
*
|
|
247
|
+
* Both encodings are accepted because the contract accepts both, and callers in
|
|
248
|
+
* the wild are split: PHP and Python examples overwhelmingly post form-encoded,
|
|
249
|
+
* Node and Go examples post JSON.
|
|
250
|
+
*/
|
|
251
|
+
function readParams(body) {
|
|
252
|
+
if (!body || typeof body !== 'object') return {};
|
|
253
|
+
const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
254
|
+
return {
|
|
255
|
+
secret: str(body.secret),
|
|
256
|
+
response: str(body.response),
|
|
257
|
+
remoteip: str(body.remoteip),
|
|
258
|
+
idempotencyKey: str(body.idempotency_key),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run a siteverify request.
|
|
264
|
+
*
|
|
265
|
+
* `verifyToken` is injected rather than imported so the standalone server and
|
|
266
|
+
* the library export can each supply their own — they hold separate token
|
|
267
|
+
* stores, and this module should not decide which one is authoritative.
|
|
268
|
+
*/
|
|
269
|
+
function siteverify({
|
|
270
|
+
body,
|
|
271
|
+
verifyToken,
|
|
272
|
+
expectedSecret,
|
|
273
|
+
idempotencyStore,
|
|
274
|
+
requireSecret = true,
|
|
275
|
+
}) {
|
|
276
|
+
const { secret, response, remoteip, idempotencyKey } = readParams(body);
|
|
277
|
+
|
|
278
|
+
if (requireSecret) {
|
|
279
|
+
if (!secret) return failure(ERROR_CODES.MISSING_SECRET);
|
|
280
|
+
if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!response) return failure(ERROR_CODES.MISSING_RESPONSE);
|
|
284
|
+
|
|
285
|
+
const cached = idempotencyStore && idempotencyStore.get(idempotencyKey, response);
|
|
286
|
+
if (cached) return cached;
|
|
287
|
+
|
|
288
|
+
let result;
|
|
289
|
+
try {
|
|
290
|
+
// remoteip is passed through to the IP-binding check. Empty means the caller
|
|
291
|
+
// declined to assert one, which the token verifier treats as "don't check".
|
|
292
|
+
result = verifyToken(response, remoteip || null);
|
|
293
|
+
} catch {
|
|
294
|
+
// Never surface an exception's text: it can carry key material or internals.
|
|
295
|
+
return failure(ERROR_CODES.INTERNAL_ERROR);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const out = result && result.valid
|
|
299
|
+
? {
|
|
300
|
+
success: true,
|
|
301
|
+
challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(),
|
|
302
|
+
hostname: result.hostname || '',
|
|
303
|
+
action: result.action || '',
|
|
304
|
+
cdata: result.cdata || '',
|
|
305
|
+
'error-codes': [],
|
|
306
|
+
// Not part of the upstream contract, but the whole reason to run
|
|
307
|
+
// FCaptcha: a caller that only wants pass/fail can ignore it, and one
|
|
308
|
+
// that wants to risk-band on the score has it without a second call.
|
|
309
|
+
score: typeof result.score === 'number' ? result.score : null,
|
|
310
|
+
}
|
|
311
|
+
: failure(reasonToErrorCode(result && result.reason));
|
|
312
|
+
|
|
313
|
+
if (idempotencyStore) idempotencyStore.set(idempotencyKey, response, out);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
ERROR_CODES,
|
|
319
|
+
HostnameAllowlist,
|
|
320
|
+
IdempotencyStore,
|
|
321
|
+
hostFromUrl,
|
|
322
|
+
requestHostname,
|
|
323
|
+
reasonToErrorCode,
|
|
324
|
+
sanitizeAction,
|
|
325
|
+
sanitizeCdata,
|
|
326
|
+
secretMatches,
|
|
327
|
+
siteverify,
|
|
328
|
+
MAX_ACTION_LENGTH,
|
|
329
|
+
MAX_CDATA_LENGTH,
|
|
330
|
+
IDEMPOTENCY_TTL_SECONDS,
|
|
331
|
+
};
|
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
|
+
};
|