@blamejs/core 0.16.38 → 0.16.40

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.40 (2026-07-16) — **Four request-path fixes: the router no longer lets the Host header steer which route runs, per-route rate limits can no longer be evaded with a rotating query string, and the message-format and URI-template expanders no longer leak inherited object properties.** b.router derived the dispatch path from the client-controlled Host header, so a path-like Host value bled into the parsed pathname and could steer a request to a different route than its request line named -- a path-ACL bypass in front-proxied deployments. b.middleware.rateLimit in per-route scope fell back to the full request URL (query string included) when the router had not populated the path, letting a rotating throwaway query parameter mint a fresh bucket per request. And b.i18n message-format and b.uriTemplate resolved a template-derived variable name with a bare property read, so a name like {toString} or {constructor} returned an inherited Object.prototype member (or a prototype-polluted value) instead of treating the variable as absent. **Security:** *The Host header can no longer influence which route is dispatched* — b.router derived the request path it matches routes against (req.pathname) by parsing the Host header concatenated with the request URL. WHATWG URL parsing folds any path-like characters in the Host value into the parsed pathname, so a request whose request line was /x but whose Host header was trusted/admin dispatched as /admin/x while req.url stayed /x. Because req.pathname is what the route matcher and every path-scoped guard (auth, CSRF, mTLS) compare against, a client could steer which handler runs -- and a front proxy or WAF that authorizes on the visible request path is bypassed. The router now derives the path and query from the request URL alone, resolved against a fixed internal authority, so the Host header cannot perturb routing; a request target that is not origin-form (a single leading slash) -- an absolute-form, authority-form, asterisk-form, or network-path-reference target -- is rejected with 400 rather than coerced into a routable path. The request's real scheme and host remain available to the consumers (canonical URL, CORS, host allowlist) that legitimately need them. · *Per-route rate limits key on the path only, not the query string* — b.middleware.rateLimit with scope: "per-route" composed its bucket key from req.pathname, falling back to the full request URL when pathname was absent (a raw Node request handed to the middleware without the router populating it). The fallback included the query string, which is not part of route identity, so an attacker could rotate a throwaway parameter (?nonce=1, ?nonce=2, ...) to mint a fresh bucket per request from the same client and evade the limit. The per-route key now strips the query in the fallback, matching the path-normalization the other request-scoped guards already apply. · *Message-format and URI-template variable lookups are own-property only* — b.i18n message-format ({arg}, {n, plural, ...}, {s, select, ...}) and b.uriTemplate ({var} expansion) resolved a variable name parsed from the template with a bare property read of the caller's variables object. A template naming an inherited member -- {toString}, {constructor}, {valueOf}, {__proto__} -- therefore returned the Object.prototype member (a function's source, or a prototype-polluted value) instead of treating the variable as absent, leaking it into rendered output or an expanded URI. Both now resolve variables through an own-property check, so an inherited or polluted name renders empty (message-format argument) or is omitted per RFC 6570 (URI template), matching the own-property discipline the HTML template engine and the simple interpolator already enforce.
12
+
13
+ - v0.16.39 (2026-07-16) — **Three fixes across the outbound HTTP cache, session device binding, and XML canonicalization: a shared cache no longer serves one principal's authenticated response to another, a strict device-binding policy no longer admits an unbound session, and XML canonicalization no longer collides literal and character-reference line endings.** The RFC 9111 outbound HTTP cache (b.httpClient) leaked across principals: a shared cache (the default) stored and re-served the response to an Authorization-bearing GET to a subsequent request from a different caller, violating RFC 9111 §3.5. b.session.verify failed open under a strict device-binding policy for a session that carried no stored fingerprint -- the normal state for any session created without a request context, including API-token, OAuth-callback, and admin-created flows -- so requireFingerprintMatch / maxAnomalyScore silently admitted a session from any device. And b.xmlC14n canonicalized a literal TAB / CR / LF and the equivalent character reference to identical bytes, a distinct-input / identical-output collision in the exact primitive whose purpose is preventing XML-signature-wrapping, affecting the attribute values and the element text and CDATA that XMLDSig signatures cover. **Security:** *Shared HTTP cache no longer serves an authenticated response across principals* — b.httpClient's RFC 9111 response cache, in shared mode (the default), stored and re-served the response to a GET carrying an Authorization header to a later request from a different principal. RFC 9111 §3.5 forbids a shared cache from reusing a stored response to an Authorization-bearing request unless the response opts in via public, s-maxage, or must-revalidate; the storage decision never inspected the request headers, so an authenticated per-user response with an ordinary max-age was cached and served to other users. The storage decision now refuses to persist an Authorization-bearing request's response in a shared cache unless the origin supplies one of those opt-ins. Private caches (sharedCache: false) and the opt-in directives are unaffected. · *Strict session device binding refuses an unbound session instead of admitting it* — b.session.verify treats requireFingerprintMatch: true or a maxAnomalyScore threshold as a per-request assertion that the session is device-bound and the current device matches. Those refusals were reached only when a stored fingerprint was present, so a session with no binding -- the state of any session created without a request context (API-token, OAuth-callback, admin-created, and 'remember me' flows) -- skipped the strict gate entirely and was admitted from any device. verify now fails closed (returns null, audit event auth.session.binding_missing) whenever a strict binding policy is requested but the session carries no comparable fingerprint, covering both a never-bound session and one whose sealed binding cannot be decrypted. Bind any session you intend to verify strictly by passing the request context to create(); verifications that do not request a strict policy are unchanged. · *XML canonicalization distinguishes literal whitespace from character references* — b.xmlC14n produces the canonical byte form that XML signatures cover, so distinct inputs must yield distinct bytes or a signed document can be swapped for a different one whose canonical form still matches (signature wrapping). A literal TAB, CR, or LF and the equivalent character reference (	 / 
 / 
) canonicalized to identical bytes: attribute values were not normalized per XML 1.0 §3.3.3, and character data (element text and CDATA) was not line-ending-normalized per §2.11. Attribute-value whitespace now folds to a single space while character-reference whitespace is preserved, and literal CR / CRLF in character data now folds to a single LF while a 
 reference is preserved -- so a literal control character and its character-reference form canonicalize distinctly everywhere a signature covers. The SAML XMLDSig verification and b.guardXml signature-wrapping defense that consume the canonical form inherit the fix.
