@webdecoy/fcaptcha 1.32.0 → 1.33.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/package.json +2 -2
- package/redis-state.js +173 -0
- package/server.js +141 -58
- package/siteverify.js +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.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 index.test.js && node --test suspicion.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",
|
|
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/redis-state.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { createClient } = require('redis');
|
|
5
|
+
|
|
6
|
+
const PREFIX = 'fcaptcha:v1:';
|
|
7
|
+
const POW_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
const SPENT_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
const DETECTION_TTL_MS = 15 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
const CLAIM_CHALLENGE = `
|
|
13
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
|
14
|
+
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) == false then return -1 end
|
|
15
|
+
redis.call('DEL', KEYS[1])
|
|
16
|
+
return 1
|
|
17
|
+
`;
|
|
18
|
+
const RATE_CHECK = `
|
|
19
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
|
|
20
|
+
local count = redis.call('ZCARD', KEYS[1])
|
|
21
|
+
local added = 0
|
|
22
|
+
if count < tonumber(ARGV[3]) then
|
|
23
|
+
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
|
|
24
|
+
count = count + 1
|
|
25
|
+
added = 1
|
|
26
|
+
end
|
|
27
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[5])
|
|
28
|
+
return {count, added}
|
|
29
|
+
`;
|
|
30
|
+
const SITEKEY_CLAIM = `
|
|
31
|
+
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
|
|
32
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
|
33
|
+
return 1
|
|
34
|
+
end
|
|
35
|
+
if redis.call('SCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
|
|
36
|
+
redis.call('SADD', KEYS[1], ARGV[1])
|
|
37
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
|
38
|
+
return 1
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
class RedisState {
|
|
42
|
+
constructor(url, client = null) {
|
|
43
|
+
this.client = client || createClient({ url });
|
|
44
|
+
this.client.on?.('error', (err) => console.error('[redis]', err.message));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async connect() {
|
|
48
|
+
if (!this.client.isOpen) await this.client.connect();
|
|
49
|
+
await this.client.ping();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
challengeKey(id) {
|
|
53
|
+
return `${PREFIX}pow:challenge:${id}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
opaqueKey(kind, value) {
|
|
57
|
+
const digest = crypto.createHash('sha256').update(value).digest('hex');
|
|
58
|
+
return `${PREFIX}${kind}:${digest}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async putChallenge(challenge) {
|
|
62
|
+
const ttl = challenge.expiresAt - Date.now();
|
|
63
|
+
if (ttl <= 0) throw new Error('challenge already expired');
|
|
64
|
+
// challengeId is the canonical cross-runtime field used by Go and Node.
|
|
65
|
+
const stored = { ...challenge, challengeId: challenge.id };
|
|
66
|
+
delete stored.id;
|
|
67
|
+
await this.client.set(this.challengeKey(challenge.id), JSON.stringify(stored), { PX: ttl });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getChallenge(id) {
|
|
71
|
+
const payload = await this.client.get(this.challengeKey(id));
|
|
72
|
+
if (!payload) return null;
|
|
73
|
+
const challenge = JSON.parse(payload);
|
|
74
|
+
return { ...challenge, id: challenge.challengeId };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async claimChallenge(challengeId, solutionKey) {
|
|
78
|
+
const result = Number(await this.client.eval(CLAIM_CHALLENGE, {
|
|
79
|
+
keys: [this.challengeKey(challengeId), `${PREFIX}pow:spent:${solutionKey}`],
|
|
80
|
+
arguments: [String(SPENT_TTL_MS)]
|
|
81
|
+
}));
|
|
82
|
+
if (result === 1) return { claimed: true };
|
|
83
|
+
return {
|
|
84
|
+
claimed: false,
|
|
85
|
+
reason: result === -1 ? 'solution_already_used' : 'challenge_not_found'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async claimToken(signature) {
|
|
90
|
+
return Boolean(await this.client.set(
|
|
91
|
+
`${PREFIX}token:spent:${signature}`,
|
|
92
|
+
'1',
|
|
93
|
+
{ NX: true, PX: SPENT_TTL_MS }
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
idempotencyKey(idempotencyKey, token) {
|
|
98
|
+
const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
|
|
99
|
+
const composite = `${idempotencyKey}:${tokenHash}`;
|
|
100
|
+
const opaque = crypto.createHash('sha256').update(composite).digest('hex');
|
|
101
|
+
return `${PREFIX}siteverify:idempotency:${opaque}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getIdempotency(idempotencyKey, token) {
|
|
105
|
+
if (!idempotencyKey) return null;
|
|
106
|
+
const payload = await this.client.get(this.idempotencyKey(idempotencyKey, token));
|
|
107
|
+
return payload ? JSON.parse(payload) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async setIdempotency(idempotencyKey, token, response) {
|
|
111
|
+
if (!idempotencyKey) return;
|
|
112
|
+
await this.client.set(
|
|
113
|
+
this.idempotencyKey(idempotencyKey, token),
|
|
114
|
+
JSON.stringify(response),
|
|
115
|
+
{ PX: IDEMPOTENCY_TTL_MS }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async rateCheck(key, windowSeconds, maxRequests) {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
const result = await this.client.eval(RATE_CHECK, {
|
|
122
|
+
keys: [this.opaqueKey('rate', key)],
|
|
123
|
+
arguments: [
|
|
124
|
+
String(now - windowSeconds * 1000), String(now), String(maxRequests),
|
|
125
|
+
`${now}:${crypto.randomBytes(8).toString('hex')}`, String(windowSeconds * 1000 + 1000)
|
|
126
|
+
]
|
|
127
|
+
});
|
|
128
|
+
return [Number(result[1]) === 0, Number(result[0])];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async recordSuspicion(siteKey, ip) {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
134
|
+
await this.client.multi()
|
|
135
|
+
.zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
|
|
136
|
+
.zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
|
|
137
|
+
.zRemRangeByRank(key, 0, -17)
|
|
138
|
+
.pExpire(key, DETECTION_TTL_MS)
|
|
139
|
+
.exec();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async suspicionCount(siteKey, ip) {
|
|
143
|
+
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
144
|
+
await this.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS);
|
|
145
|
+
return Number(await this.client.zCard(key));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async recordFingerprint(fingerprint, ip, siteKey) {
|
|
149
|
+
const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
|
|
150
|
+
const ipKey = this.opaqueKey('fingerprint:fps', ip);
|
|
151
|
+
await this.client.multi()
|
|
152
|
+
.sAdd(fpKey, this.opaqueKey('value:ip', ip)).pExpire(fpKey, DETECTION_TTL_MS)
|
|
153
|
+
.sAdd(ipKey, this.opaqueKey('value:fp', fingerprint)).pExpire(ipKey, DETECTION_TTL_MS)
|
|
154
|
+
.exec();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async ipFingerprintCount(ip) {
|
|
158
|
+
return Number(await this.client.sCard(this.opaqueKey('fingerprint:fps', ip)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async fingerprintIpCount(fingerprint, siteKey) {
|
|
162
|
+
return Number(await this.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async claimSiteKey(siteKey, ip, maxPerIp) {
|
|
166
|
+
return Number(await this.client.eval(SITEKEY_CLAIM, {
|
|
167
|
+
keys: [this.opaqueKey('sitekeys', ip)],
|
|
168
|
+
arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
|
|
169
|
+
})) === 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS, IDEMPOTENCY_TTL_MS };
|
package/server.js
CHANGED
|
@@ -15,6 +15,7 @@ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
|
15
15
|
const { signingSecretFromEnv } = require('./config');
|
|
16
16
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
17
17
|
const { detectInputForensics } = require('./inputforensics');
|
|
18
|
+
const { RedisState } = require('./redis-state');
|
|
18
19
|
const {
|
|
19
20
|
HostnameAllowlist,
|
|
20
21
|
IdempotencyStore,
|
|
@@ -22,7 +23,7 @@ const {
|
|
|
22
23
|
sanitizeAction,
|
|
23
24
|
sanitizeCdata,
|
|
24
25
|
secretMatches,
|
|
25
|
-
|
|
26
|
+
siteverifyAsync
|
|
26
27
|
} = require('./siteverify');
|
|
27
28
|
|
|
28
29
|
const app = express();
|
|
@@ -37,6 +38,8 @@ app.use(express.json({ limit: MAX_REQUEST_BODY_BYTES }));
|
|
|
37
38
|
app.use(express.urlencoded({ extended: false, limit: MAX_REQUEST_BODY_BYTES }));
|
|
38
39
|
|
|
39
40
|
const SECRET_KEY = signingSecretFromEnv();
|
|
41
|
+
const REDIS_URL = process.env.REDIS_URL || '';
|
|
42
|
+
const SHARED_STATE = REDIS_URL ? new RedisState(REDIS_URL) : null;
|
|
40
43
|
|
|
41
44
|
// The credential a backend presents to validate a token. Defaults to the signing
|
|
42
45
|
// key, which is what the README has always documented, but can be separated:
|
|
@@ -58,7 +61,12 @@ const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test(
|
|
|
58
61
|
const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv();
|
|
59
62
|
|
|
60
63
|
// Lets a caller retry a validation that timed out without burning the token.
|
|
61
|
-
const IDEMPOTENCY =
|
|
64
|
+
const IDEMPOTENCY = SHARED_STATE
|
|
65
|
+
? {
|
|
66
|
+
get: (key, token) => SHARED_STATE.getIdempotency(key, token),
|
|
67
|
+
set: (key, token, response) => SHARED_STATE.setIdempotency(key, token, response)
|
|
68
|
+
}
|
|
69
|
+
: new IdempotencyStore();
|
|
62
70
|
|
|
63
71
|
const PORT = process.env.PORT || 3000;
|
|
64
72
|
const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
|
|
@@ -73,9 +81,32 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
|
|
|
73
81
|
// limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
|
|
74
82
|
const SITE_KEYS = SiteKeyGuard.fromEnv();
|
|
75
83
|
|
|
84
|
+
async function normalizeSiteKey(siteKey, ip) {
|
|
85
|
+
const key = siteKey || 'default';
|
|
86
|
+
if (SITE_KEYS.allowlist && !SITE_KEYS.allowlist.has(key)) return ' overflow';
|
|
87
|
+
if (!SHARED_STATE || !ip) return SITE_KEYS.normalize(key, ip);
|
|
88
|
+
try {
|
|
89
|
+
return await SHARED_STATE.claimSiteKey(key, ip, SITE_KEYS.maxPerIp) ? key : ' overflow';
|
|
90
|
+
} catch (_) {
|
|
91
|
+
return ' overflow';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
// Recent strong verdicts per source, used to price the next challenge that
|
|
77
96
|
// source asks for. Bounded and short-lived; see suspicion.js.
|
|
78
|
-
const
|
|
97
|
+
const localSuspicionLedger = new SuspicionLedger();
|
|
98
|
+
const suspicionLedger = {
|
|
99
|
+
async record(siteKey, ip, score) {
|
|
100
|
+
if (!SHARED_STATE) return localSuspicionLedger.record(siteKey, ip, score);
|
|
101
|
+
if (score < 0.8 || !ip) return;
|
|
102
|
+
try { await SHARED_STATE.recordSuspicion(siteKey, ip); } catch (_) { /* fail closed on reads */ }
|
|
103
|
+
},
|
|
104
|
+
async count(siteKey, ip) {
|
|
105
|
+
if (!SHARED_STATE) return localSuspicionLedger.count(siteKey, ip);
|
|
106
|
+
if (!ip) return 0;
|
|
107
|
+
try { return await SHARED_STATE.suspicionCount(siteKey, ip); } catch (_) { return 16; }
|
|
108
|
+
}
|
|
109
|
+
};
|
|
79
110
|
|
|
80
111
|
// Express's own `trust proxy` would re-derive req.ip from the same headers on
|
|
81
112
|
// its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
|
|
@@ -142,7 +173,7 @@ const powChallengeStore = {
|
|
|
142
173
|
usedSolutions: new BoundedSet(),
|
|
143
174
|
|
|
144
175
|
// Generate a new challenge
|
|
145
|
-
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
176
|
+
async generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
146
177
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
147
178
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
148
179
|
const timestamp = Date.now();
|
|
@@ -167,12 +198,16 @@ const powChallengeStore = {
|
|
|
167
198
|
.update(JSON.stringify(challengeData))
|
|
168
199
|
.digest('hex');
|
|
169
200
|
|
|
170
|
-
|
|
171
|
-
this.challenges.set(challengeId, {
|
|
201
|
+
const storedChallenge = {
|
|
172
202
|
...challengeData,
|
|
173
203
|
ip,
|
|
174
204
|
createdAt: timestamp
|
|
175
|
-
}
|
|
205
|
+
};
|
|
206
|
+
if (SHARED_STATE) {
|
|
207
|
+
await SHARED_STATE.putChallenge(storedChallenge);
|
|
208
|
+
} else {
|
|
209
|
+
this.challenges.set(challengeId, storedChallenge);
|
|
210
|
+
}
|
|
176
211
|
|
|
177
212
|
// Cleanup old challenges periodically
|
|
178
213
|
if (Math.random() < 0.1) this._cleanup();
|
|
@@ -181,15 +216,22 @@ const powChallengeStore = {
|
|
|
181
216
|
},
|
|
182
217
|
|
|
183
218
|
// Verify a PoW solution (signalsHash is optional for backward compat)
|
|
184
|
-
verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
|
|
185
|
-
|
|
219
|
+
async verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
|
|
220
|
+
let challenge;
|
|
221
|
+
try {
|
|
222
|
+
challenge = SHARED_STATE
|
|
223
|
+
? await SHARED_STATE.getChallenge(challengeId)
|
|
224
|
+
: this.challenges.get(challengeId);
|
|
225
|
+
} catch (_) {
|
|
226
|
+
return { valid: false, reason: 'state_unavailable' };
|
|
227
|
+
}
|
|
186
228
|
|
|
187
229
|
if (!challenge) {
|
|
188
230
|
return { valid: false, reason: 'challenge_not_found' };
|
|
189
231
|
}
|
|
190
232
|
|
|
191
233
|
if (Date.now() > challenge.expiresAt) {
|
|
192
|
-
this.challenges.delete(challengeId);
|
|
234
|
+
if (!SHARED_STATE) this.challenges.delete(challengeId);
|
|
193
235
|
return { valid: false, reason: 'challenge_expired' };
|
|
194
236
|
}
|
|
195
237
|
|
|
@@ -223,11 +265,22 @@ const powChallengeStore = {
|
|
|
223
265
|
return { valid: false, reason: 'insufficient_difficulty' };
|
|
224
266
|
}
|
|
225
267
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
268
|
+
if (SHARED_STATE) {
|
|
269
|
+
try {
|
|
270
|
+
const claim = await SHARED_STATE.claimChallenge(challengeId, solutionKey);
|
|
271
|
+
if (!claim.claimed) return { valid: false, reason: claim.reason };
|
|
272
|
+
} catch (_) {
|
|
273
|
+
return { valid: false, reason: 'state_unavailable' };
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
// The local store is synchronous, so this test-and-set is atomic within
|
|
277
|
+
// one event-loop turn.
|
|
278
|
+
if (this.usedSolutions.has(solutionKey)) {
|
|
279
|
+
return { valid: false, reason: 'solution_already_used' };
|
|
280
|
+
}
|
|
281
|
+
this.usedSolutions.add(solutionKey);
|
|
282
|
+
this.challenges.delete(challengeId);
|
|
283
|
+
}
|
|
231
284
|
|
|
232
285
|
// Calculate server-side elapsed time (un-spoofable)
|
|
233
286
|
const serverElapsed = Date.now() - challenge.createdAt;
|
|
@@ -254,7 +307,11 @@ const powChallengeStore = {
|
|
|
254
307
|
const rateLimiter = {
|
|
255
308
|
requests: new BoundedMap(),
|
|
256
309
|
|
|
257
|
-
check(key, windowSeconds = 60, maxRequests = 10) {
|
|
310
|
+
async check(key, windowSeconds = 60, maxRequests = 10) {
|
|
311
|
+
if (SHARED_STATE) {
|
|
312
|
+
try { return await SHARED_STATE.rateCheck(key, windowSeconds, maxRequests); }
|
|
313
|
+
catch (_) { return [true, maxRequests]; }
|
|
314
|
+
}
|
|
258
315
|
const now = Date.now();
|
|
259
316
|
const cutoff = now - (windowSeconds * 1000);
|
|
260
317
|
|
|
@@ -276,7 +333,11 @@ const fingerprintStore = {
|
|
|
276
333
|
fingerprints: new BoundedMap(),
|
|
277
334
|
ipFingerprints: new BoundedMap(),
|
|
278
335
|
|
|
279
|
-
record(fp, ip, siteKey) {
|
|
336
|
+
async record(fp, ip, siteKey) {
|
|
337
|
+
if (SHARED_STATE) {
|
|
338
|
+
try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
280
341
|
const key = `${siteKey}:${fp}`;
|
|
281
342
|
|
|
282
343
|
if (!this.fingerprints.has(key)) {
|
|
@@ -292,11 +353,17 @@ const fingerprintStore = {
|
|
|
292
353
|
this.ipFingerprints.get(ip).add(fp);
|
|
293
354
|
},
|
|
294
355
|
|
|
295
|
-
getIpFpCount(ip) {
|
|
356
|
+
async getIpFpCount(ip) {
|
|
357
|
+
if (SHARED_STATE) {
|
|
358
|
+
try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
|
|
359
|
+
}
|
|
296
360
|
return this.ipFingerprints.get(ip)?.size || 0;
|
|
297
361
|
},
|
|
298
362
|
|
|
299
|
-
getFpIpCount(fp, siteKey) {
|
|
363
|
+
async getFpIpCount(fp, siteKey) {
|
|
364
|
+
if (SHARED_STATE) {
|
|
365
|
+
try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
|
|
366
|
+
}
|
|
300
367
|
const key = `${siteKey}:${fp}`;
|
|
301
368
|
return this.fingerprints.get(key)?.ips.size || 0;
|
|
302
369
|
}
|
|
@@ -351,7 +418,7 @@ const {
|
|
|
351
418
|
} = require('./engine');
|
|
352
419
|
|
|
353
420
|
// Stateful detectors stay here: they read the module-level stores below.
|
|
354
|
-
function detectFingerprint(signals, ip, siteKey) {
|
|
421
|
+
async function detectFingerprint(signals, ip, siteKey) {
|
|
355
422
|
const detections = [];
|
|
356
423
|
const env = signals.environmental || {};
|
|
357
424
|
const automation = env.automationFlags || {};
|
|
@@ -365,10 +432,10 @@ function detectFingerprint(signals, ip, siteKey) {
|
|
|
365
432
|
];
|
|
366
433
|
const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
|
|
367
434
|
|
|
368
|
-
fingerprintStore.record(fp, ip, siteKey);
|
|
435
|
+
await fingerprintStore.record(fp, ip, siteKey);
|
|
369
436
|
|
|
370
437
|
// IP fingerprint count
|
|
371
|
-
const ipFpCount = fingerprintStore.getIpFpCount(ip);
|
|
438
|
+
const ipFpCount = await fingerprintStore.getIpFpCount(ip);
|
|
372
439
|
if (ipFpCount > 5) {
|
|
373
440
|
detections.push({
|
|
374
441
|
category: 'fingerprint', score: 0.6, confidence: 0.6,
|
|
@@ -377,7 +444,7 @@ function detectFingerprint(signals, ip, siteKey) {
|
|
|
377
444
|
}
|
|
378
445
|
|
|
379
446
|
// Fingerprint IP count
|
|
380
|
-
const fpIpCount = fingerprintStore.getFpIpCount(fp, siteKey);
|
|
447
|
+
const fpIpCount = await fingerprintStore.getFpIpCount(fp, siteKey);
|
|
381
448
|
if (fpIpCount > 10) {
|
|
382
449
|
detections.push({
|
|
383
450
|
category: 'fingerprint', score: 0.5, confidence: 0.5,
|
|
@@ -397,11 +464,11 @@ function detectFingerprint(signals, ip, siteKey) {
|
|
|
397
464
|
return detections;
|
|
398
465
|
}
|
|
399
466
|
|
|
400
|
-
function detectRateAbuse(ip, siteKey) {
|
|
467
|
+
async function detectRateAbuse(ip, siteKey) {
|
|
401
468
|
const detections = [];
|
|
402
469
|
const key = `${siteKey}:${ip}`;
|
|
403
470
|
|
|
404
|
-
const [exceeded, count] = rateLimiter.check(key, 60, 10);
|
|
471
|
+
const [exceeded, count] = await rateLimiter.check(key, 60, 10);
|
|
405
472
|
if (exceeded) {
|
|
406
473
|
detections.push({
|
|
407
474
|
category: 'rate_limit', score: 0.8, confidence: 0.9,
|
|
@@ -446,7 +513,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
|
|
|
446
513
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
447
514
|
}
|
|
448
515
|
|
|
449
|
-
function verifyToken(token, ip = null) {
|
|
516
|
+
async function verifyToken(token, ip = null) {
|
|
450
517
|
try {
|
|
451
518
|
const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
|
|
452
519
|
|
|
@@ -465,11 +532,6 @@ function verifyToken(token, ip = null) {
|
|
|
465
532
|
return { valid: false, reason: 'invalid_signature' };
|
|
466
533
|
}
|
|
467
534
|
|
|
468
|
-
// Check for token replay (single-use tokens)
|
|
469
|
-
if (tokenStore.isUsed(sig)) {
|
|
470
|
-
return { valid: false, reason: 'token_already_used' };
|
|
471
|
-
}
|
|
472
|
-
|
|
473
535
|
// Verify IP matches (if provided)
|
|
474
536
|
if (ip) {
|
|
475
537
|
const expectedIpHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
@@ -478,8 +540,15 @@ function verifyToken(token, ip = null) {
|
|
|
478
540
|
}
|
|
479
541
|
}
|
|
480
542
|
|
|
481
|
-
|
|
482
|
-
|
|
543
|
+
let claimed;
|
|
544
|
+
try {
|
|
545
|
+
claimed = SHARED_STATE
|
|
546
|
+
? await SHARED_STATE.claimToken(sig)
|
|
547
|
+
: tokenStore.markUsed(sig);
|
|
548
|
+
} catch (_) {
|
|
549
|
+
return { valid: false, reason: 'state_unavailable' };
|
|
550
|
+
}
|
|
551
|
+
if (!claimed) return { valid: false, reason: 'token_already_used' };
|
|
483
552
|
|
|
484
553
|
// hostname/action/cdata default to '' so a token minted before they existed
|
|
485
554
|
// still verifies and reports the same shape. The signature covers whatever
|
|
@@ -495,8 +564,8 @@ function verifyToken(token, ip = null) {
|
|
|
495
564
|
action: decoded.action || '',
|
|
496
565
|
cdata: decoded.cdata || ''
|
|
497
566
|
};
|
|
498
|
-
} catch (
|
|
499
|
-
return { valid: false, reason:
|
|
567
|
+
} catch (_) {
|
|
568
|
+
return { valid: false, reason: 'invalid_token' };
|
|
500
569
|
}
|
|
501
570
|
}
|
|
502
571
|
|
|
@@ -516,7 +585,7 @@ async function verifyWebBotAuth(req) {
|
|
|
516
585
|
// require the raw request — currently Web Bot Auth signature verification, which
|
|
517
586
|
// needs the accurately-reconstructed signed request. They are seeded into the
|
|
518
587
|
// detection set so they participate in scoring like any other detection.
|
|
519
|
-
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
|
|
588
|
+
async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
|
|
520
589
|
const detections = Array.isArray(preDetections) ? [...preDetections] : [];
|
|
521
590
|
|
|
522
591
|
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
@@ -567,7 +636,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
567
636
|
let powSatisfied = false;
|
|
568
637
|
let powVerification = null;
|
|
569
638
|
if (powSolution && powSolution.challengeId) {
|
|
570
|
-
powVerification = powChallengeStore.verify(
|
|
639
|
+
powVerification = await powChallengeStore.verify(
|
|
571
640
|
powSolution.challengeId,
|
|
572
641
|
powSolution.nonce,
|
|
573
642
|
powSolution.hash,
|
|
@@ -658,10 +727,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
658
727
|
...detectInputForensics(signals),
|
|
659
728
|
...detectTouchAuthenticity(signals, userAgent),
|
|
660
729
|
...detectSensorEntropy(signals, userAgent),
|
|
661
|
-
...detectTouchKinematics(signals)
|
|
662
|
-
...detectFingerprint(signals, ip, siteKey),
|
|
663
|
-
...detectRateAbuse(ip, siteKey)
|
|
730
|
+
...detectTouchKinematics(signals)
|
|
664
731
|
);
|
|
732
|
+
detections.push(...await detectFingerprint(signals, ip, siteKey));
|
|
733
|
+
detections.push(...await detectRateAbuse(ip, siteKey));
|
|
665
734
|
|
|
666
735
|
// Add IP reputation check (async but we'll use sync version for simplicity)
|
|
667
736
|
if (detection.isDatacenterIP(ip)) {
|
|
@@ -763,7 +832,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
763
832
|
|
|
764
833
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
765
834
|
// what it just did.
|
|
766
|
-
suspicionLedger.record(siteKey, ip, finalScore);
|
|
835
|
+
await suspicionLedger.record(siteKey, ip, finalScore);
|
|
767
836
|
|
|
768
837
|
return {
|
|
769
838
|
success,
|
|
@@ -805,7 +874,7 @@ app.post('/api/verify', async (req, res) => {
|
|
|
805
874
|
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
806
875
|
const ip = PROXY_TRUST.clientIP(req);
|
|
807
876
|
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
808
|
-
const siteKey =
|
|
877
|
+
const siteKey = await normalizeSiteKey(rawSiteKey, ip);
|
|
809
878
|
const userAgent = req.headers['user-agent'] || '';
|
|
810
879
|
// Only honoured from a trusted proxy: a client that can state its own TLS
|
|
811
880
|
// fingerprint would just claim a stock Chrome one.
|
|
@@ -818,7 +887,7 @@ app.post('/api/verify', async (req, res) => {
|
|
|
818
887
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
819
888
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
820
889
|
|
|
821
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
|
|
890
|
+
const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
|
|
822
891
|
logVerdict('verify', siteKey, result);
|
|
823
892
|
res.json(result);
|
|
824
893
|
});
|
|
@@ -826,7 +895,7 @@ app.post('/api/verify', async (req, res) => {
|
|
|
826
895
|
app.post('/api/score', async (req, res) => {
|
|
827
896
|
const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
|
|
828
897
|
const ip = PROXY_TRUST.clientIP(req);
|
|
829
|
-
const siteKey =
|
|
898
|
+
const siteKey = await normalizeSiteKey(rawSiteKey, ip);
|
|
830
899
|
const userAgent = req.headers['user-agent'] || '';
|
|
831
900
|
const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
|
|
832
901
|
|
|
@@ -840,7 +909,7 @@ app.post('/api/score', async (req, res) => {
|
|
|
840
909
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
841
910
|
|
|
842
911
|
// Invisible scoring: no widget, so no click analysis in the signals.
|
|
843
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: false });
|
|
912
|
+
const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: false });
|
|
844
913
|
logVerdict('score', siteKey, result);
|
|
845
914
|
res.json({
|
|
846
915
|
success: result.success,
|
|
@@ -857,7 +926,7 @@ app.post('/api/score', async (req, res) => {
|
|
|
857
926
|
});
|
|
858
927
|
});
|
|
859
928
|
|
|
860
|
-
app.post('/api/token/verify', (req, res) => {
|
|
929
|
+
app.post('/api/token/verify', async (req, res) => {
|
|
861
930
|
const { token, secret, remoteip } = req.body;
|
|
862
931
|
|
|
863
932
|
// The secret gate. This endpoint is the boundary between "a browser finished a
|
|
@@ -876,7 +945,7 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
876
945
|
// This request comes from the integrating backend, not from the visitor who
|
|
877
946
|
// received the token. Bind only when that trusted backend explicitly supplies
|
|
878
947
|
// the visitor address; using the caller socket here compares unrelated hosts.
|
|
879
|
-
res.json(verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
|
|
948
|
+
res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
|
|
880
949
|
});
|
|
881
950
|
|
|
882
951
|
// Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
|
|
@@ -885,9 +954,9 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
885
954
|
// plugins we want to be usable against FCaptcha: pointing an existing
|
|
886
955
|
// integration at this server should be a base-URL change and nothing else.
|
|
887
956
|
// See siteverify.js for the adapter itself.
|
|
888
|
-
function siteverifyHandler(req, res) {
|
|
957
|
+
async function siteverifyHandler(req, res) {
|
|
889
958
|
res.json(
|
|
890
|
-
|
|
959
|
+
await siteverifyAsync({
|
|
891
960
|
body: req.body,
|
|
892
961
|
// Bind to this server's token store, so replay state is shared with the
|
|
893
962
|
// native endpoint rather than kept in a parallel universe.
|
|
@@ -904,22 +973,27 @@ app.post('/recaptcha/api/siteverify', siteverifyHandler);
|
|
|
904
973
|
app.post('/siteverify', siteverifyHandler);
|
|
905
974
|
|
|
906
975
|
// PoW Challenge endpoint - client fetches this on page load
|
|
907
|
-
app.get('/api/pow/challenge', (req, res) => {
|
|
976
|
+
app.get('/api/pow/challenge', async (req, res) => {
|
|
908
977
|
const ip = PROXY_TRUST.clientIP(req);
|
|
909
|
-
const siteKey =
|
|
978
|
+
const siteKey = await normalizeSiteKey(req.query.siteKey, ip);
|
|
910
979
|
|
|
911
980
|
// Cost scaling. See suspicion.js for why the escalation lands almost
|
|
912
981
|
// entirely on minAgeMs rather than on difficulty.
|
|
913
982
|
const rateKey = `pow:${siteKey}:${ip}`;
|
|
914
|
-
const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
|
|
983
|
+
const [exceeded, count] = await rateLimiter.check(rateKey, 60, 20);
|
|
915
984
|
const cost = computeChallengeCost(
|
|
916
|
-
suspicionLedger.count(siteKey, ip),
|
|
985
|
+
await suspicionLedger.count(siteKey, ip),
|
|
917
986
|
detection.isDatacenterIP(ip),
|
|
918
987
|
count,
|
|
919
988
|
exceeded
|
|
920
989
|
);
|
|
921
990
|
|
|
922
|
-
|
|
991
|
+
let challenge;
|
|
992
|
+
try {
|
|
993
|
+
challenge = await powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
|
|
994
|
+
} catch (_) {
|
|
995
|
+
return res.status(503).json({ error: 'state_unavailable' });
|
|
996
|
+
}
|
|
923
997
|
|
|
924
998
|
res.json({
|
|
925
999
|
challengeId: challenge.id,
|
|
@@ -959,8 +1033,17 @@ app.use((err, req, res, next) => {
|
|
|
959
1033
|
// Start
|
|
960
1034
|
// =============================================================================
|
|
961
1035
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1036
|
+
async function start() {
|
|
1037
|
+
if (SHARED_STATE) await SHARED_STATE.connect();
|
|
1038
|
+
app.listen(PORT, () => {
|
|
1039
|
+
console.log(`FCaptcha server running on port ${PORT}`);
|
|
1040
|
+
console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
|
|
1041
|
+
console.log(`Site keys: ${SITE_KEYS.describe()}`);
|
|
1042
|
+
console.log(`Shared state: ${SHARED_STATE ? 'Redis (PoW)' : 'in-memory'}`);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
start().catch((err) => {
|
|
1047
|
+
console.error(`FCaptcha failed to start: ${err.message}`);
|
|
1048
|
+
process.exit(1);
|
|
966
1049
|
});
|
package/siteverify.js
CHANGED
|
@@ -314,6 +314,44 @@ function siteverify({
|
|
|
314
314
|
return out;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// Async form used by Redis-backed servers. Kept separate so the public
|
|
318
|
+
// synchronous adapter remains backward compatible for library callers.
|
|
319
|
+
async function siteverifyAsync({
|
|
320
|
+
body,
|
|
321
|
+
verifyToken,
|
|
322
|
+
expectedSecret,
|
|
323
|
+
idempotencyStore,
|
|
324
|
+
requireSecret = true,
|
|
325
|
+
}) {
|
|
326
|
+
const { secret, response, remoteip, idempotencyKey } = readParams(body);
|
|
327
|
+
if (requireSecret) {
|
|
328
|
+
if (!secret) return failure(ERROR_CODES.MISSING_SECRET);
|
|
329
|
+
if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET);
|
|
330
|
+
}
|
|
331
|
+
if (!response) return failure(ERROR_CODES.MISSING_RESPONSE);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const cached = idempotencyStore && await idempotencyStore.get(idempotencyKey, response);
|
|
335
|
+
if (cached) return cached;
|
|
336
|
+
const result = await verifyToken(response, remoteip || null);
|
|
337
|
+
const out = result && result.valid
|
|
338
|
+
? {
|
|
339
|
+
success: true,
|
|
340
|
+
challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(),
|
|
341
|
+
hostname: result.hostname || '',
|
|
342
|
+
action: result.action || '',
|
|
343
|
+
cdata: result.cdata || '',
|
|
344
|
+
'error-codes': [],
|
|
345
|
+
score: typeof result.score === 'number' ? result.score : null,
|
|
346
|
+
}
|
|
347
|
+
: failure(reasonToErrorCode(result && result.reason));
|
|
348
|
+
if (idempotencyStore) await idempotencyStore.set(idempotencyKey, response, out);
|
|
349
|
+
return out;
|
|
350
|
+
} catch {
|
|
351
|
+
return failure(ERROR_CODES.INTERNAL_ERROR);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
317
355
|
module.exports = {
|
|
318
356
|
ERROR_CODES,
|
|
319
357
|
HostnameAllowlist,
|
|
@@ -325,6 +363,7 @@ module.exports = {
|
|
|
325
363
|
sanitizeCdata,
|
|
326
364
|
secretMatches,
|
|
327
365
|
siteverify,
|
|
366
|
+
siteverifyAsync,
|
|
328
367
|
MAX_ACTION_LENGTH,
|
|
329
368
|
MAX_CDATA_LENGTH,
|
|
330
369
|
IDEMPOTENCY_TTL_SECONDS,
|