@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 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 and must not be the public development key. For local-only development, explicitly set FCAPTCHA_INSECURE_DEV_MODE=1.');
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
- if (jsTime < 0.1) {
517
- detections.push({
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 BoundedMap();
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 !== crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8)) {
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 = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
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.37.0",
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.18.0",
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": ">=18.0.0"
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.client.set(this.challengeKey(challenge.id), JSON.stringify(stored), { PX: ttl });
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.client.get(this.challengeKey(id));
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.client.eval(CLAIM_CHALLENGE, {
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.client.set(
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.client.get(this.idempotencyKey(idempotencyKey, token));
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.client.set(
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.client.eval(RATE_CHECK, {
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.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS);
155
- return Number(await this.client.zCard(key));
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.client.eval(RECORD_FINGERPRINT, {
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.client.sCard(this.opaqueKey('fingerprint:fps', ip)));
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.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
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.client.eval(SITEKEY_CLAIM, {
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 BoundedMap(),
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 = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
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 = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
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 server = app.listen(PORT, () => {
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 MAX_DIRECTORY_BYTES = 1 << 20; // 1 MiB, matches the Go client's cap
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 directoryCache = new Map();
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
- const keys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
242
- directoryCache.set(url, { keys, fetchedAt: now });
243
- return keys;
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