@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.
- package/config.js +22 -0
- package/index.js +16 -4
- package/package.json +2 -2
- 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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
// =============================================================================
|