@blamejs/core 0.8.43 → 0.8.49
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 +92 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/auth/jwt-external.js
CHANGED
|
@@ -59,6 +59,7 @@ var { AuthError } = require("../framework-error");
|
|
|
59
59
|
|
|
60
60
|
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
61
61
|
var cache = lazyRequire(function () { return require("../cache"); });
|
|
62
|
+
var auditFwk = lazyRequire(function () { return require("../audit"); });
|
|
62
63
|
|
|
63
64
|
// ---- constants ----
|
|
64
65
|
|
|
@@ -250,6 +251,22 @@ async function verifyExternal(token, opts) {
|
|
|
250
251
|
|
|
251
252
|
// Decode header + payload.
|
|
252
253
|
var parts = token.split(".");
|
|
254
|
+
// CVE-2026-29000 / CVE-2026-23993 / CVE-2026-22817 / CVE-2026-34950 —
|
|
255
|
+
// JWE-bypass + alg-confusion. A 5-segment compact serialization is a
|
|
256
|
+
// JWE (RFC 7516); accepting it on a JWS verifier is the canonical
|
|
257
|
+
// confused-deputy shape. verifyExternal is JWS-only; refuse JWE
|
|
258
|
+
// outright. Operators with JWE need a separate handler wired to
|
|
259
|
+
// their KMS — never a defaulted JWE path on the JWS verifier.
|
|
260
|
+
if (parts.length === 5) {
|
|
261
|
+
try { auditFwk().safeEmit({
|
|
262
|
+
action: "jwt.jwe.refused",
|
|
263
|
+
outcome: "denied",
|
|
264
|
+
metadata: { reason: "jwe-on-jws-verifier" },
|
|
265
|
+
}); } catch (_e) { /* audit best-effort */ }
|
|
266
|
+
throw new AuthError("auth-jwt-external/jwe-refused",
|
|
267
|
+
"5-segment JWE token refused — verifyExternal only handles JWS " +
|
|
268
|
+
"(JWE bypass class — CVE-2026-29000 / CVE-2026-23993 / CVE-2026-22817 / CVE-2026-34950)");
|
|
269
|
+
}
|
|
253
270
|
if (parts.length !== 3) {
|
|
254
271
|
throw new AuthError("auth-jwt-external/malformed-jwt",
|
|
255
272
|
"token does not have 3 parts");
|
package/lib/auth/oauth.js
CHANGED
|
@@ -461,6 +461,13 @@ function create(opts) {
|
|
|
461
461
|
async function authorizationUrl(uopts) {
|
|
462
462
|
uopts = uopts || {};
|
|
463
463
|
var endpoint = await _resolveEndpoint("authorizationEndpoint");
|
|
464
|
+
// CVE-2026-34511 — PKCE verifier leak via state. The state token is
|
|
465
|
+
// an opaque CSPRNG output; the PKCE verifier is generated separately
|
|
466
|
+
// and returned in its own field for the caller to store. The
|
|
467
|
+
// `code_verifier` is NEVER concatenated into `state` and `state`
|
|
468
|
+
// never carries operator-supplied PII. PKCE-S256 is the default
|
|
469
|
+
// (pkce: false throws above); _generatePkce() emits
|
|
470
|
+
// base64url(SHA-256(verifier)) per RFC 7636.
|
|
464
471
|
var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
|
|
465
472
|
var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
|
|
466
473
|
var pkceVals = pkce ? _generatePkce() : null;
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.authBotChallenge
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title Auth Bot Challenge
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Adaptive bot-challenge gate for authentication paths. Composes
|
|
9
|
+
* `b.middleware.botGuard` + `b.auth.lockout` + an operator-supplied
|
|
10
|
+
* challenge function (captcha / email confirmation / second-factor
|
|
11
|
+
* prompt) into a deterministic staircase that escalates protection
|
|
12
|
+
* as failed-auth attempts accumulate.
|
|
13
|
+
*
|
|
14
|
+
* Staircase: below `threshold` failures, requests flow through
|
|
15
|
+
* unchanged. At `threshold`, bot-guard heuristics gate the session.
|
|
16
|
+
* After bot-guard passes but failures keep accumulating, the
|
|
17
|
+
* operator's `challengeFn(req, res)` runs (returning `true` clears
|
|
18
|
+
* the challenge). Past `escalationThreshold`, `escalationFn(req)`
|
|
19
|
+
* runs (typically `b.auth.atoKillSwitch.trigger`) and the middleware
|
|
20
|
+
* answers 423 Locked.
|
|
21
|
+
*
|
|
22
|
+
* Session state is operator-storage — pass a `b.cache`-shaped
|
|
23
|
+
* sessionStore (any backend) and the gate persists per-key
|
|
24
|
+
* (stage, failures, challengedAt, passedAt). The lockout primitive
|
|
25
|
+
* stays the cluster-shared counter authority; this primitive layers
|
|
26
|
+
* the human-vs-bot ladder above it.
|
|
27
|
+
*
|
|
28
|
+
* Audit emissions: `auth.bot_challenge.required` /
|
|
29
|
+
* `.passed` / `.failed` / `.escalated` / `.cleared`. Validation
|
|
30
|
+
* policy: `create()` throws on bad opts at boot; `middleware()`
|
|
31
|
+
* never throws (staircase failures audit and answer 401/423);
|
|
32
|
+
* `recordFailure` / `recordSuccess` / `check` / `reset` throw on
|
|
33
|
+
* bad keys.
|
|
34
|
+
*
|
|
35
|
+
* @card
|
|
36
|
+
* Adaptive bot-challenge gate for authentication paths.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var C = require("./constants");
|
|
40
|
+
var lazyRequire = require("./lazy-require");
|
|
41
|
+
var requestHelpers = require("./request-helpers");
|
|
42
|
+
var validateOpts = require("./validate-opts");
|
|
43
|
+
var { AuthBotChallengeError } = require("./framework-error");
|
|
44
|
+
|
|
45
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
46
|
+
|
|
47
|
+
var DEFAULT_THRESHOLD = 3;
|
|
48
|
+
var DEFAULT_ESCALATION_THRESHOLD = 6;
|
|
49
|
+
var DEFAULT_CHALLENGE_TTL_MS = C.TIME.minutes(30);
|
|
50
|
+
|
|
51
|
+
var STATE_NEW = "new";
|
|
52
|
+
var STATE_CHALLENGED = "challenged";
|
|
53
|
+
var STATE_PASSED = "passed";
|
|
54
|
+
var STATE_LOCKED = "locked";
|
|
55
|
+
|
|
56
|
+
var ALLOWED_OPTS = [
|
|
57
|
+
"botGuard", "lockout", "sessionStore", "threshold", "escalationThreshold",
|
|
58
|
+
"challengeFn", "escalationFn", "audit", "challengeTtlMs", "keyExtractor",
|
|
59
|
+
"observability", "clock",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function _requireFunction(name, val) {
|
|
63
|
+
if (typeof val !== "function") {
|
|
64
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
65
|
+
name + ": expected function, got " + typeof val);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _requirePositiveInt(name, val) {
|
|
70
|
+
if (typeof val !== "number" || !isFinite(val) || val < 1 || Math.floor(val) !== val) {
|
|
71
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
72
|
+
name + ": expected positive integer, got " + JSON.stringify(val));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _requireNonNegFinite(name, val) {
|
|
77
|
+
if (typeof val !== "number" || !isFinite(val) || val < 0) {
|
|
78
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
79
|
+
name + ": expected non-negative finite number, got " + JSON.stringify(val));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _requireKey(key) {
|
|
84
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
85
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-key",
|
|
86
|
+
"key must be a non-empty string, got " + typeof key + " " + JSON.stringify(key));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _requireSessionStore(store) {
|
|
91
|
+
if (!store || typeof store !== "object" ||
|
|
92
|
+
typeof store.get !== "function" ||
|
|
93
|
+
typeof store.set !== "function" ||
|
|
94
|
+
typeof store.del !== "function") {
|
|
95
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
96
|
+
"sessionStore must be a b.cache-shaped object (get/set/del)");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _requireBotGuard(bg) {
|
|
101
|
+
if (typeof bg !== "function") {
|
|
102
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
103
|
+
"botGuard must be a connect-style middleware function (got " + typeof bg + ")");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _requireLockout(lk) {
|
|
108
|
+
if (!lk || typeof lk !== "object" ||
|
|
109
|
+
typeof lk.recordFailure !== "function" ||
|
|
110
|
+
typeof lk.recordSuccess !== "function" ||
|
|
111
|
+
typeof lk.check !== "function") {
|
|
112
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
113
|
+
"lockout must be a b.auth.lockout-shaped instance " +
|
|
114
|
+
"(recordFailure/recordSuccess/check)");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _defaultKeyExtractor(req) {
|
|
119
|
+
// Default key strategy: prefer user-supplied identifier (req.body.email,
|
|
120
|
+
// req.body.username), fall back to client IP. Operators override via
|
|
121
|
+
// opts.keyExtractor for OAuth flows / passkey ceremonies.
|
|
122
|
+
if (req && req.body && typeof req.body === "object") {
|
|
123
|
+
if (typeof req.body.email === "string" && req.body.email.length > 0) {
|
|
124
|
+
return req.body.email.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
if (typeof req.body.username === "string" && req.body.username.length > 0) {
|
|
127
|
+
return req.body.username.toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try { return requestHelpers.clientIp(req); }
|
|
131
|
+
catch (_e) { return "<unknown>"; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @primitive b.authBotChallenge.create
|
|
136
|
+
* @signature b.authBotChallenge.create(opts)
|
|
137
|
+
* @since 0.8.48
|
|
138
|
+
* @status stable
|
|
139
|
+
* @related b.middleware.botGuard, b.auth.lockout, b.auth.atoKillSwitch
|
|
140
|
+
*
|
|
141
|
+
* Build an adaptive bot-challenge gate. Returns
|
|
142
|
+
* `{ middleware, recordFailure, recordSuccess, check, reset }`.
|
|
143
|
+
* `middleware()` is the connect-style entry point; `recordFailure(key)`
|
|
144
|
+
* and `recordSuccess(key)` advance / clear the ladder from the
|
|
145
|
+
* operator's post-verify code path.
|
|
146
|
+
*
|
|
147
|
+
* @opts
|
|
148
|
+
* botGuard: Function, // connect-style (req, res, next) middleware
|
|
149
|
+
* lockout: Object, // b.auth.lockout instance (recordFailure / recordSuccess / check)
|
|
150
|
+
* sessionStore: Object, // b.cache-shaped store (get / set / del)
|
|
151
|
+
* threshold: number, // failures before challenge stage (default 3)
|
|
152
|
+
* escalationThreshold: number, // failures before lockout (default 6; must exceed threshold)
|
|
153
|
+
* challengeFn: Function, // async (req, res) → boolean | thrown
|
|
154
|
+
* escalationFn: Function, // async (req) → void; runs at lockout
|
|
155
|
+
* audit: Object, // b.audit instance (safeEmit-shaped)
|
|
156
|
+
* challengeTtlMs: number, // session-mark TTL (default 30 minutes)
|
|
157
|
+
* keyExtractor: Function, // (req) → string; default body.email / body.username / clientIp
|
|
158
|
+
* observability: Object, // observability sink (event-shaped)
|
|
159
|
+
* clock: Function, // () → number; testing override (default Date.now)
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* var gate = b.authBotChallenge.create({
|
|
163
|
+
* botGuard: botGuardMiddleware,
|
|
164
|
+
* lockout: lockoutInstance,
|
|
165
|
+
* sessionStore: cacheInstance,
|
|
166
|
+
* threshold: 3,
|
|
167
|
+
* escalationThreshold: 6,
|
|
168
|
+
* challengeFn: async function (req, res) {
|
|
169
|
+
* return req.body && req.body.captchaToken === "verified";
|
|
170
|
+
* },
|
|
171
|
+
* escalationFn: async function (req) {
|
|
172
|
+
* // Kill session, lock account, page on-call.
|
|
173
|
+
* },
|
|
174
|
+
* audit: auditInstance,
|
|
175
|
+
* });
|
|
176
|
+
*
|
|
177
|
+
* // Mount on the login route — the gate decides 200 / 401 / 423.
|
|
178
|
+
* var loginRoute = [gate.middleware(), function (req, res) { res.end("ok"); }];
|
|
179
|
+
*
|
|
180
|
+
* // After verifying the credential, advance the ladder explicitly:
|
|
181
|
+
* var advanced = await gate.recordFailure("user@example.com");
|
|
182
|
+
* advanced.stage; // → "new" | "challenged" | "locked"
|
|
183
|
+
*
|
|
184
|
+
* var status = await gate.check("user@example.com");
|
|
185
|
+
* status.failures; // → 1
|
|
186
|
+
*/
|
|
187
|
+
function create(opts) {
|
|
188
|
+
opts = opts || {};
|
|
189
|
+
validateOpts(opts, ALLOWED_OPTS, "authBotChallenge.create");
|
|
190
|
+
|
|
191
|
+
_requireBotGuard(opts.botGuard);
|
|
192
|
+
_requireLockout(opts.lockout);
|
|
193
|
+
_requireSessionStore(opts.sessionStore);
|
|
194
|
+
|
|
195
|
+
var threshold = opts.threshold !== undefined ? opts.threshold : DEFAULT_THRESHOLD;
|
|
196
|
+
_requirePositiveInt("threshold", threshold);
|
|
197
|
+
var escalationThreshold = opts.escalationThreshold !== undefined
|
|
198
|
+
? opts.escalationThreshold : DEFAULT_ESCALATION_THRESHOLD;
|
|
199
|
+
_requirePositiveInt("escalationThreshold", escalationThreshold);
|
|
200
|
+
if (escalationThreshold <= threshold) {
|
|
201
|
+
throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
|
|
202
|
+
"escalationThreshold (" + escalationThreshold + ") must exceed threshold (" + threshold + ")");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
var challengeTtlMs = opts.challengeTtlMs !== undefined
|
|
206
|
+
? opts.challengeTtlMs : DEFAULT_CHALLENGE_TTL_MS;
|
|
207
|
+
_requireNonNegFinite("challengeTtlMs", challengeTtlMs);
|
|
208
|
+
|
|
209
|
+
if (opts.challengeFn !== undefined) _requireFunction("challengeFn", opts.challengeFn);
|
|
210
|
+
if (opts.escalationFn !== undefined) _requireFunction("escalationFn", opts.escalationFn);
|
|
211
|
+
if (opts.keyExtractor !== undefined) _requireFunction("keyExtractor", opts.keyExtractor);
|
|
212
|
+
|
|
213
|
+
validateOpts.auditShape(opts.audit, "authBotChallenge.create", AuthBotChallengeError);
|
|
214
|
+
|
|
215
|
+
var botGuard = opts.botGuard;
|
|
216
|
+
var lockout = opts.lockout;
|
|
217
|
+
var sessionStore = opts.sessionStore;
|
|
218
|
+
var challengeFn = opts.challengeFn || null;
|
|
219
|
+
var escalationFn = opts.escalationFn || null;
|
|
220
|
+
var keyExtractor = opts.keyExtractor || _defaultKeyExtractor;
|
|
221
|
+
var auditInst = opts.audit || null;
|
|
222
|
+
var obsInst = opts.observability || null;
|
|
223
|
+
var clock = opts.clock || Date.now;
|
|
224
|
+
|
|
225
|
+
function _emitObs(name, labels) {
|
|
226
|
+
var sink = obsInst || _safeGlobalObs();
|
|
227
|
+
if (!sink) return;
|
|
228
|
+
try { sink.event(name, 1, labels); } catch (_e) { /* drop-silent */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _safeGlobalObs() {
|
|
232
|
+
try { return observability(); } catch (_e) { return null; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _emitAudit(action, key, outcome, metadata, req) {
|
|
236
|
+
if (!auditInst) return;
|
|
237
|
+
try {
|
|
238
|
+
var event = {
|
|
239
|
+
action: action,
|
|
240
|
+
outcome: outcome,
|
|
241
|
+
resource: { kind: "auth.bot_challenge", id: key },
|
|
242
|
+
metadata: metadata || {},
|
|
243
|
+
};
|
|
244
|
+
if (req) event.actor = requestHelpers.extractActorContext(req);
|
|
245
|
+
auditInst.safeEmit(event);
|
|
246
|
+
} catch (_e) { /* audit best-effort */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function _readState(key) {
|
|
250
|
+
try {
|
|
251
|
+
var raw = await sessionStore.get(key);
|
|
252
|
+
return raw || null;
|
|
253
|
+
} catch (_e) { return null; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function _writeState(key, state, ttlMs) {
|
|
257
|
+
try { await sessionStore.set(key, state, { ttlMs: ttlMs }); }
|
|
258
|
+
catch (_e) { /* drop-silent: store transient */ }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function _deleteState(key) {
|
|
262
|
+
try { await sessionStore.del(key); }
|
|
263
|
+
catch (_e) { /* drop-silent */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Run the bot-guard middleware in a captured-response harness — bot-
|
|
267
|
+
// guard is a (req, res, next) middleware shape. The challenge gate
|
|
268
|
+
// does NOT block here; it only inspects whether bot-guard's
|
|
269
|
+
// heuristics flagged the request.
|
|
270
|
+
function _runBotGuardCheck(req) {
|
|
271
|
+
return new Promise(function (resolve) {
|
|
272
|
+
var capturedRes = {
|
|
273
|
+
statusCode: 200, // allow:raw-byte-literal — HTTP 200 status code, not bytes
|
|
274
|
+
writableEnded: false,
|
|
275
|
+
writeHead: function (status) { capturedRes.statusCode = status; },
|
|
276
|
+
end: function () { capturedRes.writableEnded = true; },
|
|
277
|
+
};
|
|
278
|
+
var settled = false;
|
|
279
|
+
function done(passed, reason) {
|
|
280
|
+
if (settled) return;
|
|
281
|
+
settled = true;
|
|
282
|
+
resolve({ passed: passed, reason: reason || null });
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
botGuard(req, capturedRes, function () {
|
|
286
|
+
// If bot-guard tagged the request, surface that. The default
|
|
287
|
+
// botGuard mode is "block"; in tag mode req.suspectedBot
|
|
288
|
+
// gets set. Either way: flagged = challenge required.
|
|
289
|
+
if (req.suspectedBot) return done(false, req.suspectedBot);
|
|
290
|
+
return done(true, null);
|
|
291
|
+
});
|
|
292
|
+
// If middleware terminated by writing a response, treat as flagged.
|
|
293
|
+
if (capturedRes.writableEnded) {
|
|
294
|
+
done(false, "bot-guard-blocked");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
} catch (_e) {
|
|
298
|
+
done(false, "bot-guard-exception");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- Internal staircase advance ----
|
|
305
|
+
|
|
306
|
+
async function _advanceFailure(key, req) {
|
|
307
|
+
var now = clock();
|
|
308
|
+
var state = await _readState(key) || {
|
|
309
|
+
stage: STATE_NEW, failures: 0, challengedAt: null, passedAt: null,
|
|
310
|
+
};
|
|
311
|
+
state.failures = (state.failures || 0) + 1;
|
|
312
|
+
|
|
313
|
+
// Lockout subscriber — propagate the failure into the lockout
|
|
314
|
+
// primitive so cluster-shared counters stay accurate.
|
|
315
|
+
try { await lockout.recordFailure(key, { req: req, reason: "auth-bot-challenge" }); }
|
|
316
|
+
catch (_e) { /* lockout best-effort */ }
|
|
317
|
+
|
|
318
|
+
if (state.failures >= escalationThreshold) {
|
|
319
|
+
state.stage = STATE_LOCKED;
|
|
320
|
+
await _writeState(key, state, challengeTtlMs);
|
|
321
|
+
_emitObs("auth.bot_challenge.escalated", { stage: STATE_LOCKED });
|
|
322
|
+
_emitAudit("auth.bot_challenge.escalated", key, "denied",
|
|
323
|
+
{ failures: state.failures, threshold: escalationThreshold }, req);
|
|
324
|
+
if (escalationFn) {
|
|
325
|
+
try { await escalationFn(req); }
|
|
326
|
+
catch (_e) { /* escalation best-effort */ }
|
|
327
|
+
}
|
|
328
|
+
return { stage: STATE_LOCKED, failures: state.failures };
|
|
329
|
+
}
|
|
330
|
+
if (state.failures >= threshold) {
|
|
331
|
+
state.stage = STATE_CHALLENGED;
|
|
332
|
+
state.challengedAt = now;
|
|
333
|
+
await _writeState(key, state, challengeTtlMs);
|
|
334
|
+
_emitObs("auth.bot_challenge.required", { stage: STATE_CHALLENGED });
|
|
335
|
+
_emitAudit("auth.bot_challenge.required", key, "denied",
|
|
336
|
+
{ failures: state.failures, threshold: threshold }, req);
|
|
337
|
+
return { stage: STATE_CHALLENGED, failures: state.failures };
|
|
338
|
+
}
|
|
339
|
+
await _writeState(key, state, challengeTtlMs);
|
|
340
|
+
return { stage: STATE_NEW, failures: state.failures };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- Public surface ----
|
|
344
|
+
|
|
345
|
+
function middleware() {
|
|
346
|
+
return async function authBotChallengeMiddleware(req, res, next) {
|
|
347
|
+
var key;
|
|
348
|
+
try { key = keyExtractor(req); }
|
|
349
|
+
catch (_e) { key = "<unknown>"; }
|
|
350
|
+
if (typeof key !== "string" || key.length === 0) key = "<unknown>";
|
|
351
|
+
|
|
352
|
+
var state = await _readState(key);
|
|
353
|
+
|
|
354
|
+
if (state && state.stage === STATE_LOCKED) {
|
|
355
|
+
_emitAudit("auth.bot_challenge.escalated", key, "denied",
|
|
356
|
+
{ reason: "already-locked" }, req);
|
|
357
|
+
return _writeLocked(res);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (state && state.stage === STATE_CHALLENGED) {
|
|
361
|
+
// Run bot-guard heuristics first — fastest path. If those don't
|
|
362
|
+
// pass, defer to the operator-supplied challengeFn.
|
|
363
|
+
var bgVerdict = await _runBotGuardCheck(req);
|
|
364
|
+
if (bgVerdict.passed) {
|
|
365
|
+
state.stage = STATE_PASSED;
|
|
366
|
+
state.passedAt = clock();
|
|
367
|
+
await _writeState(key, state, challengeTtlMs);
|
|
368
|
+
_emitObs("auth.bot_challenge.passed", { stage: "bot-guard" });
|
|
369
|
+
_emitAudit("auth.bot_challenge.passed", key, "success",
|
|
370
|
+
{ stage: "bot-guard" }, req);
|
|
371
|
+
return next();
|
|
372
|
+
}
|
|
373
|
+
if (challengeFn) {
|
|
374
|
+
var challengeResult;
|
|
375
|
+
try { challengeResult = await challengeFn(req, res); }
|
|
376
|
+
catch (e) {
|
|
377
|
+
_emitAudit("auth.bot_challenge.failed", key, "denied",
|
|
378
|
+
{ stage: "challenge-fn", error: e && e.message }, req);
|
|
379
|
+
// Challenge-fn threw — treat as a failure; advance the ladder.
|
|
380
|
+
await _advanceFailure(key, req);
|
|
381
|
+
return _writeLocked(res);
|
|
382
|
+
}
|
|
383
|
+
// The challengeFn may have responded itself (e.g. rendered a
|
|
384
|
+
// captcha page on GET). Detect that.
|
|
385
|
+
if (res && res.writableEnded) return;
|
|
386
|
+
if (challengeResult === true) {
|
|
387
|
+
state.stage = STATE_PASSED;
|
|
388
|
+
state.passedAt = clock();
|
|
389
|
+
await _writeState(key, state, challengeTtlMs);
|
|
390
|
+
_emitObs("auth.bot_challenge.passed", { stage: "challenge-fn" });
|
|
391
|
+
_emitAudit("auth.bot_challenge.passed", key, "success",
|
|
392
|
+
{ stage: "challenge-fn" }, req);
|
|
393
|
+
return next();
|
|
394
|
+
}
|
|
395
|
+
_emitObs("auth.bot_challenge.failed", { stage: "challenge-fn" });
|
|
396
|
+
_emitAudit("auth.bot_challenge.failed", key, "denied",
|
|
397
|
+
{ stage: "challenge-fn" }, req);
|
|
398
|
+
await _advanceFailure(key, req);
|
|
399
|
+
return _writeChallengeRequired(res);
|
|
400
|
+
}
|
|
401
|
+
// No challengeFn supplied and bot-guard failed → 401.
|
|
402
|
+
_emitObs("auth.bot_challenge.failed", { stage: "bot-guard-only" });
|
|
403
|
+
_emitAudit("auth.bot_challenge.failed", key, "denied",
|
|
404
|
+
{ stage: "bot-guard-only", reason: bgVerdict.reason }, req);
|
|
405
|
+
return _writeChallengeRequired(res);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// STATE_NEW or STATE_PASSED — flow through. Whether the wrapped
|
|
409
|
+
// handler counts the attempt as a failure is the operator's
|
|
410
|
+
// responsibility (they call gate.recordFailure(key) post-verify).
|
|
411
|
+
return next();
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _writeChallengeRequired(res) {
|
|
416
|
+
if (!res || res.writableEnded) return;
|
|
417
|
+
if (typeof res.writeHead === "function") {
|
|
418
|
+
res.writeHead(401, {
|
|
419
|
+
"Content-Type": "text/plain",
|
|
420
|
+
"WWW-Authenticate": 'Bearer error="bot_challenge_required"',
|
|
421
|
+
});
|
|
422
|
+
} else if (typeof res.statusCode !== "undefined") {
|
|
423
|
+
res.statusCode = 401;
|
|
424
|
+
}
|
|
425
|
+
if (typeof res.end === "function") res.end("Bot challenge required");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function _writeLocked(res) {
|
|
429
|
+
if (!res || res.writableEnded) return;
|
|
430
|
+
if (typeof res.writeHead === "function") {
|
|
431
|
+
res.writeHead(423, { "Content-Type": "text/plain" });
|
|
432
|
+
} else if (typeof res.statusCode !== "undefined") {
|
|
433
|
+
res.statusCode = 423;
|
|
434
|
+
}
|
|
435
|
+
if (typeof res.end === "function") res.end("Locked");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function recordFailure(key, callOpts) {
|
|
439
|
+
_requireKey(key);
|
|
440
|
+
callOpts = callOpts || {};
|
|
441
|
+
return await _advanceFailure(key, callOpts.req || null);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function recordSuccess(key, callOpts) {
|
|
445
|
+
_requireKey(key);
|
|
446
|
+
callOpts = callOpts || {};
|
|
447
|
+
var state = await _readState(key);
|
|
448
|
+
if (state) await _deleteState(key);
|
|
449
|
+
try { await lockout.recordSuccess(key, { req: callOpts.req }); }
|
|
450
|
+
catch (_e) { /* best-effort */ }
|
|
451
|
+
_emitObs("auth.bot_challenge.cleared", {});
|
|
452
|
+
_emitAudit("auth.bot_challenge.passed", key, "success",
|
|
453
|
+
{ stage: "auth-success", failuresCleared: (state && state.failures) || 0 },
|
|
454
|
+
callOpts.req);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function check(key) {
|
|
458
|
+
_requireKey(key);
|
|
459
|
+
var state = await _readState(key);
|
|
460
|
+
if (!state) return { stage: STATE_NEW, failures: 0 };
|
|
461
|
+
return {
|
|
462
|
+
stage: state.stage,
|
|
463
|
+
failures: state.failures || 0,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function reset(key, callOpts) {
|
|
468
|
+
_requireKey(key);
|
|
469
|
+
callOpts = callOpts || {};
|
|
470
|
+
var state = await _readState(key);
|
|
471
|
+
if (state) await _deleteState(key);
|
|
472
|
+
try { await lockout.unlock(key, { req: callOpts.req, reason: "bot-challenge:reset" }); }
|
|
473
|
+
catch (_e) { /* best-effort */ }
|
|
474
|
+
_emitAudit("auth.bot_challenge.passed", key, "success",
|
|
475
|
+
{ stage: "admin-reset", reason: callOpts.reason || null,
|
|
476
|
+
priorStage: state && state.stage || null,
|
|
477
|
+
priorFailures: state && state.failures || 0 },
|
|
478
|
+
callOpts.req);
|
|
479
|
+
return !!state;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
middleware: middleware,
|
|
484
|
+
recordFailure: recordFailure,
|
|
485
|
+
recordSuccess: recordSuccess,
|
|
486
|
+
check: check,
|
|
487
|
+
reset: reset,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
module.exports = {
|
|
492
|
+
create: create,
|
|
493
|
+
AuthBotChallengeError: AuthBotChallengeError,
|
|
494
|
+
STATES: Object.freeze({
|
|
495
|
+
NEW: STATE_NEW,
|
|
496
|
+
CHALLENGED: STATE_CHALLENGED,
|
|
497
|
+
PASSED: STATE_PASSED,
|
|
498
|
+
LOCKED: STATE_LOCKED,
|
|
499
|
+
}),
|
|
500
|
+
DEFAULTS: Object.freeze({
|
|
501
|
+
threshold: DEFAULT_THRESHOLD,
|
|
502
|
+
escalationThreshold: DEFAULT_ESCALATION_THRESHOLD,
|
|
503
|
+
challengeTtlMs: DEFAULT_CHALLENGE_TTL_MS,
|
|
504
|
+
}),
|
|
505
|
+
};
|