@webdecoy/fcaptcha 1.15.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.
Files changed (4) hide show
  1. package/clientip.js +229 -0
  2. package/index.js +13 -15
  3. package/package.json +3 -2
  4. package/server.js +34 -42
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/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.15.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": {
@@ -11,7 +11,8 @@
11
11
  "scripts": {
12
12
  "start": "node server.js",
13
13
  "dev": "node --watch server.js",
14
- "test:webbotauth": "node webbotauth.test.js"
14
+ "test:webbotauth": "node webbotauth.test.js",
15
+ "test:clientip": "node clientip.test.js"
15
16
  },
16
17
  "dependencies": {
17
18
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -10,6 +10,7 @@ const crypto = require('crypto');
10
10
  const path = require('path');
11
11
  const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
+ const { ProxyTrust } = require('./clientip');
13
14
 
14
15
  const app = express();
15
16
  app.use(cors());
@@ -19,6 +20,14 @@ const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-producti
19
20
  const PORT = process.env.PORT || 3000;
20
21
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
21
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
+
22
31
  // Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
23
32
  // logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
24
33
  // When on, each /api/verify and /api/score logs one privacy-safe JSON line
@@ -1235,25 +1244,31 @@ app.get('/health', (req, res) => {
1235
1244
  res.json({ status: 'ok' });
1236
1245
  });
1237
1246
 
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;
1258
+ }
1259
+ return headers;
1260
+ }
1261
+
1238
1262
  app.post('/api/verify', async (req, res) => {
1239
1263
  const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1240
- let ip = req.headers['x-real-ip'] || '';
1241
- if (!ip) {
1242
- const forwarded = req.headers['x-forwarded-for'];
1243
- if (forwarded) {
1244
- ip = forwarded.split(',')[0].trim();
1245
- } else {
1246
- ip = req.socket.remoteAddress;
1247
- }
1248
- }
1264
+ const ip = PROXY_TRUST.clientIP(req);
1249
1265
  const userAgent = req.headers['user-agent'] || '';
1250
- 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;
1251
1269
 
1252
1270
  // Collect headers for analysis
1253
- const headers = {};
1254
- for (const [key, value] of Object.entries(req.headers)) {
1255
- headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1256
- }
1271
+ const headers = collectHeaders(req);
1257
1272
 
1258
1273
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1259
1274
  const webBotAuth = await verifyWebBotAuth(req);
@@ -1265,17 +1280,9 @@ app.post('/api/verify', async (req, res) => {
1265
1280
 
1266
1281
  app.post('/api/score', async (req, res) => {
1267
1282
  const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1268
- let ip = req.headers['x-real-ip'] || '';
1269
- if (!ip) {
1270
- const forwarded = req.headers['x-forwarded-for'];
1271
- if (forwarded) {
1272
- ip = forwarded.split(',')[0].trim();
1273
- } else {
1274
- ip = req.socket.remoteAddress;
1275
- }
1276
- }
1283
+ const ip = PROXY_TRUST.clientIP(req);
1277
1284
  const userAgent = req.headers['user-agent'] || '';
1278
- const ja3Hash = req.headers['x-ja3-hash'] || null;
1285
+ const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1279
1286
 
1280
1287
  const headers = {};
1281
1288
  for (const [key, value] of Object.entries(req.headers)) {
@@ -1300,15 +1307,7 @@ app.post('/api/token/verify', (req, res) => {
1300
1307
  const { token } = req.body;
1301
1308
 
1302
1309
  // Extract client IP for verification
1303
- let ip = req.headers['x-real-ip'] || '';
1304
- if (!ip) {
1305
- const forwarded = req.headers['x-forwarded-for'];
1306
- if (forwarded) {
1307
- ip = forwarded.split(',')[0].trim();
1308
- } else {
1309
- ip = req.socket.remoteAddress;
1310
- }
1311
- }
1310
+ const ip = PROXY_TRUST.clientIP(req);
1312
1311
 
1313
1312
  res.json(verifyToken(token, ip));
1314
1313
  });
@@ -1317,15 +1316,7 @@ app.post('/api/token/verify', (req, res) => {
1317
1316
  app.get('/api/pow/challenge', (req, res) => {
1318
1317
  const siteKey = req.query.siteKey || 'default';
1319
1318
 
1320
- let ip = req.headers['x-real-ip'] || '';
1321
- if (!ip) {
1322
- const forwarded = req.headers['x-forwarded-for'];
1323
- if (forwarded) {
1324
- ip = forwarded.split(',')[0].trim();
1325
- } else {
1326
- ip = req.socket.remoteAddress;
1327
- }
1328
- }
1319
+ const ip = PROXY_TRUST.clientIP(req);
1329
1320
 
1330
1321
  // Difficulty scaling based on IP reputation
1331
1322
  let difficulty = 4; // Default: ~100-500ms on average hardware
@@ -1373,4 +1364,5 @@ app.get('/api/challenge', (req, res) => {
1373
1364
 
1374
1365
  app.listen(PORT, () => {
1375
1366
  console.log(`FCaptcha server running on port ${PORT}`);
1367
+ console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1376
1368
  });