@blamejs/core 0.16.39 → 0.17.0
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 +6 -0
- package/lib/archive-read.js +8 -0
- package/lib/auth/sd-jwt-vc.js +4 -4
- package/lib/content-credentials.js +1 -1
- package/lib/i18n-messageformat.js +16 -3
- package/lib/jwk.js +20 -3
- package/lib/middleware/rate-limit.js +12 -1
- package/lib/object-store/gcs.js +7 -0
- package/lib/object-store/sigv4.js +31 -0
- package/lib/router.js +60 -6
- package/lib/tsa.js +2 -2
- package/lib/uri-template.js +8 -1
- package/lib/vc.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.17.x
|
|
10
|
+
|
|
11
|
+
- v0.17.0 (2026-07-17) — **Algorithm and key-type lookups across the crypto verifiers reject a prototype-member name instead of accepting it, the ZIP reader honors the operator's decompression-ratio policy, and signed S3/GCS query parameters transmit the space encoding they were signed with.** A family of verifiers resolved an algorithm, hash, or key-type name against a plain-object lookup table with a truthiness or `in`-operator guard, so a name that is an inherited Object.prototype member (constructor, __proto__, toString, valueOf) slipped the guard and resolved to the inherited member: b.jwk thumbprinted an attacker-crafted key to a predictable digest, b.vc and b.contentCredentials emitted a real signature under a bogus algorithm, and b.sdJwtVc accepted an attacker-controlled hash name from an unsigned issuer payload. All of them now reject a name that is not an own property of the table. Separately, b.archive's ZIP reader silently capped the decompression ratio at its composed default and ignored the operator's configured bomb policy, refusing legitimate highly-compressible entries; and b.storage's SigV4 (and the GCS V4 presigner) signed a query space as %20 but transmitted it as +, so a signed parameter carrying a space was rejected as a signature mismatch. **Fixed:** *ZIP extraction honors the operator's decompression-ratio policy* — b.archive's random-access ZIP reader composed b.safeDecompress for each DEFLATE entry without forwarding the reader's own expansion-ratio cap, so the decompression inherited safeDecompress's stricter default ratio and silently overrode the operator's configured bomb policy. A ZIP entry that legitimately compressed better than the default ratio (logs, JSON, telemetry, zero-padded or sparse binaries) was refused even when the operator's policy permitted it. The reader now forwards its configured maximum expansion ratio into the decode, so the actual-bytes ratio re-check uses the same cap the declared-size gate already enforced. · *Signed S3 and GCS query parameters transmit the space encoding they were signed with* — b.storage's SigV4 request signing (S3, R2, MinIO, and the S3-compatible backends) and the GCS V4 presigner sign the canonical query string, which encodes a space as %20 per the AWS/GCS specification, but transmitted the request via the WHATWG URL serializer, which encodes a space as +. A signed query parameter carrying a literal space -- a response-content-disposition filename or a list prefix -- was therefore signed over %20 but sent as +, so the storage server re-canonicalized to different bytes and rejected the request with a signature mismatch. The signer now aligns the wire query to the signed canonical encoding (rewriting a bare + back to %20) at each signing sink, so the transmitted query is byte-identical to what the signature commits to. **Security:** *Crypto algorithm and key-type lookups reject a prototype-member name* — Several verifiers resolved a caller- or attacker-supplied algorithm / hash / key-type name against a plain-object lookup table using a truthiness check (`var v = TABLE[name]; if (!v) reject`) or the prototype-chain-aware `in` operator. A name that names an inherited Object.prototype member -- constructor, __proto__, toString, valueOf, toLocaleString -- is truthy (or present via the prototype chain), so it slipped the guard and resolved to the inherited member. The observed consequences: b.jwk.thumbprint / canonicalize accepted an unsupported kty and thumbprinted the key to a single predictable digest; b.vc.issue (JOSE) and b.contentCredentials.signCose emitted a real signature under a bogus algorithm id; and b.sdJwtVc.present accepted an attacker-controlled _sd_alg read from the unsigned issuer payload. b.tsa and the SD-JWT hash-disclosure path returned a raw runtime error rather than a typed rejection. Every one of these table lookups now checks Object.prototype.hasOwnProperty before indexing, so a name that is not an own member of the table is rejected with the primitive's typed error. Supported algorithm and key-type names are unaffected.
|
|
12
|
+
|
|
9
13
|
## v0.16.x
|
|
10
14
|
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
11
17
|
- 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
18
|
|
|
13
19
|
- 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.
|
package/lib/archive-read.js
CHANGED
|
@@ -606,6 +606,14 @@ async function _decompressEntry(adapter, entry, dataStart, bombPolicy) {
|
|
|
606
606
|
algorithm: "deflate-raw",
|
|
607
607
|
maxOutputBytes: maxOutput,
|
|
608
608
|
maxCompressedBytes: entry.compressedSize,
|
|
609
|
+
// Honor the reader's own expansion-ratio cap. Without this, the
|
|
610
|
+
// composition inherits safeDecompress's DEFAULT_MAX_RATIO (50),
|
|
611
|
+
// which silently overrides bombPolicy.maxExpansionRatio and refuses
|
|
612
|
+
// every entry compressing better than 50:1 even when the operator's
|
|
613
|
+
// policy (default 100) permits it. The declared-size ratio was
|
|
614
|
+
// already validated in _enforceBombPolicy; this re-check on the
|
|
615
|
+
// actual inflated bytes uses the SAME configured cap.
|
|
616
|
+
maxRatio: bombPolicy.maxExpansionRatio,
|
|
609
617
|
});
|
|
610
618
|
if (decompressed.length !== entry.uncompressedSize) {
|
|
611
619
|
throw new ArchiveReadError("archive-read/inflate-size-mismatch",
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -119,11 +119,11 @@ function _b64uDecodeBuf(s) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
function _hashDisclosure(disclosureStr, hashAlg) {
|
|
122
|
-
|
|
123
|
-
if (!nodeAlg) {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(SUPPORTED_HASH_ALGS, hashAlg)) {
|
|
124
123
|
throw new AuthError("auth-sd-jwt-vc/bad-hash",
|
|
125
124
|
"Unsupported hash algorithm: " + hashAlg);
|
|
126
125
|
}
|
|
126
|
+
var nodeAlg = SUPPORTED_HASH_ALGS[hashAlg];
|
|
127
127
|
var h = nodeCrypto.createHash(nodeAlg);
|
|
128
128
|
h.update(disclosureStr, "ascii");
|
|
129
129
|
return h.digest().toString("base64url");
|
|
@@ -338,12 +338,12 @@ function present(opts) {
|
|
|
338
338
|
}
|
|
339
339
|
var _sdAlg = (_issuerPayload && typeof _issuerPayload._sd_alg === "string")
|
|
340
340
|
? _issuerPayload._sd_alg : "sha-256";
|
|
341
|
-
|
|
342
|
-
if (!_sdNodeHash) {
|
|
341
|
+
if (!Object.prototype.hasOwnProperty.call(SUPPORTED_HASH_ALGS, _sdAlg)) {
|
|
343
342
|
throw new AuthError("auth-sd-jwt-vc/bad-hash",
|
|
344
343
|
"present: issuer credential declares _sd_alg \"" + _sdAlg +
|
|
345
344
|
"\" which this framework version does not support");
|
|
346
345
|
}
|
|
346
|
+
var _sdNodeHash = SUPPORTED_HASH_ALGS[_sdAlg];
|
|
347
347
|
|
|
348
348
|
var disclosedNames = Array.isArray(opts.disclosedClaimNames)
|
|
349
349
|
? opts.disclosedClaimNames.slice() : [];
|
|
@@ -595,7 +595,7 @@ function signCose(manifest, opts) {
|
|
|
595
595
|
validateOpts.requireNonEmptyString(opts.privateKeyPem,
|
|
596
596
|
"contentCredentials.signCose: privateKeyPem", ContentCredentialsError, "BAD_KEY");
|
|
597
597
|
var algName = (opts.alg || "ml-dsa-87").toLowerCase();
|
|
598
|
-
if (!(algName
|
|
598
|
+
if (!Object.prototype.hasOwnProperty.call(COSE_ALGS, algName)) {
|
|
599
599
|
throw ContentCredentialsError.factory("content-credentials/bad-alg",
|
|
600
600
|
"contentCredentials.signCose: alg '" + algName +
|
|
601
601
|
"' not in COSE alg registry. Known: " + Object.keys(COSE_ALGS).join(", "));
|
|
@@ -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);
|
package/lib/jwk.js
CHANGED
|
@@ -58,8 +58,17 @@ function _requiredMembers(jwk) {
|
|
|
58
58
|
if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
|
|
59
59
|
throw new JwkError("jwk/bad-jwk", "jwk: 'kty' is required");
|
|
60
60
|
}
|
|
61
|
+
// Own-property lookup only: `kty` is attacker-controlled, and a plain-object
|
|
62
|
+
// bracket read resolves inherited Object.prototype members (`__proto__`,
|
|
63
|
+
// `toString`, `valueOf`, `toLocaleString`, …). Those are truthy, so an
|
|
64
|
+
// unguarded `if (!names)` would treat them as supported key types — the
|
|
65
|
+
// zero-`length` prototype methods then iterate no required members and
|
|
66
|
+
// thumbprint the empty object, collapsing distinct inputs onto one
|
|
67
|
+
// predictable digest instead of the intended unsupported-kty refusal.
|
|
68
|
+
if (!Object.prototype.hasOwnProperty.call(REQUIRED, jwk.kty)) {
|
|
69
|
+
throw new JwkError("jwk/unsupported-kty", "jwk: unsupported kty '" + jwk.kty + "'");
|
|
70
|
+
}
|
|
61
71
|
var names = REQUIRED[jwk.kty];
|
|
62
|
-
if (!names) throw new JwkError("jwk/unsupported-kty", "jwk: unsupported kty '" + jwk.kty + "'");
|
|
63
72
|
var out = {};
|
|
64
73
|
for (var i = 0; i < names.length; i++) {
|
|
65
74
|
var n = names[i];
|
|
@@ -115,8 +124,16 @@ function canonicalize(jwk) {
|
|
|
115
124
|
*/
|
|
116
125
|
function thumbprint(jwk, opts) {
|
|
117
126
|
opts = opts || {};
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
// Own-property lookup only — `opts.hash` is caller-controlled; a bare
|
|
128
|
+
// bracket read of the HASHES table resolves inherited members (`toString`,
|
|
129
|
+
// `constructor`, …) to truthy functions that slip past `!hash` and reach
|
|
130
|
+
// createHash as a non-string, leaking a raw ERR_INVALID_ARG_TYPE instead of
|
|
131
|
+
// the typed jwk/bad-hash refusal the primitive documents.
|
|
132
|
+
var hashName = opts.hash || "sha256";
|
|
133
|
+
if (!Object.prototype.hasOwnProperty.call(HASHES, hashName)) {
|
|
134
|
+
throw new JwkError("jwk/bad-hash", "jwk.thumbprint: hash must be sha256, sha384, or sha512");
|
|
135
|
+
}
|
|
136
|
+
var hash = HASHES[hashName];
|
|
120
137
|
var canon = canonicalize(jwk);
|
|
121
138
|
return nodeCrypto.createHash(hash).update(canon, "utf8").digest("base64url");
|
|
122
139
|
}
|
|
@@ -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/object-store/gcs.js
CHANGED
|
@@ -362,6 +362,13 @@ function create(config) {
|
|
|
362
362
|
|
|
363
363
|
url.searchParams.set("X-Goog-Signature", signature);
|
|
364
364
|
|
|
365
|
+
// Final query mutation done — align the wire space encoding to the signed
|
|
366
|
+
// canonical query (GCS V4 signs "%20" but url.toString() would serialize a
|
|
367
|
+
// space as "+", so a spaced response-header override / prefix would be
|
|
368
|
+
// rejected as a signature mismatch). Must precede url.toString(); do not
|
|
369
|
+
// touch url.searchParams afterward (it re-serializes spaces back to "+").
|
|
370
|
+
sigv4.alignWireQueryToSigV4(url);
|
|
371
|
+
|
|
365
372
|
var clientHeaders = {};
|
|
366
373
|
if (opts.contentType) clientHeaders["Content-Type"] = opts.contentType;
|
|
367
374
|
|
|
@@ -120,6 +120,29 @@ function canonicalQueryString(searchParams) {
|
|
|
120
120
|
}).join("&");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Reconcile the wire query with the signed canonical query. The WHATWG URL
|
|
124
|
+
// serializes its query via application/x-www-form-urlencoded, which encodes a
|
|
125
|
+
// space as "+"; SigV4 signs canonicalQueryString, which encodes a space as
|
|
126
|
+
// "%20" (AWS: "encode the space character as %20 and not +"). Left
|
|
127
|
+
// unreconciled, a signed query parameter carrying a literal space (a
|
|
128
|
+
// response-content-disposition filename, a list prefix) is transmitted
|
|
129
|
+
// (url.search / url.toString) as "+" while the signature commits to "%20", so
|
|
130
|
+
// the server re-canonicalizes to different bytes and rejects the request with
|
|
131
|
+
// SignatureDoesNotMatch. A bare "+" in url.search is ALWAYS an encoded space —
|
|
132
|
+
// URLSearchParams serializes a literal "+" as "%2B" and parses "+" back to a
|
|
133
|
+
// space, so canonicalQueryString already signed that position as "%20";
|
|
134
|
+
// rewriting "+" to "%20" makes the transmitted query byte-identical to the
|
|
135
|
+
// signed canonical query. Bare subresource tokens (?uploads / ?lifecycle) carry
|
|
136
|
+
// no "+", so they pass through untouched. Mutates url.search in place (the
|
|
137
|
+
// string setter preserves "%20"); callers MUST NOT touch url.searchParams
|
|
138
|
+
// afterward — that re-serializes spaces back to "+".
|
|
139
|
+
function _alignWireQueryToSigV4(url) {
|
|
140
|
+
var search = url.search;
|
|
141
|
+
if (search.indexOf("+") !== -1) {
|
|
142
|
+
url.search = search.replace(/\+/g, "%20"); // allow:regex-no-length-cap — single-char global replace, input bounded by maxUrlLength
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
function canonicalHeaders(headers) {
|
|
124
147
|
var pairs = [];
|
|
125
148
|
for (var k in headers) {
|
|
@@ -233,6 +256,10 @@ function signRequest(opts) {
|
|
|
233
256
|
", Signature=" + signature;
|
|
234
257
|
headers["Authorization"] = auth;
|
|
235
258
|
|
|
259
|
+
// The URL just signed is the one the caller transmits — make its wire query
|
|
260
|
+
// byte-identical to the canonical query the signature commits to.
|
|
261
|
+
_alignWireQueryToSigV4(url);
|
|
262
|
+
|
|
236
263
|
return { headers: headers, signature: signature, canonicalRequest: canon, stringToSign: sts };
|
|
237
264
|
}
|
|
238
265
|
|
|
@@ -810,6 +837,9 @@ function create(config) {
|
|
|
810
837
|
var signature = nodeCrypto.createHmac("sha256", signingKey).update(sts).digest("hex");
|
|
811
838
|
|
|
812
839
|
url.searchParams.set("X-Amz-Signature", signature);
|
|
840
|
+
// Final query mutation done — align the wire space encoding to the signed
|
|
841
|
+
// "%20" form before serializing the URL the client will use.
|
|
842
|
+
_alignWireQueryToSigV4(url);
|
|
813
843
|
|
|
814
844
|
var clientHeaders = {};
|
|
815
845
|
if (opts.contentType) clientHeaders["Content-Type"] = opts.contentType;
|
|
@@ -994,6 +1024,7 @@ module.exports = {
|
|
|
994
1024
|
deriveSigningKey: deriveSigningKey,
|
|
995
1025
|
canonicalQueryString: canonicalQueryString,
|
|
996
1026
|
canonicalHeaders: canonicalHeaders,
|
|
1027
|
+
alignWireQueryToSigV4: _alignWireQueryToSigV4,
|
|
997
1028
|
awsUriEncode: awsUriEncode,
|
|
998
1029
|
sha256Hex: sha256Hex,
|
|
999
1030
|
formatAmzDate: _formatAmzDate,
|
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/tsa.js
CHANGED
|
@@ -122,11 +122,11 @@ function _normHex(h) {
|
|
|
122
122
|
// Resolve the message imprint: hash the data, or use a pre-computed hash.
|
|
123
123
|
function _imprint(data, opts, fnName) {
|
|
124
124
|
var hashName = opts.hashAlg || "SHA-512";
|
|
125
|
-
|
|
126
|
-
if (!h) {
|
|
125
|
+
if (!Object.prototype.hasOwnProperty.call(IMPRINT_HASHES, hashName)) {
|
|
127
126
|
throw new TsaError("tsa/bad-hash-alg",
|
|
128
127
|
fnName + ": hashAlg must be one of " + Object.keys(IMPRINT_HASHES).join(" / "));
|
|
129
128
|
}
|
|
129
|
+
var h = IMPRINT_HASHES[hashName];
|
|
130
130
|
var digest;
|
|
131
131
|
if (opts.hashed) {
|
|
132
132
|
digest = _bytes(data, "hash");
|
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/vc.js
CHANGED
|
@@ -247,10 +247,10 @@ function _sign(doc, opts, joseTyp, coseTyp, coseContentType, fnName) {
|
|
|
247
247
|
});
|
|
248
248
|
}
|
|
249
249
|
if (opts.securing === "jose") {
|
|
250
|
-
|
|
251
|
-
if (!params) {
|
|
250
|
+
if (!Object.prototype.hasOwnProperty.call(JOSE_ALGS, opts.alg)) {
|
|
252
251
|
throw new VcError("vc/bad-alg", fnName + ": JOSE securing requires alg ES256/384/512 or EdDSA (got " + opts.alg + ")");
|
|
253
252
|
}
|
|
253
|
+
var params = JOSE_ALGS[opts.alg];
|
|
254
254
|
var key = _toKey(opts.privateKey, "private");
|
|
255
255
|
var header = { alg: opts.alg, typ: joseTyp };
|
|
256
256
|
if (typeof opts.kid === "string") header.kid = opts.kid;
|
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:cbebf8cd-986b-48a0-823b-7bd7981981bf",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-17T06:14:29.525Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.17.0",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.
|
|
25
|
+
"version": "0.17.0",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.17.0",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.17.0",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|