@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 +229 -0
- package/index.js +13 -15
- package/limits.js +0 -0
- package/package.json +4 -2
- package/server.js +61 -66
- package/webbotauth.js +194 -35
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/limits.js
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
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
|
|
80
|
-
usedSolutions: new
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
206
|
-
ipFingerprints: new
|
|
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
|
|
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
|
-
//
|
|
257
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
27
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
166
|
-
directoryCache.set(
|
|
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
|
|
177
|
-
if (!
|
|
178
|
-
throw new FetchFailure('no resolvable Signature-Agent
|
|
252
|
+
const source = keySourceFromHeader(signatureAgentHeader);
|
|
253
|
+
if (!source) {
|
|
254
|
+
throw new FetchFailure('no resolvable Signature-Agent key source');
|
|
179
255
|
}
|
|
180
|
-
const keys = await
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
300
|
+
const parsed = parseSignatureAgent(raw);
|
|
301
|
+
return (parsed && parsed.url) || 'unknown';
|
|
209
302
|
}
|
|
210
303
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
313
|
+
u = new URL(parsed.url);
|
|
217
314
|
} catch {
|
|
218
315
|
return null;
|
|
219
316
|
}
|
|
220
317
|
if (u.protocol !== 'https:') return null;
|
|
221
|
-
|
|
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
|
-
|
|
503
|
+
parseSignatureAgent,
|
|
504
|
+
keySourceFromHeader,
|
|
505
|
+
assertWebBotAuthParams,
|
|
350
506
|
displayAgent,
|
|
351
507
|
verifiedDetection,
|
|
352
508
|
forgedDetection,
|
|
353
509
|
presenceDetection,
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
};
|