@webdecoy/fcaptcha 1.14.0 → 1.16.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/clientip.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Client IP resolution.
3
+ *
4
+ * Every IP-derived signal in FCaptcha — datacenter ranges, Tor/VPN, rate
5
+ * limiting, token IP binding, PoW difficulty — is only as trustworthy as the
6
+ * address it is handed. X-Forwarded-For and X-Real-IP are set by whoever is on
7
+ * the other end of the socket, so honouring them unconditionally lets any
8
+ * caller claim a clean residential IP and walk past all of it. They are
9
+ * credible only when the peer that sent them is a proxy we put there.
10
+ *
11
+ * TRUSTED_PROXIES holds the peers allowed to speak for someone else, as a
12
+ * comma-separated list of CIDRs and bare IPs. Unset means the private and
13
+ * loopback defaults below. '*' trusts every peer (only correct when an edge you
14
+ * control always overwrites the headers). 'none' trusts nobody, so the socket
15
+ * address is always used.
16
+ */
17
+
18
+ const ipaddr = require('ipaddr.js');
19
+
20
+ /**
21
+ * Fallback trust set: loopback plus the private and link-local ranges. A server
22
+ * exposed directly to the internet sees public peer addresses, which are not in
23
+ * this set, so forged headers are ignored. A server behind a sidecar or
24
+ * in-cluster reverse proxy (nginx, Railway, Fly, Kubernetes ingress) sees a
25
+ * loopback or private peer and behaves as before.
26
+ */
27
+ const DEFAULT_TRUSTED_PROXY_CIDRS = [
28
+ '127.0.0.0/8',
29
+ '::1/128',
30
+ '10.0.0.0/8',
31
+ '172.16.0.0/12',
32
+ '192.168.0.0/16',
33
+ '169.254.0.0/16',
34
+ 'fe80::/10',
35
+ 'fc00::/7'
36
+ ];
37
+
38
+ // hostOnly strips a trailing :port or wrapping brackets. Node's
39
+ // socket.remoteAddress carries no port, but X-Forwarded-For hops sometimes do.
40
+ function hostOnly(value) {
41
+ const s = String(value).trim();
42
+ if (s.startsWith('[')) {
43
+ const end = s.indexOf(']');
44
+ return end === -1 ? s : s.slice(1, end);
45
+ }
46
+ // A bare IPv6 address has several colons; only strip a port from host:port.
47
+ const first = s.indexOf(':');
48
+ if (first !== -1 && s.indexOf(':', first + 1) === -1) return s.slice(0, first);
49
+ return s;
50
+ }
51
+
52
+ // normalizeIP validates an address and renders IPv4-mapped IPv6
53
+ // (::ffff:1.2.3.4) in dotted form, so the CIDR checks in detection.js match
54
+ // either spelling. Returns null when the value is not an IP.
55
+ function normalizeIP(value) {
56
+ if (!value) return null;
57
+ try {
58
+ return ipaddr.process(hostOnly(value)).toString();
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Node merges repeated headers into one comma-joined string for everything
65
+ // except set-cookie, but accept an array defensively.
66
+ function headerValue(req, name) {
67
+ const raw = req.headers?.[name];
68
+ if (Array.isArray(raw)) return raw.join(',');
69
+ return raw || '';
70
+ }
71
+
72
+ // Bounds the misconfiguration hint below. A directly exposed server sees spoof
73
+ // attempts continuously, and that is working as intended — the operator needs
74
+ // the hint once, not a running commentary.
75
+ const MAX_PROXY_MISCONFIG_WARNINGS = 10;
76
+
77
+ /** Decides which peers may set client-identifying headers. */
78
+ class ProxyTrust {
79
+ /**
80
+ * @param {string} spec comma-separated CIDRs and bare IPs, or '*' / 'none'.
81
+ * Invalid entries are warned about and skipped rather than thrown: a typo in
82
+ * one entry should narrow trust, not take the server down.
83
+ */
84
+ constructor(spec) {
85
+ this.trustAll = false;
86
+ this.ranges = [];
87
+ this.warnings = 0;
88
+
89
+ for (const raw of String(spec ?? '').split(',')) {
90
+ const entry = raw.trim();
91
+ if (!entry || entry.toLowerCase() === 'none') continue;
92
+ if (entry === '*') {
93
+ this.trustAll = true;
94
+ continue;
95
+ }
96
+ try {
97
+ if (entry.includes('/')) {
98
+ this.ranges.push(ipaddr.parseCIDR(entry));
99
+ } else {
100
+ // A bare IP is a single-host range.
101
+ const addr = ipaddr.process(entry);
102
+ this.ranges.push([addr, addr.kind() === 'ipv4' ? 32 : 128]);
103
+ }
104
+ } catch {
105
+ console.warn(`warning: ignoring invalid TRUSTED_PROXIES entry "${raw}"`);
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Builds the trust set from TRUSTED_PROXIES, defaulting when unset. An
111
+ * explicitly empty value means "trust nothing", so unset and empty differ. */
112
+ static fromEnv(env = process.env) {
113
+ const spec = env.TRUSTED_PROXIES === undefined
114
+ ? DEFAULT_TRUSTED_PROXY_CIDRS.join(',')
115
+ : env.TRUSTED_PROXIES;
116
+ return new ProxyTrust(spec);
117
+ }
118
+
119
+ /** Renders the trust set for the startup log. */
120
+ describe() {
121
+ if (this.trustAll) return '* (all peers)';
122
+ if (this.ranges.length === 0) return 'none (forwarding headers ignored)';
123
+ return this.ranges.map(([addr, bits]) => `${addr.toString()}/${bits}`).join(', ');
124
+ }
125
+
126
+ /** Reports whether an address belongs to a configured proxy. */
127
+ trusts(ip) {
128
+ if (!ip) return false;
129
+ if (this.trustAll) return true;
130
+ let addr;
131
+ try {
132
+ addr = ipaddr.process(ip);
133
+ } catch {
134
+ return false;
135
+ }
136
+ for (const range of this.ranges) {
137
+ // match() throws when the address families differ, so skip those.
138
+ if (range[0].kind() !== addr.kind()) continue;
139
+ if (addr.match(range)) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ /** Reports whether the immediate peer may speak for another client. */
145
+ peerTrusted(req) {
146
+ return this.trusts(normalizeIP(req.socket?.remoteAddress));
147
+ }
148
+
149
+ /**
150
+ * Returns a header value only when the peer is a trusted proxy, and ''
151
+ * otherwise. Used for X-JA3-Hash: a client free to state its own TLS
152
+ * fingerprint would just present a stock Chrome one and erase the signal.
153
+ */
154
+ trustedHeader(req, name) {
155
+ if (!this.peerTrusted(req)) return '';
156
+ return headerValue(req, name);
157
+ }
158
+
159
+ /**
160
+ * Flags the one misconfiguration this design can cause. A peer that sends
161
+ * forwarding headers but is not in the trust set is either a real reverse
162
+ * proxy the operator forgot to list — in which case every visitor is now
163
+ * collapsed onto that proxy's address, silently wrecking rate limiting and IP
164
+ * reputation — or a client spoofing them, which is exactly what we want
165
+ * ignored. The two are indistinguishable here, so say both and name the peer
166
+ * so it can be pasted into TRUSTED_PROXIES.
167
+ */
168
+ _warnUntrustedForwarding(peer, req) {
169
+ if (!headerValue(req, 'x-forwarded-for') && !headerValue(req, 'x-real-ip')) return;
170
+ if (this.warnings >= MAX_PROXY_MISCONFIG_WARNINGS) return;
171
+ this.warnings++;
172
+ const suffix = this.warnings === MAX_PROXY_MISCONFIG_WARNINGS
173
+ ? ' (further warnings suppressed)'
174
+ : '';
175
+ console.warn(
176
+ `warning: ignoring forwarding headers from untrusted peer ${peer}. ` +
177
+ `If that is your reverse proxy, add it to TRUSTED_PROXIES — until then every ` +
178
+ `visitor is attributed to ${peer}. If not, a client is spoofing them and they ` +
179
+ `are correctly ignored.${suffix}`
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Resolves the address to attribute the request to. Forwarding headers are
185
+ * read only when the peer is trusted; otherwise the socket address wins.
186
+ */
187
+ clientIP(req) {
188
+ const peer = normalizeIP(req.socket?.remoteAddress);
189
+ // No usable socket address means nothing to trust and nothing to forward.
190
+ if (!peer) return '127.0.0.1';
191
+ if (!this.trusts(peer)) {
192
+ this._warnUntrustedForwarding(peer, req);
193
+ return peer;
194
+ }
195
+
196
+ // A trusted proxy sets X-Real-IP to the address it accepted the connection
197
+ // from, so it needs no chain walking.
198
+ const realIP = normalizeIP(headerValue(req, 'x-real-ip'));
199
+ if (realIP) return realIP;
200
+
201
+ // Otherwise walk X-Forwarded-For right to left. Each proxy appends the peer
202
+ // it saw, so the rightmost entries are the ones our own infrastructure
203
+ // wrote. The first entry that is not a proxy we trust is the closest thing
204
+ // to a real client; everything to its left was supplied by that client and
205
+ // cannot be believed.
206
+ const forwarded = headerValue(req, 'x-forwarded-for');
207
+ if (forwarded) {
208
+ const parts = forwarded.split(',');
209
+ for (let i = parts.length - 1; i >= 0; i--) {
210
+ const entry = parts[i].trim();
211
+ if (!entry) continue;
212
+ const ip = normalizeIP(entry);
213
+ // A malformed hop makes everything to its left unreadable, so stop and
214
+ // fall back to the peer rather than guess.
215
+ if (!ip) break;
216
+ if (this.trusts(ip)) continue;
217
+ return ip;
218
+ }
219
+ }
220
+
221
+ return peer;
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ ProxyTrust,
227
+ DEFAULT_TRUSTED_PROXY_CIDRS,
228
+ normalizeIP
229
+ };
package/detection.js CHANGED
@@ -131,10 +131,16 @@ const DECLARED_AI_AGENT_PATTERNS = [
131
131
  /\bYouBot\b/i
132
132
  ];
133
133
 
134
- // checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent and
135
- // detects Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures). Both
136
- // are high-confidence identifications surfaced as the declared_ai category so
137
- // callers can apply an allow/block policy instead of relying on a hard score.
134
+ // checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent as
135
+ // the declared_ai category, so callers can apply an allow/block policy instead
136
+ // of relying on a hard score.
137
+ //
138
+ // Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures) used to be
139
+ // detected here by header presence alone. That moved to webbotauth.js, which
140
+ // cryptographically verifies the signature against the agent's published key
141
+ // directory — a presence check can't tell a real signer from three spoofed
142
+ // headers. Verification needs the raw request, so it runs in the route handler;
143
+ // the headers argument is retained for signature parity with the Go server.
138
144
  function checkDeclaredAIAgent(userAgent, headers = {}) {
139
145
  const detections = [];
140
146
  const ua = userAgent || '';
@@ -153,20 +159,6 @@ function checkDeclaredAIAgent(userAgent, headers = {}) {
153
159
  }
154
160
  }
155
161
 
156
- // Web Bot Auth: presence of HTTP Message Signature headers identifies a
157
- // (claimed) verified agent. v1 detects/identifies only; signature verification
158
- // against the agent's published JWKS is a follow-up before trusting it.
159
- const h = headers || {};
160
- if (h['signature'] && h['signature-input'] && h['signature-agent']) {
161
- detections.push({
162
- category: 'declared_ai',
163
- score: 0.4,
164
- confidence: 0.95,
165
- reason: `Signed agent request (Web Bot Auth): ${h['signature-agent']}`,
166
- details: { signatureAgent: h['signature-agent'], verified: false }
167
- });
168
- }
169
-
170
162
  return detections;
171
163
  }
172
164
 
package/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  const crypto = require('crypto');
13
13
  const detection = require('./detection');
14
+ const { ProxyTrust } = require('./clientip');
14
15
 
15
16
  // =============================================================================
16
17
  // PoW Challenge Store (can be extended with Redis)
@@ -719,28 +720,25 @@ class ScoringEngine {
719
720
 
720
721
  function createMiddleware(options = {}) {
721
722
  const engine = new ScoringEngine(options);
723
+ const proxyTrust = options.trustedProxies !== undefined
724
+ ? new ProxyTrust(options.trustedProxies)
725
+ : ProxyTrust.fromEnv();
722
726
 
723
727
  return {
724
728
  engine,
729
+ proxyTrust,
725
730
 
726
- // Middleware to extract IP from request
727
- getIP: (req) => {
728
- let ip = req.headers['x-real-ip'] || '';
729
- if (!ip) {
730
- const forwarded = req.headers['x-forwarded-for'];
731
- if (forwarded) {
732
- ip = forwarded.split(',')[0].trim();
733
- } else {
734
- ip = req.socket?.remoteAddress || '127.0.0.1';
735
- }
736
- }
737
- return ip;
738
- },
731
+ // Middleware to extract IP from request. Forwarding headers are honoured
732
+ // only from a peer in the trusted-proxy set (TRUSTED_PROXIES, or the
733
+ // private/loopback defaults) — otherwise any caller could claim a clean
734
+ // residential IP and skip the datacenter, Tor/VPN and rate-limit checks.
735
+ // Pass options.trustedProxies to override the env for this instance.
736
+ getIP: (req) => proxyTrust.clientIP(req),
739
737
 
740
738
  // Challenge route handler
741
739
  challengeHandler: (req, res) => {
742
740
  const siteKey = req.query.siteKey || 'default';
743
- const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
741
+ const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
744
742
  const challenge = engine.generateChallenge(siteKey, ip);
745
743
 
746
744
  res.json({
@@ -755,7 +753,7 @@ function createMiddleware(options = {}) {
755
753
  // Verify route handler
756
754
  verifyHandler: (req, res) => {
757
755
  const { siteKey, signals, powSolution } = req.body;
758
- const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
756
+ const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
759
757
  const userAgent = req.headers['user-agent'] || '';
760
758
 
761
759
  const headers = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -10,12 +10,16 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node server.js",
13
- "dev": "node --watch server.js"
13
+ "dev": "node --watch server.js",
14
+ "test:webbotauth": "node webbotauth.test.js",
15
+ "test:clientip": "node clientip.test.js"
14
16
  },
15
17
  "dependencies": {
16
- "express": "^4.18.2",
17
18
  "cors": "^2.8.5",
18
- "redis": "^4.6.12"
19
+ "express": "^4.18.0",
20
+ "ipaddr.js": "^2.4.0",
21
+ "redis": "^4.6.12",
22
+ "web-bot-auth": "^0.1.3"
19
23
  },
20
24
  "peerDependencies": {
21
25
  "express": "^4.18.0"
package/server.js CHANGED
@@ -9,6 +9,8 @@ const cors = require('cors');
9
9
  const crypto = require('crypto');
10
10
  const path = require('path');
11
11
  const detection = require('./detection');
12
+ const webbotauth = require('./webbotauth');
13
+ const { ProxyTrust } = require('./clientip');
12
14
 
13
15
  const app = express();
14
16
  app.use(cors());
@@ -18,6 +20,14 @@ const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-producti
18
20
  const PORT = process.env.PORT || 3000;
19
21
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
20
22
 
23
+ // Which peers may speak for another client via X-Forwarded-For / X-Real-IP and
24
+ // the TLS-fingerprint headers. See clientip.js.
25
+ const PROXY_TRUST = ProxyTrust.fromEnv();
26
+
27
+ // Express's own `trust proxy` would re-derive req.ip from the same headers on
28
+ // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
29
+ app.set('trust proxy', false);
30
+
21
31
  // Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
22
32
  // logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
23
33
  // When on, each /api/verify and /api/score logs one privacy-safe JSON line
@@ -1044,8 +1054,24 @@ function verifyToken(token, ip = null) {
1044
1054
  }
1045
1055
  }
1046
1056
 
1047
- function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
1048
- const detections = [];
1057
+ // verifyWebBotAuth runs Web Bot Auth verification and never throws into the
1058
+ // request path: any unexpected error degrades to no detection (fail open), so a
1059
+ // verification bug can't turn a scoring request into a 500.
1060
+ async function verifyWebBotAuth(req) {
1061
+ try {
1062
+ return await webbotauth.checkWebBotAuth(req);
1063
+ } catch (e) {
1064
+ console.error('[webbotauth] verification error:', e && e.message);
1065
+ return [];
1066
+ }
1067
+ }
1068
+
1069
+ // preDetections are detections computed by the caller (the route handler) that
1070
+ // require the raw request — currently Web Bot Auth signature verification, which
1071
+ // needs the accurately-reconstructed signed request. They are seeded into the
1072
+ // detection set so they participate in scoring like any other detection.
1073
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = []) {
1074
+ const detections = Array.isArray(preDetections) ? [...preDetections] : [];
1049
1075
 
1050
1076
  // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
1051
1077
  const clientSignalsHash = powSolution?.signalsHash || null;
@@ -1218,51 +1244,55 @@ app.get('/health', (req, res) => {
1218
1244
  res.json({ status: 'ok' });
1219
1245
  });
1220
1246
 
1221
- app.post('/api/verify', (req, res) => {
1222
- const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1223
- let ip = req.headers['x-real-ip'] || '';
1224
- if (!ip) {
1225
- const forwarded = req.headers['x-forwarded-for'];
1226
- if (forwarded) {
1227
- ip = forwarded.split(',')[0].trim();
1228
- } else {
1229
- ip = req.socket.remoteAddress;
1230
- }
1247
+ // collectHeaders lowercases the request headers for the detectors, dropping the
1248
+ // TLS-fingerprint headers when the peer is not a proxy we trust. TRUSTED_JA4_HEADERS
1249
+ // is an allowlist of header *names*, which says nothing about who set them — without
1250
+ // this gate a client could send cf-ja4 itself and present a clean fingerprint.
1251
+ function collectHeaders(req) {
1252
+ const peerTrusted = PROXY_TRUST.peerTrusted(req);
1253
+ const headers = {};
1254
+ for (const [key, value] of Object.entries(req.headers)) {
1255
+ const name = key.toLowerCase();
1256
+ if (!peerTrusted && TRUSTED_JA4_HEADERS.includes(name)) continue;
1257
+ headers[name] = Array.isArray(value) ? value[0] : value;
1231
1258
  }
1259
+ return headers;
1260
+ }
1261
+
1262
+ app.post('/api/verify', async (req, res) => {
1263
+ const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1264
+ const ip = PROXY_TRUST.clientIP(req);
1232
1265
  const userAgent = req.headers['user-agent'] || '';
1233
- const ja3Hash = req.headers['x-ja3-hash'] || null;
1266
+ // Only honoured from a trusted proxy: a client that can state its own TLS
1267
+ // fingerprint would just claim a stock Chrome one.
1268
+ const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1234
1269
 
1235
1270
  // Collect headers for analysis
1236
- const headers = {};
1237
- for (const [key, value] of Object.entries(req.headers)) {
1238
- headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1239
- }
1271
+ const headers = collectHeaders(req);
1272
+
1273
+ // Web Bot Auth verification needs the raw request; its verdict is scored.
1274
+ const webBotAuth = await verifyWebBotAuth(req);
1240
1275
 
1241
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1276
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1242
1277
  logVerdict('verify', siteKey, result);
1243
1278
  res.json(result);
1244
1279
  });
1245
1280
 
1246
- app.post('/api/score', (req, res) => {
1281
+ app.post('/api/score', async (req, res) => {
1247
1282
  const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1248
- let ip = req.headers['x-real-ip'] || '';
1249
- if (!ip) {
1250
- const forwarded = req.headers['x-forwarded-for'];
1251
- if (forwarded) {
1252
- ip = forwarded.split(',')[0].trim();
1253
- } else {
1254
- ip = req.socket.remoteAddress;
1255
- }
1256
- }
1283
+ const ip = PROXY_TRUST.clientIP(req);
1257
1284
  const userAgent = req.headers['user-agent'] || '';
1258
- const ja3Hash = req.headers['x-ja3-hash'] || null;
1285
+ const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1259
1286
 
1260
1287
  const headers = {};
1261
1288
  for (const [key, value] of Object.entries(req.headers)) {
1262
1289
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1263
1290
  }
1264
1291
 
1265
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1292
+ // Web Bot Auth verification needs the raw request; its verdict is scored.
1293
+ const webBotAuth = await verifyWebBotAuth(req);
1294
+
1295
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1266
1296
  logVerdict('score', siteKey, result);
1267
1297
  res.json({
1268
1298
  success: result.success,
@@ -1277,15 +1307,7 @@ app.post('/api/token/verify', (req, res) => {
1277
1307
  const { token } = req.body;
1278
1308
 
1279
1309
  // Extract client IP for verification
1280
- let ip = req.headers['x-real-ip'] || '';
1281
- if (!ip) {
1282
- const forwarded = req.headers['x-forwarded-for'];
1283
- if (forwarded) {
1284
- ip = forwarded.split(',')[0].trim();
1285
- } else {
1286
- ip = req.socket.remoteAddress;
1287
- }
1288
- }
1310
+ const ip = PROXY_TRUST.clientIP(req);
1289
1311
 
1290
1312
  res.json(verifyToken(token, ip));
1291
1313
  });
@@ -1294,15 +1316,7 @@ app.post('/api/token/verify', (req, res) => {
1294
1316
  app.get('/api/pow/challenge', (req, res) => {
1295
1317
  const siteKey = req.query.siteKey || 'default';
1296
1318
 
1297
- let ip = req.headers['x-real-ip'] || '';
1298
- if (!ip) {
1299
- const forwarded = req.headers['x-forwarded-for'];
1300
- if (forwarded) {
1301
- ip = forwarded.split(',')[0].trim();
1302
- } else {
1303
- ip = req.socket.remoteAddress;
1304
- }
1305
- }
1319
+ const ip = PROXY_TRUST.clientIP(req);
1306
1320
 
1307
1321
  // Difficulty scaling based on IP reputation
1308
1322
  let difficulty = 4; // Default: ~100-500ms on average hardware
@@ -1350,4 +1364,5 @@ app.get('/api/challenge', (req, res) => {
1350
1364
 
1351
1365
  app.listen(PORT, () => {
1352
1366
  console.log(`FCaptcha server running on port ${PORT}`);
1367
+ console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1353
1368
  });
package/webbotauth.js ADDED
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ // Web Bot Auth (RFC 9421 HTTP Message Signatures) verification for signed AI
4
+ // agents. Built on Cloudflare's web-bot-auth crypto primitives; mirrors the Go
5
+ // server's ScoringEngine.CheckWebBotAuth verdict model:
6
+ //
7
+ // - verified → declared_ai (verified:true, high confidence): a trustworthy
8
+ // signed identity (e.g. OpenAI Operator). A policy signal.
9
+ // - forged → bot (low confidence, contributory): the request claimed an
10
+ // agent identity and the signature failed cryptographic
11
+ // verification. Only a genuine crypto failure counts.
12
+ // - otherwise → fail open to a presence-only declared_ai signal identical to
13
+ // the pre-verification behavior. A directory we could not fetch
14
+ // (unreachable, blocked, timed out) is not proof of spoofing,
15
+ // so it must never be scored as one.
16
+ //
17
+ // The signer's key directory is named by the untrusted Signature-Agent header,
18
+ // so every fetch is treated as an SSRF vector: https-only, a DNS-lookup guard
19
+ // that refuses any non-global-unicast address (blocks loopback, private,
20
+ // link-local — including the 169.254.169.254 cloud metadata endpoint — CGNAT,
21
+ // ULA, and IPv4-mapped IPv6), a response-size cap, a short timeout, and no
22
+ // redirect following. Directories are cached with a TTL.
23
+ //
24
+ // The upstream package (cloudflare/web-bot-auth) is the reference implementation
25
+ // the Go module (WebDecoy/web-bot-auth) is cross-validated against, so the two
26
+ // servers agree on verdicts. Python has no maintained equivalent and stays
27
+ // presence-only.
28
+
29
+ const https = require('https');
30
+ const dns = require('dns');
31
+ const { URL } = require('url');
32
+
33
+ const ipaddr = require('ipaddr.js');
34
+ const {
35
+ verify,
36
+ jwkToKeyID,
37
+ helpers,
38
+ HTTP_MESSAGE_SIGNATURES_DIRECTORY,
39
+ } = require('web-bot-auth');
40
+ const { verifierFromJWK } = require('web-bot-auth/crypto');
41
+
42
+ const VERIFY_TIMEOUT_MS = 3000;
43
+ const MAX_DIRECTORY_BYTES = 1 << 20; // 1 MiB, matches the Go client's cap
44
+ const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
45
+
46
+ // The exact message web-bot-auth/crypto throws when crypto.subtle.verify()
47
+ // returns false. This — and only this — is affirmative evidence of a forged
48
+ // signature. Everything else (fetch failure, expiry, missing key) fails open.
49
+ const CRYPTO_FAILURE_MESSAGE = 'invalid signature';
50
+
51
+ // origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
52
+ const directoryCache = new Map();
53
+
54
+ // FetchFailure marks failures that are NOT cryptographic — the caller treats
55
+ // them as "could not verify" (fail open), never as a forged-signature signal.
56
+ class FetchFailure extends Error {}
57
+
58
+ // --- SSRF guard ---------------------------------------------------------------
59
+
60
+ // isPublicUnicast reports whether an IP literal is a globally-routable unicast
61
+ // address. ipaddr.process() un-maps IPv4-mapped IPv6 first, so an address like
62
+ // ::ffff:10.0.0.1 is classified by its embedded v4 range ('private'), not as a
63
+ // v6 unicast. Only 'unicast' (public) is allowed; every other range is refused.
64
+ function isPublicUnicast(ip) {
65
+ let addr;
66
+ try {
67
+ addr = ipaddr.process(ip);
68
+ } catch {
69
+ return false;
70
+ }
71
+ return addr.range() === 'unicast';
72
+ }
73
+
74
+ // guardedLookup resolves a hostname and hands the socket a vetted IP. Passing
75
+ // the concrete address (not the hostname) to connect closes the DNS-rebinding
76
+ // window: the socket connects to exactly the address we validated.
77
+ function guardedLookup(hostname, options, callback) {
78
+ const opts = typeof options === 'number' ? { family: options } : options || {};
79
+ dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
80
+ if (err) {
81
+ callback(err);
82
+ return;
83
+ }
84
+ const list = Array.isArray(addresses)
85
+ ? addresses
86
+ : [{ address: addresses, family: opts.family || 0 }];
87
+ for (const a of list) {
88
+ if (isPublicUnicast(a.address)) {
89
+ callback(null, a.address, a.family);
90
+ return;
91
+ }
92
+ }
93
+ callback(new FetchFailure(`refusing to connect to non-global address for ${hostname}`));
94
+ });
95
+ }
96
+
97
+ // --- directory fetching -------------------------------------------------------
98
+
99
+ function fetchDirectory(directoryUrl) {
100
+ return new Promise((resolve, reject) => {
101
+ let url;
102
+ try {
103
+ url = new URL(directoryUrl);
104
+ } catch {
105
+ reject(new FetchFailure('invalid directory URL'));
106
+ return;
107
+ }
108
+ if (url.protocol !== 'https:') {
109
+ reject(new FetchFailure('directory URL is not https'));
110
+ return;
111
+ }
112
+
113
+ const req = https.get(
114
+ directoryUrl,
115
+ {
116
+ lookup: guardedLookup,
117
+ timeout: VERIFY_TIMEOUT_MS,
118
+ headers: {
119
+ accept: 'application/http-message-signatures-directory+json, application/json',
120
+ },
121
+ },
122
+ (res) => {
123
+ // https.get does not follow redirects; anything but 200 is a failure.
124
+ if (res.statusCode !== 200) {
125
+ res.resume();
126
+ reject(new FetchFailure(`directory returned ${res.statusCode}`));
127
+ return;
128
+ }
129
+ let len = 0;
130
+ const chunks = [];
131
+ res.on('data', (chunk) => {
132
+ len += chunk.length;
133
+ if (len > MAX_DIRECTORY_BYTES) {
134
+ req.destroy(new FetchFailure('directory exceeds size cap'));
135
+ return;
136
+ }
137
+ chunks.push(chunk);
138
+ });
139
+ res.on('end', () => {
140
+ try {
141
+ const doc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
+ resolve(Array.isArray(doc.keys) ? doc.keys : []);
143
+ } catch {
144
+ reject(new FetchFailure('directory is not valid JSON'));
145
+ }
146
+ });
147
+ },
148
+ );
149
+
150
+ req.on('timeout', () => {
151
+ req.destroy(new FetchFailure('directory fetch timed out'));
152
+ });
153
+ req.on('error', (e) => {
154
+ reject(e instanceof FetchFailure ? e : new FetchFailure(`directory fetch: ${e.message}`));
155
+ });
156
+ });
157
+ }
158
+
159
+ async function getDirectory(directoryUrl) {
160
+ const now = Date.now();
161
+ const cached = directoryCache.get(directoryUrl);
162
+ if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
163
+ return cached.keys;
164
+ }
165
+ const keys = await fetchDirectory(directoryUrl);
166
+ directoryCache.set(directoryUrl, { keys, fetchedAt: now });
167
+ return keys;
168
+ }
169
+
170
+ // resolveKey fetches the directory named by the Signature-Agent header and
171
+ // returns the JWK whose RFC 7638 thumbprint matches keyid (the Web Bot Auth
172
+ // keyid IS the thumbprint, not the JWK's `kid`). A reachable directory that
173
+ // lacks the key throws a FetchFailure — treated as fail-open, matching the Go
174
+ // engine, which counts only a crypto failure as forgery.
175
+ async function resolveKey(signatureAgentHeader, keyid) {
176
+ const origin = directoryOriginFromHeader(signatureAgentHeader);
177
+ if (!origin) {
178
+ throw new FetchFailure('no resolvable Signature-Agent directory');
179
+ }
180
+ const keys = await getDirectory(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY);
181
+ for (const jwk of keys) {
182
+ let thumbprint;
183
+ try {
184
+ thumbprint = await jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE);
185
+ } catch {
186
+ continue; // unsupported/malformed key in the set — skip it
187
+ }
188
+ if (thumbprint === keyid) {
189
+ return jwk;
190
+ }
191
+ }
192
+ throw new FetchFailure('keyid not found in directory');
193
+ }
194
+
195
+ // --- header + request helpers -------------------------------------------------
196
+
197
+ // unwrapSfString extracts the value from a Signature-Agent header, which is an
198
+ // RFC 8941 String — bare (`"https://a"`) or a dictionary member
199
+ // (`sig1="https://a"`) as some signers emit.
200
+ function unwrapSfString(raw) {
201
+ if (!raw) return '';
202
+ const s = String(raw).trim();
203
+ const m = s.match(/^(?:[A-Za-z0-9_-]+=)?"(.*)"$/);
204
+ return (m ? m[1] : s).trim();
205
+ }
206
+
207
+ function displayAgent(raw) {
208
+ return unwrapSfString(raw) || 'unknown';
209
+ }
210
+
211
+ function directoryOriginFromHeader(raw) {
212
+ const val = unwrapSfString(raw);
213
+ if (!val) return null;
214
+ let u;
215
+ try {
216
+ u = new URL(val);
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (u.protocol !== 'https:') return null;
221
+ return u.origin;
222
+ }
223
+
224
+ function forwardedScheme(req) {
225
+ const xfp = req.headers && req.headers['x-forwarded-proto'];
226
+ if (xfp) return String(xfp).split(',')[0].trim().toLowerCase();
227
+ if (req.protocol) return req.protocol; // express sets this
228
+ return req.socket && req.socket.encrypted ? 'https' : 'http';
229
+ }
230
+
231
+ // --- detections (shape mirrors detection.js / the Go DetectionResult) ---------
232
+
233
+ function verifiedDetection(agent, keyId, algorithm) {
234
+ return {
235
+ category: 'declared_ai',
236
+ score: 0.5,
237
+ confidence: 0.99,
238
+ reason: `Verified AI agent (Web Bot Auth): ${agent}`,
239
+ details: { signatureAgent: agent, verified: true, keyId, algorithm },
240
+ };
241
+ }
242
+
243
+ function forgedDetection(agent, err) {
244
+ return {
245
+ category: 'bot',
246
+ score: 0.5,
247
+ confidence: 0.5,
248
+ reason: `Forged Web Bot Auth signature (crypto verification failed): ${agent}`,
249
+ details: { signatureAgent: agent, verified: false, error: err && err.message },
250
+ };
251
+ }
252
+
253
+ function presenceDetection(agent) {
254
+ return {
255
+ category: 'declared_ai',
256
+ score: 0.4,
257
+ confidence: 0.95,
258
+ reason: `Signed agent request (Web Bot Auth, unverified): ${agent}`,
259
+ details: { signatureAgent: agent, verified: false },
260
+ };
261
+ }
262
+
263
+ // --- entry point --------------------------------------------------------------
264
+
265
+ // checkWebBotAuth verifies a Web Bot Auth signed request and returns detections
266
+ // (empty when the request carries no signature). Async because verification may
267
+ // fetch the signer's key directory. Give it the Express request so the signed
268
+ // URL is reconstructed accurately — a mis-derived authority would make a genuine
269
+ // signature fail crypto verification and false-positive a real agent.
270
+ async function checkWebBotAuth(req) {
271
+ const headers = (req && req.headers) || {};
272
+ if (!headers.signature || !headers['signature-input']) {
273
+ return [];
274
+ }
275
+
276
+ const sigAgentRaw = headers['signature-agent'];
277
+ const agent = displayAgent(sigAgentRaw);
278
+
279
+ const host = headers.host;
280
+ if (!host) {
281
+ // Cannot reconstruct @authority → cannot verify. Fail open.
282
+ return [presenceDetection(agent)];
283
+ }
284
+ const path = req.originalUrl || req.url || '/';
285
+ const message = {
286
+ method: (req.method || 'POST').toUpperCase(),
287
+ url: `${forwardedScheme(req)}://${host}${path}`,
288
+ headers,
289
+ };
290
+
291
+ let cryptoFailed = false;
292
+ let resolvedKeyId = '';
293
+ let resolvedAlg = '';
294
+
295
+ try {
296
+ await withTimeout(
297
+ verify(message, async (data, signature, params) => {
298
+ if (!sigAgentRaw) {
299
+ throw new FetchFailure('no Signature-Agent to resolve key');
300
+ }
301
+ const jwk = await resolveKey(sigAgentRaw, params.keyid);
302
+ resolvedKeyId = params.keyid;
303
+ resolvedAlg = jwk.alg || (jwk.crv === 'Ed25519' ? 'ed25519' : '');
304
+ const verifyFn = await verifierFromJWK(jwk);
305
+ try {
306
+ await verifyFn(data, signature, params);
307
+ } catch (e) {
308
+ if (e && e.message === CRYPTO_FAILURE_MESSAGE) {
309
+ cryptoFailed = true;
310
+ }
311
+ throw e;
312
+ }
313
+ }),
314
+ VERIFY_TIMEOUT_MS,
315
+ );
316
+ return [verifiedDetection(agent, resolvedKeyId, resolvedAlg)];
317
+ } catch (e) {
318
+ if (cryptoFailed) {
319
+ return [forgedDetection(agent, e)];
320
+ }
321
+ // Fetch/structural/timeout/expiry failure: not proof of forgery. Fail open.
322
+ return [presenceDetection(agent)];
323
+ }
324
+ }
325
+
326
+ // withTimeout bounds the whole verification, a backstop over the per-fetch
327
+ // timeout so a stalled directory can never hold the scoring path open.
328
+ function withTimeout(promise, ms) {
329
+ return new Promise((resolve, reject) => {
330
+ const timer = setTimeout(() => reject(new FetchFailure('verification timed out')), ms);
331
+ promise.then(
332
+ (v) => {
333
+ clearTimeout(timer);
334
+ resolve(v);
335
+ },
336
+ (e) => {
337
+ clearTimeout(timer);
338
+ reject(e);
339
+ },
340
+ );
341
+ });
342
+ }
343
+
344
+ module.exports = {
345
+ checkWebBotAuth,
346
+ // Exported for unit tests only.
347
+ _internal: {
348
+ isPublicUnicast,
349
+ directoryOriginFromHeader,
350
+ displayAgent,
351
+ verifiedDetection,
352
+ forgedDetection,
353
+ presenceDetection,
354
+ // Seeds the directory cache so an end-to-end verify can exercise resolveKey
355
+ // without a network fetch (the SSRF guard refuses the loopback address a
356
+ // local test server would listen on). Test-only.
357
+ seedDirectoryCache(origin, keys) {
358
+ directoryCache.set(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, {
359
+ keys,
360
+ fetchedAt: Date.now(),
361
+ });
362
+ },
363
+ },
364
+ };