@webdecoy/fcaptcha 1.46.2 → 1.47.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/README.md +21 -2
- package/clientip.js +6 -14
- package/index.js +36 -12
- package/package.json +1 -1
- package/server.js +6 -6
- package/token-canonical.js +32 -0
package/README.md
CHANGED
|
@@ -15,14 +15,21 @@ const { createScoringEngine } = require('@webdecoy/fcaptcha');
|
|
|
15
15
|
|
|
16
16
|
// Create a scoring engine
|
|
17
17
|
const engine = createScoringEngine({
|
|
18
|
-
secret: process.env.FCAPTCHA_SECRET
|
|
18
|
+
secret: process.env.FCAPTCHA_SECRET,
|
|
19
|
+
// Required for multiple processes or replicas: provide one implementation
|
|
20
|
+
// whose claim(signature) operation is atomic and shared (for example Redis).
|
|
21
|
+
tokenStore: sharedTokenStore
|
|
19
22
|
});
|
|
20
23
|
|
|
21
24
|
// Generate a PoW challenge
|
|
22
25
|
const challenge = engine.generateChallenge('my-site', clientIP);
|
|
23
26
|
|
|
24
27
|
// Verify a submission
|
|
25
|
-
const result = engine.verify(
|
|
28
|
+
const result = engine.verify(
|
|
29
|
+
signals, clientIP, siteKey, userAgent, headers, powSolution,
|
|
30
|
+
signalsJson, powTiming,
|
|
31
|
+
{ hostname: 'app.example.com', action: 'login', cdata: sessionId }
|
|
32
|
+
);
|
|
26
33
|
|
|
27
34
|
if (result.success) {
|
|
28
35
|
// User passed verification
|
|
@@ -31,6 +38,17 @@ if (result.success) {
|
|
|
31
38
|
}
|
|
32
39
|
```
|
|
33
40
|
|
|
41
|
+
Engines in one Node process share the default replay store. Multiple processes
|
|
42
|
+
or replicas must inject one shared atomic `tokenStore`; otherwise no in-memory
|
|
43
|
+
implementation can enforce one-time redemption across them.
|
|
44
|
+
|
|
45
|
+
`createMiddleware()` binds tokens to the request `Origin`/`Referer` and the
|
|
46
|
+
submitted `action`/`cdata`. Its `tokenVerifyHandler` is server-to-server and
|
|
47
|
+
requires `secret` in the request body. Set `verifySecret` in middleware options
|
|
48
|
+
to split that credential from the signing secret. A relying backend must compare
|
|
49
|
+
the returned `hostname` and `action` with the values expected for the protected
|
|
50
|
+
operation before granting it.
|
|
51
|
+
|
|
34
52
|
## Features
|
|
35
53
|
|
|
36
54
|
- **Proof of Work** - SHA-256 challenges that force compute cost on attackers
|
|
@@ -47,6 +65,7 @@ const {
|
|
|
47
65
|
PoWChallengeStore, // Challenge storage
|
|
48
66
|
RateLimiter, // Rate limiting
|
|
49
67
|
FingerprintStore, // Fingerprint tracking
|
|
68
|
+
TokenStore, // Process-local replay store / custom-store interface
|
|
50
69
|
detection, // Detection utilities
|
|
51
70
|
createScoringEngine, // Factory function
|
|
52
71
|
createMiddleware, // Express middleware factory
|
package/clientip.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* credible only when the peer that sent them is a proxy we put there.
|
|
10
10
|
*
|
|
11
11
|
* TRUSTED_PROXIES holds the peers allowed to speak for someone else, as a
|
|
12
|
-
* comma-separated list of CIDRs and bare IPs. Unset
|
|
13
|
-
*
|
|
12
|
+
* comma-separated list of CIDRs and bare IPs. Unset trusts loopback only.
|
|
13
|
+
* '*' trusts every peer (only correct when an edge you
|
|
14
14
|
* control always overwrites the headers). 'none' trusts nobody, so the socket
|
|
15
15
|
* address is always used.
|
|
16
16
|
*/
|
|
@@ -18,21 +18,13 @@
|
|
|
18
18
|
const ipaddr = require('ipaddr.js');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Fallback trust
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* in-cluster reverse proxy (nginx, Railway, Fly, Kubernetes ingress) sees a
|
|
25
|
-
* loopback or private peer and behaves as before.
|
|
21
|
+
* Fallback trust is deliberately loopback-only. Private address space is shared
|
|
22
|
+
* by unrelated workloads in common clusters and must be explicitly narrowed to
|
|
23
|
+
* the actual ingress or sidecar ranges.
|
|
26
24
|
*/
|
|
27
25
|
const DEFAULT_TRUSTED_PROXY_CIDRS = [
|
|
28
26
|
'127.0.0.0/8',
|
|
29
|
-
'::1/128'
|
|
30
|
-
'10.0.0.0/8',
|
|
31
|
-
'172.16.0.0/12',
|
|
32
|
-
'192.168.0.0/16',
|
|
33
|
-
'169.254.0.0/16',
|
|
34
|
-
'fe80::/10',
|
|
35
|
-
'fc00::/7'
|
|
27
|
+
'::1/128'
|
|
36
28
|
];
|
|
37
29
|
|
|
38
30
|
// hostOnly strips a trailing :port or wrapping brackets. Node's
|
package/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Usage:
|
|
7
7
|
* const fcaptcha = require('@webdecoy/fcaptcha');
|
|
8
8
|
* const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
|
|
9
|
-
* const result = engine.verify(signals, ip, siteKey, userAgent, headers,
|
|
9
|
+
* const result = engine.verify(signals, ip, siteKey, userAgent, headers,
|
|
10
|
+
* powSolution, signalsJson, powTiming, { hostname, action, cdata });
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
const crypto = require('crypto');
|
|
@@ -20,6 +21,13 @@ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
|
20
21
|
const { FingerprintStore } = require('./fingerprint-store');
|
|
21
22
|
const { TokenStore } = require('./token-store');
|
|
22
23
|
const { resolveSignals } = require('./protocol');
|
|
24
|
+
const { requestHostname, sanitizeAction, sanitizeCdata, secretMatches } = require('./siteverify');
|
|
25
|
+
const { canonicalTokenPayload } = require('./token-canonical');
|
|
26
|
+
|
|
27
|
+
// Independent engines in one process are equivalent redemption authorities.
|
|
28
|
+
// Sharing the default store prevents a token from being spent once per engine.
|
|
29
|
+
// Multi-process/replica deployments must still inject one atomic shared store.
|
|
30
|
+
const DEFAULT_TOKEN_STORE = new TokenStore();
|
|
23
31
|
|
|
24
32
|
// =============================================================================
|
|
25
33
|
// PoW Challenge Store (can be extended with Redis)
|
|
@@ -205,7 +213,10 @@ class ScoringEngine {
|
|
|
205
213
|
this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
|
|
206
214
|
this.suspicion = options.suspicion || new SuspicionLedger();
|
|
207
215
|
this.weights = options.weights || WEIGHTS;
|
|
208
|
-
this.tokenStore = options.tokenStore ||
|
|
216
|
+
this.tokenStore = options.tokenStore || DEFAULT_TOKEN_STORE;
|
|
217
|
+
this.verifySecret = options.verifySecret === undefined
|
|
218
|
+
? this.secret
|
|
219
|
+
: signingSecret(options.verifySecret);
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
// Generate a PoW challenge
|
|
@@ -232,7 +243,7 @@ class ScoringEngine {
|
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
// Verify signals and return score
|
|
235
|
-
verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null) {
|
|
246
|
+
verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null, binding = {}) {
|
|
236
247
|
const resolved = resolveSignals(signals, signalsJson, powSolution?.signalsHash);
|
|
237
248
|
signals = resolved.signals;
|
|
238
249
|
if (powTiming) {
|
|
@@ -380,7 +391,7 @@ class ScoringEngine {
|
|
|
380
391
|
// request. Keep this gate independent of scoring so weight changes cannot
|
|
381
392
|
// reopen the bypass.
|
|
382
393
|
const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded && !experimentalBlocked;
|
|
383
|
-
const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
|
|
394
|
+
const token = success ? this._generateToken(ip, siteKey, finalScore, binding) : null;
|
|
384
395
|
|
|
385
396
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
386
397
|
// what it just did.
|
|
@@ -412,7 +423,7 @@ class ScoringEngine {
|
|
|
412
423
|
const sig = decoded.sig;
|
|
413
424
|
delete decoded.sig;
|
|
414
425
|
|
|
415
|
-
const payload =
|
|
426
|
+
const payload = canonicalTokenPayload(decoded);
|
|
416
427
|
const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
417
428
|
|
|
418
429
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
@@ -505,17 +516,21 @@ class ScoringEngine {
|
|
|
505
516
|
return detections;
|
|
506
517
|
}
|
|
507
518
|
|
|
508
|
-
_generateToken(ip, siteKey, score) {
|
|
519
|
+
_generateToken(ip, siteKey, score, binding = {}) {
|
|
509
520
|
const ipHash = ipBinding(this.secret, ip);
|
|
510
521
|
const data = {
|
|
522
|
+
v: 2,
|
|
511
523
|
site_key: siteKey,
|
|
512
524
|
jti: crypto.randomBytes(16).toString('hex'),
|
|
513
525
|
timestamp: Math.floor(Date.now() / 1000),
|
|
514
526
|
score: Math.round(score * 1000) / 1000,
|
|
515
|
-
ip_hash: ipHash
|
|
527
|
+
ip_hash: ipHash,
|
|
528
|
+
hostname: typeof binding.hostname === 'string' ? binding.hostname : '',
|
|
529
|
+
action: sanitizeAction(binding.action),
|
|
530
|
+
cdata: sanitizeCdata(binding.cdata)
|
|
516
531
|
};
|
|
517
532
|
|
|
518
|
-
const payload =
|
|
533
|
+
const payload = canonicalTokenPayload(data);
|
|
519
534
|
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
520
535
|
data.sig = sig;
|
|
521
536
|
|
|
@@ -540,7 +555,7 @@ function createMiddleware(options = {}) {
|
|
|
540
555
|
|
|
541
556
|
// Middleware to extract IP from request. Forwarding headers are honoured
|
|
542
557
|
// only from a peer in the trusted-proxy set (TRUSTED_PROXIES, or the
|
|
543
|
-
//
|
|
558
|
+
// loopback-only default) — otherwise any caller could claim a clean
|
|
544
559
|
// residential IP and skip the datacenter, Tor/VPN and rate-limit checks.
|
|
545
560
|
// Pass options.trustedProxies to override the env for this instance.
|
|
546
561
|
getIP: (req) => proxyTrust.clientIP(req),
|
|
@@ -565,7 +580,7 @@ function createMiddleware(options = {}) {
|
|
|
565
580
|
|
|
566
581
|
// Verify route handler
|
|
567
582
|
verifyHandler: (req, res) => {
|
|
568
|
-
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
583
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
569
584
|
const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
|
|
570
585
|
const siteKey = siteKeys.normalize(rawSiteKey || 'default', ip);
|
|
571
586
|
const userAgent = req.headers['user-agent'] || '';
|
|
@@ -575,13 +590,21 @@ function createMiddleware(options = {}) {
|
|
|
575
590
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
576
591
|
}
|
|
577
592
|
|
|
578
|
-
const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming
|
|
593
|
+
const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming, {
|
|
594
|
+
hostname: requestHostname(headers),
|
|
595
|
+
action: sanitizeAction(action),
|
|
596
|
+
cdata: sanitizeCdata(cdata)
|
|
597
|
+
});
|
|
579
598
|
res.json(result);
|
|
580
599
|
},
|
|
581
600
|
|
|
582
601
|
// Token verify route handler
|
|
583
602
|
tokenVerifyHandler: (req, res) => {
|
|
584
|
-
const { token, remoteip } = req.body;
|
|
603
|
+
const { token, secret, remoteip } = req.body || {};
|
|
604
|
+
if (!secret) return res.status(401).json({ valid: false, reason: 'missing_secret' });
|
|
605
|
+
if (!secretMatches(secret, engine.verifySecret)) {
|
|
606
|
+
return res.status(401).json({ valid: false, reason: 'invalid_secret' });
|
|
607
|
+
}
|
|
585
608
|
res.json(engine.verifyToken(token, typeof remoteip === 'string' ? remoteip : null));
|
|
586
609
|
}
|
|
587
610
|
};
|
|
@@ -597,6 +620,7 @@ module.exports = {
|
|
|
597
620
|
PoWChallengeStore,
|
|
598
621
|
RateLimiter,
|
|
599
622
|
FingerprintStore,
|
|
623
|
+
TokenStore,
|
|
600
624
|
|
|
601
625
|
// Detection module (re-export)
|
|
602
626
|
detection,
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -20,6 +20,7 @@ const { detectInputForensics } = require('./inputforensics');
|
|
|
20
20
|
const { RedisState } = require('./redis-state');
|
|
21
21
|
const { FingerprintStore } = require('./fingerprint-store');
|
|
22
22
|
const { TokenStore } = require('./token-store');
|
|
23
|
+
const { canonicalTokenPayload } = require('./token-canonical');
|
|
23
24
|
const { resolveSignals, isObject, invalidRequest } = require('./protocol');
|
|
24
25
|
const {
|
|
25
26
|
HostnameAllowlist,
|
|
@@ -457,13 +458,12 @@ async function detectRateAbuse(ip, siteKey) {
|
|
|
457
458
|
// endpoint or presented from a site that lifted the key — the backend compares
|
|
458
459
|
// what siteverify reports against what it expected.
|
|
459
460
|
//
|
|
460
|
-
// Empty strings rather than omitted keys
|
|
461
|
-
//
|
|
462
|
-
// happened to send would be a second payload shape to keep in sync across three
|
|
463
|
-
// languages. Fixed shape, empty when unknown.
|
|
461
|
+
// Empty strings rather than omitted keys keep one fixed signed claim set across
|
|
462
|
+
// all three runtimes.
|
|
464
463
|
function generateToken(ip, siteKey, score, binding = {}) {
|
|
465
464
|
const ipHash = ipBinding(SECRET_KEY, ip);
|
|
466
465
|
const data = {
|
|
466
|
+
v: 2,
|
|
467
467
|
site_key: siteKey,
|
|
468
468
|
jti: crypto.randomBytes(16).toString('hex'),
|
|
469
469
|
timestamp: Math.floor(Date.now() / 1000),
|
|
@@ -474,7 +474,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
|
|
|
474
474
|
cdata: binding.cdata || ''
|
|
475
475
|
};
|
|
476
476
|
|
|
477
|
-
const payload =
|
|
477
|
+
const payload = canonicalTokenPayload(data);
|
|
478
478
|
data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
479
479
|
|
|
480
480
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
@@ -492,7 +492,7 @@ async function verifyToken(token, ip = null) {
|
|
|
492
492
|
const sig = decoded.sig;
|
|
493
493
|
delete decoded.sig;
|
|
494
494
|
|
|
495
|
-
const payload =
|
|
495
|
+
const payload = canonicalTokenPayload(decoded);
|
|
496
496
|
const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
|
|
497
497
|
|
|
498
498
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function encodedString(value, field) {
|
|
4
|
+
if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
|
|
5
|
+
return Buffer.from(value, 'utf8').toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Version 2 signs a language-neutral, length-safe representation instead of
|
|
10
|
+
* reserializing JSON. Native JSON encoders disagree on integral floats and on
|
|
11
|
+
* escaping Unicode/HTML-sensitive strings, which broke mixed-runtime pools.
|
|
12
|
+
* Tokens without v=2 retain the legacy canonicalization for a rolling upgrade.
|
|
13
|
+
*/
|
|
14
|
+
function canonicalTokenPayload(data) {
|
|
15
|
+
if (data.v !== 2) return JSON.stringify(data, Object.keys(data).sort());
|
|
16
|
+
if (!Number.isSafeInteger(data.timestamp)) throw new TypeError('timestamp must be an integer');
|
|
17
|
+
if (typeof data.score !== 'number' || !Number.isFinite(data.score)) throw new TypeError('score must be finite');
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
'fcaptcha-token-v2',
|
|
21
|
+
encodedString(data.site_key, 'site_key'),
|
|
22
|
+
encodedString(data.jti, 'jti'),
|
|
23
|
+
String(data.timestamp),
|
|
24
|
+
data.score.toFixed(3),
|
|
25
|
+
encodedString(data.ip_hash, 'ip_hash'),
|
|
26
|
+
encodedString(data.hostname, 'hostname'),
|
|
27
|
+
encodedString(data.action, 'action'),
|
|
28
|
+
encodedString(data.cdata, 'cdata')
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { canonicalTokenPayload };
|