@webdecoy/fcaptcha 1.21.0 → 1.23.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 +3 -2
- package/server.js +173 -15
- package/siteverify.js +331 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.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,8 @@
|
|
|
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 --test suspicion.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",
|
|
19
|
+
"test:siteverify": "node siteverify.test.js",
|
|
19
20
|
"test:forensics": "node inputforensics.test.js",
|
|
20
21
|
"test:suspicion": "node --test suspicion.test.js"
|
|
21
22
|
},
|
package/server.js
CHANGED
|
@@ -14,12 +14,49 @@ const { ProxyTrust } = require('./clientip');
|
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
15
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
16
16
|
const { detectInputForensics } = require('./inputforensics');
|
|
17
|
+
const {
|
|
18
|
+
ERROR_CODES,
|
|
19
|
+
HostnameAllowlist,
|
|
20
|
+
IdempotencyStore,
|
|
21
|
+
requestHostname,
|
|
22
|
+
sanitizeAction,
|
|
23
|
+
sanitizeCdata,
|
|
24
|
+
secretMatches,
|
|
25
|
+
siteverify
|
|
26
|
+
} = require('./siteverify');
|
|
17
27
|
|
|
18
28
|
const app = express();
|
|
19
29
|
app.use(cors());
|
|
20
30
|
app.use(express.json());
|
|
31
|
+
// The siteverify contract accepts form-encoded bodies as well as JSON, and most
|
|
32
|
+
// PHP and Python integrations in the wild post form-encoded. Parsing both here
|
|
33
|
+
// costs nothing on the JSON path.
|
|
34
|
+
app.use(express.urlencoded({ extended: false }));
|
|
21
35
|
|
|
22
36
|
const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
|
|
37
|
+
|
|
38
|
+
// The credential a backend presents to validate a token. Defaults to the signing
|
|
39
|
+
// key, which is what the README has always documented, but can be separated:
|
|
40
|
+
// the signing key is a long-lived secret that must never leave the server, while
|
|
41
|
+
// this one is handed to every backend that verifies. Splitting them means a
|
|
42
|
+
// leaked verify credential does not let the holder mint tokens.
|
|
43
|
+
const VERIFY_SECRET = process.env.FCAPTCHA_VERIFY_SECRET || SECRET_KEY;
|
|
44
|
+
|
|
45
|
+
// Token verification used to accept anyone who could reach the endpoint: all
|
|
46
|
+
// three servers read `secret` out of the body and dropped it. Enforcing it is a
|
|
47
|
+
// breaking change for deployments that never sent one, so there is one release
|
|
48
|
+
// of escape hatch before the parameter becomes mandatory outright.
|
|
49
|
+
const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test(
|
|
50
|
+
process.env.FCAPTCHA_LEGACY_UNAUTH_VERIFY || ''
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Optional: restrict which page origins may mint tokens. Off by default so
|
|
54
|
+
// zero-config self-hosting keeps working. See siteverify.js.
|
|
55
|
+
const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv();
|
|
56
|
+
|
|
57
|
+
// Lets a caller retry a validation that timed out without burning the token.
|
|
58
|
+
const IDEMPOTENCY = new IdempotencyStore();
|
|
59
|
+
|
|
23
60
|
const PORT = process.env.PORT || 3000;
|
|
24
61
|
const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
|
|
25
62
|
|
|
@@ -1184,13 +1221,26 @@ function applyDispositiveFloor(score, detections) {
|
|
|
1184
1221
|
return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
|
|
1185
1222
|
}
|
|
1186
1223
|
|
|
1187
|
-
|
|
1224
|
+
// Bind the token to the page that minted it and to the action it was minted
|
|
1225
|
+
// for. All three ride inside the signed payload, so a token issued for
|
|
1226
|
+
// `action=login` on example.com cannot be replayed against a password-reset
|
|
1227
|
+
// endpoint or presented from a site that lifted the key — the backend compares
|
|
1228
|
+
// what siteverify reports against what it expected.
|
|
1229
|
+
//
|
|
1230
|
+
// Empty strings rather than omitted keys: the signing payload is a sorted-key
|
|
1231
|
+
// JSON serialisation, so a token whose key set varies with what the browser
|
|
1232
|
+
// happened to send would be a second payload shape to keep in sync across three
|
|
1233
|
+
// languages. Fixed shape, empty when unknown.
|
|
1234
|
+
function generateToken(ip, siteKey, score, binding = {}) {
|
|
1188
1235
|
const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
|
|
1189
1236
|
const data = {
|
|
1190
1237
|
site_key: siteKey,
|
|
1191
1238
|
timestamp: Math.floor(Date.now() / 1000),
|
|
1192
1239
|
score: Math.round(score * 1000) / 1000,
|
|
1193
|
-
ip_hash: ipHash
|
|
1240
|
+
ip_hash: ipHash,
|
|
1241
|
+
hostname: binding.hostname || '',
|
|
1242
|
+
action: binding.action || '',
|
|
1243
|
+
cdata: binding.cdata || ''
|
|
1194
1244
|
};
|
|
1195
1245
|
|
|
1196
1246
|
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
@@ -1234,12 +1284,19 @@ function verifyToken(token, ip = null) {
|
|
|
1234
1284
|
// Mark token as used (prevents replay)
|
|
1235
1285
|
tokenStore.markUsed(sig);
|
|
1236
1286
|
|
|
1287
|
+
// hostname/action/cdata default to '' so a token minted before they existed
|
|
1288
|
+
// still verifies and reports the same shape. The signature covers whatever
|
|
1289
|
+
// keys the token actually carries, so old four-key tokens validate
|
|
1290
|
+
// unchanged — this is additive, not a format break.
|
|
1237
1291
|
return {
|
|
1238
1292
|
valid: true,
|
|
1239
1293
|
site_key: decoded.site_key,
|
|
1240
1294
|
timestamp: decoded.timestamp,
|
|
1241
1295
|
score: decoded.score,
|
|
1242
|
-
ip_hash: decoded.ip_hash
|
|
1296
|
+
ip_hash: decoded.ip_hash,
|
|
1297
|
+
hostname: decoded.hostname || '',
|
|
1298
|
+
action: decoded.action || '',
|
|
1299
|
+
cdata: decoded.cdata || ''
|
|
1243
1300
|
};
|
|
1244
1301
|
} catch (e) {
|
|
1245
1302
|
return { valid: false, reason: e.message };
|
|
@@ -1293,6 +1350,19 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1293
1350
|
|
|
1294
1351
|
// Verify PoW if provided
|
|
1295
1352
|
let powValid = false;
|
|
1353
|
+
// powSatisfied records whether the caller actually completed the challenge
|
|
1354
|
+
// this server issued. It gates token issuance below rather than only feeding
|
|
1355
|
+
// the score, because a proof of work is a precondition, not evidence: the
|
|
1356
|
+
// widget solves one on every path and aborts rather than submit without it,
|
|
1357
|
+
// so a request that arrives without a valid solution did not come from the
|
|
1358
|
+
// widget at all.
|
|
1359
|
+
//
|
|
1360
|
+
// Scoring it alone was not enough. The final score is a weighted sum, so the
|
|
1361
|
+
// bot category contributes at most its 0.13 weight — every PoW failure firing
|
|
1362
|
+
// at once reached 0.1298 against a 0.5 threshold, and a bare `curl` with no
|
|
1363
|
+
// solution and no signals was issued a valid token. The detections were all
|
|
1364
|
+
// correct; the aggregation discarded them.
|
|
1365
|
+
let powSatisfied = false;
|
|
1296
1366
|
let powVerification = null;
|
|
1297
1367
|
if (powSolution && powSolution.challengeId) {
|
|
1298
1368
|
powVerification = powChallengeStore.verify(
|
|
@@ -1309,19 +1379,30 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1309
1379
|
category: 'bot',
|
|
1310
1380
|
score: 0.7,
|
|
1311
1381
|
confidence: 0.8,
|
|
1312
|
-
reason: `PoW verification failed: ${powVerification.reason}
|
|
1382
|
+
reason: `PoW verification failed: ${powVerification.reason}`,
|
|
1383
|
+
// A solution that does not verify against a challenge this server
|
|
1384
|
+
// issued is not weak evidence of automation, it is proof the challenge
|
|
1385
|
+
// was not completed. See DISPOSITIVE_FLOOR.
|
|
1386
|
+
dispositive: true
|
|
1313
1387
|
});
|
|
1388
|
+
} else {
|
|
1389
|
+
powSatisfied = true;
|
|
1314
1390
|
}
|
|
1315
1391
|
|
|
1316
1392
|
// Verify challenge nonce binding
|
|
1317
1393
|
if (powValid && powVerification.nonce) {
|
|
1318
1394
|
const clientNonce = signals.meta?.challengeNonce;
|
|
1319
1395
|
if (!clientNonce || clientNonce !== powVerification.nonce) {
|
|
1396
|
+
// The solution verifies but the signals it commits to are not the ones
|
|
1397
|
+
// presented, so the work was done for a different payload. Revokes the
|
|
1398
|
+
// pass granted above.
|
|
1399
|
+
powSatisfied = false;
|
|
1320
1400
|
detections.push({
|
|
1321
1401
|
category: 'bot',
|
|
1322
1402
|
score: 0.9,
|
|
1323
1403
|
confidence: 0.9,
|
|
1324
|
-
reason: 'Challenge nonce mismatch (signals not bound to challenge)'
|
|
1404
|
+
reason: 'Challenge nonce mismatch (signals not bound to challenge)',
|
|
1405
|
+
dispositive: true
|
|
1325
1406
|
});
|
|
1326
1407
|
}
|
|
1327
1408
|
}
|
|
@@ -1353,7 +1434,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1353
1434
|
category: 'bot',
|
|
1354
1435
|
score: 0.9,
|
|
1355
1436
|
confidence: 0.95,
|
|
1356
|
-
reason: 'No PoW solution provided'
|
|
1437
|
+
reason: 'No PoW solution provided',
|
|
1438
|
+
dispositive: true
|
|
1357
1439
|
});
|
|
1358
1440
|
}
|
|
1359
1441
|
|
|
@@ -1433,8 +1515,42 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1433
1515
|
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
1434
1516
|
else recommendation = 'block';
|
|
1435
1517
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1518
|
+
// The hostname comes from the request headers rather than the request body:
|
|
1519
|
+
// it is what the browser reported about the page, not what the page claimed
|
|
1520
|
+
// about itself.
|
|
1521
|
+
const hostname = requestHostname(headers);
|
|
1522
|
+
|
|
1523
|
+
// An unlisted hostname withholds the token but does not touch the score. The
|
|
1524
|
+
// visitor is not the problem — a key registered to another site is — so the
|
|
1525
|
+
// detection layer has nothing to say about it and the refusal is reported as
|
|
1526
|
+
// its own reason rather than smuggled in as a bot verdict.
|
|
1527
|
+
const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
|
|
1528
|
+
|
|
1529
|
+
// Three independent conditions, deliberately not folded into the score.
|
|
1530
|
+
//
|
|
1531
|
+
// powSatisfied is the one that matters most: a score threshold answers "how
|
|
1532
|
+
// suspicious is this visitor", which is the wrong question to ask of someone
|
|
1533
|
+
// who never completed the challenge. Gating here means no future reweighting
|
|
1534
|
+
// can reopen the bypass, and it holds even if the dispositive floor is
|
|
1535
|
+
// lowered or removed.
|
|
1536
|
+
const success = finalScore < 0.5 && hostnameAllowed && powSatisfied;
|
|
1537
|
+
|
|
1538
|
+
// Name the failed precondition whenever one fails, not only when the score
|
|
1539
|
+
// would otherwise have allowed. Gating it on the score made the PoW case
|
|
1540
|
+
// unreachable — a PoW failure is dispositive, so it floors the score at 0.9
|
|
1541
|
+
// and the branch never fired — which is exactly the case a caller most needs
|
|
1542
|
+
// explained.
|
|
1543
|
+
let withheldReason = '';
|
|
1544
|
+
if (!powSatisfied) withheldReason = 'pow_not_satisfied';
|
|
1545
|
+
else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
|
|
1546
|
+
|
|
1547
|
+
const token = success
|
|
1548
|
+
? generateToken(ip, siteKey, finalScore, {
|
|
1549
|
+
hostname,
|
|
1550
|
+
action: sanitizeAction(opts.action),
|
|
1551
|
+
cdata: sanitizeCdata(opts.cdata)
|
|
1552
|
+
})
|
|
1553
|
+
: null;
|
|
1438
1554
|
|
|
1439
1555
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
1440
1556
|
// what it just did.
|
|
@@ -1447,7 +1563,9 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1447
1563
|
timestamp: Math.floor(Date.now() / 1000),
|
|
1448
1564
|
recommendation,
|
|
1449
1565
|
categoryScores,
|
|
1450
|
-
detections
|
|
1566
|
+
detections,
|
|
1567
|
+
...(withheldReason ? { reason: withheldReason } : {}),
|
|
1568
|
+
...(hostnameAllowed ? {} : { hostname })
|
|
1451
1569
|
};
|
|
1452
1570
|
}
|
|
1453
1571
|
|
|
@@ -1475,7 +1593,7 @@ function collectHeaders(req) {
|
|
|
1475
1593
|
}
|
|
1476
1594
|
|
|
1477
1595
|
app.post('/api/verify', async (req, res) => {
|
|
1478
|
-
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1596
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
1479
1597
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1480
1598
|
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
1481
1599
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
@@ -1491,13 +1609,13 @@ app.post('/api/verify', async (req, res) => {
|
|
|
1491
1609
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1492
1610
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1493
1611
|
|
|
1494
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1612
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1495
1613
|
logVerdict('verify', siteKey, result);
|
|
1496
1614
|
res.json(result);
|
|
1497
1615
|
});
|
|
1498
1616
|
|
|
1499
1617
|
app.post('/api/score', async (req, res) => {
|
|
1500
|
-
const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1618
|
+
const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
|
|
1501
1619
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1502
1620
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
1503
1621
|
const userAgent = req.headers['user-agent'] || '';
|
|
@@ -1512,19 +1630,35 @@ app.post('/api/score', async (req, res) => {
|
|
|
1512
1630
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1513
1631
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1514
1632
|
|
|
1515
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1633
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1516
1634
|
logVerdict('score', siteKey, result);
|
|
1517
1635
|
res.json({
|
|
1518
1636
|
success: result.success,
|
|
1519
1637
|
score: result.score,
|
|
1520
1638
|
token: result.token,
|
|
1521
|
-
|
|
1639
|
+
// Echo the sanitized form, not the raw input: this is what got signed into
|
|
1640
|
+
// the token, so a caller comparing the two sees the same value.
|
|
1641
|
+
action: sanitizeAction(action),
|
|
1642
|
+
cdata: sanitizeCdata(cdata),
|
|
1522
1643
|
recommendation: result.recommendation
|
|
1523
1644
|
});
|
|
1524
1645
|
});
|
|
1525
1646
|
|
|
1526
1647
|
app.post('/api/token/verify', (req, res) => {
|
|
1527
|
-
const { token } = req.body;
|
|
1648
|
+
const { token, secret } = req.body;
|
|
1649
|
+
|
|
1650
|
+
// The secret gate. This endpoint is the boundary between "a browser finished a
|
|
1651
|
+
// challenge" and "my backend believes it", so it is server-to-server and needs
|
|
1652
|
+
// a credential — without one, anyone who can reach the host can spend a token
|
|
1653
|
+
// they observed, and the single-use guard then denies the real user.
|
|
1654
|
+
if (!LEGACY_UNAUTH_VERIFY) {
|
|
1655
|
+
if (!secret) {
|
|
1656
|
+
return res.status(401).json({ valid: false, reason: 'missing_secret' });
|
|
1657
|
+
}
|
|
1658
|
+
if (!secretMatches(secret, VERIFY_SECRET)) {
|
|
1659
|
+
return res.status(401).json({ valid: false, reason: 'invalid_secret' });
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1528
1662
|
|
|
1529
1663
|
// Extract client IP for verification
|
|
1530
1664
|
const ip = PROXY_TRUST.clientIP(req);
|
|
@@ -1532,6 +1666,30 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
1532
1666
|
res.json(verifyToken(token, ip));
|
|
1533
1667
|
});
|
|
1534
1668
|
|
|
1669
|
+
// Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
|
|
1670
|
+
//
|
|
1671
|
+
// Same contract, three paths, because the path is hardcoded in the SDKs and
|
|
1672
|
+
// plugins we want to be usable against FCaptcha: pointing an existing
|
|
1673
|
+
// integration at this server should be a base-URL change and nothing else.
|
|
1674
|
+
// See siteverify.js for the adapter itself.
|
|
1675
|
+
function siteverifyHandler(req, res) {
|
|
1676
|
+
res.json(
|
|
1677
|
+
siteverify({
|
|
1678
|
+
body: req.body,
|
|
1679
|
+
// Bind to this server's token store, so replay state is shared with the
|
|
1680
|
+
// native endpoint rather than kept in a parallel universe.
|
|
1681
|
+
verifyToken,
|
|
1682
|
+
expectedSecret: VERIFY_SECRET,
|
|
1683
|
+
idempotencyStore: IDEMPOTENCY,
|
|
1684
|
+
requireSecret: !LEGACY_UNAUTH_VERIFY
|
|
1685
|
+
})
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
app.post('/turnstile/v0/siteverify', siteverifyHandler);
|
|
1690
|
+
app.post('/recaptcha/api/siteverify', siteverifyHandler);
|
|
1691
|
+
app.post('/siteverify', siteverifyHandler);
|
|
1692
|
+
|
|
1535
1693
|
// PoW Challenge endpoint - client fetches this on page load
|
|
1536
1694
|
app.get('/api/pow/challenge', (req, res) => {
|
|
1537
1695
|
const ip = PROXY_TRUST.clientIP(req);
|
package/siteverify.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drop-in compatibility with the Turnstile / reCAPTCHA / hCaptcha siteverify
|
|
5
|
+
* contract.
|
|
6
|
+
*
|
|
7
|
+
* Every one of those services validates a token the same way: a server-to-server
|
|
8
|
+
* POST carrying `secret` and `response`, answered with a small JSON object whose
|
|
9
|
+
* shape has been stable for a decade. Every backend SDK, CMS plugin and Stack
|
|
10
|
+
* Overflow snippet in circulation speaks it:
|
|
11
|
+
*
|
|
12
|
+
* POST /turnstile/v0/siteverify (form-encoded or JSON)
|
|
13
|
+
* secret, response, remoteip, idempotency_key
|
|
14
|
+
* -> {success, challenge_ts, hostname, action, cdata, error-codes}
|
|
15
|
+
*
|
|
16
|
+
* FCaptcha's native endpoint (`POST /api/token/verify` -> `{valid, score, ...}`)
|
|
17
|
+
* is a different shape, which means none of that existing integration work
|
|
18
|
+
* applies. Serving the familiar contract alongside the native one turns a
|
|
19
|
+
* migration into a base-URL change, so this module is deliberately an *adapter*
|
|
20
|
+
* over `verifyToken` rather than a second verification path — there is one
|
|
21
|
+
* implementation of token validity and this translates its vocabulary.
|
|
22
|
+
*
|
|
23
|
+
* Three things the native endpoint did not do, which the contract requires:
|
|
24
|
+
*
|
|
25
|
+
* - `secret` is checked. All three servers previously accepted the parameter
|
|
26
|
+
* and ignored it, so anyone who could reach the endpoint could verify
|
|
27
|
+
* tokens. The README documented sending it, which made the omission worse:
|
|
28
|
+
* integrators believed they were authenticated.
|
|
29
|
+
* - `hostname` is reported, so the caller can confirm the token was minted on
|
|
30
|
+
* a page they actually serve. This is what stops a lifted site key from
|
|
31
|
+
* being used against someone else's deployment.
|
|
32
|
+
* - `idempotency_key` makes retries safe. Tokens are single-use, so a network
|
|
33
|
+
* timeout on the first validation would otherwise burn the token and fail
|
|
34
|
+
* the user's request on the retry.
|
|
35
|
+
*
|
|
36
|
+
* Mirrors server-go/siteverify.go and server-python/siteverify.py.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
const { BoundedMap } = require('./limits');
|
|
41
|
+
|
|
42
|
+
// How long a validation result stays replayable under its idempotency key.
|
|
43
|
+
// Matched to the token lifetime: past that the token is expired anyway, so a
|
|
44
|
+
// retry has nothing left to be idempotent about.
|
|
45
|
+
const IDEMPOTENCY_TTL_SECONDS = 300;
|
|
46
|
+
|
|
47
|
+
// Bounded because the key is caller-supplied. Same reasoning as limits.js:
|
|
48
|
+
// anything keyed on a value the client chooses needs a ceiling.
|
|
49
|
+
const MAX_IDEMPOTENCY_ENTRIES = 10000;
|
|
50
|
+
|
|
51
|
+
// `action` and `cdata` are echoed back verbatim, so they are attacker-controlled
|
|
52
|
+
// output. Length-capped to keep them from bloating the token, and in cdata's
|
|
53
|
+
// case to keep it a label rather than a smuggling channel.
|
|
54
|
+
const MAX_ACTION_LENGTH = 32;
|
|
55
|
+
const MAX_CDATA_LENGTH = 255;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The error-code vocabulary. These strings are the contract — integrators
|
|
59
|
+
* branch on them — so they match Cloudflare's spelling exactly, hyphens and all.
|
|
60
|
+
*/
|
|
61
|
+
const ERROR_CODES = {
|
|
62
|
+
MISSING_SECRET: 'missing-input-secret',
|
|
63
|
+
INVALID_SECRET: 'invalid-input-secret',
|
|
64
|
+
MISSING_RESPONSE: 'missing-input-response',
|
|
65
|
+
INVALID_RESPONSE: 'invalid-input-response',
|
|
66
|
+
BAD_REQUEST: 'bad-request',
|
|
67
|
+
TIMEOUT_OR_DUPLICATE: 'timeout-or-duplicate',
|
|
68
|
+
INTERNAL_ERROR: 'internal-error',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How `verifyToken`'s internal `reason` maps onto that vocabulary.
|
|
73
|
+
*
|
|
74
|
+
* The collapse is lossy on purpose. A caller learning *why* a token failed
|
|
75
|
+
* learns something about the signing key or the replay window, so every
|
|
76
|
+
* structural failure reports the same `invalid-input-response`, and expiry and
|
|
77
|
+
* replay share `timeout-or-duplicate` exactly as Cloudflare's do.
|
|
78
|
+
*/
|
|
79
|
+
const REASON_TO_ERROR_CODE = {
|
|
80
|
+
expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
81
|
+
token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
|
|
82
|
+
invalid_signature: ERROR_CODES.INVALID_RESPONSE,
|
|
83
|
+
invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
|
|
84
|
+
invalid_json: ERROR_CODES.INVALID_RESPONSE,
|
|
85
|
+
missing_signature: ERROR_CODES.INVALID_RESPONSE,
|
|
86
|
+
ip_mismatch: ERROR_CODES.INVALID_RESPONSE,
|
|
87
|
+
hostname_not_allowed: ERROR_CODES.INVALID_RESPONSE,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function reasonToErrorCode(reason) {
|
|
91
|
+
return REASON_TO_ERROR_CODE[reason] || ERROR_CODES.INVALID_RESPONSE;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pull the host out of a URL-shaped header value.
|
|
96
|
+
*
|
|
97
|
+
* Returns '' rather than throwing on anything unparseable, including the
|
|
98
|
+
* literal `null` that browsers send as Origin from a sandboxed iframe or a
|
|
99
|
+
* file:// page. An opaque origin is genuinely the absence of a hostname, not an
|
|
100
|
+
* error — the token simply carries no host binding.
|
|
101
|
+
*/
|
|
102
|
+
function hostFromUrl(value) {
|
|
103
|
+
if (!value || typeof value !== 'string') return '';
|
|
104
|
+
if (value === 'null') return '';
|
|
105
|
+
try {
|
|
106
|
+
// URL.hostname excludes the port and keeps IPv6 brackets, which is the
|
|
107
|
+
// normalisation we want: example.com:8443 and example.com are one host.
|
|
108
|
+
return new URL(value).hostname.toLowerCase();
|
|
109
|
+
} catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The hostname a token should be bound to, from the headers of the request that
|
|
116
|
+
* mints it.
|
|
117
|
+
*
|
|
118
|
+
* Origin first: the widget's verification call is a CORS POST, so browsers
|
|
119
|
+
* attach it, and unlike Referer it is not suppressed by referrer policy. Referer
|
|
120
|
+
* is the fallback for same-origin deployments where Origin may be absent on some
|
|
121
|
+
* request shapes. Neither is trustworthy against a non-browser caller — anything
|
|
122
|
+
* that can forge one can forge both — so this binds the *browser* case, which is
|
|
123
|
+
* the case that matters: it stops a site key lifted from your page from minting
|
|
124
|
+
* tokens that your own backend will accept.
|
|
125
|
+
*/
|
|
126
|
+
function requestHostname(headers) {
|
|
127
|
+
if (!headers) return '';
|
|
128
|
+
return hostFromUrl(headers['origin']) || hostFromUrl(headers['referer']) || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Trim a caller-supplied label to something safe to sign and echo. */
|
|
132
|
+
function sanitizeLabel(value, maxLength) {
|
|
133
|
+
if (typeof value !== 'string') return '';
|
|
134
|
+
// Control characters would ride through into the JSON response and any log
|
|
135
|
+
// line that records it.
|
|
136
|
+
return value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, maxLength);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sanitizeAction(value) {
|
|
140
|
+
return sanitizeLabel(value, MAX_ACTION_LENGTH);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sanitizeCdata(value) {
|
|
144
|
+
return sanitizeLabel(value, MAX_CDATA_LENGTH);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Constant-time secret comparison.
|
|
149
|
+
*
|
|
150
|
+
* timingSafeEqual demands equal-length buffers and throws otherwise, which would
|
|
151
|
+
* itself leak length, so both sides are hashed to a fixed width first.
|
|
152
|
+
*/
|
|
153
|
+
function secretMatches(provided, expected) {
|
|
154
|
+
if (typeof provided !== 'string' || typeof expected !== 'string') return false;
|
|
155
|
+
const a = crypto.createHash('sha256').update(provided).digest();
|
|
156
|
+
const b = crypto.createHash('sha256').update(expected).digest();
|
|
157
|
+
return crypto.timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional allowlist of hostnames permitted to mint tokens.
|
|
162
|
+
*
|
|
163
|
+
* Off by default so zero-config self-hosting keeps working, and reported rather
|
|
164
|
+
* than enforced when a request carries no derivable Origin — a native mobile
|
|
165
|
+
* client or a server-side integration legitimately has none, and refusing those
|
|
166
|
+
* would break them for no security gain.
|
|
167
|
+
*/
|
|
168
|
+
class HostnameAllowlist {
|
|
169
|
+
constructor(hostnames = []) {
|
|
170
|
+
this.hostnames = new Set(
|
|
171
|
+
hostnames
|
|
172
|
+
.map((h) => String(h).trim().toLowerCase())
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static fromEnv(env = process.env) {
|
|
178
|
+
const raw = env.FCAPTCHA_ALLOWED_HOSTNAMES || '';
|
|
179
|
+
return new HostnameAllowlist(raw.split(',').filter(Boolean));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get enabled() {
|
|
183
|
+
return this.hostnames.size > 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a hostname may mint tokens. An empty hostname passes: see the class
|
|
188
|
+
* comment — absence of an Origin is not evidence of a bad one.
|
|
189
|
+
*/
|
|
190
|
+
permits(hostname) {
|
|
191
|
+
if (!this.enabled) return true;
|
|
192
|
+
if (!hostname) return true;
|
|
193
|
+
return this.hostnames.has(hostname);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe() {
|
|
197
|
+
return this.enabled ? [...this.hostnames].join(', ') : 'any (unrestricted)';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Caches validation results so a retried siteverify returns the first answer
|
|
203
|
+
* instead of tripping the single-use guard.
|
|
204
|
+
*
|
|
205
|
+
* Keyed on the idempotency key *and* the token: reusing one key across different
|
|
206
|
+
* tokens is a caller bug, and answering the second one from the first one's
|
|
207
|
+
* cache entry would report success for a token nobody validated. Binding both
|
|
208
|
+
* degrades that case to an ordinary fresh verification.
|
|
209
|
+
*/
|
|
210
|
+
class IdempotencyStore {
|
|
211
|
+
constructor({ ttlSeconds = IDEMPOTENCY_TTL_SECONDS, maxEntries = MAX_IDEMPOTENCY_ENTRIES } = {}) {
|
|
212
|
+
this.ttlMs = ttlSeconds * 1000;
|
|
213
|
+
this.entries = new BoundedMap(maxEntries);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_key(idempotencyKey, token) {
|
|
217
|
+
const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
|
|
218
|
+
return `${idempotencyKey}:${tokenHash}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get(idempotencyKey, token) {
|
|
222
|
+
if (!idempotencyKey) return null;
|
|
223
|
+
const entry = this.entries.get(this._key(idempotencyKey, token));
|
|
224
|
+
if (!entry) return null;
|
|
225
|
+
// >= so that a zero TTL means "never replay" rather than "replay once".
|
|
226
|
+
if (Date.now() - entry.storedAt >= this.ttlMs) return null;
|
|
227
|
+
return entry.response;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
set(idempotencyKey, token, response) {
|
|
231
|
+
if (!idempotencyKey) return;
|
|
232
|
+
this.entries.set(this._key(idempotencyKey, token), {
|
|
233
|
+
storedAt: Date.now(),
|
|
234
|
+
response,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A failing siteverify response carrying the given codes. */
|
|
240
|
+
function failure(...codes) {
|
|
241
|
+
return { success: false, 'error-codes': codes };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the siteverify parameters out of a request body.
|
|
246
|
+
*
|
|
247
|
+
* Both encodings are accepted because the contract accepts both, and callers in
|
|
248
|
+
* the wild are split: PHP and Python examples overwhelmingly post form-encoded,
|
|
249
|
+
* Node and Go examples post JSON.
|
|
250
|
+
*/
|
|
251
|
+
function readParams(body) {
|
|
252
|
+
if (!body || typeof body !== 'object') return {};
|
|
253
|
+
const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
254
|
+
return {
|
|
255
|
+
secret: str(body.secret),
|
|
256
|
+
response: str(body.response),
|
|
257
|
+
remoteip: str(body.remoteip),
|
|
258
|
+
idempotencyKey: str(body.idempotency_key),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run a siteverify request.
|
|
264
|
+
*
|
|
265
|
+
* `verifyToken` is injected rather than imported so the standalone server and
|
|
266
|
+
* the library export can each supply their own — they hold separate token
|
|
267
|
+
* stores, and this module should not decide which one is authoritative.
|
|
268
|
+
*/
|
|
269
|
+
function siteverify({
|
|
270
|
+
body,
|
|
271
|
+
verifyToken,
|
|
272
|
+
expectedSecret,
|
|
273
|
+
idempotencyStore,
|
|
274
|
+
requireSecret = true,
|
|
275
|
+
}) {
|
|
276
|
+
const { secret, response, remoteip, idempotencyKey } = readParams(body);
|
|
277
|
+
|
|
278
|
+
if (requireSecret) {
|
|
279
|
+
if (!secret) return failure(ERROR_CODES.MISSING_SECRET);
|
|
280
|
+
if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!response) return failure(ERROR_CODES.MISSING_RESPONSE);
|
|
284
|
+
|
|
285
|
+
const cached = idempotencyStore && idempotencyStore.get(idempotencyKey, response);
|
|
286
|
+
if (cached) return cached;
|
|
287
|
+
|
|
288
|
+
let result;
|
|
289
|
+
try {
|
|
290
|
+
// remoteip is passed through to the IP-binding check. Empty means the caller
|
|
291
|
+
// declined to assert one, which the token verifier treats as "don't check".
|
|
292
|
+
result = verifyToken(response, remoteip || null);
|
|
293
|
+
} catch {
|
|
294
|
+
// Never surface an exception's text: it can carry key material or internals.
|
|
295
|
+
return failure(ERROR_CODES.INTERNAL_ERROR);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const out = result && result.valid
|
|
299
|
+
? {
|
|
300
|
+
success: true,
|
|
301
|
+
challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(),
|
|
302
|
+
hostname: result.hostname || '',
|
|
303
|
+
action: result.action || '',
|
|
304
|
+
cdata: result.cdata || '',
|
|
305
|
+
'error-codes': [],
|
|
306
|
+
// Not part of the upstream contract, but the whole reason to run
|
|
307
|
+
// FCaptcha: a caller that only wants pass/fail can ignore it, and one
|
|
308
|
+
// that wants to risk-band on the score has it without a second call.
|
|
309
|
+
score: typeof result.score === 'number' ? result.score : null,
|
|
310
|
+
}
|
|
311
|
+
: failure(reasonToErrorCode(result && result.reason));
|
|
312
|
+
|
|
313
|
+
if (idempotencyStore) idempotencyStore.set(idempotencyKey, response, out);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
ERROR_CODES,
|
|
319
|
+
HostnameAllowlist,
|
|
320
|
+
IdempotencyStore,
|
|
321
|
+
hostFromUrl,
|
|
322
|
+
requestHostname,
|
|
323
|
+
reasonToErrorCode,
|
|
324
|
+
sanitizeAction,
|
|
325
|
+
sanitizeCdata,
|
|
326
|
+
secretMatches,
|
|
327
|
+
siteverify,
|
|
328
|
+
MAX_ACTION_LENGTH,
|
|
329
|
+
MAX_CDATA_LENGTH,
|
|
330
|
+
IDEMPOTENCY_TTL_SECONDS,
|
|
331
|
+
};
|