@blamejs/core 0.11.23 → 0.11.25
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/CHANGELOG.md +4 -0
- package/index.js +8 -0
- package/lib/auth/bot-challenge.js +573 -0
- package/lib/framework-error.js +6 -0
- package/lib/fsm.js +469 -0
- package/lib/guard-mail-query.js +14 -0
- package/lib/mail-agent.js +24 -10
- package/lib/mail-send-deliver.js +629 -0
- package/lib/mail-store-fts.js +394 -0
- package/lib/mail-store.js +142 -4
- package/lib/money.js +699 -0
- package/lib/webhook.js +229 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.auth.botChallenge
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title Bot Challenge Verifier
|
|
6
|
+
* @order 375
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Server-side verifier for the modern privacy-preserving bot-
|
|
10
|
+
* challenge widgets: Cloudflare Turnstile, hCaptcha, and Google
|
|
11
|
+
* reCAPTCHA v3. The client-side widget produces a short-lived
|
|
12
|
+
* token; the server POSTs that token (along with the operator's
|
|
13
|
+
* secret + optionally the remote IP) to the provider's siteverify
|
|
14
|
+
* endpoint and inspects the verdict.
|
|
15
|
+
*
|
|
16
|
+
* Why a verifier and not a heuristic — `b.middleware.botGuard`
|
|
17
|
+
* inspects User-Agent / Accept-Language / fetch-metadata for
|
|
18
|
+
* stale crawlers, but a determined adversary forges those bytes
|
|
19
|
+
* trivially. A widget-issued token is a cryptographic claim from
|
|
20
|
+
* the provider that the request originated from a human (or a
|
|
21
|
+
* passable approximation under reCAPTCHA-v3's score model).
|
|
22
|
+
*
|
|
23
|
+
* The verifier:
|
|
24
|
+
*
|
|
25
|
+
* - POSTs the token via `b.httpClient` — every outbound hop
|
|
26
|
+
* goes through `b.ssrfGuard` + the framework's DNS pinning,
|
|
27
|
+
* so a redirect to a cloud-metadata endpoint can't smuggle
|
|
28
|
+
* past the first-hop gate. Raw `node:http` / `node:https` /
|
|
29
|
+
* global `fetch` is never used.
|
|
30
|
+
* - Sends the secret in the POST body as
|
|
31
|
+
* `application/x-www-form-urlencoded` (Cloudflare's
|
|
32
|
+
* documented shape). The secret never appears in the URL,
|
|
33
|
+
* query string, headers, log lines, or audit metadata.
|
|
34
|
+
* - Refuses a token that is not a non-empty string under
|
|
35
|
+
* `MAX_TOKEN_BYTES` (4 KiB) — Cloudflare tokens cap around
|
|
36
|
+
* 2 KiB; a 1 MiB "token" is operator misuse or an attack.
|
|
37
|
+
* - Validates `success === true` AND (when configured)
|
|
38
|
+
* hostname-in-allowlist AND action-in-allowlist before
|
|
39
|
+
* returning. The provider's hostname / action fields are
|
|
40
|
+
* embedded in the token by the widget; operators using
|
|
41
|
+
* multi-domain or multi-action deployments allowlist the
|
|
42
|
+
* expected values to refuse cross-site token replay.
|
|
43
|
+
* - For reCAPTCHA-v3, exposes the `score` (0.0–1.0) on the
|
|
44
|
+
* success shape so the operator can threshold per-route.
|
|
45
|
+
* - Audits every verify call drop-silent via
|
|
46
|
+
* `b.audit.safeEmit` (action `auth.bot_challenge.verify`,
|
|
47
|
+
* outcome `success` / `failure`, metadata
|
|
48
|
+
* `{ provider, hostname?, ok, errorCodes? }`). The token
|
|
49
|
+
* and secret NEVER appear in audit metadata; only the
|
|
50
|
+
* token's 8-char prefix surfaces, and only when the operator
|
|
51
|
+
* has opted into trace-level metadata.
|
|
52
|
+
*
|
|
53
|
+
* Compose with `b.authBotChallenge` (the adaptive staircase gate)
|
|
54
|
+
* by passing the verifier's `verify` function as the staircase's
|
|
55
|
+
* `challengeFn` — failed-auth attempts ride the staircase up to
|
|
56
|
+
* the challenge stage, the operator renders the Turnstile widget,
|
|
57
|
+
* and the verifier validates the resulting token. The two
|
|
58
|
+
* primitives are deliberately separate concerns.
|
|
59
|
+
*
|
|
60
|
+
* References:
|
|
61
|
+
* - Cloudflare Turnstile siteverify
|
|
62
|
+
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
|
63
|
+
* - hCaptcha siteverify
|
|
64
|
+
* https://docs.hcaptcha.com/#verify-the-user-response-server-side
|
|
65
|
+
* - reCAPTCHA v3 siteverify
|
|
66
|
+
* https://developers.google.com/recaptcha/docs/v3
|
|
67
|
+
* - OWASP ASVS v5 §11.5 (bot-defense controls)
|
|
68
|
+
* - RFC 6749 §4.1.3 (`application/x-www-form-urlencoded` body
|
|
69
|
+
* conventions for OAuth-style endpoints)
|
|
70
|
+
*
|
|
71
|
+
* @card
|
|
72
|
+
* Server-side verifier for Cloudflare Turnstile / hCaptcha / reCAPTCHA-v3 widget tokens with SSRF-guarded outbound, hostname + action allowlists, and drop-silent audit.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
var nodeQuerystring = require("node:querystring");
|
|
76
|
+
|
|
77
|
+
var lazyRequire = require("../lazy-require");
|
|
78
|
+
var validateOpts = require("../validate-opts");
|
|
79
|
+
var safeJson = require("../safe-json");
|
|
80
|
+
var C = require("../constants");
|
|
81
|
+
var { BotChallengeError } = require("../framework-error");
|
|
82
|
+
|
|
83
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
84
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
85
|
+
|
|
86
|
+
// ---- constants ----
|
|
87
|
+
|
|
88
|
+
// Token byte ceiling. Turnstile tokens hover around 2 KiB; hCaptcha is
|
|
89
|
+
// similar; reCAPTCHA-v3 tokens are slightly larger but well under 4 KiB.
|
|
90
|
+
// A token that exceeds this cap is operator misuse (passed the wrong
|
|
91
|
+
// field) or a probe — refuse at the boundary rather than forwarding
|
|
92
|
+
// kilobytes of operator-supplied bytes to the provider.
|
|
93
|
+
var MAX_TOKEN_BYTES = C.BYTES.kib(4);
|
|
94
|
+
|
|
95
|
+
// Default wall-clock timeout for the siteverify round-trip. Five seconds
|
|
96
|
+
// is the documented Cloudflare service-level target with healthy
|
|
97
|
+
// headroom; operators can override per-call but cannot drop below
|
|
98
|
+
// MIN_TIMEOUT_MS without the create() factory refusing the opts.
|
|
99
|
+
var DEFAULT_TIMEOUT_MS = C.TIME.seconds(5);
|
|
100
|
+
var MIN_TIMEOUT_MS = 500; // anti-misconfiguration floor // allow:raw-byte-literal — 500ms wall-clock floor, not a byte literal
|
|
101
|
+
|
|
102
|
+
// Response-body cap. Provider siteverify responses are small JSON
|
|
103
|
+
// (well under 4 KiB); a multi-MiB response is either a redirect to
|
|
104
|
+
// HTML (shouldn't happen — providers terminate JSON-only) or an
|
|
105
|
+
// attacker-shaped probe via DNS poisoning.
|
|
106
|
+
var MAX_RESPONSE_BYTES = C.BYTES.kib(64);
|
|
107
|
+
|
|
108
|
+
// Allowed siteverify response Content-Type — providers return
|
|
109
|
+
// `application/json` (Cloudflare + hCaptcha) or
|
|
110
|
+
// `application/json; charset=utf-8` (Google). The verifier rejects
|
|
111
|
+
// other content types rather than attempting to parse arbitrary bodies.
|
|
112
|
+
var EXPECTED_CONTENT_TYPE_PREFIX = "application/json";
|
|
113
|
+
|
|
114
|
+
// Number of characters of the token's prefix that surface in audit
|
|
115
|
+
// metadata for diagnosability. Eight characters is small enough that
|
|
116
|
+
// the surfaced bytes are not the secret token (≈ 48 bits visible vs.
|
|
117
|
+
// ~2 KiB total), but large enough to cluster verifications belonging
|
|
118
|
+
// to the same widget render in a debug session.
|
|
119
|
+
var TOKEN_PREFIX_AUDIT_CHARS = 8; // allow:raw-byte-literal — debug-prefix length, not a byte literal
|
|
120
|
+
|
|
121
|
+
// ---- provider catalog ----
|
|
122
|
+
//
|
|
123
|
+
// Each provider entry exposes:
|
|
124
|
+
// endpoint — the siteverify URL (https only)
|
|
125
|
+
// contentTypeBody — the POST body's Content-Type
|
|
126
|
+
// parseResponse(body, raw) → { ok, hostname, action, ts, score?, errorCodes }
|
|
127
|
+
//
|
|
128
|
+
// The parseResponse contract is uniform across providers even though
|
|
129
|
+
// each provider's response shape differs slightly. Cloudflare /
|
|
130
|
+
// hCaptcha return `{ success, hostname, action, challenge_ts,
|
|
131
|
+
// "error-codes" }`; Google reCAPTCHA-v3 also returns `score` (and
|
|
132
|
+
// `action` is REQUIRED for v3).
|
|
133
|
+
//
|
|
134
|
+
// Each parseResponse normalises the raw body into the unified shape.
|
|
135
|
+
// `errorCodes` is always an array of strings (possibly empty); `ok`
|
|
136
|
+
// reflects the provider's success flag verbatim with no operator-
|
|
137
|
+
// configurable bypass.
|
|
138
|
+
|
|
139
|
+
function _parseCloudflareLike(rawObj) {
|
|
140
|
+
var errorCodes = [];
|
|
141
|
+
var raw = rawObj && typeof rawObj === "object" ? rawObj : {};
|
|
142
|
+
if (Array.isArray(raw["error-codes"])) {
|
|
143
|
+
for (var i = 0; i < raw["error-codes"].length; i++) {
|
|
144
|
+
var ec = raw["error-codes"][i];
|
|
145
|
+
if (typeof ec === "string") errorCodes.push(ec);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: raw.success === true,
|
|
150
|
+
hostname: typeof raw.hostname === "string" ? raw.hostname : null,
|
|
151
|
+
action: typeof raw.action === "string" ? raw.action : null,
|
|
152
|
+
challengeTs: typeof raw.challenge_ts === "string" ? raw.challenge_ts : null,
|
|
153
|
+
score: null,
|
|
154
|
+
errorCodes: errorCodes,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _parseRecaptchaV3(rawObj) {
|
|
159
|
+
var base = _parseCloudflareLike(rawObj);
|
|
160
|
+
var raw = rawObj && typeof rawObj === "object" ? rawObj : {};
|
|
161
|
+
if (typeof raw.score === "number" && isFinite(raw.score)) {
|
|
162
|
+
base.score = raw.score;
|
|
163
|
+
}
|
|
164
|
+
return base;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var PROVIDERS = Object.freeze({
|
|
168
|
+
"turnstile": Object.freeze({
|
|
169
|
+
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
170
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
171
|
+
parseResponse: _parseCloudflareLike,
|
|
172
|
+
}),
|
|
173
|
+
"hcaptcha": Object.freeze({
|
|
174
|
+
endpoint: "https://api.hcaptcha.com/siteverify",
|
|
175
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
176
|
+
parseResponse: _parseCloudflareLike,
|
|
177
|
+
}),
|
|
178
|
+
"recaptcha-v3": Object.freeze({
|
|
179
|
+
endpoint: "https://www.google.com/recaptcha/api/siteverify",
|
|
180
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
181
|
+
parseResponse: _parseRecaptchaV3,
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
var DEFAULT_PROVIDER = "turnstile";
|
|
186
|
+
|
|
187
|
+
var ALLOWED_CREATE_OPTS = [
|
|
188
|
+
"secret", "provider", "httpClient", "timeoutMs",
|
|
189
|
+
"allowedHostnames", "allowedActions", "audit",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
var ALLOWED_VERIFY_OPTS = [
|
|
193
|
+
"remoteIp", "expectedAction", "expectedHostname",
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
// ---- helpers ----
|
|
197
|
+
|
|
198
|
+
function _requireNonEmptyString(name, val) {
|
|
199
|
+
if (typeof val !== "string" || val.length === 0) {
|
|
200
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
201
|
+
name + ": expected non-empty string, got " + typeof val);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _normaliseAllowlist(name, val) {
|
|
206
|
+
if (val === undefined || val === null) return null;
|
|
207
|
+
if (!Array.isArray(val)) {
|
|
208
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
209
|
+
name + ": expected array of strings or null/undefined, got " + typeof val);
|
|
210
|
+
}
|
|
211
|
+
var out = [];
|
|
212
|
+
for (var i = 0; i < val.length; i++) {
|
|
213
|
+
var entry = val[i];
|
|
214
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
215
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
216
|
+
name + "[" + i + "]: expected non-empty string");
|
|
217
|
+
}
|
|
218
|
+
out.push(entry);
|
|
219
|
+
}
|
|
220
|
+
if (out.length === 0) {
|
|
221
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
222
|
+
name + ": allowlist must contain at least one entry when set");
|
|
223
|
+
}
|
|
224
|
+
return Object.freeze(out);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _httpClientShape(client, callerLabel) {
|
|
228
|
+
if (client === undefined || client === null) return null;
|
|
229
|
+
if (typeof client !== "object" || typeof client.request !== "function") {
|
|
230
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
231
|
+
callerLabel + ": httpClient must be a b.httpClient-shaped object (request fn)");
|
|
232
|
+
}
|
|
233
|
+
return client;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _normaliseTimeoutMs(val) {
|
|
237
|
+
if (val === undefined || val === null) return DEFAULT_TIMEOUT_MS;
|
|
238
|
+
if (typeof val !== "number" || !isFinite(val) || val < MIN_TIMEOUT_MS ||
|
|
239
|
+
Math.floor(val) !== val) {
|
|
240
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
241
|
+
"timeoutMs: expected integer >= " + MIN_TIMEOUT_MS + " ms, got " +
|
|
242
|
+
JSON.stringify(val));
|
|
243
|
+
}
|
|
244
|
+
return val;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _byteLengthOf(s) {
|
|
248
|
+
// Conservative UTF-8 byte count. Buffer.byteLength is the right
|
|
249
|
+
// tool here because Turnstile tokens are ASCII-base64url today —
|
|
250
|
+
// but the contract is in bytes, not chars.
|
|
251
|
+
return Buffer.byteLength(s, "utf8");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _isTimeoutError(err) {
|
|
255
|
+
if (!err) return false;
|
|
256
|
+
if (err.code === "TIMEOUT" || err.code === "WALL_CLOCK_TIMEOUT" ||
|
|
257
|
+
err.code === "IDLE_TIMEOUT") return true;
|
|
258
|
+
if (err.name === "AbortError") return true;
|
|
259
|
+
var msg = err.message || "";
|
|
260
|
+
return /timeout|timed out|aborted/i.test(msg);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _safeAudit(safeEmit, action, outcome, metadata) {
|
|
264
|
+
if (typeof safeEmit !== "function") return;
|
|
265
|
+
try {
|
|
266
|
+
safeEmit({
|
|
267
|
+
action: action,
|
|
268
|
+
outcome: outcome,
|
|
269
|
+
metadata: metadata || {},
|
|
270
|
+
});
|
|
271
|
+
} catch (_e) { /* drop-silent — audit is best-effort */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _resolveAuditSafeEmit(auditOpt) {
|
|
275
|
+
// Operator-supplied audit takes precedence (mirrors `b.audit` shape).
|
|
276
|
+
// Fall back to the framework's global `b.audit` via lazyRequire so
|
|
277
|
+
// the verifier emits without explicit wiring. Drop-silent on every
|
|
278
|
+
// failure path (per validation-tier rule §5, hot-path observability
|
|
279
|
+
// sinks NEVER throw).
|
|
280
|
+
if (auditOpt && typeof auditOpt.safeEmit === "function") {
|
|
281
|
+
return auditOpt.safeEmit.bind(auditOpt);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
var global = audit();
|
|
285
|
+
if (global && typeof global.safeEmit === "function") {
|
|
286
|
+
return global.safeEmit.bind(global);
|
|
287
|
+
}
|
|
288
|
+
} catch (_e) { /* no global audit available */ }
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- public surface ----
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @primitive b.auth.botChallenge.create
|
|
296
|
+
* @signature b.auth.botChallenge.create(opts)
|
|
297
|
+
* @since 0.11.25
|
|
298
|
+
* @status stable
|
|
299
|
+
* @compliance gdpr, soc2
|
|
300
|
+
* @related b.authBotChallenge.create, b.middleware.botGuard, b.httpClient
|
|
301
|
+
*
|
|
302
|
+
* Build a server-side verifier for a bot-challenge widget token.
|
|
303
|
+
* Returns `{ verify(token, verifyOpts?) }`. The factory throws on
|
|
304
|
+
* malformed opts; `verify` throws a typed `BotChallengeError` on
|
|
305
|
+
* any verification failure and resolves on success.
|
|
306
|
+
*
|
|
307
|
+
* @opts
|
|
308
|
+
* secret: string, // provider-issued site secret — preserved verbatim
|
|
309
|
+
* provider: string, // "turnstile" | "hcaptcha" | "recaptcha-v3" (default "turnstile")
|
|
310
|
+
* httpClient: Object, // b.httpClient-shaped { request } — default: framework http-client
|
|
311
|
+
* timeoutMs: number, // wall-clock cap for the siteverify call (default 5_000; minimum 500)
|
|
312
|
+
* allowedHostnames: string[], // optional hostname allowlist — verify refuses tokens whose embedded hostname is absent
|
|
313
|
+
* allowedActions: string[], // optional action allowlist — verify refuses tokens whose embedded action is absent
|
|
314
|
+
* audit: Object, // optional b.audit-shaped sink; defaults to framework global b.audit
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* var verifier = b.auth.botChallenge.create({
|
|
318
|
+
* secret: process.env.TURNSTILE_SECRET,
|
|
319
|
+
* provider: "turnstile",
|
|
320
|
+
* allowedHostnames: ["app.example.com"],
|
|
321
|
+
* allowedActions: ["login", "signup"],
|
|
322
|
+
* });
|
|
323
|
+
*
|
|
324
|
+
* // In a login handler:
|
|
325
|
+
* try {
|
|
326
|
+
* var verdict = await verifier.verify(req.body["cf-turnstile-response"], {
|
|
327
|
+
* remoteIp: b.requestHelpers.clientIp(req),
|
|
328
|
+
* expectedAction: "login",
|
|
329
|
+
* });
|
|
330
|
+
* // verdict.ok === true; verdict.hostname / verdict.action / verdict.challengeTs populated.
|
|
331
|
+
* } catch (e) {
|
|
332
|
+
* // e instanceof b.auth.botChallenge.BotChallengeError
|
|
333
|
+
* // e.code === "bot-challenge/invalid-token" (or hostname-mismatch / timeout / etc.)
|
|
334
|
+
* }
|
|
335
|
+
*/
|
|
336
|
+
function create(opts) {
|
|
337
|
+
opts = opts || {};
|
|
338
|
+
validateOpts(opts, ALLOWED_CREATE_OPTS, "auth.botChallenge.create");
|
|
339
|
+
|
|
340
|
+
_requireNonEmptyString("secret", opts.secret);
|
|
341
|
+
// Preserve the secret verbatim — provider secrets are
|
|
342
|
+
// case-sensitive and carry no canonical-form transformation rule.
|
|
343
|
+
var secret = opts.secret;
|
|
344
|
+
|
|
345
|
+
var providerKey = opts.provider !== undefined ? opts.provider : DEFAULT_PROVIDER;
|
|
346
|
+
if (typeof providerKey !== "string" || !PROVIDERS[providerKey]) {
|
|
347
|
+
var supported = Object.keys(PROVIDERS).join(", ");
|
|
348
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
349
|
+
"provider: expected one of [" + supported + "], got " + JSON.stringify(providerKey));
|
|
350
|
+
}
|
|
351
|
+
var providerSpec = PROVIDERS[providerKey];
|
|
352
|
+
|
|
353
|
+
var client = _httpClientShape(opts.httpClient, "auth.botChallenge.create") || httpClient();
|
|
354
|
+
var timeoutMs = _normaliseTimeoutMs(opts.timeoutMs);
|
|
355
|
+
var allowedHostnames = _normaliseAllowlist("allowedHostnames", opts.allowedHostnames);
|
|
356
|
+
var allowedActions = _normaliseAllowlist("allowedActions", opts.allowedActions);
|
|
357
|
+
|
|
358
|
+
if (opts.audit !== undefined) {
|
|
359
|
+
validateOpts.auditShape(opts.audit, "auth.botChallenge.create", BotChallengeError);
|
|
360
|
+
}
|
|
361
|
+
var safeEmit = _resolveAuditSafeEmit(opts.audit);
|
|
362
|
+
|
|
363
|
+
async function verify(token, verifyOpts) {
|
|
364
|
+
verifyOpts = verifyOpts || {};
|
|
365
|
+
validateOpts(verifyOpts, ALLOWED_VERIFY_OPTS, "auth.botChallenge.verify");
|
|
366
|
+
|
|
367
|
+
var tokenPrefix = (typeof token === "string"
|
|
368
|
+
? token.slice(0, TOKEN_PREFIX_AUDIT_CHARS)
|
|
369
|
+
: "");
|
|
370
|
+
|
|
371
|
+
// Boundary refusals — every reject here is typed + audited.
|
|
372
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
373
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
374
|
+
provider: providerKey, ok: false, reason: "empty-token",
|
|
375
|
+
});
|
|
376
|
+
throw new BotChallengeError("bot-challenge/invalid-token",
|
|
377
|
+
"token must be a non-empty string");
|
|
378
|
+
}
|
|
379
|
+
if (_byteLengthOf(token) > MAX_TOKEN_BYTES) {
|
|
380
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
381
|
+
provider: providerKey, ok: false, reason: "token-too-large",
|
|
382
|
+
prefix: tokenPrefix,
|
|
383
|
+
});
|
|
384
|
+
throw new BotChallengeError("bot-challenge/invalid-token",
|
|
385
|
+
"token exceeds " + MAX_TOKEN_BYTES + " bytes");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
var expectedAction = verifyOpts.expectedAction !== undefined
|
|
389
|
+
? verifyOpts.expectedAction : null;
|
|
390
|
+
if (expectedAction !== null && (typeof expectedAction !== "string" ||
|
|
391
|
+
expectedAction.length === 0)) {
|
|
392
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
393
|
+
"expectedAction: expected non-empty string");
|
|
394
|
+
}
|
|
395
|
+
var expectedHostname = verifyOpts.expectedHostname !== undefined
|
|
396
|
+
? verifyOpts.expectedHostname : null;
|
|
397
|
+
if (expectedHostname !== null && (typeof expectedHostname !== "string" ||
|
|
398
|
+
expectedHostname.length === 0)) {
|
|
399
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
400
|
+
"expectedHostname: expected non-empty string");
|
|
401
|
+
}
|
|
402
|
+
if (verifyOpts.remoteIp !== undefined && verifyOpts.remoteIp !== null &&
|
|
403
|
+
(typeof verifyOpts.remoteIp !== "string" || verifyOpts.remoteIp.length === 0)) {
|
|
404
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
405
|
+
"remoteIp: expected non-empty string when set");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Compose the application/x-www-form-urlencoded body. The secret
|
|
409
|
+
// is in the body — never the URL/query/headers/audit metadata.
|
|
410
|
+
var bodyFields = { secret: secret, response: token };
|
|
411
|
+
if (verifyOpts.remoteIp) bodyFields.remoteip = verifyOpts.remoteIp;
|
|
412
|
+
var body = nodeQuerystring.stringify(bodyFields);
|
|
413
|
+
|
|
414
|
+
var res;
|
|
415
|
+
try {
|
|
416
|
+
res = await client.request({
|
|
417
|
+
method: "POST",
|
|
418
|
+
url: providerSpec.endpoint,
|
|
419
|
+
body: body,
|
|
420
|
+
headers: { "Content-Type": providerSpec.contentTypeBody },
|
|
421
|
+
timeoutMs: timeoutMs,
|
|
422
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (_isTimeoutError(e)) {
|
|
426
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
427
|
+
provider: providerKey, ok: false, reason: "timeout",
|
|
428
|
+
prefix: tokenPrefix,
|
|
429
|
+
});
|
|
430
|
+
throw new BotChallengeError("bot-challenge/timeout",
|
|
431
|
+
"siteverify timed out after " + timeoutMs + " ms");
|
|
432
|
+
}
|
|
433
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
434
|
+
provider: providerKey, ok: false, reason: "transport",
|
|
435
|
+
prefix: tokenPrefix,
|
|
436
|
+
// Surface the underlying message but not the secret/token bytes.
|
|
437
|
+
message: (e && e.message) || String(e),
|
|
438
|
+
});
|
|
439
|
+
throw new BotChallengeError("bot-challenge/transport-error",
|
|
440
|
+
"siteverify transport failure: " + ((e && e.message) || String(e)));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { // allow:raw-byte-literal — HTTP 2xx range bounds
|
|
444
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
445
|
+
provider: providerKey, ok: false, reason: "non-2xx",
|
|
446
|
+
statusCode: res.statusCode, prefix: tokenPrefix,
|
|
447
|
+
});
|
|
448
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
449
|
+
"siteverify returned non-2xx status " + res.statusCode);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Defensive Content-Type guard — providers return JSON.
|
|
453
|
+
var ctHeader = (res.headers && (res.headers["content-type"] ||
|
|
454
|
+
res.headers["Content-Type"])) || "";
|
|
455
|
+
if (typeof ctHeader !== "string" ||
|
|
456
|
+
ctHeader.toLowerCase().indexOf(EXPECTED_CONTENT_TYPE_PREFIX) !== 0) {
|
|
457
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
458
|
+
provider: providerKey, ok: false, reason: "bad-content-type",
|
|
459
|
+
contentType: ctHeader, prefix: tokenPrefix,
|
|
460
|
+
});
|
|
461
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
462
|
+
"siteverify returned non-JSON Content-Type: " + ctHeader);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
var raw;
|
|
466
|
+
try {
|
|
467
|
+
var bodyText = Buffer.isBuffer(res.body)
|
|
468
|
+
? res.body.toString("utf8")
|
|
469
|
+
: String(res.body || "");
|
|
470
|
+
raw = safeJson.parse(bodyText, { maxBytes: MAX_RESPONSE_BYTES });
|
|
471
|
+
} catch (e) {
|
|
472
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
473
|
+
provider: providerKey, ok: false, reason: "parse-error",
|
|
474
|
+
prefix: tokenPrefix,
|
|
475
|
+
});
|
|
476
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
477
|
+
"siteverify response parse failed: " + ((e && e.message) || String(e)));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
var parsed = providerSpec.parseResponse(raw, res);
|
|
481
|
+
|
|
482
|
+
if (!parsed.ok) {
|
|
483
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
484
|
+
provider: providerKey, ok: false, reason: "provider-rejected",
|
|
485
|
+
errorCodes: parsed.errorCodes, hostname: parsed.hostname,
|
|
486
|
+
prefix: tokenPrefix,
|
|
487
|
+
});
|
|
488
|
+
var err = new BotChallengeError("bot-challenge/invalid-token",
|
|
489
|
+
"siteverify rejected token: " +
|
|
490
|
+
(parsed.errorCodes.length ? parsed.errorCodes.join(",") : "(no error-codes)"));
|
|
491
|
+
err.errorCodes = parsed.errorCodes;
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Hostname allowlist — factory-configured allowlist OR per-call
|
|
496
|
+
// expectedHostname (the per-call value overrides the allowlist for
|
|
497
|
+
// exact-match in the same call but does not relax the allowlist).
|
|
498
|
+
if (allowedHostnames && (!parsed.hostname ||
|
|
499
|
+
allowedHostnames.indexOf(parsed.hostname) === -1)) {
|
|
500
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
501
|
+
provider: providerKey, ok: false, reason: "hostname-mismatch",
|
|
502
|
+
hostname: parsed.hostname, prefix: tokenPrefix,
|
|
503
|
+
});
|
|
504
|
+
throw new BotChallengeError("bot-challenge/hostname-mismatch",
|
|
505
|
+
"hostname '" + parsed.hostname + "' not in allowedHostnames");
|
|
506
|
+
}
|
|
507
|
+
if (expectedHostname !== null && parsed.hostname !== expectedHostname) {
|
|
508
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
509
|
+
provider: providerKey, ok: false, reason: "hostname-mismatch",
|
|
510
|
+
hostname: parsed.hostname, expectedHostname: expectedHostname,
|
|
511
|
+
prefix: tokenPrefix,
|
|
512
|
+
});
|
|
513
|
+
throw new BotChallengeError("bot-challenge/hostname-mismatch",
|
|
514
|
+
"hostname '" + parsed.hostname + "' does not match expectedHostname '" +
|
|
515
|
+
expectedHostname + "'");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Action allowlist — same shape as hostname.
|
|
519
|
+
if (allowedActions && (!parsed.action ||
|
|
520
|
+
allowedActions.indexOf(parsed.action) === -1)) {
|
|
521
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
522
|
+
provider: providerKey, ok: false, reason: "action-mismatch",
|
|
523
|
+
action: parsed.action, hostname: parsed.hostname,
|
|
524
|
+
prefix: tokenPrefix,
|
|
525
|
+
});
|
|
526
|
+
throw new BotChallengeError("bot-challenge/action-mismatch",
|
|
527
|
+
"action '" + parsed.action + "' not in allowedActions");
|
|
528
|
+
}
|
|
529
|
+
if (expectedAction !== null && parsed.action !== expectedAction) {
|
|
530
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
531
|
+
provider: providerKey, ok: false, reason: "action-mismatch",
|
|
532
|
+
action: parsed.action, expectedAction: expectedAction,
|
|
533
|
+
hostname: parsed.hostname, prefix: tokenPrefix,
|
|
534
|
+
});
|
|
535
|
+
throw new BotChallengeError("bot-challenge/action-mismatch",
|
|
536
|
+
"action '" + parsed.action + "' does not match expectedAction '" +
|
|
537
|
+
expectedAction + "'");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
var successMeta = {
|
|
541
|
+
provider: providerKey, ok: true,
|
|
542
|
+
hostname: parsed.hostname, action: parsed.action,
|
|
543
|
+
prefix: tokenPrefix,
|
|
544
|
+
};
|
|
545
|
+
if (parsed.score !== null) successMeta.score = parsed.score;
|
|
546
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "success", successMeta);
|
|
547
|
+
|
|
548
|
+
var result = {
|
|
549
|
+
ok: true,
|
|
550
|
+
provider: providerKey,
|
|
551
|
+
hostname: parsed.hostname,
|
|
552
|
+
action: parsed.action,
|
|
553
|
+
challengeTs: parsed.challengeTs,
|
|
554
|
+
raw: raw,
|
|
555
|
+
};
|
|
556
|
+
if (parsed.score !== null) result.score = parsed.score;
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { verify: verify };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
create: create,
|
|
565
|
+
PROVIDERS: PROVIDERS,
|
|
566
|
+
BotChallengeError: BotChallengeError,
|
|
567
|
+
DEFAULTS: Object.freeze({
|
|
568
|
+
provider: DEFAULT_PROVIDER,
|
|
569
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
570
|
+
minTimeoutMs: MIN_TIMEOUT_MS,
|
|
571
|
+
maxTokenBytes: MAX_TOKEN_BYTES,
|
|
572
|
+
}),
|
|
573
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -473,6 +473,11 @@ var DlpError = defineClass("DlpError", { alwaysPermane
|
|
|
473
473
|
// b.authBotChallenge when the operator-supplied challengeFn is
|
|
474
474
|
// missing, returns a non-boolean verdict, or throws. Permanent.
|
|
475
475
|
var AuthBotChallengeError = defineClass("AuthBotChallengeError", { alwaysPermanent: true });
|
|
476
|
+
// BotChallengeError — verifier-side errors raised by b.auth.botChallenge
|
|
477
|
+
// (Cloudflare Turnstile / hCaptcha / reCAPTCHA-v3 token siteverify):
|
|
478
|
+
// invalid token shape, timeout, hostname / action allowlist mismatch,
|
|
479
|
+
// provider reported success=false, malformed response body. Permanent.
|
|
480
|
+
var BotChallengeError = defineClass("BotChallengeError", { alwaysPermanent: true });
|
|
476
481
|
// SessionDeviceBindingError — fingerprint-drift refusal raised by
|
|
477
482
|
// b.sessionDeviceBinding when create-time opts are malformed or the
|
|
478
483
|
// boundKeyResolver returns a non-Buffer. Permanent.
|
|
@@ -696,6 +701,7 @@ module.exports = {
|
|
|
696
701
|
SandboxError: SandboxError,
|
|
697
702
|
DlpError: DlpError,
|
|
698
703
|
AuthBotChallengeError: AuthBotChallengeError,
|
|
704
|
+
BotChallengeError: BotChallengeError,
|
|
699
705
|
SessionDeviceBindingError: SessionDeviceBindingError,
|
|
700
706
|
AcmeError: AcmeError,
|
|
701
707
|
HpkeError: HpkeError,
|