@blamejs/core 0.15.65 → 0.15.67
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/lib/canonical-json.js +17 -3
- package/lib/i18n-messageformat.js +33 -6
- package/lib/json-schema.js +11 -1
- package/lib/network-dnssec.js +13 -2
- package/lib/redis-client.js +28 -5
- package/lib/router.js +26 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.67 (2026-06-29) — **Path-scoped router middleware can no longer be bypassed by percent-encoding the request path: the router refuses an encoded path separator or null byte and decodes the path once, so a security gate and the resource it guards always agree on the path.** b.router supports path-scoped middleware — router.use('/admin', gate) runs a gate (requireAal, bearerAuth, requireMtls, csrfProtect, …) only for requests under a prefix. The gate matched the request path with its percent-escapes intact, while downstream consumers such as b.staticServe and router.serveStatic percent-decode the path before resolving the file. Because the gate and the consumer disagreed on decoding, an attacker could encode a character in the guarded segment (/%61dmin/secret) or hide a separator (/admin%2fsecret) so the gate's segment match missed while the consumer still reached the protected resource — an authentication, authorization, CSRF, or mTLS bypass for any resource served under a scoped gate. The router now refuses a request whose path contains an encoded path separator (%2f, %5c) or null byte (%00), and decodes the remaining escapes exactly once, so the gate, the route matcher, and every consumer act on a single canonical path. A request that legitimately needs those bytes was always ambiguous and is now rejected with 400 rather than silently routed two different ways. Route parameters captured from the path are now percent-decoded, matching the conventional behavior. **Changed:** *Encoded path separators and null bytes in the request path are refused; route params are decoded* — A request whose path contains %2f, %5c, or %00 is answered with 400 Bad Request — these have no unambiguous routing meaning (a consumer would treat them as a separator or terminator), and a request that needs a literal slash in a value should carry it in a query parameter. Path parameters captured by a route pattern (for example :id) are now percent-decoded before they reach the handler in req.params, matching conventional router behavior; handlers that previously received a still-encoded value will now receive the decoded form. **Security:** *Path-scoped middleware is no longer bypassable via percent-encoded paths* — A gate mounted with router.use(prefix, mw) matched req.pathname with percent-escapes preserved, but b.staticServe and router.serveStatic decode the path before resolving the resource. An attacker could percent-encode a character in the guarded segment (/%61dmin/secret, where %61 is 'a') so the segment compare saw '%61dmin' and skipped the gate, or hide a separator (/admin%2fsecret) so the gate saw one segment while the file resolver saw two — in both cases the protected resource was served without the gate running. The router now rejects a path containing an encoded separator (%2f/%5c) or null byte (%00) with 400, and percent-decodes the path exactly once before any path-scoped decision, so the gate and the resource it protects always resolve the same path. Operators who mounted authentication, authorization, CSRF, or mTLS gates with the scoped form no longer have a silent bypass beneath them.
|
|
12
|
+
|
|
13
|
+
- v0.15.66 (2026-06-29) — **Recursive serializers and parsers refuse pathologically deep input with a typed error instead of overflowing the stack and crashing the process.** Several of the framework's recursive walkers over attacker-reachable input lacked an effective nesting cap, so a deeply nested value could exhaust the V8 call stack and throw an uncaught RangeError — crashing the process (a denial of service). b.canonicalJson is the most exposed: content-credentials verification canonicalises an untrusted manifest before it checks the signature, so a hostile credential could crash a verifier pre-authentication. b.jsonSchema.validate had a depth guard, but its limit was set so high the stack overflowed before the guard could fire, so validating a request body against a recursive schema crashed rather than returning a typed error. b.i18n.messageFormat parsed and rendered nested plural/select cases with no cap. Each walker now throws a typed framework error well before native overflow, and legitimate (even deeply nested) input is unaffected. The release also corrects DNSSEC signature-window comparison to the RFC 1982 serial-number arithmetic the spec requires, and makes the Redis client treat a malformed reply frame as a connection fault rather than letting it crash the host. **Fixed:** *DNSSEC compares RRSIG validity windows with RFC 1982 serial arithmetic* — b.network.dns.dnssec compared an RRSIG's inception and expiration against the current time by magnitude. RFC 4034 §3.1.5 requires the 32-bit timestamps to be interpreted with RFC 1982 serial-number arithmetic, which agrees with a plain comparison only while the values stay below 2^31. A signature whose window straddles the 2^31 (January 2038) or 2^32 (February 2106) boundary was mis-ordered — an in-window signature rejected, or a stale one accepted. The comparison now masks the clock into the same 32-bit serial space and tests the wrapped signed delta. **Security:** *Canonical JSON refuses excessively nested input before it can overflow the stack* — b.canonicalJson.stringify and stringifyJcs walk the value recursively. They detected circular references but had no nesting cap, so a deeply nested (acyclic) object or array overflowed the V8 stack with an uncaught RangeError. This is reachable before authentication: content-credentials verification canonicalises the untrusted manifest to hash it before verifying the signature, so a hostile credential could crash the verifier. Both serializers now throw a typed nesting-depth error at a depth far beyond any legitimate signed document and well short of native overflow. · *JSON Schema validation depth guard now fires before the stack overflows* — b.jsonSchema.validate caps subschema-validation nesting to defend against a recursive schema (for example items pointing back at the root with $ref) applied to a deeply nested instance — both attacker-controlled when validating a request body. The cap was set so high the V8 stack overflowed first, so the guard never ran and a crafted body crashed the validator with an uncaught RangeError instead of the typed reference-depth error. The limit is now well under native overflow; legitimate documents — deeply nested or wide — continue to validate. · *ICU MessageFormat refuses pathologically nested templates* — b.i18n.messageFormat.parse and format recurse once per nested plural/select case, with no depth cap. A template nested thousands of levels deep overflowed the stack. format() and parse() are public and b.i18n.t can render operator-supplied entries, so a hostile template is a denial-of-service vector; it now fails as a typed bad-template error. Real-world nesting (a handful of levels) is unaffected. · *Redis client treats a malformed reply frame as a connection fault, not a crash* — The RESP decoder recurses on nested arrays with no depth cap, and the data handler did not guard the parse, so a malformed or hostilely deep frame from a server threw out of the socket callback and could crash the host. The decoder now caps reply nesting and any parse fault tears the socket down and rejects in-flight commands for a reconnect — the same path as any other lost connection.
|
|
14
|
+
|
|
11
15
|
- v0.15.65 (2026-06-29) — **The account-takeover kill-switch now actually locks the account: b.auth.lockout gains a lock() method and the kill-switch engages it through the operator's lockout instance.** b.auth.atoKillSwitch.trigger is the incident-response path for a compromised account: it destroys the user's sessions and then locks them out of new logins. The lockout step called b.auth.lockout.lock(), but no such method existed (and the kill-switch invoked it on the lockout module, which has no store), so the call threw and was swallowed — the kill-switch reported success while never locking the account, leaving an attacker who still held the credentials free to log straight back in. b.auth.lockout instances now expose lock(key, { durationMs?, untilMs?, reason? }) to force an account into lockout immediately, atomically, independent of the failure counter, and atoKillSwitch.trigger engages it through the lockout instance the operator passes as opts.lockout. When no lockout instance is supplied the step is skipped and the result's lockoutApplied is false (with an audit row), rather than silently claiming the account was locked. The release also closes a lock-clear race in lockout and refuses a self-disabling window. **Added:** *b.auth.lockout instances expose lock()* — lock(key, { durationMs?, untilMs?, reason? }) forces an account into lockout immediately — the operator action behind an ATO kill-switch or incident response — independent of the failure counter, defaulting to a long admin lock. A forced lock is released only by an explicit unlock(): recordSuccess() does not clear it, so a successful login by someone who still holds the compromised password cannot release a kill-switch lock. It uses the same atomic compare-and-set as the failure counter and throws if the lock cannot be committed (the caller must know it did not lock). **Security:** *Account-takeover kill-switch engages the lockout instead of silently doing nothing* — b.auth.atoKillSwitch.trigger invoked b.auth.lockout.lock(), which did not exist; the call threw and was caught by a best-effort guard, so the kill-switch destroyed sessions but never locked the account — an attacker still holding valid credentials could immediately re-authenticate. b.auth.lockout instances now provide lock() (an atomic, compare-and-set forced lockout), and the kill-switch calls it on the lockout instance supplied as opts.lockout. Without a lockout instance the lockout step cannot run; the result's lockoutApplied is false and an audit row records that the account was not locked, so an operator is not misled into believing a compromised account was secured. opts.lockout is now the lockout instance (or false to skip), not a boolean toggle. · *lockout clears the failure counter atomically and refuses a zero window* — b.auth.lockout.recordSuccess and unlock cleared state with a read-then-delete, which could erase a lock a concurrent recordFailure had just engaged; they now clear under the same compare-and-set the failure path uses. create() also refuses windowMs: 0, which previously disabled lockout entirely (every failure decayed immediately and the zero-TTL state never persisted).
|
|
12
16
|
|
|
13
17
|
- v0.15.64 (2026-06-29) — **Token and HTTP-message-signature verifiers now reject a non-finite clock-skew or tolerance value instead of letting it disable the expiry, not-before, and future-dating checks.** Several verifiers read an operator-supplied clock-skew or tolerance value with a bare type check that accepted Infinity and NaN. Because the resulting comparison (for example exp + skew < now, or age > tolerance) is always false when the skew or tolerance is Infinity, a misconfigured non-finite value silently disabled the time-window check — so an expired token, a not-yet-valid token, a future-dated signature, or a replayed (too-old) signature would verify. The affected paths are the shared external-JWT verifier b.auth.jwt.verifyExternal (which the JAR / signed-request-object path also uses), the SD-JWT-VC credential verifier, the OAuth ID-token and client-attestation verifiers, and the HTTP message signature verifier's freshness and clock-skew windows. Each now requires a present skew or tolerance to be a non-negative finite number: the throw-based JWT, SD-JWT-VC, and OAuth verifiers reject a malformed value, and the verdict-returning HTTP-message-signature verifier falls back to its safe default rather than disabling the window. A build check now fails if any verifier reads one of these options without a finiteness guard. **Added:** *verifyExternal and jar.parse accept an `now` clock override* — b.auth.jwt.verifyExternal and b.auth.jar.parse now accept an optional `now` (a finite epoch-milliseconds value) to evaluate a token's exp / nbf / iat as of a specific instant instead of the wall clock — consistent with b.auth.sdJwtVc.verify. A negative clockSkewMs is no longer accepted as a way to shift the evaluation time; use `now`. **Security:** *Non-finite clock-skew / tolerance no longer disables a verifier's time-window check* — b.auth.jwt.verifyExternal, b.auth.sdJwtVc.verify, b.auth.oauth (ID-token and client-attestation verification), and the HTTP message signature verifier read their clock-skew, max-clock-skew, max-PoP-age, and tolerance options with a bare `typeof === "number"` check that let Infinity and NaN through. With such a value the expiry / not-before / future-dating comparison is always false, so the check was silently skipped and an expired or not-yet-valid token — or a future-dated or replayed signature — would verify. The JWT, SD-JWT-VC, and OAuth verifiers now reject a non-finite or negative skew (the option is operator configuration, not attacker-controlled), and the HTTP message signature verifier falls back to its default tolerance and skew. The CWT, DPoP, SAML, and OpenID Federation verifiers already enforced this and are unchanged. **Detectors:** *Verifier clock-skew / tolerance options must be finite-guarded* — A build check fails if a verifier reads a clock-skew or tolerance option with a bare `typeof === "number"` without a finiteness guard (a non-negative-finite check, an inline isFinite fallback, or a create-time non-negative-finite schema), preventing a future verifier from reintroducing the disable-the-window class.
|
package/lib/canonical-json.js
CHANGED
|
@@ -42,7 +42,17 @@
|
|
|
42
42
|
// RFC 8785 §3.2.3 sort. Primitives, strings, and numbers use
|
|
43
43
|
// JSON.stringify, whose escaping (§3.2.2.2) and ECMAScript number format
|
|
44
44
|
// (§3.2.2.3) are exactly what JCS references.
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
// Maximum nesting depth. The walk is recursive, so a deeply-nested input
|
|
47
|
+
// (e.g. an attacker-supplied manifest handed to content-credentials.verify
|
|
48
|
+
// BEFORE signature verification, or untrusted MCP tool-call args) would
|
|
49
|
+
// otherwise overflow the V8 stack with an unhandled RangeError — a crash /
|
|
50
|
+
// pre-auth DoS. Throw a typed framework error well before native overflow.
|
|
51
|
+
// The cycle WeakSet below catches references that loop; this catches a tree
|
|
52
|
+
// that is merely very deep. 512 is far beyond any legitimate signed document.
|
|
53
|
+
var MAX_DEPTH = 512;
|
|
54
|
+
|
|
55
|
+
function _emit(value, seen, bufferAs, depth) {
|
|
46
56
|
if (value === null || typeof value === "undefined") return "null";
|
|
47
57
|
var t = typeof value;
|
|
48
58
|
if (t === "number" || t === "string" || t === "boolean") return JSON.stringify(value);
|
|
@@ -90,6 +100,10 @@ function _emit(value, seen, bufferAs) {
|
|
|
90
100
|
if (seen.has(value)) {
|
|
91
101
|
throw new Error("canonical-json: circular reference detected");
|
|
92
102
|
}
|
|
103
|
+
depth = depth || 0;
|
|
104
|
+
if (depth > MAX_DEPTH) {
|
|
105
|
+
throw new Error("canonical-json: maximum nesting depth exceeded (" + MAX_DEPTH + ")");
|
|
106
|
+
}
|
|
93
107
|
seen.add(value);
|
|
94
108
|
if (Array.isArray(value)) {
|
|
95
109
|
// Index loop, not .map(): map() skips holes in a sparse array,
|
|
@@ -97,7 +111,7 @@ function _emit(value, seen, bufferAs) {
|
|
|
97
111
|
// reads as undefined → _emit returns "null" (matching JSON.stringify).
|
|
98
112
|
var items = [];
|
|
99
113
|
for (var ai = 0; ai < value.length; ai += 1) {
|
|
100
|
-
items.push(_emit(value[ai], seen, bufferAs));
|
|
114
|
+
items.push(_emit(value[ai], seen, bufferAs, depth + 1));
|
|
101
115
|
}
|
|
102
116
|
return "[" + items.join(",") + "]";
|
|
103
117
|
}
|
|
@@ -107,7 +121,7 @@ function _emit(value, seen, bufferAs) {
|
|
|
107
121
|
keys.sort();
|
|
108
122
|
var parts = [];
|
|
109
123
|
for (var i = 0; i < keys.length; i++) {
|
|
110
|
-
parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs));
|
|
124
|
+
parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs, depth + 1));
|
|
111
125
|
}
|
|
112
126
|
return "{" + parts.join(",") + "}";
|
|
113
127
|
}
|
|
@@ -63,6 +63,18 @@ function _err(code, message) {
|
|
|
63
63
|
return new I18nMessageFormatError(code, message, true);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Maximum case-nesting depth. The parser and the renderer both recurse
|
|
67
|
+
// once per nested plural/select case body; a template like
|
|
68
|
+
// `{a,select,x{{b,select,x{{c,select,...}}}}}` nested thousands deep would
|
|
69
|
+
// otherwise overflow the V8 stack with an uncaught RangeError. Templates
|
|
70
|
+
// usually come from operator translation files, but format()/parse() are
|
|
71
|
+
// public and `b.i18n.t(key, vars, { messageFormat: true })` renders entries
|
|
72
|
+
// that may be operator-supplied per-tenant, so a hostile template must fail
|
|
73
|
+
// as a typed BAD_TEMPLATE rather than crashing the process. Real
|
|
74
|
+
// translations nest 2-3 deep; 100 is far beyond any legitimate message yet
|
|
75
|
+
// well under native overflow.
|
|
76
|
+
var MAX_NESTING_DEPTH = 100;
|
|
77
|
+
|
|
66
78
|
// ---- Parser ----
|
|
67
79
|
//
|
|
68
80
|
// AST node shapes:
|
|
@@ -89,6 +101,12 @@ function parse(template) {
|
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
function _parseSequence(state, topLevel) {
|
|
104
|
+
state.depth = (state.depth || 0) + 1;
|
|
105
|
+
if (state.depth > MAX_NESTING_DEPTH) {
|
|
106
|
+
throw _err("BAD_TEMPLATE",
|
|
107
|
+
"messageFormat.parse: case nesting too deep (max " +
|
|
108
|
+
MAX_NESTING_DEPTH + ")");
|
|
109
|
+
}
|
|
92
110
|
var nodes = [];
|
|
93
111
|
var lit = "";
|
|
94
112
|
while (state.pos < state.src.length) {
|
|
@@ -146,6 +164,7 @@ function _parseSequence(state, topLevel) {
|
|
|
146
164
|
state.pos += 1;
|
|
147
165
|
}
|
|
148
166
|
if (lit.length > 0) nodes.push({ type: "literal", value: lit });
|
|
167
|
+
state.depth -= 1;
|
|
149
168
|
return nodes;
|
|
150
169
|
}
|
|
151
170
|
|
|
@@ -322,18 +341,26 @@ function _pluralRules(locale, type) {
|
|
|
322
341
|
|
|
323
342
|
function format(template, vars, locale) {
|
|
324
343
|
var nodes = parse(template);
|
|
325
|
-
return _renderSequence(nodes, vars || {}, locale || "en", null);
|
|
344
|
+
return _renderSequence(nodes, vars || {}, locale || "en", null, 0);
|
|
326
345
|
}
|
|
327
346
|
|
|
328
|
-
function _renderSequence(nodes, vars, locale, hashContext) {
|
|
347
|
+
function _renderSequence(nodes, vars, locale, hashContext, depth) {
|
|
348
|
+
depth = depth || 0;
|
|
349
|
+
// parse() already bounds AST nesting to MAX_NESTING_DEPTH, so this guard
|
|
350
|
+
// is defence-in-depth for any future caller that hands render a hand-built
|
|
351
|
+
// tree — it must still fail typed rather than overflow the stack.
|
|
352
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
353
|
+
throw _err("BAD_TEMPLATE",
|
|
354
|
+
"messageFormat: render nesting too deep (max " + MAX_NESTING_DEPTH + ")");
|
|
355
|
+
}
|
|
329
356
|
var out = "";
|
|
330
357
|
for (var i = 0; i < nodes.length; i++) {
|
|
331
|
-
out += _renderNode(nodes[i], vars, locale, hashContext);
|
|
358
|
+
out += _renderNode(nodes[i], vars, locale, hashContext, depth);
|
|
332
359
|
}
|
|
333
360
|
return out;
|
|
334
361
|
}
|
|
335
362
|
|
|
336
|
-
function _renderNode(node, vars, locale, hashContext) {
|
|
363
|
+
function _renderNode(node, vars, locale, hashContext, depth) {
|
|
337
364
|
if (node.type === "literal") return node.value;
|
|
338
365
|
if (node.type === "hash") {
|
|
339
366
|
return hashContext != null ? String(hashContext) : "#";
|
|
@@ -358,13 +385,13 @@ function _renderNode(node, vars, locale, hashContext) {
|
|
|
358
385
|
var category = pr.select(adjusted);
|
|
359
386
|
caseBody = node.cases[category] || node.cases.other;
|
|
360
387
|
}
|
|
361
|
-
return _renderSequence(caseBody, vars, locale, adjusted);
|
|
388
|
+
return _renderSequence(caseBody, vars, locale, adjusted, depth + 1);
|
|
362
389
|
}
|
|
363
390
|
if (node.type === "select") {
|
|
364
391
|
var sv = vars[node.name];
|
|
365
392
|
var key = (sv === undefined || sv === null) ? "other" : String(sv);
|
|
366
393
|
var body = node.cases[key] || node.cases.other;
|
|
367
|
-
return _renderSequence(body, vars, locale, hashContext);
|
|
394
|
+
return _renderSequence(body, vars, locale, hashContext, depth + 1);
|
|
368
395
|
}
|
|
369
396
|
return "";
|
|
370
397
|
}
|
package/lib/json-schema.js
CHANGED
|
@@ -54,7 +54,17 @@ var { defineClass } = require("./framework-error");
|
|
|
54
54
|
var JsonSchemaError = defineClass("JsonSchemaError", { alwaysPermanent: true });
|
|
55
55
|
|
|
56
56
|
var DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
|
|
57
|
-
|
|
57
|
+
// Subschema-validation nesting cap. ctx.depth is incremented on entry to
|
|
58
|
+
// _validate and decremented in its finally, so it tracks the live nesting
|
|
59
|
+
// depth (sibling keywords/properties do not accumulate) — the deepest the
|
|
60
|
+
// instance×schema walk has recursed. validate(schema, requestBody) reaches
|
|
61
|
+
// it with attacker input on BOTH sides: a recursive schema (items:{$ref:"#"})
|
|
62
|
+
// against a deeply-nested array/object, or a deeply-nested allOf chain.
|
|
63
|
+
// Left unbounded the walk overflows the V8 stack (~1000 levels) with an
|
|
64
|
+
// uncaught RangeError before this guard fired — a pre-validation DoS. 256 is
|
|
65
|
+
// far beyond any legitimate document yet well under native overflow, so the
|
|
66
|
+
// typed json-schema/ref-loop error is what an operator sees.
|
|
67
|
+
var MAX_REF_DEPTH = 256; // recursion-depth cap (count, not a byte size)
|
|
58
68
|
var DEFAULT_MAX_ERRORS = 100; // error-collection cap
|
|
59
69
|
|
|
60
70
|
function _typeOf(v) {
|
package/lib/network-dnssec.js
CHANGED
|
@@ -295,8 +295,19 @@ function verifyRrset(opts) {
|
|
|
295
295
|
validateOpts.optionalDate(opts.at, "dnssec.verifyRrset: opts.at", DnssecError, "dnssec/bad-at");
|
|
296
296
|
var atMs = (opts.at !== undefined && opts.at !== null) ? opts.at.getTime() : Date.now();
|
|
297
297
|
var nowSec = Math.floor(atMs / 1000);
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
// RFC 4034 §3.1.5: the RRSIG inception/expiration fields are 32-bit and
|
|
299
|
+
// MUST be compared with RFC 1982 serial-number arithmetic, not magnitude.
|
|
300
|
+
// A plain `<` / `>` agrees with serial arithmetic only while both operands
|
|
301
|
+
// stay under 2^31; it mis-orders any window that straddles the 2^31 (Jan
|
|
302
|
+
// 2038) or 2^32 (Feb 2106) boundary — accepting an expired signature or
|
|
303
|
+
// rejecting a valid one. Mask the clock into the same 32-bit serial space
|
|
304
|
+
// and compare the wrapped signed delta: a negative (now - inception) means
|
|
305
|
+
// inception is in the future; a negative (expiration - now) means expired.
|
|
306
|
+
var nowSer = nowSec >>> 0;
|
|
307
|
+
var incepSer = rrsig.inception >>> 0;
|
|
308
|
+
var expirSer = rrsig.expiration >>> 0;
|
|
309
|
+
if (((nowSer - incepSer) | 0) < 0) throw new DnssecError("dnssec/not-yet-valid", "dnssec.verifyRrset: RRSIG inception is in the future");
|
|
310
|
+
if (((expirSer - nowSer) | 0) < 0) throw new DnssecError("dnssec/expired", "dnssec.verifyRrset: RRSIG has expired");
|
|
300
311
|
|
|
301
312
|
var klass = typeof opts.class === "number" ? opts.class : 1;
|
|
302
313
|
var ownerWire = _canonicalName(opts.name);
|
package/lib/redis-client.js
CHANGED
|
@@ -78,7 +78,20 @@ function _encodeCommand(args) {
|
|
|
78
78
|
// { type: "int", value, consumed } — integer (:42)
|
|
79
79
|
// { type: "bulk", value, consumed } — bulk string buffer (or null)
|
|
80
80
|
// { type: "array", value, consumed } — array of decoded items
|
|
81
|
-
|
|
81
|
+
//
|
|
82
|
+
// RESP arrays nest (an array whose elements are arrays), so the decoder
|
|
83
|
+
// recurses. A hostile or compromised server can stream an arbitrarily deep
|
|
84
|
+
// nest of `*1\r\n` headers to overflow the V8 stack with an uncaught
|
|
85
|
+
// RangeError out of the socket 'data' handler — a crash. Cap the nesting
|
|
86
|
+
// well above any real reply (cluster-slots / XRANGE replies nest a handful
|
|
87
|
+
// deep) and throw a typed PROTOCOL error, which _onData turns into a socket
|
|
88
|
+
// teardown + reconnect instead of a process crash.
|
|
89
|
+
var MAX_RESP_DEPTH = 64;
|
|
90
|
+
function _parseFrame(buf, offset, depth) {
|
|
91
|
+
depth = depth || 0;
|
|
92
|
+
if (depth > MAX_RESP_DEPTH) {
|
|
93
|
+
throw _err("PROTOCOL", "reply nesting exceeds " + MAX_RESP_DEPTH + " levels");
|
|
94
|
+
}
|
|
82
95
|
if (offset >= buf.length) return { type: "incomplete" };
|
|
83
96
|
var marker = buf[offset];
|
|
84
97
|
// Find next CRLF after the marker
|
|
@@ -121,7 +134,7 @@ function _parseFrame(buf, offset) {
|
|
|
121
134
|
var items = [];
|
|
122
135
|
var cursor = crlf + 2;
|
|
123
136
|
for (var i = 0; i < arrLen; i++) {
|
|
124
|
-
var sub = _parseFrame(buf, cursor);
|
|
137
|
+
var sub = _parseFrame(buf, cursor, depth + 1);
|
|
125
138
|
if (sub.type === "incomplete") return { type: "incomplete" };
|
|
126
139
|
items.push(sub);
|
|
127
140
|
cursor += sub.consumed;
|
|
@@ -299,9 +312,19 @@ function create(opts) {
|
|
|
299
312
|
function _onData(chunk) {
|
|
300
313
|
rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
|
|
301
314
|
while (rxBuffer.length > 0) {
|
|
302
|
-
var frame
|
|
303
|
-
|
|
304
|
-
|
|
315
|
+
var frame, value;
|
|
316
|
+
try {
|
|
317
|
+
frame = _parseFrame(rxBuffer, 0);
|
|
318
|
+
if (frame.type === "incomplete") return;
|
|
319
|
+
value = _frameToValue(frame);
|
|
320
|
+
} catch (parseErr) {
|
|
321
|
+
// A malformed or hostilely-nested RESP frame must not throw out of
|
|
322
|
+
// the socket 'data' handler and crash the host. Treat it as a fatal
|
|
323
|
+
// connection fault: reject in-flight commands and tear the socket
|
|
324
|
+
// down for a reconnect, the same as any other lost-socket path.
|
|
325
|
+
_teardownSocket(parseErr);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
305
328
|
rxBuffer = rxBuffer.slice(frame.consumed);
|
|
306
329
|
|
|
307
330
|
// Pub/sub push detection — server-initiated arrays beginning with
|
package/lib/router.js
CHANGED
|
@@ -698,7 +698,32 @@ class Router {
|
|
|
698
698
|
var parsed = safeUrl.parse(absolute, {
|
|
699
699
|
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
700
700
|
});
|
|
701
|
-
|
|
701
|
+
// Canonicalize the path to a single decoded form BEFORE any path-scoped
|
|
702
|
+
// decision runs. safeUrl.parse preserves percent-escapes in the path, but
|
|
703
|
+
// path-scoped middleware (`router.use("/admin", guard)` — requireAal,
|
|
704
|
+
// bearerAuth, requireMtls, csrfProtect) matches req.pathname segment-for-
|
|
705
|
+
// segment while downstream consumers (b.staticServe, router.serveStatic)
|
|
706
|
+
// percent-decode the path before resolving the resource. If the gate and
|
|
707
|
+
// the consumer disagree on decoding, an attacker percent-encodes a
|
|
708
|
+
// character in the guarded segment ("/%61dmin/secret") or hides a
|
|
709
|
+
// separator ("/admin%2fsecret") so the guard's compare misses while the
|
|
710
|
+
// consumer still reaches the protected resource — an authn/authz/CSRF/
|
|
711
|
+
// mTLS bypass. Refuse an encoded path separator or NUL (no legitimate
|
|
712
|
+
// routing use — the consumer would treat them as a separator/terminator),
|
|
713
|
+
// then decode the remaining escapes exactly once so the guard, the route
|
|
714
|
+
// matcher, and every consumer share one canonical path.
|
|
715
|
+
if (/%2[fF]|%5[cC]|%00/.test(parsed.pathname)) {
|
|
716
|
+
res.statusCode = 400;
|
|
717
|
+
res.end("400 Bad Request: encoded path separator or null byte");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
req.pathname = decodeURIComponent(parsed.pathname);
|
|
722
|
+
} catch (_decodeErr) {
|
|
723
|
+
res.statusCode = 400;
|
|
724
|
+
res.end("400 Bad Request: malformed percent-encoding in path");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
702
727
|
// CVE-2026-21717 V8 HashDoS defense — cap distinct query keys
|
|
703
728
|
// before forming the dense object. Integer-shaped keys past 1000
|
|
704
729
|
// entries degrade V8 hidden-class transitions to O(n²).
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:926ac1ba-da85-48b2-b9f9-671502d7a33e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-30T01:05:41.702Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.67",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.67",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.67",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.67",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|