@webdecoy/fcaptcha 1.37.0 → 1.38.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/admission.js +50 -0
- package/config.js +2 -2
- package/engine.js +2 -6
- package/index.js +7 -3
- package/ip-binding.js +11 -0
- package/package.json +4 -4
- package/redis-state.js +76 -16
- package/server.js +30 -5
- package/webbotauth.js +40 -5
package/admission.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fixed windows bound both request work and limiter memory. A full table denies
|
|
4
|
+
// new sources; it never evicts a source that still has a live quota.
|
|
5
|
+
class AdmissionLimiter {
|
|
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
|
+
allow(key, maximum, seconds = 60) {
|
|
14
|
+
const now = this.now();
|
|
15
|
+
if (now >= this.nextCleanup) {
|
|
16
|
+
for (const [k, entry] of this.entries) if (entry.expires <= now) this.entries.delete(k);
|
|
17
|
+
this.nextCleanup = now + 60000;
|
|
18
|
+
}
|
|
19
|
+
let entry = this.entries.get(key);
|
|
20
|
+
if (entry && entry.expires <= now) { this.entries.delete(key); entry = null; }
|
|
21
|
+
if (!entry) {
|
|
22
|
+
if (this.entries.size >= this.maxEntries) return false;
|
|
23
|
+
entry = { count: 0, expires: now + seconds * 1000 };
|
|
24
|
+
this.entries.set(key, entry);
|
|
25
|
+
}
|
|
26
|
+
if (entry.count >= maximum) return false;
|
|
27
|
+
entry.count++;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Insertion order is expiry order (all challenges have the same lifetime).
|
|
33
|
+
// Cleanup only visits expired entries, rather than scanning the live map.
|
|
34
|
+
class ChallengeMap extends Map {
|
|
35
|
+
constructor(maxEntries = 100000) { super(); this.maxEntries = maxEntries; }
|
|
36
|
+
pruneExpired(now = Date.now()) {
|
|
37
|
+
for (const [id, challenge] of this) {
|
|
38
|
+
if (challenge.expiresAt > now) break;
|
|
39
|
+
this.delete(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
set(id, challenge) {
|
|
43
|
+
this.pruneExpired();
|
|
44
|
+
if (!this.has(id) && this.size >= this.maxEntries) throw new Error('challenge_store_full');
|
|
45
|
+
return super.set(id, challenge);
|
|
46
|
+
}
|
|
47
|
+
prune() { this.pruneExpired(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { AdmissionLimiter, ChallengeMap };
|
package/config.js
CHANGED
|
@@ -10,7 +10,7 @@ let devModeWarned = false;
|
|
|
10
10
|
|
|
11
11
|
function signingSecretFromEnv(env = process.env) {
|
|
12
12
|
const secret = String(env.FCAPTCHA_SECRET || '').trim();
|
|
13
|
-
if (secret && secret !== INSECURE_DEFAULT_SECRET) return secret;
|
|
13
|
+
if (Buffer.byteLength(secret) >= 32 && new Set(secret).size >= 8 && secret !== INSECURE_DEFAULT_SECRET) return secret;
|
|
14
14
|
if (/^(1|true|yes|on)$/i.test(String(env.FCAPTCHA_INSECURE_DEV_MODE || '').trim())) {
|
|
15
15
|
if (!devModeWarned) {
|
|
16
16
|
devModeWarned = true;
|
|
@@ -18,7 +18,7 @@ function signingSecretFromEnv(env = process.env) {
|
|
|
18
18
|
}
|
|
19
19
|
return INSECURE_DEFAULT_SECRET;
|
|
20
20
|
}
|
|
21
|
-
throw new Error('FCAPTCHA_SECRET is required
|
|
21
|
+
throw new Error('FCAPTCHA_SECRET is required: use at least 32 random bytes (openssl rand -hex 32), not a short or repetitive password. For local-only development, explicitly set FCAPTCHA_INSECURE_DEV_MODE=1.');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function signingSecret(explicit, env = process.env) {
|
package/engine.js
CHANGED
|
@@ -513,12 +513,8 @@ function detectAutomation(signals) {
|
|
|
513
513
|
// JS execution timing
|
|
514
514
|
const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
|
|
515
515
|
if (jsTime > 0) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
category: 'automation', score: 0.4, confidence: 0.3,
|
|
519
|
-
reason: 'JS execution unusually fast'
|
|
520
|
-
});
|
|
521
|
-
} else if (jsTime > 50) {
|
|
516
|
+
// Fast execution is common on real hardware and is not evidence of automation.
|
|
517
|
+
if (jsTime > 50) {
|
|
522
518
|
detections.push({
|
|
523
519
|
category: 'automation', score: 0.3, confidence: 0.3,
|
|
524
520
|
reason: 'JS execution unusually slow'
|
package/index.js
CHANGED
|
@@ -14,6 +14,8 @@ 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 { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
18
|
+
const { ipBinding } = require('./ip-binding');
|
|
17
19
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
18
20
|
const { FingerprintStore } = require('./fingerprint-store');
|
|
19
21
|
const { TokenStore } = require('./token-store');
|
|
@@ -26,12 +28,14 @@ const { resolveSignals } = require('./protocol');
|
|
|
26
28
|
class PoWChallengeStore {
|
|
27
29
|
constructor(options = {}) {
|
|
28
30
|
this.secret = signingSecret(options.secret);
|
|
29
|
-
this.challenges = new
|
|
31
|
+
this.challenges = new ChallengeMap();
|
|
32
|
+
this.issuance = new AdmissionLimiter();
|
|
30
33
|
this.usedSolutions = new BoundedSet();
|
|
31
34
|
this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
38
|
+
if (!this.issuance.allow(ip, 128, Math.ceil(this.expirationMs / 1000))) throw new Error('challenge_quota_exceeded');
|
|
35
39
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
36
40
|
const timestamp = Date.now();
|
|
37
41
|
const expiresAt = timestamp + this.expirationMs;
|
|
@@ -408,7 +412,7 @@ class ScoringEngine {
|
|
|
408
412
|
return { valid: false, reason: 'invalid_signature' };
|
|
409
413
|
}
|
|
410
414
|
|
|
411
|
-
if (ip && decoded.ip_hash !==
|
|
415
|
+
if (ip && decoded.ip_hash !== ipBinding(this.secret, ip)) {
|
|
412
416
|
return { valid: false, reason: 'ip_mismatch' };
|
|
413
417
|
}
|
|
414
418
|
const claim = this.tokenStore.claim(sig);
|
|
@@ -495,7 +499,7 @@ class ScoringEngine {
|
|
|
495
499
|
}
|
|
496
500
|
|
|
497
501
|
_generateToken(ip, siteKey, score) {
|
|
498
|
-
const ipHash =
|
|
502
|
+
const ipHash = ipBinding(this.secret, ip);
|
|
499
503
|
const data = {
|
|
500
504
|
site_key: siteKey,
|
|
501
505
|
jti: crypto.randomBytes(16).toString('hex'),
|
package/ip-binding.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const ipaddr = require('ipaddr.js');
|
|
4
|
+
|
|
5
|
+
function ipBinding(secret, ip) {
|
|
6
|
+
let canonical = String(ip);
|
|
7
|
+
try { canonical = ipaddr.process(canonical).toString(); } catch (_) { /* non-IP test identities */ }
|
|
8
|
+
const key = crypto.createHmac('sha256', secret).update('fcaptcha:ip-binding:v2').digest();
|
|
9
|
+
return crypto.createHmac('sha256', key).update(canonical).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
module.exports = { ipBinding };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,14 +15,14 @@
|
|
|
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 security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.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 admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.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"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"cors": "^2.8.5",
|
|
25
|
-
"express": "^4.
|
|
25
|
+
"express": "^4.22.3",
|
|
26
26
|
"ipaddr.js": "^2.4.0",
|
|
27
27
|
"redis": "^4.6.12",
|
|
28
28
|
"web-bot-auth": "^0.1.3"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
39
|
+
"node": ">=22.0.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
package/redis-state.js
CHANGED
|
@@ -8,6 +8,23 @@ const POW_TTL_MS = 5 * 60 * 1000;
|
|
|
8
8
|
const SPENT_TTL_MS = 10 * 60 * 1000;
|
|
9
9
|
const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
const DETECTION_TTL_MS = 15 * 60 * 1000;
|
|
11
|
+
const ADMIT = `
|
|
12
|
+
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
13
|
+
if count >= tonumber(ARGV[1]) then return 0 end
|
|
14
|
+
redis.call('INCR', KEYS[1])
|
|
15
|
+
if count == 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
|
16
|
+
return 1
|
|
17
|
+
`;
|
|
18
|
+
const PUT_CHALLENGE = `
|
|
19
|
+
for i = 2, 3 do redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', ARGV[1]) end
|
|
20
|
+
if redis.call('ZCARD', KEYS[2]) >= 100000 or redis.call('ZCARD', KEYS[3]) >= 128 then return 0 end
|
|
21
|
+
if not redis.call('SET', KEYS[1], ARGV[4], 'NX', 'PX', ARGV[3]) then return 0 end
|
|
22
|
+
for i = 2, 3 do
|
|
23
|
+
redis.call('ZADD', KEYS[i], ARGV[2], KEYS[1])
|
|
24
|
+
redis.call('PEXPIRE', KEYS[i], ARGV[3])
|
|
25
|
+
end
|
|
26
|
+
return 1
|
|
27
|
+
`;
|
|
11
28
|
|
|
12
29
|
const CLAIM_CHALLENGE = `
|
|
13
30
|
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
|
@@ -50,8 +67,22 @@ return 1
|
|
|
50
67
|
|
|
51
68
|
class RedisState {
|
|
52
69
|
constructor(url, client = null) {
|
|
53
|
-
this.client = client || createClient({ url });
|
|
70
|
+
this.client = client || createClient({ url, disableOfflineQueue: true, commandsQueueMaxLength: 1024, socket: { connectTimeout: 2000 } });
|
|
54
71
|
this.client.on?.('error', (err) => console.error('[redis]', err.message));
|
|
72
|
+
this.commandTimeoutMs = 2000;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async deadline(promise) {
|
|
76
|
+
let timer;
|
|
77
|
+
try {
|
|
78
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(new Error('state_unavailable')), this.commandTimeoutMs);
|
|
80
|
+
})]);
|
|
81
|
+
} finally { clearTimeout(timer); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
execute(method, ...args) {
|
|
85
|
+
return this.deadline(Promise.resolve().then(() => this.client[method](...args)));
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
async connect() {
|
|
@@ -59,6 +90,25 @@ class RedisState {
|
|
|
59
90
|
await this.client.ping();
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
async ready(timeoutMs = 2000) {
|
|
94
|
+
if (this.readinessPending) return this.readinessPending;
|
|
95
|
+
// node-redis 4's abort signal only handles queued commands and may outlive
|
|
96
|
+
// their queue nodes. Bound the probe externally instead. Retain a timed-out
|
|
97
|
+
// probe until it settles so repeated health checks cannot fill the queue.
|
|
98
|
+
let timer;
|
|
99
|
+
const ping = Promise.resolve().then(() => this.client.ping());
|
|
100
|
+
const pending = Promise.race([ping, new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error('state_unavailable')), timeoutMs);
|
|
102
|
+
})]);
|
|
103
|
+
this.readinessPending = pending;
|
|
104
|
+
const settled = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
if (this.readinessPending === pending) this.readinessPending = null;
|
|
107
|
+
};
|
|
108
|
+
ping.then(settled, settled);
|
|
109
|
+
return pending;
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
challengeKey(id) {
|
|
63
113
|
return `${PREFIX}pow:challenge:${id}`;
|
|
64
114
|
}
|
|
@@ -74,18 +124,28 @@ class RedisState {
|
|
|
74
124
|
// challengeId is the canonical cross-runtime field used by Go and Node.
|
|
75
125
|
const stored = { ...challenge, challengeId: challenge.id };
|
|
76
126
|
delete stored.id;
|
|
77
|
-
await this.
|
|
127
|
+
const added = await this.execute('eval', PUT_CHALLENGE, {
|
|
128
|
+
keys: [this.challengeKey(challenge.id), `${PREFIX}pow:quota:global`, this.opaqueKey('pow:quota:source', challenge.ip)],
|
|
129
|
+
arguments: [String(Date.now()), String(challenge.expiresAt), String(ttl), JSON.stringify(stored)]
|
|
130
|
+
});
|
|
131
|
+
if (Number(added) !== 1) throw new Error('challenge_quota_exceeded');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async admit(key, maximum, seconds = 60) {
|
|
135
|
+
return Number(await this.execute('eval', ADMIT, {
|
|
136
|
+
keys: [this.opaqueKey('admission', key)], arguments: [String(maximum), String(seconds * 1000)]
|
|
137
|
+
})) === 1;
|
|
78
138
|
}
|
|
79
139
|
|
|
80
140
|
async getChallenge(id) {
|
|
81
|
-
const payload = await this.
|
|
141
|
+
const payload = await this.execute('get', this.challengeKey(id));
|
|
82
142
|
if (!payload) return null;
|
|
83
143
|
const challenge = JSON.parse(payload);
|
|
84
144
|
return { ...challenge, id: challenge.challengeId };
|
|
85
145
|
}
|
|
86
146
|
|
|
87
147
|
async claimChallenge(challengeId, solutionKey) {
|
|
88
|
-
const result = Number(await this.
|
|
148
|
+
const result = Number(await this.execute('eval', CLAIM_CHALLENGE, {
|
|
89
149
|
keys: [this.challengeKey(challengeId), `${PREFIX}pow:spent:${solutionKey}`],
|
|
90
150
|
arguments: [String(SPENT_TTL_MS)]
|
|
91
151
|
}));
|
|
@@ -97,7 +157,7 @@ class RedisState {
|
|
|
97
157
|
}
|
|
98
158
|
|
|
99
159
|
async claimToken(signature) {
|
|
100
|
-
return Boolean(await this.
|
|
160
|
+
return Boolean(await this.execute('set',
|
|
101
161
|
`${PREFIX}token:spent:${signature}`,
|
|
102
162
|
'1',
|
|
103
163
|
{ NX: true, PX: SPENT_TTL_MS }
|
|
@@ -113,13 +173,13 @@ class RedisState {
|
|
|
113
173
|
|
|
114
174
|
async getIdempotency(idempotencyKey, token) {
|
|
115
175
|
if (!idempotencyKey) return null;
|
|
116
|
-
const payload = await this.
|
|
176
|
+
const payload = await this.execute('get', this.idempotencyKey(idempotencyKey, token));
|
|
117
177
|
return payload ? JSON.parse(payload) : null;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
async setIdempotency(idempotencyKey, token, response) {
|
|
121
181
|
if (!idempotencyKey) return;
|
|
122
|
-
await this.
|
|
182
|
+
await this.execute('set',
|
|
123
183
|
this.idempotencyKey(idempotencyKey, token),
|
|
124
184
|
JSON.stringify(response),
|
|
125
185
|
{ PX: IDEMPOTENCY_TTL_MS }
|
|
@@ -128,7 +188,7 @@ class RedisState {
|
|
|
128
188
|
|
|
129
189
|
async rateCheck(key, windowSeconds, maxRequests) {
|
|
130
190
|
const now = Date.now();
|
|
131
|
-
const result = await this.
|
|
191
|
+
const result = await this.execute('eval', RATE_CHECK, {
|
|
132
192
|
keys: [this.opaqueKey('rate', key)],
|
|
133
193
|
arguments: [
|
|
134
194
|
String(now - windowSeconds * 1000), String(now), String(maxRequests),
|
|
@@ -141,39 +201,39 @@ class RedisState {
|
|
|
141
201
|
async recordSuspicion(siteKey, ip) {
|
|
142
202
|
const now = Date.now();
|
|
143
203
|
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
144
|
-
await this.client.multi()
|
|
204
|
+
await this.deadline(this.client.multi()
|
|
145
205
|
.zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
|
|
146
206
|
.zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
|
|
147
207
|
.zRemRangeByRank(key, 0, -17)
|
|
148
208
|
.pExpire(key, DETECTION_TTL_MS)
|
|
149
|
-
.exec();
|
|
209
|
+
.exec());
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
async suspicionCount(siteKey, ip) {
|
|
153
213
|
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
154
|
-
await this.
|
|
155
|
-
return Number(await this.
|
|
214
|
+
await this.execute('zRemRangeByScore', key, '-inf', Date.now() - DETECTION_TTL_MS);
|
|
215
|
+
return Number(await this.execute('zCard', key));
|
|
156
216
|
}
|
|
157
217
|
|
|
158
218
|
async recordFingerprint(fingerprint, ip, siteKey) {
|
|
159
219
|
const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
|
|
160
220
|
const ipKey = this.opaqueKey('fingerprint:fps', ip);
|
|
161
|
-
await this.
|
|
221
|
+
await this.execute('eval', RECORD_FINGERPRINT, {
|
|
162
222
|
keys: [fpKey, ipKey],
|
|
163
223
|
arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
|
|
164
224
|
});
|
|
165
225
|
}
|
|
166
226
|
|
|
167
227
|
async ipFingerprintCount(ip) {
|
|
168
|
-
return Number(await this.
|
|
228
|
+
return Number(await this.execute('sCard', this.opaqueKey('fingerprint:fps', ip)));
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
async fingerprintIpCount(fingerprint, siteKey) {
|
|
172
|
-
return Number(await this.
|
|
232
|
+
return Number(await this.execute('sCard', this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
|
|
173
233
|
}
|
|
174
234
|
|
|
175
235
|
async claimSiteKey(siteKey, ip, maxPerIp) {
|
|
176
|
-
return Number(await this.
|
|
236
|
+
return Number(await this.execute('eval', SITEKEY_CLAIM, {
|
|
177
237
|
keys: [this.opaqueKey('sitekeys', ip)],
|
|
178
238
|
arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
|
|
179
239
|
})) === 1;
|
package/server.js
CHANGED
|
@@ -13,6 +13,8 @@ const webbotauth = require('./webbotauth');
|
|
|
13
13
|
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
15
|
const { signingSecretFromEnv } = require('./config');
|
|
16
|
+
const { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
17
|
+
const { ipBinding } = require('./ip-binding');
|
|
16
18
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
17
19
|
const { detectInputForensics } = require('./inputforensics');
|
|
18
20
|
const { RedisState } = require('./redis-state');
|
|
@@ -172,11 +174,13 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
|
|
|
172
174
|
|
|
173
175
|
// PoW Challenge Store
|
|
174
176
|
const powChallengeStore = {
|
|
175
|
-
challenges: new
|
|
177
|
+
challenges: new ChallengeMap(),
|
|
178
|
+
issuance: new AdmissionLimiter(),
|
|
176
179
|
usedSolutions: new BoundedSet(),
|
|
177
180
|
|
|
178
181
|
// Generate a new challenge
|
|
179
182
|
async generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
183
|
+
if (!SHARED_STATE && !this.issuance.allow(ip, 128, 300)) throw new Error('challenge_quota_exceeded');
|
|
180
184
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
181
185
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
182
186
|
const timestamp = Date.now();
|
|
@@ -451,7 +455,7 @@ async function detectRateAbuse(ip, siteKey) {
|
|
|
451
455
|
// happened to send would be a second payload shape to keep in sync across three
|
|
452
456
|
// languages. Fixed shape, empty when unknown.
|
|
453
457
|
function generateToken(ip, siteKey, score, binding = {}) {
|
|
454
|
-
const ipHash =
|
|
458
|
+
const ipHash = ipBinding(SECRET_KEY, ip);
|
|
455
459
|
const data = {
|
|
456
460
|
site_key: siteKey,
|
|
457
461
|
jti: crypto.randomBytes(16).toString('hex'),
|
|
@@ -490,7 +494,7 @@ async function verifyToken(token, ip = null) {
|
|
|
490
494
|
|
|
491
495
|
// Verify IP matches (if provided)
|
|
492
496
|
if (ip) {
|
|
493
|
-
const expectedIpHash =
|
|
497
|
+
const expectedIpHash = ipBinding(SECRET_KEY, ip);
|
|
494
498
|
if (decoded.ip_hash !== expectedIpHash) {
|
|
495
499
|
return { valid: false, reason: 'ip_mismatch' };
|
|
496
500
|
}
|
|
@@ -828,7 +832,27 @@ function collectHeaders(req) {
|
|
|
828
832
|
return headers;
|
|
829
833
|
}
|
|
830
834
|
|
|
831
|
-
const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
|
|
835
|
+
const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res, next)).catch(next);
|
|
836
|
+
|
|
837
|
+
const admission = new AdmissionLimiter();
|
|
838
|
+
app.use(['/api', '/siteverify', '/turnstile', '/recaptcha'], asyncRoute(async (req, res, next) => {
|
|
839
|
+
const ip = PROXY_TRUST.clientIP(req);
|
|
840
|
+
const allow = (key, max) => SHARED_STATE ? SHARED_STATE.admit(key, max) : admission.allow(key, max);
|
|
841
|
+
try {
|
|
842
|
+
if (!await allow('global', 20000) || !await allow(`source:${ip}`, 600) ||
|
|
843
|
+
(req.originalUrl.split('?')[0].replace(/\/+$/, '').toLowerCase() === '/api/pow/challenge' && !await allow(`challenge:${ip}`, 60))) {
|
|
844
|
+
return res.status(429).set('Retry-After', '60').json({ error: 'rate_limited' });
|
|
845
|
+
}
|
|
846
|
+
} catch (_) { return res.status(503).json({ error: 'state_unavailable' }); }
|
|
847
|
+
next();
|
|
848
|
+
}));
|
|
849
|
+
|
|
850
|
+
app.get('/ready', asyncRoute(async (req, res) => {
|
|
851
|
+
try {
|
|
852
|
+
if (SHARED_STATE) await SHARED_STATE.ready();
|
|
853
|
+
res.json({ status: 'ok' });
|
|
854
|
+
} catch (_) { res.status(503).json({ status: 'unavailable' }); }
|
|
855
|
+
}));
|
|
832
856
|
|
|
833
857
|
function validateScoringRequest(req, res, next) {
|
|
834
858
|
const body = req.body;
|
|
@@ -1006,7 +1030,8 @@ app.use((err, req, res, next) => {
|
|
|
1006
1030
|
|
|
1007
1031
|
async function start() {
|
|
1008
1032
|
if (SHARED_STATE) await SHARED_STATE.connect();
|
|
1009
|
-
const
|
|
1033
|
+
const host = SECRET_KEY === require('./config').INSECURE_DEFAULT_SECRET ? '127.0.0.1' : '0.0.0.0';
|
|
1034
|
+
const server = app.listen(PORT, host, () => {
|
|
1010
1035
|
console.log(`FCaptcha server running on port ${server.address().port}`);
|
|
1011
1036
|
console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
|
|
1012
1037
|
console.log(`Site keys: ${SITE_KEYS.describe()}`);
|
package/webbotauth.js
CHANGED
|
@@ -65,7 +65,8 @@ const {
|
|
|
65
65
|
const { verifierFromJWK } = require('web-bot-auth/crypto');
|
|
66
66
|
|
|
67
67
|
const VERIFY_TIMEOUT_MS = 3000;
|
|
68
|
-
const
|
|
68
|
+
const directoryAgent = new https.Agent({ keepAlive: true, maxTotalSockets: 16, maxSockets: 2, maxFreeSockets: 2, timeout: VERIFY_TIMEOUT_MS });
|
|
69
|
+
const MAX_DIRECTORY_BYTES = 256 * 1024;
|
|
69
70
|
const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
70
71
|
|
|
71
72
|
// The exact message web-bot-auth/crypto throws when crypto.subtle.verify()
|
|
@@ -91,7 +92,11 @@ const REQUIRED_TAG = 'web-bot-auth';
|
|
|
91
92
|
const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
|
|
92
93
|
|
|
93
94
|
// origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
|
|
94
|
-
const
|
|
95
|
+
const { BoundedMap } = require('./limits');
|
|
96
|
+
// Each response is at most 256 KiB. Sixteen entries bound retained payload bytes;
|
|
97
|
+
// limiting parsed keys also bounds key-import work per verification.
|
|
98
|
+
const directoryCache = new BoundedMap(16);
|
|
99
|
+
const directoryInflight = new Map();
|
|
95
100
|
|
|
96
101
|
// FetchFailure marks failures that are NOT cryptographic — the caller treats
|
|
97
102
|
// them as "could not verify" (fail open), never as a forged-signature signal.
|
|
@@ -159,6 +164,7 @@ function fetchJSON(documentUrl) {
|
|
|
159
164
|
documentUrl,
|
|
160
165
|
{
|
|
161
166
|
lookup: guardedLookup,
|
|
167
|
+
agent: directoryAgent,
|
|
162
168
|
timeout: VERIFY_TIMEOUT_MS,
|
|
163
169
|
headers: {
|
|
164
170
|
accept: 'application/http-message-signatures-directory+json, application/json',
|
|
@@ -191,6 +197,8 @@ function fetchJSON(documentUrl) {
|
|
|
191
197
|
},
|
|
192
198
|
);
|
|
193
199
|
|
|
200
|
+
const deadline = setTimeout(() => req.destroy(new FetchFailure('directory fetch deadline exceeded')), VERIFY_TIMEOUT_MS);
|
|
201
|
+
req.on('close', () => clearTimeout(deadline));
|
|
194
202
|
req.on('timeout', () => {
|
|
195
203
|
req.destroy(new FetchFailure('directory fetch timed out'));
|
|
196
204
|
});
|
|
@@ -236,11 +244,38 @@ async function getKeys(url, type) {
|
|
|
236
244
|
const now = Date.now();
|
|
237
245
|
const cached = directoryCache.get(url);
|
|
238
246
|
if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
|
|
247
|
+
if (cached.error) {
|
|
248
|
+
if (now - cached.fetchedAt < 30000) throw new FetchFailure('directory temporarily unavailable');
|
|
249
|
+
} else {
|
|
239
250
|
return cached.keys;
|
|
251
|
+
}
|
|
240
252
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
if (directoryInflight.has(url)) return directoryInflight.get(url);
|
|
254
|
+
if (directoryInflight.size >= 8) throw new FetchFailure('directory fetch capacity reached');
|
|
255
|
+
const pending = (async () => {
|
|
256
|
+
try {
|
|
257
|
+
const rawKeys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
|
|
258
|
+
if (rawKeys.length > 64) throw new FetchFailure('too many directory keys');
|
|
259
|
+
// Cache only public JWK fields, not arbitrary nested directory metadata.
|
|
260
|
+
const keys = rawKeys.map(raw => {
|
|
261
|
+
if (!raw || typeof raw !== 'object') throw new FetchFailure('invalid directory key');
|
|
262
|
+
const key = {};
|
|
263
|
+
for (const field of ['kty', 'kid', 'alg', 'crv', 'x', 'y', 'n', 'e', 'use']) {
|
|
264
|
+
if (raw[field] == null) continue;
|
|
265
|
+
if (typeof raw[field] !== 'string' || raw[field].length > 4096) throw new FetchFailure('invalid key field');
|
|
266
|
+
key[field] = raw[field];
|
|
267
|
+
}
|
|
268
|
+
return key;
|
|
269
|
+
});
|
|
270
|
+
directoryCache.set(url, { keys, fetchedAt: Date.now() });
|
|
271
|
+
return keys;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
directoryCache.set(url, { error: true, fetchedAt: Date.now() });
|
|
274
|
+
throw error;
|
|
275
|
+
} finally { directoryInflight.delete(url); }
|
|
276
|
+
})();
|
|
277
|
+
directoryInflight.set(url, pending);
|
|
278
|
+
return pending;
|
|
244
279
|
}
|
|
245
280
|
|
|
246
281
|
// resolveKey fetches the directory named by the Signature-Agent header and
|