@blamejs/core 0.16.38 → 0.16.39

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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 */ }
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
  });
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.39",
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:ebd78666-3b8e-4324-8760-e49b09c58af5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T20:35:21.934Z",
8
+ "timestamp": "2026-07-17T00:12:36.718Z",
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.39",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.38",
25
+ "version": "0.16.39",
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.39",
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.39",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]