@webdecoy/fcaptcha 1.14.0 → 1.15.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/detection.js +10 -18
- package/package.json +7 -4
- package/server.js +29 -6
- package/webbotauth.js +364 -0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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,15 @@
|
|
|
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"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
|
-
"express": "^4.18.2",
|
|
17
17
|
"cors": "^2.8.5",
|
|
18
|
-
"
|
|
18
|
+
"express": "^4.18.0",
|
|
19
|
+
"ipaddr.js": "^2.4.0",
|
|
20
|
+
"redis": "^4.6.12",
|
|
21
|
+
"web-bot-auth": "^0.1.3"
|
|
19
22
|
},
|
|
20
23
|
"peerDependencies": {
|
|
21
24
|
"express": "^4.18.0"
|
package/server.js
CHANGED
|
@@ -9,6 +9,7 @@ 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');
|
|
12
13
|
|
|
13
14
|
const app = express();
|
|
14
15
|
app.use(cors());
|
|
@@ -1044,8 +1045,24 @@ function verifyToken(token, ip = null) {
|
|
|
1044
1045
|
}
|
|
1045
1046
|
}
|
|
1046
1047
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1048
|
+
// verifyWebBotAuth runs Web Bot Auth verification and never throws into the
|
|
1049
|
+
// request path: any unexpected error degrades to no detection (fail open), so a
|
|
1050
|
+
// verification bug can't turn a scoring request into a 500.
|
|
1051
|
+
async function verifyWebBotAuth(req) {
|
|
1052
|
+
try {
|
|
1053
|
+
return await webbotauth.checkWebBotAuth(req);
|
|
1054
|
+
} catch (e) {
|
|
1055
|
+
console.error('[webbotauth] verification error:', e && e.message);
|
|
1056
|
+
return [];
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// preDetections are detections computed by the caller (the route handler) that
|
|
1061
|
+
// require the raw request — currently Web Bot Auth signature verification, which
|
|
1062
|
+
// needs the accurately-reconstructed signed request. They are seeded into the
|
|
1063
|
+
// detection set so they participate in scoring like any other detection.
|
|
1064
|
+
function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = []) {
|
|
1065
|
+
const detections = Array.isArray(preDetections) ? [...preDetections] : [];
|
|
1049
1066
|
|
|
1050
1067
|
// Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
|
|
1051
1068
|
const clientSignalsHash = powSolution?.signalsHash || null;
|
|
@@ -1218,7 +1235,7 @@ app.get('/health', (req, res) => {
|
|
|
1218
1235
|
res.json({ status: 'ok' });
|
|
1219
1236
|
});
|
|
1220
1237
|
|
|
1221
|
-
app.post('/api/verify', (req, res) => {
|
|
1238
|
+
app.post('/api/verify', async (req, res) => {
|
|
1222
1239
|
const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1223
1240
|
let ip = req.headers['x-real-ip'] || '';
|
|
1224
1241
|
if (!ip) {
|
|
@@ -1238,12 +1255,15 @@ app.post('/api/verify', (req, res) => {
|
|
|
1238
1255
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
1239
1256
|
}
|
|
1240
1257
|
|
|
1241
|
-
|
|
1258
|
+
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1259
|
+
const webBotAuth = await verifyWebBotAuth(req);
|
|
1260
|
+
|
|
1261
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
|
|
1242
1262
|
logVerdict('verify', siteKey, result);
|
|
1243
1263
|
res.json(result);
|
|
1244
1264
|
});
|
|
1245
1265
|
|
|
1246
|
-
app.post('/api/score', (req, res) => {
|
|
1266
|
+
app.post('/api/score', async (req, res) => {
|
|
1247
1267
|
const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1248
1268
|
let ip = req.headers['x-real-ip'] || '';
|
|
1249
1269
|
if (!ip) {
|
|
@@ -1262,7 +1282,10 @@ app.post('/api/score', (req, res) => {
|
|
|
1262
1282
|
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
1263
1283
|
}
|
|
1264
1284
|
|
|
1265
|
-
|
|
1285
|
+
// Web Bot Auth verification needs the raw request; its verdict is scored.
|
|
1286
|
+
const webBotAuth = await verifyWebBotAuth(req);
|
|
1287
|
+
|
|
1288
|
+
const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
|
|
1266
1289
|
logVerdict('score', siteKey, result);
|
|
1267
1290
|
res.json({
|
|
1268
1291
|
success: result.success,
|
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
|
+
};
|