@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.
@@ -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
+ };
@@ -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,