@webdecoy/fcaptcha 1.21.0 → 1.22.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 +127 -12
- 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.22.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 };
|
|
@@ -1433,8 +1490,25 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1433
1490
|
else if (finalScore < 0.6) recommendation = 'challenge';
|
|
1434
1491
|
else recommendation = 'block';
|
|
1435
1492
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1493
|
+
// The hostname comes from the request headers rather than the request body:
|
|
1494
|
+
// it is what the browser reported about the page, not what the page claimed
|
|
1495
|
+
// about itself.
|
|
1496
|
+
const hostname = requestHostname(headers);
|
|
1497
|
+
|
|
1498
|
+
// An unlisted hostname withholds the token but does not touch the score. The
|
|
1499
|
+
// visitor is not the problem — a key registered to another site is — so the
|
|
1500
|
+
// detection layer has nothing to say about it and the refusal is reported as
|
|
1501
|
+
// its own reason rather than smuggled in as a bot verdict.
|
|
1502
|
+
const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
|
|
1503
|
+
const success = finalScore < 0.5 && hostnameAllowed;
|
|
1504
|
+
|
|
1505
|
+
const token = success
|
|
1506
|
+
? generateToken(ip, siteKey, finalScore, {
|
|
1507
|
+
hostname,
|
|
1508
|
+
action: sanitizeAction(opts.action),
|
|
1509
|
+
cdata: sanitizeCdata(opts.cdata)
|
|
1510
|
+
})
|
|
1511
|
+
: null;
|
|
1438
1512
|
|
|
1439
1513
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
1440
1514
|
// what it just did.
|
|
@@ -1447,7 +1521,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
|
|
|
1447
1521
|
timestamp: Math.floor(Date.now() / 1000),
|
|
1448
1522
|
recommendation,
|
|
1449
1523
|
categoryScores,
|
|
1450
|
-
detections
|
|
1524
|
+
detections,
|
|
1525
|
+
...(hostnameAllowed ? {} : { reason: 'hostname_not_allowed', hostname })
|
|
1451
1526
|
};
|
|
1452
1527
|
}
|
|
1453
1528
|
|
|
@@ -1475,7 +1550,7 @@ function collectHeaders(req) {
|
|
|
1475
1550
|
}
|
|
1476
1551
|
|
|
1477
1552
|
app.post('/api/verify', async (req, res) => {
|
|
1478
|
-
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1553
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
|
|
1479
1554
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1480
1555
|
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
1481
1556
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
@@ -1491,13 +1566,13 @@ app.post('/api/verify', async (req, res) => {
|
|
|
1491
1566
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1492
1567
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1493
1568
|
|
|
1494
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1569
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1495
1570
|
logVerdict('verify', siteKey, result);
|
|
1496
1571
|
res.json(result);
|
|
1497
1572
|
});
|
|
1498
1573
|
|
|
1499
1574
|
app.post('/api/score', async (req, res) => {
|
|
1500
|
-
const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1575
|
+
const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
|
|
1501
1576
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1502
1577
|
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
1503
1578
|
const userAgent = req.headers['user-agent'] || '';
|
|
@@ -1512,19 +1587,35 @@ app.post('/api/score', async (req, res) => {
|
|
|
1512
1587
|
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1513
1588
|
const webBotAuth = await verifyWebBotAuth(req);
|
|
1514
1589
|
|
|
1515
|
-
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
|
|
1590
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
|
|
1516
1591
|
logVerdict('score', siteKey, result);
|
|
1517
1592
|
res.json({
|
|
1518
1593
|
success: result.success,
|
|
1519
1594
|
score: result.score,
|
|
1520
1595
|
token: result.token,
|
|
1521
|
-
|
|
1596
|
+
// Echo the sanitized form, not the raw input: this is what got signed into
|
|
1597
|
+
// the token, so a caller comparing the two sees the same value.
|
|
1598
|
+
action: sanitizeAction(action),
|
|
1599
|
+
cdata: sanitizeCdata(cdata),
|
|
1522
1600
|
recommendation: result.recommendation
|
|
1523
1601
|
});
|
|
1524
1602
|
});
|
|
1525
1603
|
|
|
1526
1604
|
app.post('/api/token/verify', (req, res) => {
|
|
1527
|
-
const { token } = req.body;
|
|
1605
|
+
const { token, secret } = req.body;
|
|
1606
|
+
|
|
1607
|
+
// The secret gate. This endpoint is the boundary between "a browser finished a
|
|
1608
|
+
// challenge" and "my backend believes it", so it is server-to-server and needs
|
|
1609
|
+
// a credential — without one, anyone who can reach the host can spend a token
|
|
1610
|
+
// they observed, and the single-use guard then denies the real user.
|
|
1611
|
+
if (!LEGACY_UNAUTH_VERIFY) {
|
|
1612
|
+
if (!secret) {
|
|
1613
|
+
return res.status(401).json({ valid: false, reason: 'missing_secret' });
|
|
1614
|
+
}
|
|
1615
|
+
if (!secretMatches(secret, VERIFY_SECRET)) {
|
|
1616
|
+
return res.status(401).json({ valid: false, reason: 'invalid_secret' });
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1528
1619
|
|
|
1529
1620
|
// Extract client IP for verification
|
|
1530
1621
|
const ip = PROXY_TRUST.clientIP(req);
|
|
@@ -1532,6 +1623,30 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
1532
1623
|
res.json(verifyToken(token, ip));
|
|
1533
1624
|
});
|
|
1534
1625
|
|
|
1626
|
+
// Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
|
|
1627
|
+
//
|
|
1628
|
+
// Same contract, three paths, because the path is hardcoded in the SDKs and
|
|
1629
|
+
// plugins we want to be usable against FCaptcha: pointing an existing
|
|
1630
|
+
// integration at this server should be a base-URL change and nothing else.
|
|
1631
|
+
// See siteverify.js for the adapter itself.
|
|
1632
|
+
function siteverifyHandler(req, res) {
|
|
1633
|
+
res.json(
|
|
1634
|
+
siteverify({
|
|
1635
|
+
body: req.body,
|
|
1636
|
+
// Bind to this server's token store, so replay state is shared with the
|
|
1637
|
+
// native endpoint rather than kept in a parallel universe.
|
|
1638
|
+
verifyToken,
|
|
1639
|
+
expectedSecret: VERIFY_SECRET,
|
|
1640
|
+
idempotencyStore: IDEMPOTENCY,
|
|
1641
|
+
requireSecret: !LEGACY_UNAUTH_VERIFY
|
|
1642
|
+
})
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
app.post('/turnstile/v0/siteverify', siteverifyHandler);
|
|
1647
|
+
app.post('/recaptcha/api/siteverify', siteverifyHandler);
|
|
1648
|
+
app.post('/siteverify', siteverifyHandler);
|
|
1649
|
+
|
|
1535
1650
|
// PoW Challenge endpoint - client fetches this on page load
|
|
1536
1651
|
app.get('/api/pow/challenge', (req, res) => {
|
|
1537
1652
|
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
|
+
};
|