@webdecoy/fcaptcha 1.39.0 → 1.40.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 +11 -1
- package/detection.js +7 -14
- package/engine.js +13 -0
- package/experimental.js +37 -0
- package/index.js +11 -4
- package/package.json +2 -2
- package/server.js +13 -4
package/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { EXPERIMENTAL_POLICY } = require('./experimental');
|
|
4
|
+
|
|
3
5
|
const INSECURE_DEFAULT_SECRET = 'dev-secret-change-in-production';
|
|
4
6
|
|
|
5
7
|
// The library constructs the secret more than once per process (the challenge
|
|
@@ -28,4 +30,12 @@ function signingSecret(explicit, env = process.env) {
|
|
|
28
30
|
return signingSecretFromEnv(env);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
function experimentalBlockingEnabled(explicit, env = process.env) {
|
|
34
|
+
if (explicit !== undefined && explicit !== false && typeof explicit !== 'string') {
|
|
35
|
+
throw new TypeError('experimentalBlocking must be a policy name or false');
|
|
36
|
+
}
|
|
37
|
+
const policy = explicit === undefined ? env.FCAPTCHA_EXPERIMENTAL_BLOCKING : explicit;
|
|
38
|
+
return typeof policy === 'string' && policy.trim() === EXPERIMENTAL_POLICY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { INSECURE_DEFAULT_SECRET, signingSecret, signingSecretFromEnv, experimentalBlockingEnabled };
|
package/detection.js
CHANGED
|
@@ -894,16 +894,6 @@ function analyzeWebRTC(webrtcInfo) {
|
|
|
894
894
|
});
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
-
// Check local IP detection - VMs and some headless setups may not expose local IPs
|
|
898
|
-
if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
|
|
899
|
-
detections.push({
|
|
900
|
-
category: 'headless',
|
|
901
|
-
score: 0.4,
|
|
902
|
-
confidence: 0.5,
|
|
903
|
-
reason: 'No local IP addresses detected via WebRTC'
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
|
|
907
897
|
return detections;
|
|
908
898
|
}
|
|
909
899
|
|
|
@@ -956,14 +946,17 @@ function analyzeWorkerConsistency(workerConsistency) {
|
|
|
956
946
|
|
|
957
947
|
const detections = [];
|
|
958
948
|
|
|
959
|
-
// Mismatches indicate fingerprint spoofing
|
|
960
|
-
|
|
961
|
-
|
|
949
|
+
// Mismatches indicate fingerprint spoofing. hardwareConcurrency is scored
|
|
950
|
+
// separately (engine.js detectStealthArtifacts); counting it here as well
|
|
951
|
+
// would count the same mismatch twice.
|
|
952
|
+
const mismatches = (workerConsistency.mismatches || []).filter((m) => m !== 'hardwareConcurrency');
|
|
953
|
+
if (!workerConsistency.consistent && mismatches.length > 0) {
|
|
954
|
+
const score = Math.min(0.9, 0.3 + (mismatches.length * 0.15));
|
|
962
955
|
detections.push({
|
|
963
956
|
category: 'bot',
|
|
964
957
|
score: score,
|
|
965
958
|
confidence: 0.85,
|
|
966
|
-
reason: `Worker/main thread mismatch detected: ${
|
|
959
|
+
reason: `Worker/main thread mismatch detected: ${mismatches.join(', ')}`
|
|
967
960
|
});
|
|
968
961
|
}
|
|
969
962
|
|
package/engine.js
CHANGED
|
@@ -502,6 +502,19 @@ function detectStealthArtifacts(signals) {
|
|
|
502
502
|
});
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// The page and a Worker disagree on hardwareConcurrency. Stealth tooling
|
|
506
|
+
// patches the page's value and not the worker's. Contributory only: Chrome's
|
|
507
|
+
// DevTools hardware-concurrency override produces the same disagreement with
|
|
508
|
+
// native getters and an attached console, so a developer testing that
|
|
509
|
+
// setting is indistinguishable from the patch.
|
|
510
|
+
const worker = env.workerConsistency;
|
|
511
|
+
if (worker && Array.isArray(worker.mismatches) && worker.mismatches.includes('hardwareConcurrency')) {
|
|
512
|
+
detections.push({
|
|
513
|
+
category: 'bot', score: 0.45, confidence: 0.85,
|
|
514
|
+
reason: 'Page and Worker disagree on hardwareConcurrency'
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
505
518
|
return detections;
|
|
506
519
|
}
|
|
507
520
|
|
package/experimental.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { calculateCategoryScores, BEHAVIOURAL_CATEGORIES, CORROBORATION_AGREE_AT,
|
|
4
|
+
CORROBORATION_FLOOR } = require('./engine');
|
|
5
|
+
|
|
6
|
+
// Change this ID whenever the policy's evidence, thresholds, or scoring semantics
|
|
7
|
+
// change. An old selector must never silently opt an operator into a new policy.
|
|
8
|
+
const EXPERIMENTAL_POLICY = 'stealth-corroboration-v1';
|
|
9
|
+
|
|
10
|
+
// Observe by default. This intentionally measures a known-ambiguous hypothesis:
|
|
11
|
+
// #87's stealth session and a DevTools hardware override can look identical.
|
|
12
|
+
// Never merge these detections into production scoring or state updates. The
|
|
13
|
+
// server's explicit blocking option adds a separate token gate.
|
|
14
|
+
function evaluateExperimental(signals, productionScore, detections, blocking = false) {
|
|
15
|
+
const worker = signals.environmental?.workerConsistency;
|
|
16
|
+
const mismatch = worker?.supported === true && worker.consistent === false &&
|
|
17
|
+
Array.isArray(worker.mismatches) && worker.mismatches.includes('hardwareConcurrency');
|
|
18
|
+
const categoryScores = calculateCategoryScores(detections.filter((d) => !d.nonCorroborating));
|
|
19
|
+
const corroboratingCategories = BEHAVIOURAL_CATEGORIES.filter(
|
|
20
|
+
(c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT - 1e-9);
|
|
21
|
+
const score = mismatch && corroboratingCategories.length > 0
|
|
22
|
+
? Math.max(productionScore, CORROBORATION_FLOOR) : productionScore;
|
|
23
|
+
return {
|
|
24
|
+
mode: blocking ? 'block' : 'observe',
|
|
25
|
+
policy: EXPERIMENTAL_POLICY,
|
|
26
|
+
score,
|
|
27
|
+
// Score threshold only; this is not a hypothetical PoW/hostname/rate verdict.
|
|
28
|
+
wouldBlock: score >= 0.5,
|
|
29
|
+
detections: mismatch ? [{
|
|
30
|
+
id: 'worker-hardware-concurrency-mismatch',
|
|
31
|
+
reason: 'Page and Worker disagree on hardwareConcurrency; also possible with DevTools or privacy tools',
|
|
32
|
+
}] : [],
|
|
33
|
+
corroboratingCategories,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { evaluateExperimental, EXPERIMENTAL_POLICY };
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@ const crypto = require('crypto');
|
|
|
13
13
|
const detection = require('./detection');
|
|
14
14
|
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
15
15
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
16
|
-
const { signingSecret } = require('./config');
|
|
16
|
+
const { signingSecret, experimentalBlockingEnabled } = require('./config');
|
|
17
17
|
const { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
18
18
|
const { ipBinding } = require('./ip-binding');
|
|
19
19
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
@@ -194,9 +194,11 @@ const {
|
|
|
194
194
|
widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
|
|
195
195
|
} = require('./engine');
|
|
196
196
|
const { detectInputForensics } = require('./inputforensics');
|
|
197
|
+
const { evaluateExperimental } = require('./experimental');
|
|
197
198
|
|
|
198
199
|
class ScoringEngine {
|
|
199
200
|
constructor(options = {}) {
|
|
201
|
+
this.experimentalBlocking = experimentalBlockingEnabled(options.experimentalBlocking);
|
|
200
202
|
this.secret = signingSecret(options.secret);
|
|
201
203
|
this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
|
|
202
204
|
this.rateLimiter = options.rateLimiter || new RateLimiter();
|
|
@@ -364,8 +366,11 @@ class ScoringEngine {
|
|
|
364
366
|
detections
|
|
365
367
|
);
|
|
366
368
|
|
|
369
|
+
const experimental = evaluateExperimental(signals, finalScore, detections, this.experimentalBlocking);
|
|
370
|
+
const experimentalBlocked = this.experimentalBlocking && experimental.wouldBlock && finalScore < 0.5;
|
|
367
371
|
let recommendation;
|
|
368
|
-
if (
|
|
372
|
+
if (experimentalBlocked) recommendation = 'block';
|
|
373
|
+
else if (finalScore < 0.3) recommendation = 'allow';
|
|
369
374
|
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
370
375
|
else recommendation = 'block';
|
|
371
376
|
|
|
@@ -374,7 +379,7 @@ class ScoringEngine {
|
|
|
374
379
|
// dilute it below the allow threshold and used to mint a token for an empty
|
|
375
380
|
// request. Keep this gate independent of scoring so weight changes cannot
|
|
376
381
|
// reopen the bypass.
|
|
377
|
-
const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded;
|
|
382
|
+
const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
|
|
378
383
|
const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
|
|
379
384
|
|
|
380
385
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
@@ -384,12 +389,14 @@ class ScoringEngine {
|
|
|
384
389
|
return {
|
|
385
390
|
success,
|
|
386
391
|
score: finalScore,
|
|
392
|
+
experimental,
|
|
387
393
|
token,
|
|
388
394
|
timestamp: Math.floor(Date.now() / 1000),
|
|
389
395
|
recommendation,
|
|
390
396
|
categoryScores,
|
|
391
397
|
detections,
|
|
392
|
-
...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' }
|
|
398
|
+
...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' }
|
|
399
|
+
: experimentalBlocked ? { reason: 'experimental_detection' } : {})
|
|
393
400
|
};
|
|
394
401
|
}
|
|
395
402
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.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 admission.test.js 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 experimental.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,7 +12,7 @@ const detection = require('./detection');
|
|
|
12
12
|
const webbotauth = require('./webbotauth');
|
|
13
13
|
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
|
-
const { signingSecretFromEnv } = require('./config');
|
|
15
|
+
const { signingSecretFromEnv, experimentalBlockingEnabled } = require('./config');
|
|
16
16
|
const { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
17
17
|
const { ipBinding } = require('./ip-binding');
|
|
18
18
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
@@ -43,6 +43,7 @@ app.use(express.json({ limit: MAX_REQUEST_BODY_BYTES }));
|
|
|
43
43
|
app.use(express.urlencoded({ extended: false, limit: MAX_REQUEST_BODY_BYTES }));
|
|
44
44
|
|
|
45
45
|
const SECRET_KEY = signingSecretFromEnv();
|
|
46
|
+
const EXPERIMENTAL_BLOCKING_ENABLED = experimentalBlockingEnabled();
|
|
46
47
|
const REDIS_URL = process.env.REDIS_URL || '';
|
|
47
48
|
const SHARED_STATE = REDIS_URL ? new RedisState(REDIS_URL) : null;
|
|
48
49
|
|
|
@@ -144,6 +145,7 @@ function logVerdict(endpoint, siteKey, result) {
|
|
|
144
145
|
siteKey,
|
|
145
146
|
success: result.success,
|
|
146
147
|
score: result.score,
|
|
148
|
+
experimental: result.experimental,
|
|
147
149
|
recommendation: result.recommendation,
|
|
148
150
|
categoryScores: result.categoryScores,
|
|
149
151
|
detections: (result.detections || []).map((d) => {
|
|
@@ -375,6 +377,7 @@ const {
|
|
|
375
377
|
applyCorroborationFloor,
|
|
376
378
|
widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
|
|
377
379
|
} = require('./engine');
|
|
380
|
+
const { evaluateExperimental } = require('./experimental');
|
|
378
381
|
|
|
379
382
|
// Stateful detectors stay here: they read the module-level stores below.
|
|
380
383
|
async function detectFingerprint(signals, ip, siteKey) {
|
|
@@ -748,8 +751,11 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
748
751
|
detections
|
|
749
752
|
);
|
|
750
753
|
|
|
754
|
+
const experimental = evaluateExperimental(signals, finalScore, detections, EXPERIMENTAL_BLOCKING_ENABLED);
|
|
755
|
+
const experimentalBlocked = EXPERIMENTAL_BLOCKING_ENABLED && experimental.wouldBlock && finalScore < 0.5;
|
|
751
756
|
let recommendation;
|
|
752
|
-
if (
|
|
757
|
+
if (experimentalBlocked) recommendation = 'block';
|
|
758
|
+
else if (finalScore < 0.3) recommendation = 'allow';
|
|
753
759
|
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
754
760
|
else recommendation = 'block';
|
|
755
761
|
|
|
@@ -764,14 +770,14 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
764
770
|
// its own reason rather than smuggled in as a bot verdict.
|
|
765
771
|
const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
|
|
766
772
|
|
|
767
|
-
//
|
|
773
|
+
// Independent gates, deliberately not folded into the baseline score.
|
|
768
774
|
//
|
|
769
775
|
// powSatisfied is the one that matters most: a score threshold answers "how
|
|
770
776
|
// suspicious is this visitor", which is the wrong question to ask of someone
|
|
771
777
|
// who never completed the challenge. Gating here means no future reweighting
|
|
772
778
|
// can reopen the bypass, and it holds even if the dispositive floor is
|
|
773
779
|
// lowered or removed.
|
|
774
|
-
const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded;
|
|
780
|
+
const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
|
|
775
781
|
|
|
776
782
|
// Name the failed precondition whenever one fails, not only when the score
|
|
777
783
|
// would otherwise have allowed. Gating it on the score made the PoW case
|
|
@@ -783,6 +789,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
783
789
|
if (deviceRateExceeded) withheldReason = 'rate_limited';
|
|
784
790
|
else if (!powSatisfied) withheldReason = 'pow_not_satisfied';
|
|
785
791
|
else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
|
|
792
|
+
else if (experimentalBlocked) withheldReason = 'experimental_detection';
|
|
786
793
|
|
|
787
794
|
const token = success
|
|
788
795
|
? generateToken(ip, siteKey, finalScore, {
|
|
@@ -799,6 +806,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
799
806
|
return {
|
|
800
807
|
success,
|
|
801
808
|
score: finalScore,
|
|
809
|
+
experimental,
|
|
802
810
|
token,
|
|
803
811
|
timestamp: Math.floor(Date.now() / 1000),
|
|
804
812
|
recommendation,
|
|
@@ -907,6 +915,7 @@ app.post('/api/score', validateScoringRequest, asyncRoute(async (req, res) => {
|
|
|
907
915
|
res.json({
|
|
908
916
|
success: result.success,
|
|
909
917
|
score: result.score,
|
|
918
|
+
experimental: result.experimental,
|
|
910
919
|
token: result.token,
|
|
911
920
|
// Echo the sanitized form, not the raw input: this is what got signed into
|
|
912
921
|
// the token, so a caller comparing the two sees the same value.
|