@webdecoy/fcaptcha 1.34.1 → 1.36.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/engine.js +27 -10
- package/fingerprint-store.js +53 -0
- package/index.js +59 -64
- package/package.json +2 -2
- package/protocol.js +30 -0
- package/redis-state.js +14 -4
- package/server.js +64 -104
- package/siteverify.js +1 -0
- package/token-store.js +32 -0
package/engine.js
CHANGED
|
@@ -495,7 +495,10 @@ function detectCDP(signals) {
|
|
|
495
495
|
if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
|
|
496
496
|
detections.push({
|
|
497
497
|
category: 'cdp', score: 0.6, confidence: 0.5,
|
|
498
|
-
reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
|
|
498
|
+
reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)',
|
|
499
|
+
// A developer with DevTools open produces exactly this signal, so it
|
|
500
|
+
// cannot be one of the independent views the corroboration floor counts.
|
|
501
|
+
nonCorroborating: true
|
|
499
502
|
});
|
|
500
503
|
}
|
|
501
504
|
|
|
@@ -912,33 +915,47 @@ function applyDispositiveFloor(score, detections) {
|
|
|
912
915
|
* product. It treats absence of environmental evidence as evidence of absence — but
|
|
913
916
|
* on this adversary a clean environment is the attack working, not innocence.
|
|
914
917
|
*
|
|
915
|
-
* The rule: when two or more behavioural categories independently reach 0.
|
|
916
|
-
* the score at 0.6.
|
|
918
|
+
* The rule: when two or more behavioural categories independently reach 0.4, floor
|
|
919
|
+
* the score at 0.6. A signal a developer's own tooling produces is not an
|
|
920
|
+
* independent view: the DevTools console-attach probe is marked nonCorroborating,
|
|
921
|
+
* so it still scores but cannot be one of the two that agree.
|
|
917
922
|
*
|
|
918
923
|
* Constants swept over a 40-point grid against the labelled corpus
|
|
919
924
|
* (bench/tools/sweep-corroboration.js), not reasoned about. No human in the
|
|
920
925
|
* 126-sample panel reaches two agreeing behavioural categories at any threshold
|
|
921
|
-
* tested,
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
926
|
+
* tested, and 0.3, 0.4 and 0.5 catch the same 66 of 75 agents. The bar moved from
|
|
927
|
+
* 0.5 to 0.4 on a live measurement the corpus cannot make (2026-09-09): an
|
|
928
|
+
* extension-driven click in a real Chrome on the public demo scored vision_ai 0.40
|
|
929
|
+
* and behavioral 0.44 with every environmental category clean — weighted sum
|
|
930
|
+
* 0.189, allowed at 0.5, floored at 0.4. The exclusion is what keeps the lower
|
|
931
|
+
* bar honest: the human-looking sessions beside it sat at behavioral 0.37 with cdp
|
|
932
|
+
* 0.30 from the console probe alone. Requiring three fails outright — all sixteen
|
|
933
|
+
* such combinations leave the adversary allowed. The floor value does not affect
|
|
934
|
+
* separation, so 0.6 is a policy choice: the block boundary, kept distinct from
|
|
935
|
+
* the 0.9 reserved for a browser that declares its own automation.
|
|
925
936
|
*
|
|
926
937
|
* Caveat: the adversary is one synthetic, hand-authored sample. This shows the
|
|
927
938
|
* arithmetic works on the shape the corpus describes, not that it works on a real
|
|
928
939
|
* source-patched browser.
|
|
929
940
|
*/
|
|
930
|
-
const CORROBORATION_AGREE_AT = 0.
|
|
941
|
+
const CORROBORATION_AGREE_AT = 0.4;
|
|
931
942
|
const CORROBORATION_MIN_AGREE = 2;
|
|
932
943
|
const CORROBORATION_FLOOR = 0.6;
|
|
944
|
+
// Absorbs the few ulps a noisy-OR product lands from a round number (0.75×0.8 is
|
|
945
|
+
// 0.3999999999999999, not 0.4), so agreement does not depend on arithmetic order.
|
|
946
|
+
const CORROBORATION_EPSILON = 1e-9;
|
|
933
947
|
|
|
934
948
|
// Categories a browser trips by how it moves rather than by what it is. A
|
|
935
949
|
// patched binary can hide what it is; it cannot hide that nothing is moving the
|
|
936
950
|
// pointer like a hand.
|
|
937
951
|
const BEHAVIOURAL_CATEGORIES = ['vision_ai', 'behavioral', 'automation', 'cdp'];
|
|
938
952
|
|
|
939
|
-
function applyCorroborationFloor(score,
|
|
953
|
+
function applyCorroborationFloor(score, detections) {
|
|
954
|
+
// Only independent views count. A signal that a developer's own tooling
|
|
955
|
+
// produces (nonCorroborating) still scores, but cannot be one of the two.
|
|
956
|
+
const categoryScores = calculateCategoryScores(detections.filter((d) => !d.nonCorroborating));
|
|
940
957
|
const agreeing = BEHAVIOURAL_CATEGORIES
|
|
941
|
-
.filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT).length;
|
|
958
|
+
.filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT - CORROBORATION_EPSILON).length;
|
|
942
959
|
return agreeing >= CORROBORATION_MIN_AGREE ? Math.max(score, CORROBORATION_FLOOR) : score;
|
|
943
960
|
}
|
|
944
961
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BoundedMap } = require('./limits');
|
|
4
|
+
|
|
5
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
6
|
+
// Detectors only distinguish >5 and >10. Saturating at 16 preserves those
|
|
7
|
+
// decisions without retaining every attacker-selected value.
|
|
8
|
+
const MAX_MEMBERS = 16;
|
|
9
|
+
|
|
10
|
+
class FingerprintStore {
|
|
11
|
+
constructor({ maxEntries = 100000, ttlMs = WINDOW_MS, now = Date.now } = {}) {
|
|
12
|
+
this.fingerprints = new BoundedMap(maxEntries);
|
|
13
|
+
this.ipFingerprints = new BoundedMap(maxEntries);
|
|
14
|
+
this.ttlMs = ttlMs;
|
|
15
|
+
this.now = now;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_get(store, key) {
|
|
19
|
+
const entry = store.get(key);
|
|
20
|
+
if (entry && entry.expiresAt <= this.now()) {
|
|
21
|
+
store.delete(key);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_add(store, key, value) {
|
|
28
|
+
let entry = this._get(store, key);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
entry = { values: new Set(), expiresAt: this.now() + this.ttlMs };
|
|
31
|
+
store.set(key, entry);
|
|
32
|
+
}
|
|
33
|
+
if (entry.values.size < MAX_MEMBERS) entry.values.add(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
record(fp, ip, siteKey) {
|
|
37
|
+
const entry = this._get(this.ipFingerprints, ip);
|
|
38
|
+
// Do not allocate more fingerprint keys once this source has saturated.
|
|
39
|
+
if (entry && entry.values.size >= MAX_MEMBERS && !entry.values.has(fp)) return;
|
|
40
|
+
this._add(this.ipFingerprints, ip, fp);
|
|
41
|
+
this._add(this.fingerprints, `${siteKey}:${fp}`, ip);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getIpFpCount(ip) {
|
|
45
|
+
return this._get(this.ipFingerprints, ip)?.values.size || 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getFpIpCount(fp, siteKey) {
|
|
49
|
+
return this._get(this.fingerprints, `${siteKey}:${fp}`)?.values.size || 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { FingerprintStore, WINDOW_MS, MAX_MEMBERS };
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Usage:
|
|
7
7
|
* const fcaptcha = require('@webdecoy/fcaptcha');
|
|
8
8
|
* const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
|
|
9
|
-
* const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
|
|
9
|
+
* const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const crypto = require('crypto');
|
|
@@ -14,6 +14,10 @@ const detection = require('./detection');
|
|
|
14
14
|
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
15
15
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
16
16
|
const { signingSecret } = require('./config');
|
|
17
|
+
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
18
|
+
const { FingerprintStore } = require('./fingerprint-store');
|
|
19
|
+
const { TokenStore } = require('./token-store');
|
|
20
|
+
const { resolveSignals } = require('./protocol');
|
|
17
21
|
|
|
18
22
|
// =============================================================================
|
|
19
23
|
// PoW Challenge Store (can be extended with Redis)
|
|
@@ -22,8 +26,8 @@ const { signingSecret } = require('./config');
|
|
|
22
26
|
class PoWChallengeStore {
|
|
23
27
|
constructor(options = {}) {
|
|
24
28
|
this.secret = signingSecret(options.secret);
|
|
25
|
-
this.challenges = new
|
|
26
|
-
this.usedSolutions = new
|
|
29
|
+
this.challenges = new BoundedMap();
|
|
30
|
+
this.usedSolutions = new BoundedSet();
|
|
27
31
|
this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -41,6 +45,7 @@ class PoWChallengeStore {
|
|
|
41
45
|
// How long the client must hold this challenge before submitting a
|
|
42
46
|
// solution. Inside the signed payload so it cannot be talked down.
|
|
43
47
|
minAgeMs,
|
|
48
|
+
nonce: crypto.randomBytes(16).toString('hex'),
|
|
44
49
|
prefix: `${challengeId}:${timestamp}:${difficulty}`
|
|
45
50
|
};
|
|
46
51
|
|
|
@@ -64,7 +69,7 @@ class PoWChallengeStore {
|
|
|
64
69
|
return challengeData;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
verify(challengeId, nonce, hash, siteKey, ip) {
|
|
72
|
+
verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
|
|
68
73
|
const challenge = this.challenges.get(challengeId);
|
|
69
74
|
|
|
70
75
|
if (!challenge) {
|
|
@@ -91,7 +96,7 @@ class PoWChallengeStore {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
// Verify the hash
|
|
94
|
-
const input = `${challenge.prefix}:${nonce}`;
|
|
99
|
+
const input = signalsHash ? `${challenge.prefix}:${signalsHash}:${nonce}` : `${challenge.prefix}:${nonce}`;
|
|
95
100
|
const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
|
|
96
101
|
|
|
97
102
|
if (hash !== expectedHash) {
|
|
@@ -111,6 +116,7 @@ class PoWChallengeStore {
|
|
|
111
116
|
return {
|
|
112
117
|
valid: true,
|
|
113
118
|
difficulty: challenge.difficulty,
|
|
119
|
+
nonce: challenge.nonce,
|
|
114
120
|
serverElapsed: Date.now() - challenge.timestamp,
|
|
115
121
|
// Fall back for challenges issued before adaptive cost existed.
|
|
116
122
|
minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
|
|
@@ -124,9 +130,6 @@ class PoWChallengeStore {
|
|
|
124
130
|
this.challenges.delete(id);
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
|
-
if (this.usedSolutions.size > 10000) {
|
|
128
|
-
this.usedSolutions.clear();
|
|
129
|
-
}
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -136,7 +139,7 @@ class PoWChallengeStore {
|
|
|
136
139
|
|
|
137
140
|
class RateLimiter {
|
|
138
141
|
constructor() {
|
|
139
|
-
this.requests = new
|
|
142
|
+
this.requests = new BoundedMap();
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
check(key, windowSeconds = 60, maxRequests = 10) {
|
|
@@ -157,42 +160,6 @@ class RateLimiter {
|
|
|
157
160
|
}
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
// =============================================================================
|
|
161
|
-
// Fingerprint Store
|
|
162
|
-
// =============================================================================
|
|
163
|
-
|
|
164
|
-
class FingerprintStore {
|
|
165
|
-
constructor() {
|
|
166
|
-
this.fingerprints = new Map();
|
|
167
|
-
this.ipFingerprints = new Map();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
record(fp, ip, siteKey) {
|
|
171
|
-
const key = `${siteKey}:${fp}`;
|
|
172
|
-
|
|
173
|
-
if (!this.fingerprints.has(key)) {
|
|
174
|
-
this.fingerprints.set(key, { count: 0, ips: new Set() });
|
|
175
|
-
}
|
|
176
|
-
const data = this.fingerprints.get(key);
|
|
177
|
-
data.count++;
|
|
178
|
-
data.ips.add(ip);
|
|
179
|
-
|
|
180
|
-
if (!this.ipFingerprints.has(ip)) {
|
|
181
|
-
this.ipFingerprints.set(ip, new Set());
|
|
182
|
-
}
|
|
183
|
-
this.ipFingerprints.get(ip).add(fp);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
getIpFpCount(ip) {
|
|
187
|
-
return this.ipFingerprints.get(ip)?.size || 0;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
getFpIpCount(fp, siteKey) {
|
|
191
|
-
const key = `${siteKey}:${fp}`;
|
|
192
|
-
return this.fingerprints.get(key)?.ips.size || 0;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
163
|
// =============================================================================
|
|
197
164
|
// Scoring Engine
|
|
198
165
|
// =============================================================================
|
|
@@ -231,6 +198,7 @@ class ScoringEngine {
|
|
|
231
198
|
this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
|
|
232
199
|
this.suspicion = options.suspicion || new SuspicionLedger();
|
|
233
200
|
this.weights = options.weights || WEIGHTS;
|
|
201
|
+
this.tokenStore = options.tokenStore || new TokenStore();
|
|
234
202
|
}
|
|
235
203
|
|
|
236
204
|
// Generate a PoW challenge
|
|
@@ -257,8 +225,16 @@ class ScoringEngine {
|
|
|
257
225
|
}
|
|
258
226
|
|
|
259
227
|
// Verify signals and return score
|
|
260
|
-
verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
|
|
228
|
+
verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null) {
|
|
229
|
+
const resolved = resolveSignals(signals, signalsJson, powSolution?.signalsHash);
|
|
230
|
+
signals = resolved.signals;
|
|
231
|
+
if (powTiming) {
|
|
232
|
+
signals.temporal = signals.temporal || {};
|
|
233
|
+
signals.temporal.pow = powTiming;
|
|
234
|
+
}
|
|
261
235
|
const detections = [];
|
|
236
|
+
if (!resolved.commitmentValid) detections.push({ category: 'bot', score: 0.95,
|
|
237
|
+
confidence: 0.95, reason: 'Signals tampered after PoW (signalsHash mismatch)' });
|
|
262
238
|
let powSatisfied = false;
|
|
263
239
|
|
|
264
240
|
// Run all detection modules
|
|
@@ -288,7 +264,8 @@ class ScoringEngine {
|
|
|
288
264
|
powSolution.nonce,
|
|
289
265
|
powSolution.hash,
|
|
290
266
|
siteKey,
|
|
291
|
-
ip
|
|
267
|
+
ip,
|
|
268
|
+
powSolution.signalsHash
|
|
292
269
|
);
|
|
293
270
|
|
|
294
271
|
if (!powResult.valid) {
|
|
@@ -299,7 +276,7 @@ class ScoringEngine {
|
|
|
299
276
|
reason: `PoW verification failed: ${powResult.reason}`
|
|
300
277
|
});
|
|
301
278
|
} else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
|
|
302
|
-
powSatisfied =
|
|
279
|
+
powSatisfied = false;
|
|
303
280
|
// Under the universal baseline nothing legitimate can have happened —
|
|
304
281
|
// no human completes an interaction that fast.
|
|
305
282
|
detections.push({
|
|
@@ -309,11 +286,9 @@ class ScoringEngine {
|
|
|
309
286
|
reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
|
|
310
287
|
});
|
|
311
288
|
} else if (powResult.serverElapsed < powResult.minAgeMs) {
|
|
312
|
-
powSatisfied =
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// stale cache, does not know to wait. It contributes rather than
|
|
316
|
-
// deciding.
|
|
289
|
+
powSatisfied = false;
|
|
290
|
+
// Keep the diagnostic weaker than a baseline violation; token issuance
|
|
291
|
+
// independently requires the full advertised delay.
|
|
317
292
|
detections.push({
|
|
318
293
|
category: 'bot',
|
|
319
294
|
score: 0.5,
|
|
@@ -321,7 +296,13 @@ class ScoringEngine {
|
|
|
321
296
|
reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
|
|
322
297
|
});
|
|
323
298
|
} else {
|
|
324
|
-
powSatisfied =
|
|
299
|
+
powSatisfied = resolved.commitmentValid;
|
|
300
|
+
}
|
|
301
|
+
// The server-issued nonce is required even for legacy, uncommitted PoW.
|
|
302
|
+
if (powResult.valid && powResult.nonce && signals.meta?.challengeNonce !== powResult.nonce) {
|
|
303
|
+
powSatisfied = false;
|
|
304
|
+
detections.push({ category: 'bot', score: 0.9, confidence: 0.9,
|
|
305
|
+
reason: 'Challenge nonce mismatch (signals not bound to challenge)' });
|
|
325
306
|
}
|
|
326
307
|
} else {
|
|
327
308
|
detections.push({
|
|
@@ -364,7 +345,7 @@ class ScoringEngine {
|
|
|
364
345
|
const categoryScores = calculateCategoryScores(detections, this.weights);
|
|
365
346
|
const finalScore = applyCorroborationFloor(
|
|
366
347
|
applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
|
|
367
|
-
|
|
348
|
+
detections
|
|
368
349
|
);
|
|
369
350
|
|
|
370
351
|
let recommendation;
|
|
@@ -397,7 +378,7 @@ class ScoringEngine {
|
|
|
397
378
|
}
|
|
398
379
|
|
|
399
380
|
// Verify a previously issued token
|
|
400
|
-
verifyToken(token) {
|
|
381
|
+
verifyToken(token, ip = null) {
|
|
401
382
|
try {
|
|
402
383
|
const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
|
|
403
384
|
|
|
@@ -415,14 +396,22 @@ class ScoringEngine {
|
|
|
415
396
|
return { valid: false, reason: 'invalid_signature' };
|
|
416
397
|
}
|
|
417
398
|
|
|
399
|
+
if (ip && decoded.ip_hash !== crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8)) {
|
|
400
|
+
return { valid: false, reason: 'ip_mismatch' };
|
|
401
|
+
}
|
|
402
|
+
const claim = this.tokenStore.claim(sig);
|
|
403
|
+
if (!claim.claimed) return { valid: false, reason: claim.reason };
|
|
418
404
|
return {
|
|
419
405
|
valid: true,
|
|
420
406
|
site_key: decoded.site_key,
|
|
421
407
|
timestamp: decoded.timestamp,
|
|
422
|
-
score: decoded.score
|
|
408
|
+
score: decoded.score,
|
|
409
|
+
hostname: decoded.hostname || '',
|
|
410
|
+
action: decoded.action || '',
|
|
411
|
+
cdata: decoded.cdata || ''
|
|
423
412
|
};
|
|
424
413
|
} catch (e) {
|
|
425
|
-
return { valid: false, reason:
|
|
414
|
+
return { valid: false, reason: 'invalid_token' };
|
|
426
415
|
}
|
|
427
416
|
}
|
|
428
417
|
|
|
@@ -497,6 +486,7 @@ class ScoringEngine {
|
|
|
497
486
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
498
487
|
const data = {
|
|
499
488
|
site_key: siteKey,
|
|
489
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
500
490
|
timestamp: Math.floor(Date.now() / 1000),
|
|
501
491
|
score: Math.round(score * 1000) / 1000,
|
|
502
492
|
ip_hash: ipHash
|
|
@@ -516,6 +506,7 @@ class ScoringEngine {
|
|
|
516
506
|
|
|
517
507
|
function createMiddleware(options = {}) {
|
|
518
508
|
const engine = new ScoringEngine(options);
|
|
509
|
+
const siteKeys = SiteKeyGuard.fromEnv();
|
|
519
510
|
const proxyTrust = options.trustedProxies !== undefined
|
|
520
511
|
? new ProxyTrust(options.trustedProxies)
|
|
521
512
|
: ProxyTrust.fromEnv();
|
|
@@ -533,8 +524,9 @@ function createMiddleware(options = {}) {
|
|
|
533
524
|
|
|
534
525
|
// Challenge route handler
|
|
535
526
|
challengeHandler: (req, res) => {
|
|
536
|
-
const
|
|
527
|
+
const rawSiteKey = req.query.siteKey || 'default';
|
|
537
528
|
const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
|
|
529
|
+
const siteKey = siteKeys.normalize(rawSiteKey, ip);
|
|
538
530
|
const challenge = engine.generateChallenge(siteKey, ip);
|
|
539
531
|
|
|
540
532
|
res.json({
|
|
@@ -542,14 +534,17 @@ function createMiddleware(options = {}) {
|
|
|
542
534
|
prefix: challenge.prefix,
|
|
543
535
|
difficulty: challenge.difficulty,
|
|
544
536
|
expiresAt: challenge.expiresAt,
|
|
545
|
-
sig: challenge.sig
|
|
537
|
+
sig: challenge.sig,
|
|
538
|
+
nonce: challenge.nonce,
|
|
539
|
+
minAgeMs: challenge.minAgeMs
|
|
546
540
|
});
|
|
547
541
|
},
|
|
548
542
|
|
|
549
543
|
// Verify route handler
|
|
550
544
|
verifyHandler: (req, res) => {
|
|
551
|
-
const { siteKey, signals, powSolution } = req.body;
|
|
545
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
552
546
|
const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
|
|
547
|
+
const siteKey = siteKeys.normalize(rawSiteKey || 'default', ip);
|
|
553
548
|
const userAgent = req.headers['user-agent'] || '';
|
|
554
549
|
|
|
555
550
|
const headers = {};
|
|
@@ -557,14 +552,14 @@ function createMiddleware(options = {}) {
|
|
|
557
552
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
558
553
|
}
|
|
559
554
|
|
|
560
|
-
const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
|
|
555
|
+
const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
|
|
561
556
|
res.json(result);
|
|
562
557
|
},
|
|
563
558
|
|
|
564
559
|
// Token verify route handler
|
|
565
560
|
tokenVerifyHandler: (req, res) => {
|
|
566
|
-
const { token } = req.body;
|
|
567
|
-
res.json(engine.verifyToken(token));
|
|
561
|
+
const { token, remoteip } = req.body;
|
|
562
|
+
res.json(engine.verifyToken(token, typeof remoteip === 'string' ? remoteip : null));
|
|
568
563
|
}
|
|
569
564
|
};
|
|
570
565
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,7 +15,7 @@
|
|
|
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 version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test suspicion.test.js",
|
|
18
|
+
"test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test suspicion.test.js security.test.js corroboration.test.js",
|
|
19
19
|
"test:siteverify": "node siteverify.test.js",
|
|
20
20
|
"test:forensics": "node inputforensics.test.js",
|
|
21
21
|
"test:suspicion": "node --test suspicion.test.js"
|
package/protocol.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
|
|
7
|
+
function invalidRequest() {
|
|
8
|
+
return Object.assign(new Error('invalid_request'), { status: 400 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Resolve the exact bytes committed by the proof before any detector mutates
|
|
12
|
+
// signals. Legacy proofs without a commitment still use the signals object.
|
|
13
|
+
function resolveSignals(signals, signalsJson, signalsHash) {
|
|
14
|
+
let commitmentValid = true;
|
|
15
|
+
if (signalsHash) {
|
|
16
|
+
if (typeof signalsJson !== 'string' || typeof signalsHash !== 'string') {
|
|
17
|
+
commitmentValid = false;
|
|
18
|
+
} else {
|
|
19
|
+
commitmentValid = crypto.createHash('sha256').update(signalsJson).digest('hex') === signalsHash;
|
|
20
|
+
try { signals = JSON.parse(signalsJson); } catch { throw invalidRequest(); }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!isObject(signals)) throw invalidRequest();
|
|
24
|
+
for (const key of ['behavioral', 'environmental', 'temporal', 'meta', 'formAnalysis']) {
|
|
25
|
+
if (signals[key] != null && !isObject(signals[key])) throw invalidRequest();
|
|
26
|
+
}
|
|
27
|
+
return { signals, commitmentValid };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { resolveSignals, isObject, invalidRequest };
|
package/redis-state.js
CHANGED
|
@@ -38,6 +38,16 @@ redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
|
|
38
38
|
return 1
|
|
39
39
|
`;
|
|
40
40
|
|
|
41
|
+
const RECORD_FINGERPRINT = `
|
|
42
|
+
if redis.call('SCARD', KEYS[2]) >= 16 and redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 0 then return 0 end
|
|
43
|
+
if redis.call('SCARD', KEYS[1]) < 16 then redis.call('SADD', KEYS[1], ARGV[1]) end
|
|
44
|
+
if redis.call('SCARD', KEYS[2]) < 16 then redis.call('SADD', KEYS[2], ARGV[2]) end
|
|
45
|
+
for i = 1, 2 do
|
|
46
|
+
if redis.call('PTTL', KEYS[i]) < 0 then redis.call('PEXPIRE', KEYS[i], ARGV[3]) end
|
|
47
|
+
end
|
|
48
|
+
return 1
|
|
49
|
+
`;
|
|
50
|
+
|
|
41
51
|
class RedisState {
|
|
42
52
|
constructor(url, client = null) {
|
|
43
53
|
this.client = client || createClient({ url });
|
|
@@ -148,10 +158,10 @@ class RedisState {
|
|
|
148
158
|
async recordFingerprint(fingerprint, ip, siteKey) {
|
|
149
159
|
const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
|
|
150
160
|
const ipKey = this.opaqueKey('fingerprint:fps', ip);
|
|
151
|
-
await this.client.
|
|
152
|
-
|
|
153
|
-
.
|
|
154
|
-
|
|
161
|
+
await this.client.eval(RECORD_FINGERPRINT, {
|
|
162
|
+
keys: [fpKey, ipKey],
|
|
163
|
+
arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
|
|
164
|
+
});
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
async ipFingerprintCount(ip) {
|
package/server.js
CHANGED
|
@@ -16,6 +16,9 @@ const { signingSecretFromEnv } = require('./config');
|
|
|
16
16
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
17
17
|
const { detectInputForensics } = require('./inputforensics');
|
|
18
18
|
const { RedisState } = require('./redis-state');
|
|
19
|
+
const { FingerprintStore } = require('./fingerprint-store');
|
|
20
|
+
const { TokenStore } = require('./token-store');
|
|
21
|
+
const { resolveSignals, isObject, invalidRequest } = require('./protocol');
|
|
19
22
|
const {
|
|
20
23
|
HostnameAllowlist,
|
|
21
24
|
IdempotencyStore,
|
|
@@ -283,7 +286,8 @@ const powChallengeStore = {
|
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
// Calculate server-side elapsed time (un-spoofable)
|
|
286
|
-
|
|
289
|
+
// timestamp is shared by all Redis writers; createdAt was Node-only.
|
|
290
|
+
const serverElapsed = Date.now() - challenge.timestamp;
|
|
287
291
|
|
|
288
292
|
return {
|
|
289
293
|
valid: true,
|
|
@@ -329,72 +333,22 @@ const rateLimiter = {
|
|
|
329
333
|
}
|
|
330
334
|
};
|
|
331
335
|
|
|
336
|
+
const localFingerprintStore = new FingerprintStore();
|
|
332
337
|
const fingerprintStore = {
|
|
333
|
-
fingerprints: new BoundedMap(),
|
|
334
|
-
ipFingerprints: new BoundedMap(),
|
|
335
|
-
|
|
336
338
|
async record(fp, ip, siteKey) {
|
|
337
|
-
if (SHARED_STATE)
|
|
338
|
-
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
const key = `${siteKey}:${fp}`;
|
|
342
|
-
|
|
343
|
-
if (!this.fingerprints.has(key)) {
|
|
344
|
-
this.fingerprints.set(key, { count: 0, ips: new Set() });
|
|
345
|
-
}
|
|
346
|
-
const data = this.fingerprints.get(key);
|
|
347
|
-
data.count++;
|
|
348
|
-
data.ips.add(ip);
|
|
349
|
-
|
|
350
|
-
if (!this.ipFingerprints.has(ip)) {
|
|
351
|
-
this.ipFingerprints.set(ip, new Set());
|
|
352
|
-
}
|
|
353
|
-
this.ipFingerprints.get(ip).add(fp);
|
|
339
|
+
if (!SHARED_STATE) return localFingerprintStore.record(fp, ip, siteKey);
|
|
340
|
+
try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
|
|
354
341
|
},
|
|
355
|
-
|
|
356
342
|
async getIpFpCount(ip) {
|
|
357
|
-
if (SHARED_STATE)
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
return this.ipFingerprints.get(ip)?.size || 0;
|
|
343
|
+
if (!SHARED_STATE) return localFingerprintStore.getIpFpCount(ip);
|
|
344
|
+
try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
|
|
361
345
|
},
|
|
362
|
-
|
|
363
346
|
async getFpIpCount(fp, siteKey) {
|
|
364
|
-
if (SHARED_STATE)
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
const key = `${siteKey}:${fp}`;
|
|
368
|
-
return this.fingerprints.get(key)?.ips.size || 0;
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
// Token Store - prevents token replay attacks
|
|
373
|
-
const tokenStore = {
|
|
374
|
-
usedTokens: new BoundedSet(),
|
|
375
|
-
|
|
376
|
-
// Mark a token as used (returns false if already used)
|
|
377
|
-
markUsed(tokenSig) {
|
|
378
|
-
if (this.usedTokens.has(tokenSig)) {
|
|
379
|
-
return false; // Already used
|
|
380
|
-
}
|
|
381
|
-
this.usedTokens.add(tokenSig);
|
|
382
|
-
|
|
383
|
-
// Cleanup old tokens periodically (tokens expire after 5 min anyway)
|
|
384
|
-
if (Math.random() < 0.1) this._cleanup();
|
|
385
|
-
return true;
|
|
386
|
-
},
|
|
387
|
-
|
|
388
|
-
isUsed(tokenSig) {
|
|
389
|
-
return this.usedTokens.has(tokenSig);
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
_cleanup() {
|
|
393
|
-
// In production with Redis, use TTL instead. In memory usedTokens is a
|
|
394
|
-
// BoundedSet, so it evicts its oldest entries rather than clearing wholesale
|
|
395
|
-
// — clearing would let an attacker who forced the threshold replay a token.
|
|
347
|
+
if (!SHARED_STATE) return localFingerprintStore.getFpIpCount(fp, siteKey);
|
|
348
|
+
try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
|
|
396
349
|
}
|
|
397
350
|
};
|
|
351
|
+
const tokenStore = new TokenStore();
|
|
398
352
|
|
|
399
353
|
// Detection patterns, the pure detectors and the scoring aggregation now
|
|
400
354
|
// live in engine.js, shared with the npm library entry point (index.js).
|
|
@@ -499,6 +453,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
|
|
|
499
453
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
500
454
|
const data = {
|
|
501
455
|
site_key: siteKey,
|
|
456
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
502
457
|
timestamp: Math.floor(Date.now() / 1000),
|
|
503
458
|
score: Math.round(score * 1000) / 1000,
|
|
504
459
|
ip_hash: ipHash,
|
|
@@ -540,15 +495,15 @@ async function verifyToken(token, ip = null) {
|
|
|
540
495
|
}
|
|
541
496
|
}
|
|
542
497
|
|
|
543
|
-
let
|
|
498
|
+
let claim;
|
|
544
499
|
try {
|
|
545
|
-
|
|
546
|
-
? await SHARED_STATE.claimToken(sig)
|
|
547
|
-
: tokenStore.
|
|
500
|
+
claim = SHARED_STATE
|
|
501
|
+
? { claimed: await SHARED_STATE.claimToken(sig), reason: 'token_already_used' }
|
|
502
|
+
: tokenStore.claim(sig);
|
|
548
503
|
} catch (_) {
|
|
549
504
|
return { valid: false, reason: 'state_unavailable' };
|
|
550
505
|
}
|
|
551
|
-
if (!claimed) return { valid: false, reason:
|
|
506
|
+
if (!claim.claimed) return { valid: false, reason: claim.reason };
|
|
552
507
|
|
|
553
508
|
// hostname/action/cdata default to '' so a token minted before they existed
|
|
554
509
|
// still verifies and reports the same shape. The signature covers whatever
|
|
@@ -588,24 +543,12 @@ async function verifyWebBotAuth(req) {
|
|
|
588
543
|
async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
|
|
589
544
|
const detections = Array.isArray(preDetections) ? [...preDetections] : [];
|
|
590
545
|
|
|
591
|
-
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
592
546
|
const clientSignalsHash = powSolution?.signalsHash || null;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
score: 0.95,
|
|
599
|
-
confidence: 0.95,
|
|
600
|
-
reason: 'Signals tampered after PoW (signalsHash mismatch)'
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
// Use signalsJson as the canonical signals source
|
|
604
|
-
try {
|
|
605
|
-
signals = JSON.parse(signalsJson);
|
|
606
|
-
} catch (e) {
|
|
607
|
-
// Fall back to parsed signals if signalsJson is invalid
|
|
608
|
-
}
|
|
547
|
+
const resolved = resolveSignals(signals, signalsJson, clientSignalsHash);
|
|
548
|
+
signals = resolved.signals;
|
|
549
|
+
if (!resolved.commitmentValid) {
|
|
550
|
+
detections.push({ category: 'bot', score: 0.95, confidence: 0.95,
|
|
551
|
+
reason: 'Signals tampered after PoW (signalsHash mismatch)' });
|
|
609
552
|
}
|
|
610
553
|
|
|
611
554
|
// Inject powTiming into signals.temporal.pow for detection functions
|
|
@@ -660,7 +603,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
660
603
|
// replays a solution. All ordinary things that happen to real people.
|
|
661
604
|
});
|
|
662
605
|
} else {
|
|
663
|
-
powSatisfied =
|
|
606
|
+
powSatisfied = resolved.commitmentValid &&
|
|
607
|
+
powVerification.serverElapsed >= Math.max(BASE_MIN_AGE_MS, powVerification.minAgeMs || 0);
|
|
664
608
|
}
|
|
665
609
|
|
|
666
610
|
// Verify challenge nonce binding
|
|
@@ -692,9 +636,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
692
636
|
reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
|
|
693
637
|
});
|
|
694
638
|
} else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// cache, does not know to wait. It contributes rather than deciding.
|
|
639
|
+
// Keep the diagnostic weaker than a baseline violation; token issuance
|
|
640
|
+
// independently requires the full advertised delay.
|
|
698
641
|
detections.push({
|
|
699
642
|
category: 'bot',
|
|
700
643
|
score: 0.5,
|
|
@@ -785,7 +728,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
785
728
|
const categoryScores = calculateCategoryScores(detections);
|
|
786
729
|
const finalScore = applyCorroborationFloor(
|
|
787
730
|
applyDispositiveFloor(calculateFinalScore(categoryScores), detections),
|
|
788
|
-
|
|
731
|
+
detections
|
|
789
732
|
);
|
|
790
733
|
|
|
791
734
|
let recommendation;
|
|
@@ -870,7 +813,18 @@ function collectHeaders(req) {
|
|
|
870
813
|
return headers;
|
|
871
814
|
}
|
|
872
815
|
|
|
873
|
-
|
|
816
|
+
const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
|
|
817
|
+
|
|
818
|
+
function validateScoringRequest(req, res, next) {
|
|
819
|
+
const body = req.body;
|
|
820
|
+
if (!isObject(body) || !isObject(body.signals) ||
|
|
821
|
+
(body.siteKey != null && typeof body.siteKey !== 'string') ||
|
|
822
|
+
(body.powSolution != null && !isObject(body.powSolution)) ||
|
|
823
|
+
(body.powTiming != null && !isObject(body.powTiming))) return next(invalidRequest());
|
|
824
|
+
next();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
app.post('/api/verify', validateScoringRequest, asyncRoute(async (req, res) => {
|
|
874
828
|
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
875
829
|
const ip = PROXY_TRUST.clientIP(req);
|
|
876
830
|
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
@@ -890,9 +844,9 @@ app.post('/api/verify', async (req, res) => {
|
|
|
890
844
|
const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
|
|
891
845
|
logVerdict('verify', siteKey, result);
|
|
892
846
|
res.json(result);
|
|
893
|
-
});
|
|
847
|
+
}));
|
|
894
848
|
|
|
895
|
-
app.post('/api/score', async (req, res) => {
|
|
849
|
+
app.post('/api/score', validateScoringRequest, asyncRoute(async (req, res) => {
|
|
896
850
|
const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
|
|
897
851
|
const ip = PROXY_TRUST.clientIP(req);
|
|
898
852
|
const siteKey = await normalizeSiteKey(rawSiteKey, ip);
|
|
@@ -924,9 +878,9 @@ app.post('/api/score', async (req, res) => {
|
|
|
924
878
|
// needs to know which precondition failed.
|
|
925
879
|
...(result.reason ? { reason: result.reason } : {})
|
|
926
880
|
});
|
|
927
|
-
});
|
|
881
|
+
}));
|
|
928
882
|
|
|
929
|
-
app.post('/api/token/verify', async (req, res) => {
|
|
883
|
+
app.post('/api/token/verify', asyncRoute(async (req, res) => {
|
|
930
884
|
const { token, secret, remoteip } = req.body;
|
|
931
885
|
|
|
932
886
|
// The secret gate. This endpoint is the boundary between "a browser finished a
|
|
@@ -946,7 +900,7 @@ app.post('/api/token/verify', async (req, res) => {
|
|
|
946
900
|
// received the token. Bind only when that trusted backend explicitly supplies
|
|
947
901
|
// the visitor address; using the caller socket here compares unrelated hosts.
|
|
948
902
|
res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
|
|
949
|
-
});
|
|
903
|
+
}));
|
|
950
904
|
|
|
951
905
|
// Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
|
|
952
906
|
//
|
|
@@ -968,12 +922,13 @@ async function siteverifyHandler(req, res) {
|
|
|
968
922
|
);
|
|
969
923
|
}
|
|
970
924
|
|
|
971
|
-
app.post('/turnstile/v0/siteverify', siteverifyHandler);
|
|
972
|
-
app.post('/recaptcha/api/siteverify', siteverifyHandler);
|
|
973
|
-
app.post('/siteverify', siteverifyHandler);
|
|
925
|
+
app.post('/turnstile/v0/siteverify', asyncRoute(siteverifyHandler));
|
|
926
|
+
app.post('/recaptcha/api/siteverify', asyncRoute(siteverifyHandler));
|
|
927
|
+
app.post('/siteverify', asyncRoute(siteverifyHandler));
|
|
974
928
|
|
|
975
929
|
// PoW Challenge endpoint - client fetches this on page load
|
|
976
|
-
app.get('/api/pow/challenge', async (req, res) => {
|
|
930
|
+
app.get('/api/pow/challenge', asyncRoute(async (req, res) => {
|
|
931
|
+
if (req.query.siteKey != null && typeof req.query.siteKey !== 'string') throw invalidRequest();
|
|
977
932
|
const ip = PROXY_TRUST.clientIP(req);
|
|
978
933
|
const siteKey = await normalizeSiteKey(req.query.siteKey, ip);
|
|
979
934
|
|
|
@@ -1007,7 +962,7 @@ app.get('/api/pow/challenge', async (req, res) => {
|
|
|
1007
962
|
// wait instead of as a worse score.
|
|
1008
963
|
minAgeMs: challenge.minAgeMs
|
|
1009
964
|
});
|
|
1010
|
-
});
|
|
965
|
+
}));
|
|
1011
966
|
|
|
1012
967
|
// Legacy challenge endpoint for backwards compatibility
|
|
1013
968
|
app.get('/api/challenge', (req, res) => {
|
|
@@ -1026,7 +981,8 @@ app.use((err, req, res, next) => {
|
|
|
1026
981
|
if (err && (err.status === 413 || err.type === 'entity.too.large')) {
|
|
1027
982
|
return res.status(413).json({ error: 'request_too_large' });
|
|
1028
983
|
}
|
|
1029
|
-
|
|
984
|
+
const status = err && err.status === 400 ? 400 : 500;
|
|
985
|
+
res.status(status).json({ error: status === 400 ? 'invalid_request' : 'internal_error' });
|
|
1030
986
|
});
|
|
1031
987
|
|
|
1032
988
|
// =============================================================================
|
|
@@ -1035,15 +991,19 @@ app.use((err, req, res, next) => {
|
|
|
1035
991
|
|
|
1036
992
|
async function start() {
|
|
1037
993
|
if (SHARED_STATE) await SHARED_STATE.connect();
|
|
1038
|
-
app.listen(PORT, () => {
|
|
1039
|
-
console.log(`FCaptcha server running on port ${
|
|
994
|
+
const server = app.listen(PORT, () => {
|
|
995
|
+
console.log(`FCaptcha server running on port ${server.address().port}`);
|
|
1040
996
|
console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
|
|
1041
997
|
console.log(`Site keys: ${SITE_KEYS.describe()}`);
|
|
1042
998
|
console.log(`Shared state: ${SHARED_STATE ? 'Redis (PoW)' : 'in-memory'}`);
|
|
1043
999
|
});
|
|
1000
|
+
return server;
|
|
1044
1001
|
}
|
|
1045
1002
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1003
|
+
if (require.main === module) {
|
|
1004
|
+
start().catch((err) => {
|
|
1005
|
+
console.error(`FCaptcha failed to start: ${err.message}`);
|
|
1006
|
+
process.exit(1);
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
module.exports = { app, start };
|
package/siteverify.js
CHANGED
|
@@ -79,6 +79,7 @@ const ERROR_CODES = {
|
|
|
79
79
|
const REASON_TO_ERROR_CODE = {
|
|
80
80
|
expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
81
81
|
token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
82
|
+
token_store_full: ERROR_CODES.INTERNAL_ERROR,
|
|
82
83
|
invalid_signature: ERROR_CODES.INVALID_RESPONSE,
|
|
83
84
|
invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
|
|
84
85
|
invalid_json: ERROR_CODES.INVALID_RESPONSE,
|
package/token-store.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Spent tokens must not be evicted while they can still verify. If capacity is
|
|
4
|
+
// exhausted, refuse new claims rather than reopening replay of older tokens.
|
|
5
|
+
class TokenStore {
|
|
6
|
+
constructor({ maxEntries = 100000, now = Date.now } = {}) {
|
|
7
|
+
this.entries = new Map();
|
|
8
|
+
this.maxEntries = maxEntries;
|
|
9
|
+
this.now = now;
|
|
10
|
+
this.nextCleanup = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
markUsed(signature) {
|
|
14
|
+
return this.claim(signature).claimed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
claim(signature) {
|
|
18
|
+
const now = this.now();
|
|
19
|
+
if (now >= this.nextCleanup) {
|
|
20
|
+
for (const [key, expiresAt] of this.entries) {
|
|
21
|
+
if (expiresAt <= now) this.entries.delete(key);
|
|
22
|
+
}
|
|
23
|
+
this.nextCleanup = now + 60000;
|
|
24
|
+
}
|
|
25
|
+
if (this.entries.has(signature)) return { claimed: false, reason: 'token_already_used' };
|
|
26
|
+
if (this.entries.size >= this.maxEntries) return { claimed: false, reason: 'token_store_full' };
|
|
27
|
+
this.entries.set(signature, now + 10 * 60 * 1000);
|
|
28
|
+
return { claimed: true };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { TokenStore };
|