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