@webdecoy/fcaptcha 1.15.0 → 1.17.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/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/limits.js ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.15.0",
3
+ "version": "1.17.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,9 @@
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",
16
+ "test:limits": "node limits.test.js"
15
17
  },
16
18
  "dependencies": {
17
19
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -10,6 +10,8 @@ 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');
14
+ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
13
15
 
14
16
  const app = express();
15
17
  app.use(cors());
@@ -19,6 +21,20 @@ const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-producti
19
21
  const PORT = process.env.PORT || 3000;
20
22
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
21
23
 
24
+ // Which peers may speak for another client via X-Forwarded-For / X-Real-IP and
25
+ // the TLS-fingerprint headers. See clientip.js.
26
+ const PROXY_TRUST = ProxyTrust.fromEnv();
27
+
28
+ // siteKey is client-supplied and validated against no registry, yet it is the
29
+ // first component of every rate-limit, fingerprint and challenge partition key.
30
+ // SITE_KEYS bounds how many distinct values one IP may allocate state for. See
31
+ // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
32
+ const SITE_KEYS = SiteKeyGuard.fromEnv();
33
+
34
+ // Express's own `trust proxy` would re-derive req.ip from the same headers on
35
+ // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
36
+ app.set('trust proxy', false);
37
+
22
38
  // Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
23
39
  // logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
24
40
  // When on, each /api/verify and /api/score logs one privacy-safe JSON line
@@ -76,8 +92,8 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
76
92
 
77
93
  // PoW Challenge Store
78
94
  const powChallengeStore = {
79
- challenges: new Map(),
80
- usedSolutions: new Set(),
95
+ challenges: new BoundedMap(),
96
+ usedSolutions: new BoundedSet(),
81
97
 
82
98
  // Generate a new challenge
83
99
  generate(siteKey, ip, difficulty = 4) {
@@ -168,20 +184,15 @@ const powChallengeStore = {
168
184
 
169
185
  _cleanup() {
170
186
  const now = Date.now();
171
- for (const [id, challenge] of this.challenges) {
172
- if (now > challenge.expiresAt) {
173
- this.challenges.delete(id);
174
- }
175
- }
176
- // Cleanup old solutions (keep last hour)
177
- if (this.usedSolutions.size > 10000) {
178
- this.usedSolutions.clear();
179
- }
187
+ this.challenges.prune((challenge) => now <= challenge.expiresAt);
188
+ // usedSolutions is a BoundedSet: it evicts its own oldest entries as it
189
+ // fills. The previous code cleared the whole replay guard once it passed a
190
+ // threshold, which an attacker could force in order to replay a solution.
180
191
  }
181
192
  };
182
193
 
183
194
  const rateLimiter = {
184
- requests: new Map(),
195
+ requests: new BoundedMap(),
185
196
 
186
197
  check(key, windowSeconds = 60, maxRequests = 10) {
187
198
  const now = Date.now();
@@ -202,8 +213,8 @@ const rateLimiter = {
202
213
  };
203
214
 
204
215
  const fingerprintStore = {
205
- fingerprints: new Map(),
206
- ipFingerprints: new Map(),
216
+ fingerprints: new BoundedMap(),
217
+ ipFingerprints: new BoundedMap(),
207
218
 
208
219
  record(fp, ip, siteKey) {
209
220
  const key = `${siteKey}:${fp}`;
@@ -233,7 +244,7 @@ const fingerprintStore = {
233
244
 
234
245
  // Token Store - prevents token replay attacks
235
246
  const tokenStore = {
236
- usedTokens: new Set(),
247
+ usedTokens: new BoundedSet(),
237
248
 
238
249
  // Mark a token as used (returns false if already used)
239
250
  markUsed(tokenSig) {
@@ -252,11 +263,9 @@ const tokenStore = {
252
263
  },
253
264
 
254
265
  _cleanup() {
255
- // In production with Redis, use TTL instead
256
- // For in-memory, just clear if too large (tokens expire in 5 min)
257
- if (this.usedTokens.size > 50000) {
258
- this.usedTokens.clear();
259
- }
266
+ // In production with Redis, use TTL instead. In memory usedTokens is a
267
+ // BoundedSet, so it evicts its oldest entries rather than clearing wholesale
268
+ // — clearing would let an attacker who forced the threshold replay a token.
260
269
  }
261
270
  };
262
271
 
@@ -1235,25 +1244,33 @@ app.get('/health', (req, res) => {
1235
1244
  res.json({ status: 'ok' });
1236
1245
  });
1237
1246
 
1238
- app.post('/api/verify', async (req, res) => {
1239
- 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
- }
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;
1248
1258
  }
1259
+ return headers;
1260
+ }
1261
+
1262
+ app.post('/api/verify', async (req, res) => {
1263
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1264
+ const ip = PROXY_TRUST.clientIP(req);
1265
+ // Bound the state an unvalidated siteKey can allocate (limits.js).
1266
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1249
1267
  const userAgent = req.headers['user-agent'] || '';
1250
- const ja3Hash = req.headers['x-ja3-hash'] || null;
1268
+ // Only honoured from a trusted proxy: a client that can state its own TLS
1269
+ // fingerprint would just claim a stock Chrome one.
1270
+ const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1251
1271
 
1252
1272
  // 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
- }
1273
+ const headers = collectHeaders(req);
1257
1274
 
1258
1275
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1259
1276
  const webBotAuth = await verifyWebBotAuth(req);
@@ -1264,18 +1281,11 @@ app.post('/api/verify', async (req, res) => {
1264
1281
  });
1265
1282
 
1266
1283
  app.post('/api/score', async (req, res) => {
1267
- 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
- }
1284
+ const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1285
+ const ip = PROXY_TRUST.clientIP(req);
1286
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1277
1287
  const userAgent = req.headers['user-agent'] || '';
1278
- const ja3Hash = req.headers['x-ja3-hash'] || null;
1288
+ const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1279
1289
 
1280
1290
  const headers = {};
1281
1291
  for (const [key, value] of Object.entries(req.headers)) {
@@ -1300,32 +1310,15 @@ app.post('/api/token/verify', (req, res) => {
1300
1310
  const { token } = req.body;
1301
1311
 
1302
1312
  // 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
- }
1313
+ const ip = PROXY_TRUST.clientIP(req);
1312
1314
 
1313
1315
  res.json(verifyToken(token, ip));
1314
1316
  });
1315
1317
 
1316
1318
  // PoW Challenge endpoint - client fetches this on page load
1317
1319
  app.get('/api/pow/challenge', (req, res) => {
1318
- const siteKey = req.query.siteKey || 'default';
1319
-
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
- }
1320
+ const ip = PROXY_TRUST.clientIP(req);
1321
+ const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
1329
1322
 
1330
1323
  // Difficulty scaling based on IP reputation
1331
1324
  let difficulty = 4; // Default: ~100-500ms on average hardware
@@ -1373,4 +1366,6 @@ app.get('/api/challenge', (req, res) => {
1373
1366
 
1374
1367
  app.listen(PORT, () => {
1375
1368
  console.log(`FCaptcha server running on port ${PORT}`);
1369
+ console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1370
+ console.log(`Site keys: ${SITE_KEYS.describe()}`);
1376
1371
  });
package/webbotauth.js CHANGED
@@ -23,8 +23,33 @@
23
23
  //
24
24
  // The upstream package (cloudflare/web-bot-auth) is the reference implementation
25
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.
26
+ // servers agree on verdicts. Python stays presence-only for now, though a
27
+ // maintained RFC 9421 implementation (pyauth/http-message-signatures) now exists
28
+ // and would close that gap.
29
+ //
30
+ // Wire format — draft-meunier-webbotauth-httpsig-protocol-00 (2026-06-26):
31
+ // Signature-Agent is a Structured Fields Dictionary whose member value is the
32
+ // discovery URL and whose optional `type` parameter selects how to resolve keys:
33
+ //
34
+ // sig1="https://a.test" → directory (default)
35
+ // sig1="https://a.test/jwks.json";type=jwks_uri → the URL *is* a JWK Set
36
+ // sig1="https://a.test/card";type=cimd → Client ID Metadata Document
37
+ //
38
+ // The pre-00 bare-String form ("https://a.test") is still emitted by signers
39
+ // that have not migrated and stays supported. A parser that only understood the
40
+ // bare form silently lost the *verified* verdict for any signer using the two
41
+ // new discovery types, degrading them to presence-only.
42
+ //
43
+ // DOCUMENTED DIVERGENCE — `cimd`. This implementation resolves a Client ID
44
+ // Metadata Document (embedded `jwks`, or a nested `jwks_uri` re-fetched through
45
+ // the same guard). The Go module (WebDecoy/web-bot-auth v0.2.0) parses the type
46
+ // but returns "not resolvable", so Go fails open to presence-only where Node
47
+ // reaches verified. Go is otherwise fully protocol-00 compliant — it honors
48
+ // `directory` and `jwks_uri` and enforces the tag — so this is the single
49
+ // verdict difference. It is currently theoretical: no observed signer uses
50
+ // `cimd` (chatgpt.com and agent.bot.goog both serve `directory`). Parity
51
+ // requires a change in the Go module, not here. Tracked rather than silent, per
52
+ // the CLAUDE.md sync rule.
28
53
 
29
54
  const https = require('https');
30
55
  const dns = require('dns');
@@ -48,6 +73,23 @@ const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
48
73
  // signature. Everything else (fetch failure, expiry, missing key) fails open.
49
74
  const CRYPTO_FAILURE_MESSAGE = 'invalid signature';
50
75
 
76
+ // Discovery types from protocol-00. `directory` is the default when the header
77
+ // carries no `type` parameter.
78
+ const DISCOVERY_DIRECTORY = 'directory';
79
+ const DISCOVERY_JWKS_URI = 'jwks_uri';
80
+ const DISCOVERY_CIMD = 'cimd';
81
+
82
+ // protocol-00: `tag` MUST be "web-bot-auth". A signature tagged for some other
83
+ // protocol is not a web-bot-auth signature, so it earns no verified credit.
84
+ const REQUIRED_TAG = 'web-bot-auth';
85
+
86
+ // protocol-00 RECOMMENDS an expiry no more than 24h out. Because that is a
87
+ // RECOMMENDED and not a MUST, an over-long window is not treated as forgery or
88
+ // as a hard rejection — it just does not earn the *verified* verdict, and the
89
+ // request falls back to presence-only. That bounds the replay window a stolen
90
+ // signature buys without false-positiving a signer who picked a longer TTL.
91
+ const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
92
+
51
93
  // origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
52
94
  const directoryCache = new Map();
53
95
 
@@ -96,11 +138,14 @@ function guardedLookup(hostname, options, callback) {
96
138
 
97
139
  // --- directory fetching -------------------------------------------------------
98
140
 
99
- function fetchDirectory(directoryUrl) {
141
+ // fetchJSON retrieves and parses one https document under the SSRF guard, the
142
+ // size cap and the timeout. Shared by directory, jwks_uri and cimd fetches so a
143
+ // new discovery type cannot accidentally bypass the guard.
144
+ function fetchJSON(documentUrl) {
100
145
  return new Promise((resolve, reject) => {
101
146
  let url;
102
147
  try {
103
- url = new URL(directoryUrl);
148
+ url = new URL(documentUrl);
104
149
  } catch {
105
150
  reject(new FetchFailure('invalid directory URL'));
106
151
  return;
@@ -111,7 +156,7 @@ function fetchDirectory(directoryUrl) {
111
156
  }
112
157
 
113
158
  const req = https.get(
114
- directoryUrl,
159
+ documentUrl,
115
160
  {
116
161
  lookup: guardedLookup,
117
162
  timeout: VERIFY_TIMEOUT_MS,
@@ -138,8 +183,7 @@ function fetchDirectory(directoryUrl) {
138
183
  });
139
184
  res.on('end', () => {
140
185
  try {
141
- const doc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
- resolve(Array.isArray(doc.keys) ? doc.keys : []);
186
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
143
187
  } catch {
144
188
  reject(new FetchFailure('directory is not valid JSON'));
145
189
  }
@@ -156,14 +200,46 @@ function fetchDirectory(directoryUrl) {
156
200
  });
157
201
  }
158
202
 
159
- async function getDirectory(directoryUrl) {
203
+ // fetchKeySet pulls a JWK Set — the shape served by both the well-known
204
+ // directory and a jwks_uri.
205
+ async function fetchKeySet(url) {
206
+ const doc = await fetchJSON(url);
207
+ return doc && Array.isArray(doc.keys) ? doc.keys : [];
208
+ }
209
+
210
+ // fetchViaCIMD resolves a Client ID Metadata Document, which either embeds the
211
+ // JWK Set inline or points at one. The nested jwks_uri is re-validated and
212
+ // re-fetched through the same guard rather than trusted.
213
+ async function fetchViaCIMD(cimdUrl) {
214
+ const doc = await fetchJSON(cimdUrl);
215
+ if (doc && doc.jwks && Array.isArray(doc.jwks.keys)) {
216
+ return doc.jwks.keys;
217
+ }
218
+ if (doc && typeof doc.jwks_uri === 'string') {
219
+ let nested;
220
+ try {
221
+ nested = new URL(doc.jwks_uri);
222
+ } catch {
223
+ throw new FetchFailure('cimd jwks_uri is not a URL');
224
+ }
225
+ if (nested.protocol !== 'https:') {
226
+ throw new FetchFailure('cimd jwks_uri is not https');
227
+ }
228
+ return fetchKeySet(nested.toString());
229
+ }
230
+ throw new FetchFailure('cimd document has no jwks or jwks_uri');
231
+ }
232
+
233
+ // getKeys caches by resolved URL, so the three discovery types share one cache
234
+ // without colliding.
235
+ async function getKeys(url, type) {
160
236
  const now = Date.now();
161
- const cached = directoryCache.get(directoryUrl);
237
+ const cached = directoryCache.get(url);
162
238
  if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
163
239
  return cached.keys;
164
240
  }
165
- const keys = await fetchDirectory(directoryUrl);
166
- directoryCache.set(directoryUrl, { keys, fetchedAt: now });
241
+ const keys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
242
+ directoryCache.set(url, { keys, fetchedAt: now });
167
243
  return keys;
168
244
  }
169
245
 
@@ -173,11 +249,11 @@ async function getDirectory(directoryUrl) {
173
249
  // lacks the key throws a FetchFailure — treated as fail-open, matching the Go
174
250
  // engine, which counts only a crypto failure as forgery.
175
251
  async function resolveKey(signatureAgentHeader, keyid) {
176
- const origin = directoryOriginFromHeader(signatureAgentHeader);
177
- if (!origin) {
178
- throw new FetchFailure('no resolvable Signature-Agent directory');
252
+ const source = keySourceFromHeader(signatureAgentHeader);
253
+ if (!source) {
254
+ throw new FetchFailure('no resolvable Signature-Agent key source');
179
255
  }
180
- const keys = await getDirectory(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY);
256
+ const keys = await getKeys(source.url, source.type);
181
257
  for (const jwk of keys) {
182
258
  let thumbprint;
183
259
  try {
@@ -194,31 +270,63 @@ async function resolveKey(signatureAgentHeader, keyid) {
194
270
 
195
271
  // --- header + request helpers -------------------------------------------------
196
272
 
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 '';
273
+ // parseSignatureAgent reads the first member of the Signature-Agent Structured
274
+ // Fields Dictionary, returning its URL and discovery type. Accepts, in order of
275
+ // how signers actually emit them:
276
+ //
277
+ // sig1="https://a.test/jwks.json";type=jwks_uri protocol-00, typed
278
+ // sig1="https://a.test" protocol-00, default type
279
+ // "https://a.test" pre-00 bare String
280
+ //
281
+ // The previous implementation anchored on a closing quote at end-of-string, so
282
+ // any trailing parameter fell through to the raw header text and failed to
283
+ // parse as a URL. That silently cost the *verified* verdict.
284
+ function parseSignatureAgent(raw) {
285
+ if (!raw) return null;
202
286
  const s = String(raw).trim();
203
- const m = s.match(/^(?:[A-Za-z0-9_-]+=)?"(.*)"$/);
204
- return (m ? m[1] : s).trim();
287
+ if (!s) return null;
288
+ // Optional `key=` prefix, a quoted SF String, then optional ;params up to the
289
+ // next dictionary member.
290
+ const m = s.match(/^(?:[A-Za-z0-9_*-]+\s*=\s*)?"([^"]*)"((?:\s*;[^,]*)?)/);
291
+ if (!m) return null;
292
+ const url = m[1].trim();
293
+ if (!url) return null;
294
+ const typeMatch = (m[2] || '').match(/;\s*type\s*=\s*"?([A-Za-z0-9_-]+)"?/i);
295
+ const type = typeMatch ? typeMatch[1].toLowerCase() : DISCOVERY_DIRECTORY;
296
+ return { url, type };
205
297
  }
206
298
 
207
299
  function displayAgent(raw) {
208
- return unwrapSfString(raw) || 'unknown';
300
+ const parsed = parseSignatureAgent(raw);
301
+ return (parsed && parsed.url) || 'unknown';
209
302
  }
210
303
 
211
- function directoryOriginFromHeader(raw) {
212
- const val = unwrapSfString(raw);
213
- if (!val) return null;
304
+ // keySourceFromHeader resolves the header to the concrete https URL keys are
305
+ // fetched from, plus the discovery type that says how to interpret it. Returns
306
+ // null for anything unusable — a non-https URL, or a discovery type this build
307
+ // does not implement (guessing would be worse than failing open).
308
+ function keySourceFromHeader(raw) {
309
+ const parsed = parseSignatureAgent(raw);
310
+ if (!parsed) return null;
214
311
  let u;
215
312
  try {
216
- u = new URL(val);
313
+ u = new URL(parsed.url);
217
314
  } catch {
218
315
  return null;
219
316
  }
220
317
  if (u.protocol !== 'https:') return null;
221
- return u.origin;
318
+
319
+ switch (parsed.type) {
320
+ case DISCOVERY_DIRECTORY:
321
+ // The member names the signer's origin; keys live at the well-known path.
322
+ return { url: u.origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, type: DISCOVERY_DIRECTORY };
323
+ case DISCOVERY_JWKS_URI:
324
+ case DISCOVERY_CIMD:
325
+ // The member names the document itself — path and query are significant.
326
+ return { url: u.toString(), type: parsed.type };
327
+ default:
328
+ return null;
329
+ }
222
330
  }
223
331
 
224
332
  function forwardedScheme(req) {
@@ -298,6 +406,7 @@ async function checkWebBotAuth(req) {
298
406
  if (!sigAgentRaw) {
299
407
  throw new FetchFailure('no Signature-Agent to resolve key');
300
408
  }
409
+ assertWebBotAuthParams(params);
301
410
  const jwk = await resolveKey(sigAgentRaw, params.keyid);
302
411
  resolvedKeyId = params.keyid;
303
412
  resolvedAlg = jwk.alg || (jwk.crv === 'Ed25519' ? 'ed25519' : '');
@@ -323,6 +432,51 @@ async function checkWebBotAuth(req) {
323
432
  }
324
433
  }
325
434
 
435
+ // toEpochSeconds normalizes a signature timestamp to epoch seconds. RFC 9421
436
+ // puts integer seconds on the wire, but web-bot-auth hands the verifier callback
437
+ // `Date` objects — so a naive Number() yields milliseconds and any duration
438
+ // comparison is off by 1000×. Accepts Date, seconds, or milliseconds.
439
+ function toEpochSeconds(value) {
440
+ if (value instanceof Date) return Math.floor(value.getTime() / 1000);
441
+ const n = Number(value);
442
+ if (!Number.isFinite(n)) return NaN;
443
+ // Epoch seconds are ~1.8e9 today; epoch milliseconds ~1.8e12.
444
+ return n > 1e11 ? Math.floor(n / 1000) : n;
445
+ }
446
+
447
+ // assertWebBotAuthParams enforces the protocol-00 signature-parameter rules that
448
+ // the underlying library does not.
449
+ //
450
+ // web-bot-auth's own verify() already rejects a wrong `tag`, a `created` in the
451
+ // future, an expired `expires`, and a missing `keyid`. The tag check below is
452
+ // therefore belt-and-braces — kept because it is free and because it documents a
453
+ // MUST that our verdict depends on. What is genuinely additive is the lifetime
454
+ // ceiling: nothing upstream bounds how far out `expires` may sit, and a
455
+ // long-lived signature is a long-lived replay token.
456
+ //
457
+ // Throwing FetchFailure means "not a verifiable web-bot-auth signature", which
458
+ // the caller renders as presence-only — never as forgery, since neither a wrong
459
+ // tag nor a generous TTL is cryptographic proof that anyone spoofed anything.
460
+ function assertWebBotAuthParams(params) {
461
+ const p = params || {};
462
+
463
+ if (p.tag !== REQUIRED_TAG) {
464
+ throw new FetchFailure(`signature tag is not ${REQUIRED_TAG}`);
465
+ }
466
+
467
+ // `expires` is what bounds replay; a signature without one is unbounded.
468
+ const expires = toEpochSeconds(p.expires);
469
+ if (!Number.isFinite(expires)) {
470
+ throw new FetchFailure('signature has no expires parameter');
471
+ }
472
+ const created = Number.isFinite(toEpochSeconds(p.created))
473
+ ? toEpochSeconds(p.created)
474
+ : Math.floor(Date.now() / 1000);
475
+ if (expires - created > MAX_SIGNATURE_LIFETIME_S) {
476
+ throw new FetchFailure('signature lifetime exceeds 24h');
477
+ }
478
+ }
479
+
326
480
  // withTimeout bounds the whole verification, a backstop over the per-fetch
327
481
  // timeout so a stalled directory can never hold the scoring path open.
328
482
  function withTimeout(promise, ms) {
@@ -346,19 +500,24 @@ module.exports = {
346
500
  // Exported for unit tests only.
347
501
  _internal: {
348
502
  isPublicUnicast,
349
- directoryOriginFromHeader,
503
+ parseSignatureAgent,
504
+ keySourceFromHeader,
505
+ assertWebBotAuthParams,
350
506
  displayAgent,
351
507
  verifiedDetection,
352
508
  forgedDetection,
353
509
  presenceDetection,
354
- // Seeds the directory cache so an end-to-end verify can exercise resolveKey
510
+ REQUIRED_TAG,
511
+ MAX_SIGNATURE_LIFETIME_S,
512
+ // Seeds the key cache so an end-to-end verify can exercise resolveKey
355
513
  // without a network fetch (the SSRF guard refuses the loopback address a
356
514
  // 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
- });
515
+ //
516
+ // Seeds by *origin* for the default directory discovery. Pass a full URL as
517
+ // `exactUrl` to seed a jwks_uri/cimd source instead.
518
+ seedDirectoryCache(origin, keys, exactUrl) {
519
+ const url = exactUrl || origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY;
520
+ directoryCache.set(url, { keys, fetchedAt: Date.now() });
362
521
  },
363
522
  },
364
523
  };