@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.
Files changed (3) hide show
  1. package/package.json +3 -2
  2. package/server.js +173 -15
  3. package/siteverify.js +331 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.21.0",
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
- function generateToken(ip, siteKey, score) {
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
- const success = finalScore < 0.5;
1437
- const token = success ? generateToken(ip, siteKey, finalScore) : null;
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
- action: action || '',
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
+ };