@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 +4 -0
- package/lib/http-client-cache.js +82 -4
- package/lib/http-client.js +5 -1
- package/lib/i18n-messageformat.js +16 -3
- package/lib/middleware/rate-limit.js +12 -1
- package/lib/router.js +60 -6
- package/lib/session.js +22 -8
- package/lib/uri-template.js +8 -1
- package/lib/xml-c14n.js +29 -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.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.
|
package/lib/http-client-cache.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/lib/http-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
|
|
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
|
-
|
|
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)
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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/uri-template.js
CHANGED
|
@@ -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
|
-
|
|
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 (
) 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 
 reference both canonicalize to `
` (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
|
-
|
|
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 (	 / 
 / 
) is decoded AFTER
|
|
180
|
+
// this fold and is therefore preserved. Because c14n later escapes the
|
|
181
|
+
// surviving literal control characters back to 	 / 
 / 
,
|
|
182
|
+
// skipping the fold makes `a="x<TAB>y"` and `a="x	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
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:450ccb8a-2242-4a61-a57f-516b6c8e1325",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.40",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.16.40",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|