@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.
- package/clientip.js +229 -0
- package/index.js +13 -15
- package/package.json +3 -2
- 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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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) :
|
|
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) :
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|