@webdecoy/fcaptcha 1.28.1 → 1.29.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.
Files changed (4) hide show
  1. package/config.js +22 -0
  2. package/index.js +16 -4
  3. package/package.json +2 -2
  4. package/server.js +17 -3
package/config.js ADDED
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const INSECURE_DEFAULT_SECRET = 'dev-secret-change-in-production';
4
+
5
+ function signingSecretFromEnv(env = process.env) {
6
+ const secret = String(env.FCAPTCHA_SECRET || '').trim();
7
+ if (secret && secret !== INSECURE_DEFAULT_SECRET) return secret;
8
+ if (/^(1|true|yes|on)$/i.test(String(env.FCAPTCHA_INSECURE_DEV_MODE || '').trim())) {
9
+ console.warn('WARNING: FCAPTCHA_INSECURE_DEV_MODE enabled; tokens use a public signing key. Never expose this server to a network.');
10
+ return INSECURE_DEFAULT_SECRET;
11
+ }
12
+ 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.');
13
+ }
14
+
15
+ function signingSecret(explicit, env = process.env) {
16
+ if (explicit !== undefined && explicit !== null) {
17
+ return signingSecretFromEnv({ ...env, FCAPTCHA_SECRET: explicit });
18
+ }
19
+ return signingSecretFromEnv(env);
20
+ }
21
+
22
+ module.exports = { INSECURE_DEFAULT_SECRET, signingSecret, signingSecretFromEnv };
package/index.js CHANGED
@@ -13,6 +13,7 @@ const crypto = require('crypto');
13
13
  const detection = require('./detection');
14
14
  const { ProxyTrust } = require('./clientip');
15
15
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
16
+ const { signingSecret } = require('./config');
16
17
 
17
18
  // =============================================================================
18
19
  // PoW Challenge Store (can be extended with Redis)
@@ -20,7 +21,7 @@ const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./su
20
21
 
21
22
  class PoWChallengeStore {
22
23
  constructor(options = {}) {
23
- this.secret = options.secret || 'dev-secret-change-in-production';
24
+ this.secret = signingSecret(options.secret);
24
25
  this.challenges = new Map();
25
26
  this.usedSolutions = new Set();
26
27
  this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
@@ -212,7 +213,7 @@ const AUTOMATION_UA_PATTERNS = [
212
213
 
213
214
  class ScoringEngine {
214
215
  constructor(options = {}) {
215
- this.secret = options.secret || 'dev-secret-change-in-production';
216
+ this.secret = signingSecret(options.secret);
216
217
  this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
217
218
  this.rateLimiter = options.rateLimiter || new RateLimiter();
218
219
  this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
@@ -246,6 +247,7 @@ class ScoringEngine {
246
247
  // Verify signals and return score
247
248
  verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
248
249
  const detections = [];
250
+ let powSatisfied = false;
249
251
 
250
252
  // Run all detection modules
251
253
  detections.push(...this._detectVisionAI(signals));
@@ -272,6 +274,7 @@ class ScoringEngine {
272
274
  reason: `PoW verification failed: ${powResult.reason}`
273
275
  });
274
276
  } else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
277
+ powSatisfied = true;
275
278
  // Under the universal baseline nothing legitimate can have happened —
276
279
  // no human completes an interaction that fast.
277
280
  detections.push({
@@ -281,6 +284,7 @@ class ScoringEngine {
281
284
  reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
282
285
  });
283
286
  } else if (powResult.serverElapsed < powResult.minAgeMs) {
287
+ powSatisfied = true;
284
288
  // Between the baseline and this source's own elevated floor is weaker
285
289
  // evidence: a client predating adaptive cost, or one served from a
286
290
  // stale cache, does not know to wait. It contributes rather than
@@ -291,6 +295,8 @@ class ScoringEngine {
291
295
  confidence: 0.5,
292
296
  reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
293
297
  });
298
+ } else {
299
+ powSatisfied = true;
294
300
  }
295
301
  } else {
296
302
  detections.push({
@@ -336,7 +342,12 @@ class ScoringEngine {
336
342
  else if (finalScore < 0.6) recommendation = 'challenge';
337
343
  else recommendation = 'block';
338
344
 
339
- const success = finalScore < 0.5;
345
+ // PoW is a precondition, not weighted evidence. Expressing a missing or
346
+ // invalid solution only as a bot-category score lets the category weight
347
+ // dilute it below the allow threshold and used to mint a token for an empty
348
+ // request. Keep this gate independent of scoring so weight changes cannot
349
+ // reopen the bypass.
350
+ const success = finalScore < 0.5 && powSatisfied;
340
351
  const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
341
352
 
342
353
  // Feed the ledger so the next challenge this source asks for is priced on
@@ -350,7 +361,8 @@ class ScoringEngine {
350
361
  timestamp: Math.floor(Date.now() / 1000),
351
362
  recommendation,
352
363
  categoryScores,
353
- detections
364
+ detections,
365
+ ...(!powSatisfied ? { reason: 'pow_not_satisfied' } : {})
354
366
  };
355
367
  }
356
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.28.1",
3
+ "version": "1.29.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 clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node siteverify.test.js && node --test suspicion.test.js",
18
+ "test": "node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node siteverify.test.js && node index.test.js && node --test suspicion.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/server.js CHANGED
@@ -12,6 +12,7 @@ const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
13
  const { ProxyTrust } = require('./clientip');
14
14
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
+ const { signingSecretFromEnv } = require('./config');
15
16
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
16
17
  const { detectInputForensics } = require('./inputforensics');
17
18
  const {
@@ -27,13 +28,16 @@ const {
27
28
 
28
29
  const app = express();
29
30
  app.use(cors());
30
- app.use(express.json());
31
+ // Captured widget payloads are about 10-15 KiB. Four times that leaves ample
32
+ // room for future signals while bounding work done by the public JSON parsers.
33
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024;
34
+ app.use(express.json({ limit: MAX_REQUEST_BODY_BYTES }));
31
35
  // The siteverify contract accepts form-encoded bodies as well as JSON, and most
32
36
  // PHP and Python integrations in the wild post form-encoded. Parsing both here
33
37
  // costs nothing on the JSON path.
34
- app.use(express.urlencoded({ extended: false }));
38
+ app.use(express.urlencoded({ extended: false, limit: MAX_REQUEST_BODY_BYTES }));
35
39
 
36
- const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
40
+ const SECRET_KEY = signingSecretFromEnv();
37
41
 
38
42
  // The credential a backend presents to validate a token. Defaults to the signing
39
43
  // key, which is what the README has always documented, but can be separated:
@@ -1787,6 +1791,16 @@ app.get('/api/challenge', (req, res) => {
1787
1791
  });
1788
1792
  });
1789
1793
 
1794
+ // Express identifies parser limit failures with status 413 / entity.too.large.
1795
+ // Handle them explicitly so all three servers expose the same status instead
1796
+ // of falling through to Express's HTML error response.
1797
+ app.use((err, req, res, next) => {
1798
+ if (err && (err.status === 413 || err.type === 'entity.too.large')) {
1799
+ return res.status(413).json({ error: 'request_too_large' });
1800
+ }
1801
+ return next(err);
1802
+ });
1803
+
1790
1804
  // =============================================================================
1791
1805
  // Start
1792
1806
  // =============================================================================