14
+
11
15
  - v0.16.38 (2026-07-16) — **Three fixes to data-subject scoping, break-glass grant limits, and idempotent-retry replay: a data-subject filter no longer matches every subject when it has no indexable key, a single-row break-glass grant can no longer be spent twice concurrently, and an idempotent retry returns its cached result under a vault.** b.dsr's subject-scoped ticket filter failed open: a subject carrying none of the indexable keys (email / subjectId) -- a phone-only, alias-only, or empty subject -- matched EVERY ticket instead of none, so listBySubject returned other subjects' tickets and the erasure-completion purge deleted them. Both ticket stores now fail closed. b.breakGlass's per-row grant limit could be exceeded under concurrency: two simultaneous unseals of a one-row grant against different rows both succeeded, because the claim was decided from a re-read of the shared counter that already reflected the other caller's increment; the claim is now decided from the atomic update's affected-row count. And b.agent.idempotency's putIfAbsent replay parsed the sealed result blob without unsealing it, so under a vault (the production default) every idempotent retry that landed on a completed key threw instead of returning the cached result -- and get() on a pending claim threw on a null result blob rather than reporting no cached result. **Fixed:** *Data-subject request filter fails closed when a subject has no indexable key* — b.dsr's subject-scoped ticket filter matched on the indexable keys email and subjectId. When the supplied subject carried neither -- a phone-only subject (a legitimate SMS-first identity), an alias-only subject, or an empty object -- the filter added no predicate and returned every ticket in the store instead of none. Through the exported API this meant listBySubject(subject) disclosed every subject's tickets, and the erasure-completion purge (which lists a subject's other tickets and deletes them) deleted every other subject's tickets. Both the in-memory and database ticket stores now fail closed: a subject filter that produces no usable predicate matches nothing, so an unindexable subject can neither read nor delete another subject's data. Filters that supply an indexable key are unchanged. · *A single-row break-glass grant can no longer be spent twice under concurrency* — b.breakGlass.unsealRow enforces a per-grant row limit with an atomic compare-and-increment (update the consumed counter where it is still below the cap). It then decided whether the caller won the slot by re-reading the counter and comparing it to the caller's own stale pre-value -- but a concurrent winner's increment is visible to the loser's re-read, so both callers saw a change and both proceeded, unsealing two rows under a one-row grant. The claim is now decided from the atomic update's affected-row count: exactly one caller's compare-and-increment modifies the row, and the loser (zero rows modified) is refused with grant-exhausted. The re-read is retained only for the audit's remaining-rows hint. · *Idempotent retries return their cached result under a vault, and a pending-claim read reports absent* — b.agent.idempotency seals the cached result at rest via b.cryptoField when a vault is configured (the production default). putIfAbsent's replay branch parsed the stored result blob as JSON without unsealing it first, so a retry that landed on an already-completed key threw a corrupt-result error instead of returning the cached result -- breaking the primitive's exactly-once replay guarantee exactly where operators run it. It now unseals before parsing, mirroring get(). Separately, get() on a pending claim (whose result blob is null because no result has been written yet) fed null to the JSON parser and threw the same corrupt-result error; it now reports no cached result, so a concurrent status check during another worker's in-flight claim no longer throws.
12
16
 
13
17
  - v0.16.37 (2026-07-16) — **Three fail-open / injection fixes: the age gate no longer admits a user whose age fails to compute, the query builder's OFFSET-without-LIMIT runs on every backend, and metrics exemplars can no longer carry an unsanitized value into the scrape stream.** Three defects, each in a class the framework treats as security-relevant. b.middleware.ageGate classified a non-finite age (a NaN or Infinity returned by getAge when a birth field fails to parse) as an adult -- admitting the request with none of the child-safety privacy defaults -- instead of treating an uncomputable age as unknown. b.sql (and b.db.from / b.db.collection over it) emitted a bare OFFSET with no LIMIT, which is valid only on Postgres and is a syntax error on SQLite -- the framework's own backend -- and MySQL, so a valid builder chain failed to run on two of three dialects. And b.metrics exemplars rendered their labels, value, and timestamp into the OpenMetrics scrape surface without the credential-scrub and numeric-coercion regular labels receive, so an operator-supplied exemplar could leak a credential-shaped label or inject a forged metric line through exemplar.value / exemplar.timestamp. **Fixed:** *Age gate treats a non-finite age as unknown, not as an adult* — b.middleware.ageGate now classifies a non-finite age (NaN or +/-Infinity, the shape getAge returns when a birth field fails to parse or date math goes wrong) as "unknown" rather than letting it fall through to "above-threshold". Because typeof NaN === "number" and every comparison against NaN is false, an uncomputable age previously bypassed the below-threshold branch and was admitted as a confirmed adult -- with none of the child-safety privacy defaults (Cache-Control: private, no-store; Referrer-Policy: no-referrer; the privacy-posture header) the unknown path applies. When the birth value is request-derived this is attacker-influenced. A non-finite age is now handled exactly like a null return: the request is classified unknown and the privacy defaults are applied. · *Query builder OFFSET without LIMIT runs on SQLite and MySQL, not only Postgres* — b.sql SELECT (and the b.db.from / b.db.collection consumers built on it) emitted a bare "OFFSET n" when .offset() was set without .limit(). A bare OFFSET is valid only on Postgres; SQLite (the framework's own node:sqlite backend) and MySQL both reject it as a syntax error, so a valid builder chain produced SQL that failed to prepare on two of the three supported dialects, including the default one. The builder now emits the dialect's unbounded-limit sentinel before the OFFSET -- SQLite LIMIT -1, MySQL the maximum unsigned BIGINT, Postgres LIMIT ALL -- so one query text runs unchanged across all three. Statements that set an explicit LIMIT are byte-for-byte unchanged. · *Metrics exemplars are sanitized on the scrape surface the same way regular labels are* — b.metrics histogram exemplars rendered their labels, value, and timestamp into the OpenMetrics /metrics exposition -- the same broadly-readable scrape surface regular labels reach -- without the sanitization regular labels receive. Exemplar label values bypassed the credential scrubber, so a credential-shaped value an operator attached to an exemplar (e.g. tapping a raw header alongside trace context) egressed in cleartext (CWE-532); and exemplar.value / exemplar.timestamp were appended to the exposition line raw, so a non-numeric operator-supplied value such as "1.0\n# forged 999" could inject a forged metric line. Exemplar labels now flow through the same credential scrubber as regular labels, and exemplar value and timestamp are coerced to a finite number (value falling back to the observed value, timestamp to none) at store time, so only sanitized labels and bare numbers ever reach the wire. Trace context (trace_id / span_id) and numeric values pass through unchanged.
@@ -130,6 +130,23 @@ function _ccPresent(directives, name) {
130
130
  return directives && Object.prototype.hasOwnProperty.call(directives, name);
131
131
  }
132
132
 
133
+ // RFC 9111 §3.5 — does this request carry an `Authorization` header field
134
+ // (RFC 9110 §11.6.2) with a non-empty value? A shared cache must treat the
135
+ // response to such a request as per-principal unless the origin opts in via
136
+ // public / s-maxage / must-revalidate. Proxy-Authorization is deliberately
137
+ // NOT included — §3.5 names the Authorization header specifically.
138
+ function _hasAuthorization(requestHeaders) {
139
+ if (!requestHeaders || typeof requestHeaders !== "object") return false;
140
+ var keys = Object.keys(requestHeaders);
141
+ for (var i = 0; i < keys.length; i++) {
142
+ if (keys[i].toLowerCase() === "authorization") {
143
+ var v = requestHeaders[keys[i]];
144
+ return v !== undefined && v !== null && String(v) !== "";
145
+ }
146
+ }
147
+ return false;
148
+ }
149
+
133
150
  // ---- HTTP date parsing -----------------------------------------------
134
151
 
135
152
  function _parseHttpDate(s) {
@@ -236,7 +253,7 @@ function _extractVaryValues(varyHeader, requestHeaders) {
236
253
  //
237
254
  // freshnessMs is computed from the response — 0 means "store but
238
255
  // always revalidate" (no-cache), -1 means "never cacheable".
239
- function _evaluateStorage(method, statusCode, responseHeaders, sharedCache) {
256
+ function _evaluateStorage(method, statusCode, responseHeaders, sharedCache, requestHeaders) {
240
257
  var lcResp = _lcHeaders(responseHeaders);
241
258
  var ccRaw = _headerOne(lcResp, "cache-control");
242
259
  var directives = _parseCacheControl(ccRaw);
@@ -262,6 +279,21 @@ function _evaluateStorage(method, statusCode, responseHeaders, sharedCache) {
262
279
  return { cacheable: false, reason: "private", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
263
280
  }
264
281
 
282
+ // RFC 9111 §3.5 — a shared cache MUST NOT reuse a stored response to a
283
+ // request carrying `Authorization` to satisfy a subsequent request unless
284
+ // the response explicitly permits it via `public`, `s-maxage`, or
285
+ // `must-revalidate`. Gate at store time: a per-user authenticated response
286
+ // that lacks the opt-in never lands in a fleet-shared cache where a
287
+ // different principal's request would be served it (cross-user data leak).
288
+ if (sharedCache && _hasAuthorization(requestHeaders)) {
289
+ var authShareable = _ccPresent(directives, "public") ||
290
+ _ccPresent(directives, "must-revalidate") ||
291
+ _ccNumber(directives, "s-maxage") !== null;
292
+ if (!authShareable) {
293
+ return { cacheable: false, reason: "authorization-shared", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
294
+ }
295
+ }
296
+
265
297
  // Vary: * is uncacheable per RFC 9110 §12.5.5.
266
298
  if (typeof varyHeader === "string" && varyHeader.indexOf("*") !== -1) {
267
299
  var trimmed = varyHeader.split(",").map(function (s) { return s.trim(); }); // RFC 9110 §12.5.5 Vary field-names; token grammar only, so a bare split is correct
@@ -653,6 +685,12 @@ function create(opts) {
653
685
  directives: evaluation.directives,
654
686
  etag: _headerOne(lcResp, "etag"),
655
687
  lastModified: _headerOne(lcResp, "last-modified"),
688
+ // RFC 9111 §3.5 — remember whether the request that produced this entry
689
+ // carried Authorization, so a later 304 refresh can re-apply the shared-
690
+ // cache gate (a 304 can replace Cache-Control, dropping the public /
691
+ // s-maxage / must-revalidate opt-in that first permitted an authed
692
+ // response into a shared cache).
693
+ hadAuthorization: _hasAuthorization(requestHeaders),
656
694
  };
657
695
  }
658
696
 
@@ -698,6 +736,24 @@ function create(opts) {
698
736
  // index lives in the store under (method, url, varyValues) so we
699
737
  // need both a "what Vary names apply" marker and the real entry.
700
738
  function _lookupWithVary(method, url, requestHeaders) {
739
+ var result = _resolveEntryWithVary(method, url, requestHeaders);
740
+ // RFC 9111 §3.5 fail-closed for legacy persistent-store records: an entry
741
+ // written by a version before `hadAuthorization` was recorded carries no
742
+ // such flag. In a shared cache it could be a pre-upgrade AUTHENTICATED
743
+ // response, and every serve path (fresh HIT, stale-serve, revalidation)
744
+ // resolves through here — so evict it and report a miss rather than serve
745
+ // one principal's cached body to another. Entries written by this version
746
+ // carry the flag (true or false) and are unaffected; private caches are
747
+ // out of §3.5 scope.
748
+ if (sharedCache && result && result.entry &&
749
+ !result.entry.__varyMarker && result.entry.hadAuthorization === undefined) {
750
+ try { store.delete(result.key); } catch (_e) { /* drop-silent */ }
751
+ return null;
752
+ }
753
+ return result;
754
+ }
755
+
756
+ function _resolveEntryWithVary(method, url, requestHeaders) {
701
757
  var noVary = _lookup(method, url, requestHeaders);
702
758
  if (noVary) {
703
759
  // Distinguish marker from real entry via __varyMarker flag.
@@ -775,7 +831,20 @@ function create(opts) {
775
831
  // storedAt to "now" so age math restarts.
776
832
  function _refreshFrom304(stored, fresh304Headers) {
777
833
  var mergedHeaders = _merge304Headers(stored, fresh304Headers);
778
- var evaluation = _evaluateStorage(stored.method, stored.statusCode, mergedHeaders, sharedCache);
834
+ // Re-apply the RFC 9111 §3.5 shared-cache Authorization gate against the
835
+ // MERGED headers, carrying forward whether the original request was
836
+ // Authorization-bearing. A 304 can replace Cache-Control (e.g. an entry
837
+ // first stored under `must-revalidate` now returns plain `max-age=60`);
838
+ // without the request's auth context here the refreshed entry would be
839
+ // retained as a freely-shareable response and served to other principals.
840
+ // Fail CLOSED when the flag is ABSENT (not just false): an entry written by
841
+ // a persistent shared store (Redis / filesystem) before this field existed
842
+ // carries no `hadAuthorization`, and assuming such a legacy entry was
843
+ // unauthenticated would let an originally-authenticated response survive
844
+ // the upgrade as shareable. Only an explicit `hadAuthorization === false`
845
+ // skips the gate.
846
+ var reqHeadersForGate = stored.hadAuthorization === false ? {} : { authorization: "1" };
847
+ var evaluation = _evaluateStorage(stored.method, stored.statusCode, mergedHeaders, sharedCache, reqHeadersForGate);
779
848
  var lcMerged = _lcHeaders(mergedHeaders);
780
849
  var dateMs = _parseHttpDate(_headerOne(lcMerged, "date"));
781
850
  var ageSec = parseInt(_headerOne(lcMerged, "age") || "0", 10);
@@ -792,6 +861,15 @@ function create(opts) {
792
861
  });
793
862
  var hasVary = refreshed.varyHeader && refreshed.varyValues && refreshed.varyValues.length > 0;
794
863
  var key = _buildCacheKey(refreshed.method, refreshed.url, hasVary ? refreshed.varyValues : []);
864
+ if (!evaluation.cacheable) {
865
+ // The merged 304 response no longer satisfies the storage policy (e.g. an
866
+ // Authorization-bearing entry lost its §3.5 opt-in). Evict rather than
867
+ // retain a now-unshareable entry — the current, freshly-revalidated
868
+ // caller still receives the merged body via the returned value; only its
869
+ // retention for a DIFFERENT principal is refused.
870
+ try { store.delete(key); } catch (_e) { /* drop-silent */ }
871
+ return refreshed;
872
+ }
795
873
  try { store.set(key, refreshed); } catch (_e) { /* drop-silent */ }
796
874
  return refreshed;
797
875
  }
@@ -851,8 +929,8 @@ function create(opts) {
851
929
  return _lookupWithVary(method, url, requestHeaders);
852
930
  },
853
931
 
854
- _evaluateStorage: function (method, statusCode, responseHeaders) {
855
- return _evaluateStorage(method, statusCode, responseHeaders, sharedCache);
932
+ _evaluateStorage: function (method, statusCode, responseHeaders, requestHeaders) {
933
+ return _evaluateStorage(method, statusCode, responseHeaders, sharedCache, requestHeaders);
856
934
  },
857
935
 
858
936
  _evaluateStored: _evaluateStored,
@@ -1263,7 +1263,11 @@ function _revalidate(cache, method, opts, entry, requestHeaders) {
1263
1263
  // throw so caching cannot surface as a request failure.
1264
1264
  function _maybeStore(cache, method, url, requestHeaders, res) {
1265
1265
  try {
1266
- var evaluation = cache._evaluateStorage(method, res.statusCode, res.headers || {});
1266
+ // requestHeaders drives the RFC 9111 §3.5 Authorization gate inside the
1267
+ // storage decision — a shared cache must not persist a per-principal
1268
+ // authenticated response absent an explicit public / s-maxage /
1269
+ // must-revalidate opt-in from the origin.
1270
+ var evaluation = cache._evaluateStorage(method, res.statusCode, res.headers || {}, requestHeaders);
1267
1271
  if (!evaluation.cacheable) return;
1268
1272
  cache._store(method, url, requestHeaders, res.statusCode, res.headers || {}, res.body, evaluation);
1269
1273
  } catch (_e) { /* drop-silent — caching never breaks the request */ }
@@ -373,17 +373,30 @@ function _ownCase(cases, key) {
373
373
  return Object.prototype.hasOwnProperty.call(cases, key) ? cases[key] : undefined;
374
374
  }
375
375
 
376
+ // Own-property variable lookup. A template argument NAME is parse-derived from
377
+ // the (possibly operator/tenant-supplied) message, so it can be `toString` /
378
+ // `constructor` / `valueOf` / `__proto__` / any prototype-polluted key. A bare
379
+ // `vars[name]` would then return the INHERITED Object.prototype member instead
380
+ // of treating the variable as absent — leaking a function source (or a planted
381
+ // prototype value) into rendered output and diverging from both the "missing
382
+ // arg renders empty" contract and the sibling simple interpolator in b.i18n,
383
+ // which is already own-property-only. Every argument / plural / select value
384
+ // lookup goes through this so no template name can reach the prototype chain.
385
+ function _ownVar(vars, name) {
386
+ return Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : undefined;
387
+ }
388
+
376
389
  function _renderNode(node, vars, locale, hashContext, depth) {
377
390
  if (node.type === "literal") return node.value;
378
391
  if (node.type === "hash") {
379
392
  return hashContext != null ? String(hashContext) : "#";
380
393
  }
381
394
  if (node.type === "argument") {
382
- var v = vars[node.name];
395
+ var v = _ownVar(vars, node.name);
383
396
  return v === undefined ? "" : (v === null ? "" : String(v));
384
397
  }
385
398
  if (node.type === "plural" || node.type === "ordinal") {
386
- var raw = vars[node.name];
399
+ var raw = _ownVar(vars, node.name);
387
400
  var n = Number(raw);
388
401
  if (!Number.isFinite(n)) {
389
402
  throw _err("BAD_VAR",
@@ -401,7 +414,7 @@ function _renderNode(node, vars, locale, hashContext, depth) {
401
414
  return _renderSequence(caseBody, vars, locale, adjusted, depth + 1);
402
415
  }
403
416
  if (node.type === "select") {
404
- var sv = vars[node.name];
417
+ var sv = _ownVar(vars, node.name);
405
418
  var key = (sv === undefined || sv === null) ? "other" : String(sv);
406
419
  var body = _ownCase(node.cases, key) || _ownCase(node.cases, "other");
407
420
  return _renderSequence(body, vars, locale, hashContext, depth + 1);
@@ -570,7 +570,18 @@ function create(opts) {
570
570
  var middleware = function rateLimit(req, res, next) {
571
571
  if (_shouldSkip(req)) return next();
572
572
  var k = keyFn(req);
573
- if (scope === "per-route") k = (req.method || "GET") + ":" + (req.pathname || req.url || "/") + "|" + k;
573
+ // Per-route scope keys on the route PATH only never the query string. The
574
+ // query is not part of route identity; keying on it lets an attacker rotate
575
+ // a throwaway param (?nonce=N) to mint a fresh per-route bucket per request
576
+ // and evade the limit. req.pathname (set query-free by b.router) is
577
+ // preferred; when it is absent (a raw Node req handed to the middleware
578
+ // directly) fall back to req.url with the query stripped — the same strip
579
+ // every sibling guard (request-log / require-auth / network-allowlist)
580
+ // already applies.
581
+ if (scope === "per-route") {
582
+ var routePath = req.pathname || (req.url || "/").split("?")[0] || "/";
583
+ k = (req.method || "GET") + ":" + routePath + "|" + k;
584
+ }
574
585
 
575
586
  function _handle(verdict) {
576
587
  if (emitHeaders && typeof res.setHeader === "function") {
package/lib/router.js CHANGED
@@ -298,6 +298,18 @@ function compilePattern(pattern) {
298
298
  // object on match, null otherwise. Single non-empty trailing slash
299
299
  // difference is treated as a no-match (callers that want trailing-slash
300
300
  // tolerance normalize the path before dispatch).
301
+ // Collapse a leading run of slashes in a request target to a single "/" so a
302
+ // `//host/path` network-path reference becomes a plain path. Shared by handle()
303
+ // (routing + the value written back onto req.url) and _check0RttReplay (the
304
+ // early-data replay key) so both derive from ONE canonical target: otherwise
305
+ // `//x`, `///x`, and `/x` route to the same endpoint but would each mint a
306
+ // distinct replay-cache key, letting an attacker replay Early-Data by varying
307
+ // the leading-slash run.
308
+ function _canonicalRequestTarget(url) {
309
+ var t = String(url == null ? "/" : url);
310
+ return t.charAt(0) === "/" && t.charAt(1) === "/" ? t.replace(/^\/+/, "/") : t;
311
+ }
312
+
301
313
  function _matchCompiled(compiled, pathname) {
302
314
  var pathSegments = pathname.split("/");
303
315
  var patSegments = compiled.segments;
@@ -747,11 +759,50 @@ class Router {
747
759
  }
748
760
 
749
761
  async handle(req, res) {
750
- // Compose an absolute URL from the request's path + Host header so
751
- // safeUrl.parse can validate the protocol + length. The "http://"
752
- // base is the relative-resolution origin; the request's actual
753
- // scheme lives in requestHelpers.requestProtocol elsewhere.
754
- var absolute = "http://" + (req.headers.host || "localhost") + (req.url || "/");
762
+ // Derive the request path + query from req.url ALONE, resolved against a
763
+ // FIXED internal authority the Host header must NEVER influence which
764
+ // route is dispatched. Host is client-controlled: a value like
765
+ // "trusted/admin" bleeds its path segment into WHATWG-URL's pathname
766
+ // parsing (a leading "/admin" prepended), so a request whose request-line
767
+ // is "/x" would route as "/admin/x" while req.url stays "/x". That
768
+ // desyncs req.pathname (the value the route matcher AND every path-scoped
769
+ // guard compare against) from req.url, and a front proxy that ACLs on the
770
+ // visible request path is bypassed — the proxy sees "/x", the origin
771
+ // dispatches "/admin/x". Parsing req.url against a constant base keeps
772
+ // req.pathname a pure function of the request target. The request's real
773
+ // scheme + host live on req.headers.host / requestHelpers.requestProtocol
774
+ // for the consumers (canonical-URL, CORS, host-allowlist) that need them.
775
+ var reqTarget = req.url || "/";
776
+ // Only an origin-form request target — beginning with "/" (RFC 9112
777
+ // §3.2.1) — is routable by a path router. Reject an absolute-form
778
+ // (`http://host/path`), authority-form (`host:port`), or asterisk-form
779
+ // (`OPTIONS *`) target with 400 rather than coercing it into a routable
780
+ // path by prefixing: prefixing would turn `http://evil/admin` into
781
+ // `/http://evil/admin`, which a `/:x` or catch-all route would still match
782
+ // — the opposite of failing closed. A "/"-leading target (including one
783
+ // with a redundant leading slash like `//x`, a valid absolute-path with an
784
+ // empty first segment) is safe: parsed against the fixed authority it
785
+ // becomes a pathname, never a host, so the Host header still cannot steer
786
+ // routing.
787
+ if (reqTarget.charAt(0) !== "/") {
788
+ res.statusCode = 400;
789
+ res.end("400 Bad Request: non-origin-form request target");
790
+ return;
791
+ }
792
+ // Collapse a leading run of slashes to a single "/". A `//host/path` target
793
+ // is a valid absolute-path (empty first segment) so it need not be refused,
794
+ // but left intact it stays a network-path reference: any consumer that
795
+ // resolves req.url as a URL reference (`new URL(req.url, base)`) would read
796
+ // the first segment as an AUTHORITY — a Host bleed / SSRF. Normalizing here
797
+ // (via the same canonical-target helper the 0-RTT replay key uses), and
798
+ // writing the result back onto req.url, keeps it a pure path for the route
799
+ // matcher AND every downstream req.url reader.
800
+ var canonicalTarget = _canonicalRequestTarget(reqTarget);
801
+ if (canonicalTarget !== reqTarget) {
802
+ reqTarget = canonicalTarget;
803
+ req.url = reqTarget;
804
+ }
805
+ var absolute = "http://blamejs.invalid" + reqTarget;
755
806
  var parsed = safeUrl.parse(absolute, {
756
807
  allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
757
808
  });
@@ -930,7 +981,10 @@ class Router {
930
981
  this._reap0RttCache(nowMs);
931
982
  var hash = require("node:crypto").createHash("sha3-512");
932
983
  hash.update(String(req.method || "") + "\n");
933
- hash.update(String(req.url || "") + "\n");
984
+ // Canonical target collapse a leading slash run so `//x` and `/x`, which
985
+ // dispatch to the same route, share one replay key (varying the leading
986
+ // slashes must not mint a fresh key and defeat the replay window).
987
+ hash.update(_canonicalRequestTarget(req.url) + "\n");
934
988
  hash.update(String((req.headers && req.headers["host"]) || "") + "\n");
935
989
  hash.update(String((req.headers && req.headers["authorization"]) || "") + "\n");
936
990
  hash.update(String((req.headers && req.headers["date"]) || "") + "\n");
package/lib/session.js CHANGED
@@ -488,7 +488,12 @@ async function create(opts) {
488
488
  * the bound fingerprint — the result carries `fingerprintDrift: true`
489
489
  * on mismatch (audit event always fires). `requireFingerprintMatch:
490
490
  * true` or a `maxAnomalyScore` threshold (with a `scorer` callback)
491
- * makes drift refuse the session by returning `null`.
491
+ * makes drift refuse the session by returning `null`. A strict policy
492
+ * also refuses (returns `null`) a session that carries no comparable
493
+ * binding — one created without `{ req }`, or whose sealed binding
494
+ * cannot be decrypted — since the device match cannot be proven; bind
495
+ * every session you intend to verify strictly by passing `{ req }` to
496
+ * `create`.
492
497
  *
493
498
  * @opts
494
499
  * {
@@ -627,15 +632,24 @@ async function verify(token, verifyOpts) {
627
632
  // login-from-Tokyo-then-immediately-from-Brazil pattern is not).
628
633
  var fingerprintDrift = false;
629
634
  var fingerprintAnomalyScore = null;
630
- // A strict binding policy (requireFingerprintMatch / maxAnomalyScore) cannot be
631
- // satisfied when the binding is UNREADABLE we can't prove it matches, so fail
632
- // CLOSED rather than silently skipping the gate (the pre-fix fail-open).
633
- if (bindingUnreadable && verifyOpts.req &&
634
- (verifyOpts.requireFingerprintMatch === true ||
635
- typeof verifyOpts.maxAnomalyScore === "number")) {
635
+ // A strict binding policy (requireFingerprintMatch / maxAnomalyScore) is a
636
+ // per-request assertion that this session IS device-bound and the current
637
+ // device matches. It cannot be satisfied when there is no stored fingerprint
638
+ // to compare against — whether the binding is UNREADABLE (the sealed data
639
+ // cell exists but won't decrypt: key-rotation skew / corruption / tamper) or
640
+ // simply ABSENT (the session was created without { req }, so it was never
641
+ // bound). Either way the device match cannot be proven, so fail CLOSED rather
642
+ // than silently skip the gate. Treating "no binding to compare" as "the
643
+ // binding matches" is the fail-open this refuses: it admitted an unbound (or
644
+ // unreadable-binding) session from ANY device even under a strict policy.
645
+ var strictBindingRequested = !!verifyOpts.req &&
646
+ (verifyOpts.requireFingerprintMatch === true ||
647
+ typeof verifyOpts.maxAnomalyScore === "number");
648
+ if (strictBindingRequested && !storedFingerprint) {
636
649
  try {
637
650
  audit.safeEmit({
638
- action: "auth.session.binding_unreadable",
651
+ action: bindingUnreadable ? "auth.session.binding_unreadable"
652
+ : "auth.session.binding_missing",
639
653
  outcome: "failure",
640
654
  metadata: { hasUserId: !!unsealed.userId },
641
655
  });
@@ -159,7 +159,14 @@ function _expandExpr(expr, vars) {
159
159
  var o = OPERATORS[expr.op];
160
160
  var pieces = [];
161
161
  expr.specs.forEach(function (spec) {
162
- var value = vars[spec.name];
162
+ // Own-property only: a varspec name is parse-derived from the template, so
163
+ // it can be `constructor` / `toString` / `__proto__` / any prototype-
164
+ // polluted key. A bare `vars[spec.name]` would read the INHERITED member
165
+ // and expand a function source (or a planted prototype value) into the URI.
166
+ // RFC 6570 §3.2.1 treats an undefined variable as omitted, so an inherited
167
+ // name must be undefined here — never a prototype-chain read.
168
+ var value = Object.prototype.hasOwnProperty.call(vars, spec.name)
169
+ ? vars[spec.name] : undefined;
163
170
  if (!_isDefined(value)) return;
164
171
 
165
172
  if (typeof value !== "object") {
package/lib/xml-c14n.js CHANGED
@@ -112,6 +112,20 @@ function parse(xml) {
112
112
  "parse: <!ENTITY> declarations refused");
113
113
  }
114
114
 
115
+ // XML 1.0 §2.11 end-of-line handling — a conforming processor folds every
116
+ // literal CRLF and lone CR to a single LF across the WHOLE document (text,
117
+ // CDATA, comments, attribute literals) BEFORE the InfoSet is built. A CR
118
+ // delivered through a character reference (&#xD;) is NOT a source line
119
+ // ending and is preserved. Doing this document-wide once (rather than the
120
+ // attribute literal alone) closes the same distinct-input / identical-output
121
+ // collision in element text and CDATA: without it a literal CR in text and
122
+ // the &#xD; reference both canonicalize to `&#xD;` (the escape _escapeText
123
+ // applies to a surviving CR), letting a signed document be swapped for one
124
+ // whose character data differs but whose canonical bytes match. The
125
+ // attribute §3.3.3 whitespace fold below still runs (it additionally folds
126
+ // TAB and the now-LF to a single space, which §2.11 alone does not).
127
+ xml = xml.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
128
+
115
129
  var pos = 0;
116
130
  function err(msg) {
117
131
  throw _xmlErr("xml-c14n/parse", "parse: " + msg + " at offset " + pos);
@@ -159,7 +173,21 @@ function parse(xml) {
159
173
  if (pos >= xml.length) err("unterminated attribute value");
160
174
  var raw = xml.slice(start, pos);
161
175
  pos += 1; // closing quote
162
- return _decodeEntities(raw);
176
+ // XML 1.0 §2.11 line-ending + §3.3.3 attribute-value normalization: a
177
+ // literal TAB / CR / LF in the attribute literal (and a CRLF / lone-CR
178
+ // line ending) folds to a single SPACE. The SAME character delivered
179
+ // through a character reference (&#9; / &#xA; / &#xD;) is decoded AFTER
180
+ // this fold and is therefore preserved. Because c14n later escapes the
181
+ // surviving literal control characters back to &#x9; / &#xA; / &#xD;,
182
+ // skipping the fold makes `a="x<TAB>y"` and `a="x&#9;y"` canonicalize
183
+ // to IDENTICAL bytes even though their InfoSet attribute values differ
184
+ // ("x y" vs a real TAB) — a distinct-input / identical-output collision
185
+ // that would let a signed document be swapped for a semantically
186
+ // different one whose canonical bytes still match (XML-signature-
187
+ // wrapping / smuggling). Normalize the literal text BEFORE entity
188
+ // decode so character-reference whitespace stays intact.
189
+ var normalized = raw.replace(/\r\n/g, " ").replace(/[\r\n\t]/g, " ");
190
+ return _decodeEntities(normalized);
163
191
  }
164
192
 
165
193
  function _decodeEntities(s) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.38",
3
+ "version": "0.16.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:d9b1b08c-4823-449d-bc92-12a4eb6cae3a",
5
+ "serialNumber": "urn:uuid:450ccb8a-2242-4a61-a57f-516b6c8e1325",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T20:35:21.934Z",
8
+ "timestamp": "2026-07-17T03:08:43.327Z",
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.16.38",
22
+ "bom-ref": "@blamejs/core@0.16.40",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.38",
25
+ "version": "0.16.40",
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.16.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.40",
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.16.38",
57
+ "ref": "@blamejs/core@0.16.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]