@blamejs/core 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.17.x
10
10
 
11
+ - v0.17.3 (2026-07-17) — **A family of security guards that compared a token case-sensitively where the browser or HTTP stack matches it case-insensitively now normalize first, closing a CSP unsafe-keyword bypass and a cookie-prefix bypass and fixing several over-strict refusals of spec-compliant input.** Browsers, HTTP stacks, and URL parsers match a whole family of security-relevant tokens ASCII case-insensitively -- CSP source keywords and scheme sources, the __Secure-/__Host- cookie name prefixes, HTTP auth schemes, media types, and URL schemes. Several framework guards compared those tokens case-sensitively, so a mixed-case variant behaved differently in the guard than it does downstream. In the bypass direction this let a case-variant unsafe CSP keyword reach a header the browser still honors, and let a prefix-violating cookie past the invariant that keeps the browser from silently dropping it. In the over-strict direction it wrongly refused spec-compliant input (a lowercase auth scheme, a mixed-case media type, an uppercase URL scheme, a mixed-case operator allowlist entry). Every case now normalizes the token to ASCII lowercase before the membership, equality, or prefix test. Separately, two crypto algorithm-name lookups and a CBOR integer-boundary round-trip are corrected. **Fixed:** *Auth scheme, media type, and URL scheme comparisons accept spec-compliant case variants* — Three guards refused input that a case-insensitive downstream would accept. The CIBA notification endpoint (b.auth.ciba) matched the Authorization Bearer scheme case-sensitively, rejecting a spec-compliant 'bearer <token>' sender (RFC 7235 auth-schemes are case-insensitive); it now matches the scheme case-insensitively while comparing the token itself verbatim. The tus upload middleware's creation-with-upload path compared the application/offset+octet-stream media type as an exact string, so a compliant 'Application/Offset+Octet-Stream' (or a variant carrying a parameter) created the upload but silently dropped the body; it now compares the lowercased media type with parameters stripped (RFC 7231 §3.1.1.1). The NEL middleware required its collector URL to begin with a lowercase 'https://', rejecting an equivalent 'HTTPS://'; it now compares the scheme case-insensitively (RFC 3986). · *Redirect allowlist matches a mixed-case operator origin or host* — b.safeRedirect.resolve compared the parser-canonicalized (lowercased) origin and host of a candidate URL against the operator's allowedOrigins/allowedHosts entries verbatim. A mixed-case operator entry such as 'Example.COM' or 'HTTPS://Example.com' therefore silently never matched, and a legitimate redirect fell through to the fallback. The operator entries are now canonicalized to lowercase before the comparison. This only makes the operator's intended allowlist work; because the attacker-controlled target is already normalized by the parser, it never widens the allowlist. · *Streaming-hash and OPRF suite selection reject an unknown algorithm name cleanly* — b.crypto.hashStream (and b.crypto.hashFile, which composes it) and b.crypto.oprf.suite resolved a caller-supplied algorithm/suite name against a plain-object lookup table with a truthiness check. A name colliding with an inherited Object.prototype member -- 'constructor' or '__proto__' -- read back a truthy inherited value and slipped the guard: hashStream then threw a synchronous TypeError from a function documented to return a rejected Promise (an unhandled exception for a caller wired only to .catch()), and oprf.suite returned a malformed suite object instead of throwing its documented bad-suite error. Both now resolve the name through an own-property check, matching the guarding used for the SRI algorithm table, so any unknown name is refused with the documented error. · *Deterministic CBOR re-encodes a negative integer at the -2^53 boundary as an integer* — The CBOR decoder returned the negative integer at the -2^53 boundary as a plain Number, but that value is one below the safe-integer range, so the deterministic encoder's integer branch re-emitted it as a float -- breaking round-trip and falsely tripping the requireDeterministic check on a value that is canonically an integer. The decoder now promotes a negative integer that is not a safe integer to a BigInt, so the encoder re-emits the integer head (RFC 8949 §3). **Security:** *CSP builder refuses unsafe keywords and catch-all schemes in any case* — b.csp.build screened its source tokens ('unsafe-inline', 'unsafe-eval', 'unsafe-hashes', the catch-all '*'/'https:', and 'data:' in img/media/font) against its guard sets with a case-sensitive comparison. A User-Agent matches CSP keywords and scheme sources ASCII case-insensitively (CSP3 §2.3/§6.7.2), so a case-variant such as 'Unsafe-Inline', 'HTTPS:', or 'DATA:' slipped the guard and was emitted verbatim into a Content-Security-Policy header the browser still enforces -- reintroducing the exact XSS-defense hole the guard exists to refuse, without the required acknowledgement opt-in. The builder now lowercases each token before the membership test; the original case is still emitted for real hosts and paths, only the guard comparison is normalized. mergeDirectives routes added sources through the same guard, so a case-variant in a merged policy is refused too. · *Cookie name-prefix invariants hold for any case of __Secure-/__Host-* — Browsers apply the __Secure-/__Host- cookie name-prefix requirements (RFC 6265bis §4.1.3) case-insensitively -- they lowercase the cookie name before the prefix test. b.cookies.serialize and the CSRF middleware's cookie-name safety check compared the prefix case-sensitively, so a case-variant name like __host- or __SECURE- dodged the framework's Secure/Path=//no-Domain invariant while still being subject to the browser's enforcement -- the cookie would then be silently rejected by the browser (never set), defeating the middleware or session it belonged to. Both now compare a lowercased copy of the name, so a prefix-violating cookie is refused at the source for every case. The CSRF middleware, which builds its own Set-Cookie header rather than routing through serialize, additionally gained the __Secure- branch it was missing entirely.
12
+
13
+ - v0.17.2 (2026-07-17) — **Recurrence expansion refuses to spin or crash on an out-of-range interval, the CIDR guard rejects the whole of the ULA and link-local IPv6 ranges, and a device-bound session assertion no longer accepts a far-future issued-at.** b.calendar.expandRecurrence accepted an unbounded, unvalidated recurrence interval; a large interval drove its date arithmetic past the representable range, which either spun the expansion loop forever at full CPU (a denial of service reachable from any JSCalendar event in a request body) or threw an uncaught error that crashed the caller. The expander now stops as soon as the date arithmetic overflows. b.guardCidr tested IPv6 reserved-range membership on whole hex nibbles, so it missed the parts of the ULA (fc00::/7) and link-local (fe80::/10) ranges that do not fall on a nibble boundary -- accepting fd00::/8 and several fe80::/10 sub-ranges as clean under the strict profile that is supposed to refuse them. And b.dbsc.verifyBindingAssertion bounded the assertion's issued-at only from below, so a far-future issued-at was accepted and never aged out of the replay window. **Security:** *Recurrence expansion refuses an out-of-range interval instead of hanging or crashing* — b.calendar.expandRecurrence validated a recurrence rule's frequency but not its interval, so a caller-supplied interval was used unbounded. A large interval drives the expander's date arithmetic past the representable ECMAScript date range and yields a non-finite date, with two consequences from the one root: with a by-set-position rule the outer loop computed a non-finite period whose inner day-enumeration ran zero times, so the shared step budget never decremented and the not-after break comparisons (which compare against a non-finite value) never fired -- an infinite loop pinning a CPU at 100%; without by-set-position, the loop advanced to a non-finite date and threw an uncaught error when serializing it. Because the rule passed validation, any attacker supplying a JSCalendar event (a JMAP request body, an imported calendar) could trigger it. The expander now stops stepping as soon as the date arithmetic overflows the representable range -- no further instances can exist -- so a hostile interval yields the finite instances that fit and returns, rather than hanging or crashing. · *CIDR guard rejects the full ULA and link-local IPv6 ranges under the strict profile* — b.guardCidr tested whether an IPv6 address falls in a reserved range by comparing hex-nibble prefixes with a string prefix match. Reserved membership is a bit-prefix relation, and the unique-local (fc00::/7, 7 bits) and link-local (fe80::/10, 10 bits) ranges end mid-nibble, so the nibble comparison covered only fc00-fcff and fe80-fe8f -- it missed fd00::/8 (the half of the ULA block that real deployments actually assign) and the fe90::/16 through febf::/16 sub-ranges of link-local. Under the strict profile, which refuses reserved ranges, those CIDRs were accepted as clean and sanitize normalized them into place instead of refusing them. The reserved-range check now compares the whole nibbles and then the remaining prefix bits of the boundary nibble under a mask -- the same bit-prefix relation the IPv4 path and the SSRF guard already use -- so the entire reserved range is caught; nibble-aligned ranges (documentation, multicast, loopback) are unaffected. · *Device-bound session assertions reject a far-future issued-at* — b.dbsc.verifyBindingAssertion enforced the assertion's issued-at only as a lower bound (refusing one older than the configured max age), with no upper bound. A forward-dated issued-at makes the age check compare a negative interval, which never trips, so an assertion carrying an issued-at far in the future was accepted and could not age out of the replay window on the default 300-second path. verifyBindingAssertion now also refuses an issued-at more than a small clock-skew allowance in the future, matching the future-issued-at bound the JWT, DPoP, and client-attestation verifiers already enforce.
14
+
11
15
  - v0.17.1 (2026-07-17) — **Profile, posture, and capability name lookups across the content-safety and mail-protocol guards reject a prototype-member name instead of running under it, the mTLS CA can generate a CRL after a fingerprint-only revocation, and a hostile MIME filename no longer crashes attachment extraction.** A second family of guards resolved a profile, posture, or capability 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) slipped the guard and the gate ran under the inherited member instead of refusing the unknown name. The shared profile resolver (b.gateContract.makeProfileResolver, which the guard family composes), the iCal and vCard content guards, the dark-patterns posture check, the JMAP capability allowlist, and the IMAP/POP3/ManageSieve command guards now all resolve names through an own-property check. Separately, b.mtlsCa.generateCrl crashed with a null-serial error once any certificate had been revoked by fingerprint (the mode the require-mTLS gate pins on), dropping every serial-keyed revocation from the published CRL; and b.safeMime.extractAttachments threw an uncaught URIError on a hostile Content-Disposition filename with a malformed percent-escape, crashing the caller. **Fixed:** *mTLS CA generates a CRL after a fingerprint-only revocation* — b.mtlsCa.revoke accepts a certificate fingerprint (the value the require-mTLS gate pins on and generateClientCert surfaces for exactly this use), which is stored without a serial number. b.mtlsCa.generateCrl then mapped the whole revocation registry into CRL entries and handed the null serial to the CRL encoder, which threw -- aborting CRL generation entirely. Because one fingerprint-only revocation broke every subsequent CRL build, all serial-keyed revocations were silently dropped from the published CRL and it could never be regenerated, going stale for external CRL-based revocation checking. generateCrl now projects out fingerprint-only entries (which a standard X.509 CRL cannot represent) before encoding and reports how many were omitted; fingerprint-only revocations remain enforced through the mTLS gate, and the CRL publishes every serial-keyed revocation. · *A hostile MIME filename no longer crashes attachment extraction* — b.safeMime.extractAttachments decoded an RFC 2231 / RFC 5987 extended filename parameter (filename*=charset''percent-encoded) with an unguarded percent-decode, so a malformed escape -- a truncated %, a non-hex %ZZ, or a sequence that decodes to invalid UTF-8 -- threw an uncaught URIError that escaped the parser's typed-error contract and crashed the caller (for example a mail store extracting attachments). A one-line hostile Content-Disposition header was a trivial denial of service. The decode now degrades to the still-encoded filename on failure, matching the framework's handling of every other percent-decode site, so a hostile filename yields a best-effort name that downstream filename guards still vet rather than a crash. **Security:** *Guard profile, posture, and capability lookups reject a prototype-member name* — A group of content-safety and mail-protocol guards resolved a caller- or request-supplied profile / posture / capability name against a plain-object lookup table using a truthiness check (var caps = TABLE[name]; if (!caps)) or the prototype-chain-aware in operator. A name that names an inherited Object.prototype member -- constructor, __proto__, toString -- is truthy (or present via the prototype chain), so it slipped the guard and the gate ran under the inherited member instead of refusing the unknown name. The shared resolver b.gateContract.makeProfileResolver (composed by the idempotency-key, mail-compose, message-id and other guards) now guards both its posture and profile lookups with hasOwnProperty; b.safeIcal, b.safeVcard, b.darkPatterns, b.guardJmap (capability allowlist and profile/posture), and the b.guardImapCommand / b.guardPop3Command / b.guardManageSieveCommand command guards do the same. An unknown or prototype-member name is now rejected with the guard's typed error; supported names are unaffected. This extends the same own-property hardening applied to the crypto algorithm-table lookups in 0.17.0 to the guard-family profile resolvers.
12
16
 
13
17
  - 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.
package/lib/auth/ciba.js CHANGED
@@ -632,11 +632,16 @@ function create(opts) {
632
632
  // node:http's normalization could re-introduce the unreachable
633
633
  // branch). Read lowercase only.
634
634
  var authzHeader = req.headers["authorization"];
635
- if (!authzHeader || authzHeader.indexOf("Bearer ") !== 0) {
635
+ // RFC 7235 §2.1 — the auth-scheme token ("Bearer") is ASCII
636
+ // case-insensitive; match it case-insensitively so a spec-compliant
637
+ // `bearer <token>` sender isn't wrongly refused. The token that follows
638
+ // keeps its case (it is compared verbatim below).
639
+ var BEARER_PREFIX = "bearer ";
640
+ if (!authzHeader || authzHeader.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) {
636
641
  throw new AuthError("auth-ciba/missing-bearer",
637
642
  "ciba.parseNotification: Authorization: Bearer header missing");
638
643
  }
639
- var presented = authzHeader.substring("Bearer ".length).trim();
644
+ var presented = authzHeader.slice(BEARER_PREFIX.length).trim();
640
645
  if (presented.length === 0 || !clientNotificationToken) {
641
646
  throw new AuthError("auth-ciba/bad-bearer",
642
647
  "ciba.parseNotification: empty bearer or no expected token configured");
package/lib/calendar.js CHANGED
@@ -804,6 +804,11 @@ function _expandSingleRule(rule, startMs, ctx) {
804
804
  throw new CalendarError("calendar/bad-recurrence",
805
805
  "b.calendar.expandRecurrence: unsupported frequency '" + freq + "'");
806
806
  }
807
+ // The caller-supplied `interval` is unbounded; a large one drives
808
+ // _advance's Date arithmetic past the representable range, returning
809
+ // NaN. No further instances can exist — stop before the NaN reaches
810
+ // _msToIsoZ on the next iteration, which throws on an invalid Date.
811
+ if (!isFinite(t)) break;
807
812
  }
808
813
  return { instances: out, stepBudgetRemaining: ctx.stepBudgetRef.remaining };
809
814
  }
@@ -856,6 +861,14 @@ function _expandWithBysetpos(ctx) {
856
861
  while (out.length < count && out.length < maxCount && stepBudgetRef.remaining > 0) {
857
862
  var period = _periodForIndex(freq, startDate, periodIndex * interval);
858
863
  periodIndex += 1;
864
+ // A non-finite period means the FREQ interval (unbounded and
865
+ // caller-supplied) drove Date.UTC past the representable ECMAScript
866
+ // date range. No further instances can exist. This break is also
867
+ // load-bearing against a hang: a NaN period runs the day-enumeration
868
+ // loop zero times, so it never decrements the shared step budget,
869
+ // and the untilMs / toMs comparisons below are NaN no-ops (`NaN > x`
870
+ // is false) — without stopping here the outer loop spins forever.
871
+ if (!isFinite(period.startMs) || !isFinite(period.endMs)) break;
859
872
  // Out-of-window early exit. Window-uppper applies once the period
860
873
  // start crosses toMs; until applies once period-start crosses untilMs.
861
874
  if (period.startMs > untilMs) break;
package/lib/cbor.js CHANGED
@@ -393,7 +393,18 @@ function _decodeItem(state, depth) {
393
393
  case 0: return _readArgument(state, ai); // unsigned int
394
394
  case 1: { // negative int
395
395
  var n = _readArgument(state, ai);
396
- return (typeof n === "bigint") ? (-1n - n) : (-1 - n);
396
+ if (typeof n === "bigint") return -1n - n;
397
+ // The argument n is a safe Number, but a negative CBOR value is
398
+ // -1 - n, which reaches one below the safe range: n === 2^53-1
399
+ // yields -2^53, NOT a safe integer. The deterministic encoder's
400
+ // integer branch is |v| <= 2^53-1, so it would re-emit that value
401
+ // as a float — breaking round-trip and falsely tripping
402
+ // requireDeterministic on a canonical integer. Promote to BigInt
403
+ // when the value isn't a safe integer so encode() re-emits the
404
+ // integer head (RFC 8949 §3 — a negative-int argument is an
405
+ // integer, never a float).
406
+ var neg = -1 - n;
407
+ return Number.isSafeInteger(neg) ? neg : (-1n - BigInt(n));
397
408
  }
398
409
  case 2: { // byte string
399
410
  var blen = _lenOf(_readArgument(state, ai));
package/lib/cookies.js CHANGED
@@ -214,15 +214,25 @@ function serialize(name, value, attrs) {
214
214
  // __Host-* — MUST be Secure, Path=/, NO Domain
215
215
  //
216
216
  // Caught at the source so every caller (csrf-protect / session /
217
- // operator) gets the same enforcement.
218
- if (name.indexOf("__Secure-") === 0) {
217
+ // operator) gets the same enforcement. RFC 6265bis §5.4 requires user
218
+ // agents to match these prefixes case-INSENSITIVELY (the server-side
219
+ // description in §4.1.3 reads "case-sensitive", but the UA is what
220
+ // actually enforces the cookie-drop): Chromium and Firefox both
221
+ // lowercase the name before the prefix test, so `__host-` / `__SECURE-`
222
+ // get the same browser enforcement -- Secure, Path=/, no Domain -- as
223
+ // `__Host-` / `__Secure-`. Case-sensitive matching was itself the
224
+ // vulnerability CVE-2024-5699 (httpwg/http-extensions#2231). Compare a
225
+ // lowercased copy so a case-variant name can't dodge the invariant here
226
+ // and then be silently dropped by the browser.
227
+ var lowerName = name.toLowerCase();
228
+ if (lowerName.indexOf("__secure-") === 0) {
219
229
  if (attrs.secure !== true) {
220
230
  throw new CookieError("cookies/prefix-secure-required",
221
231
  "__Secure-* cookies MUST set Secure (RFC 6265bis §4.1.3.1) — got '" +
222
232
  name + "' without secure: true");
223
233
  }
224
234
  }
225
- if (name.indexOf("__Host-") === 0) {
235
+ if (lowerName.indexOf("__host-") === 0) {
226
236
  if (attrs.secure !== true) {
227
237
  throw new CookieError("cookies/prefix-host-secure-required",
228
238
  "__Host-* cookies MUST set Secure (RFC 6265bis §4.1.3.2) — got '" +
@@ -98,8 +98,17 @@ var SUITES = Object.keys(SUITE_IMPL);
98
98
  * // out === s.oprf.evaluate(kp.secretKey, Buffer.from("user@example.com"))
99
99
  */
100
100
  function suite(name) {
101
- var impl = SUITE_IMPL[String(name).toLowerCase()];
102
- if (!impl) throw new OprfError("oprf/bad-suite", "crypto.oprf.suite: unknown suite '" + name + "'; expected one of " + SUITES.join(", "));
101
+ // Own-property guard, not a truthiness check: SUITE_IMPL is a plain object,
102
+ // so a bracket read with a prototype-chain name ("__proto__" Object.prototype,
103
+ // "constructor" → the Object constructor) returns a truthy inherited value that
104
+ // an `if (!impl)` guard would accept as a "suite". Match the hasOwnProperty
105
+ // shape b.crypto.sri uses for its algorithm table so an unknown name — however
106
+ // it collides with Object.prototype — refuses cleanly with OprfError.
107
+ var key = String(name).toLowerCase();
108
+ if (!Object.prototype.hasOwnProperty.call(SUITE_IMPL, key)) {
109
+ throw new OprfError("oprf/bad-suite", "crypto.oprf.suite: unknown suite '" + name + "'; expected one of " + SUITES.join(", "));
110
+ }
111
+ var impl = SUITE_IMPL[key];
103
112
  // Expose only the modes the vendored @noble/curves implements (base +
104
113
  // verifiable). poprf is omitted rather than surfaced as an empty stub.
105
114
  return { name: impl.name, oprf: impl.oprf, voprf: impl.voprf };
package/lib/crypto.js CHANGED
@@ -121,13 +121,20 @@ function hmac(key, data, algorithm) {
121
121
  */
122
122
  function hashStream(readable, algorithm) {
123
123
  var alg = (algorithm || STREAM_HASH_DEFAULT).toLowerCase();
124
- var entry = STREAM_HASH_ALGORITHMS[alg];
125
- if (!entry) {
124
+ // Own-property guard, not a truthiness check: STREAM_HASH_ALGORITHMS is a
125
+ // plain object, so a name colliding with an Object.prototype member
126
+ // ("constructor" → the Object constructor, "__proto__" → Object.prototype)
127
+ // would read back a truthy inherited value, slip past `if (!entry)`, and then
128
+ // throw ERR_INVALID_ARG_TYPE synchronously at createHash(entry.algorithm ===
129
+ // undefined) — a sync throw from a Promise-returning function. Refuse it here
130
+ // as the documented Promise.reject, matching b.crypto.sri's algorithm guard.
131
+ if (!Object.prototype.hasOwnProperty.call(STREAM_HASH_ALGORITHMS, alg)) {
126
132
  return Promise.reject(new TypeError(
127
133
  "crypto.hashStream: unsupported algorithm '" + algorithm +
128
134
  "' (allowed: " + Object.keys(STREAM_HASH_ALGORITHMS).join(", ") + ")"
129
135
  ));
130
136
  }
137
+ var entry = STREAM_HASH_ALGORITHMS[alg];
131
138
  if (!readable || typeof readable.pipe !== "function") {
132
139
  return Promise.reject(new TypeError(
133
140
  "crypto.hashStream: readable must be a Readable stream"
package/lib/csp.js CHANGED
@@ -221,20 +221,28 @@ function build(directives, opts) {
221
221
  "csp.build: source '" + src + "' contains whitespace or ';' — a CSP source " +
222
222
  "must be a single token (directive-injection defense)");
223
223
  }
224
+ // A UA matches CSP source keywords ('unsafe-inline' ...) and scheme
225
+ // sources (https: / data:) ASCII case-INSENSITIVELY (CSP3 §2.3 /
226
+ // §6.7.2). Compare a lowercased copy against the (lowercase) guard sets
227
+ // so a case-variant token ("'Unsafe-Inline'" / "HTTPS:" / "DATA:") can't
228
+ // slip past and be emitted verbatim into a header the browser still
229
+ // honors. The ORIGINAL src is what gets emitted (hosts/paths stay
230
+ // case-preserved); only the guard comparison is normalized.
231
+ var srcLower = src.toLowerCase();
224
232
  if (!acknowledgeUnsafe && SCRIPT_DIRECTIVES.indexOf(name) !== -1 &&
225
- UNSAFE_KEYWORDS.indexOf(src) !== -1) {
233
+ UNSAFE_KEYWORDS.indexOf(srcLower) !== -1) {
226
234
  throw new CspError("csp/unsafe-keyword",
227
235
  "csp.build: " + name + " contains " + src + "; pass acknowledgeUnsafe:true with a " +
228
236
  "documented justification to allow it (CSP3 §6.2.5.x — unsafe keywords are a " +
229
237
  "common XSS bypass surface)");
230
238
  }
231
- if (CATCH_ALL_SOURCES.indexOf(src) !== -1) {
239
+ if (CATCH_ALL_SOURCES.indexOf(srcLower) !== -1) {
232
240
  throw new CspError("csp/catch-all-source",
233
241
  "csp.build: " + name + " contains catch-all source '" + src + "'; CSP3 best " +
234
242
  "practice refuses these (use an explicit allowlist instead)");
235
243
  }
236
244
  if (!allowDataImages && (name === "img-src" || name === "media-src" || name === "font-src") &&
237
- src === "data:") {
245
+ srcLower === "data:") {
238
246
  throw new CspError("csp/data-source",
239
247
  "csp.build: " + name + " contains 'data:'; pass allowDataImages:true with a " +
240
248
  "documented reason (data: URLs sidestep most CSP defenses)");
package/lib/dbsc.js CHANGED
@@ -47,6 +47,15 @@ var DbscError = defineClass("DbscError", { alwaysPermanent: true });
47
47
 
48
48
  var DEFAULT_CHALLENGE_TTL_MS = C.TIME.minutes(5);
49
49
 
50
+ // Clock-skew allowance for a forward-dated `iat`. An assertion whose iat
51
+ // sits further ahead than this is refused: without an upper bound a future
52
+ // iat makes the stale check (Date.now() - iat*1000 > maxAge) permanently
53
+ // false, so the assertion never expires and the maxAge replay window is
54
+ // defeated. Mirrors the future-iat bound in b.auth.jwt.verifyExternal
55
+ // (iat-future) and dpop's ±window; sized to tolerate ordinary client clock
56
+ // drift without admitting a far-future token.
57
+ var IAT_FUTURE_SKEW_MS = C.TIME.minutes(1);
58
+
50
59
  /**
51
60
  * @primitive b.dbsc.challenge
52
61
  * @signature b.dbsc.challenge(opts)
@@ -243,6 +252,14 @@ function verifyBindingAssertion(assertion, opts) {
243
252
  throw new DbscError("dbsc/stale",
244
253
  "verifyBindingAssertion: iat is more than " + opts.maxAgeSec + "s old");
245
254
  }
255
+ // Upper-bound iat: a forward-dated assertion (beyond IAT_FUTURE_SKEW_MS)
256
+ // is refused. A future iat makes the stale check above never fire, so the
257
+ // assertion would stay "fresh" indefinitely — a freshness fail-open on an
258
+ // attacker-chosen iat that defeats the maxAge replay bound.
259
+ if (typeof payloadJson.iat === "number" && payloadJson.iat * 1000 - Date.now() > IAT_FUTURE_SKEW_MS) { // allow:raw-time-literal — sec→ms
260
+ throw new DbscError("dbsc/iat-future",
261
+ "verifyBindingAssertion: iat is more than " + (IAT_FUTURE_SKEW_MS / C.TIME.seconds(1)) + "s in the future");
262
+ }
246
263
  // Re-verify any embedded challenge if the assertion claims one.
247
264
  if (payloadJson.challenge) {
248
265
  verifyChallenge(payloadJson.challenge, { secretKey: opts.secretKey });
package/lib/guard-cidr.js CHANGED
@@ -237,13 +237,34 @@ function _ipv4InReservedRange(octets, prefix) {
237
237
  return hits;
238
238
  }
239
239
 
240
+ // Bit-accurate prefix match over two hex strings. Reserved-range membership
241
+ // is a relation on the first `prefixBits` bits, NOT on a whole number of hex
242
+ // nibbles: ULA fc00::/7 (7 bits) and link-local fe80::/10 (10 bits) end
243
+ // mid-nibble, so a hex-string startsWith on the reserved nibbles silently
244
+ // misses every address whose trailing (masked-off) bits differ — e.g. fd00::
245
+ // (the assigned half of fc00::/7) and fe90::/fea0::/feb0:: (all inside
246
+ // fe80::/10). Compare the whole nibbles, then the top `prefixBits % 4` bits of
247
+ // the boundary nibble under a mask — the same bit-prefix relation the IPv4
248
+ // side gets from `(addr & mask) === net` and ssrf-guard gets from its byte-
249
+ // level `_ipv6PrefixMatch`.
250
+ function _ipv6HexPrefixMatch(hex, reservedHex, prefixBits) {
251
+ var fullNibbles = Math.floor(prefixBits / 4); // whole hex chars fully inside the prefix
252
+ if (hex.slice(0, fullNibbles) !== reservedHex.slice(0, fullNibbles)) return false;
253
+ var remBits = prefixBits - fullNibbles * 4; // 0-3 bits in the boundary nibble
254
+ if (remBits === 0) return true;
255
+ var mask = (0xF << (4 - remBits)) & 0xF; // high `remBits` bits of the nibble
256
+ var aNib = parseInt(hex.charAt(fullNibbles), 16); // base-16 radix
257
+ var rNib = parseInt(reservedHex.charAt(fullNibbles), 16); // base-16 radix
258
+ return (aNib & mask) === (rNib & mask);
259
+ }
260
+
240
261
  function _ipv6InReservedRange(groups, prefix) {
241
262
  var hex = groups.join("");
242
263
  var hits = [];
243
264
  for (var i = 0; i < IPV6_RESERVED.length; i += 1) {
244
265
  var r = IPV6_RESERVED[i];
245
266
  if (prefix < r.prefix) continue;
246
- if (hex.startsWith(r.hexPrefix)) hits.push(r.label);
267
+ if (_ipv6HexPrefixMatch(hex, r.hexPrefix, r.prefix)) hits.push(r.label);
247
268
  }
248
269
  return hits;
249
270
  }
@@ -430,16 +430,30 @@ function create(opts) {
430
430
  if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
431
431
  throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
432
432
  }
433
- // __Host- prefix safety: if operator picks a __Host- name, the
434
- // Path/Domain/Secure constraints must be compatible. Path must be "/",
435
- // no Domain (we never set one), Secure resolved per-request. Catch
436
- // operator-side typos (e.g. __Host-csrf with a custom path) at boot.
437
- if (cookieCfg.name && /^__Host-/.test(cookieCfg.name)) {
438
- if (cookieCfg.path !== "/") {
439
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
440
- }
441
- if (cookieCfg.secure === false) {
442
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
433
+ // Cookie name-prefix safety (RFC 6265bis §4.1.3). csrf-protect builds its
434
+ // own Set-Cookie header rather than routing through b.cookies.serialize, so
435
+ // this boot check is the only enforcement point. §5.4 requires user agents
436
+ // to apply the prefix test case-INSENSITIVELY (the server-side §4.1.3
437
+ // description reads "case-sensitive", but the UA is what drops the cookie),
438
+ // so `__host-`/`__SECURE-` get the same browser enforcement as
439
+ // `__Host-`/`__Secure-` -- case-sensitive matching was itself CVE-2024-5699.
440
+ // Compare a lowercased copy so a case-variant name can't dodge the invariant
441
+ // here and then be silently rejected by the browser. Catch typos at boot.
442
+ // __Host-* — Path must be "/", no Domain (we never set one), Secure.
443
+ // __Secure-* — Secure.
444
+ if (cookieCfg.name) {
445
+ var lowerCookieName = cookieCfg.name.toLowerCase();
446
+ if (lowerCookieName.indexOf("__host-") === 0) {
447
+ if (cookieCfg.path !== "/") {
448
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
449
+ }
450
+ if (cookieCfg.secure === false) {
451
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
452
+ }
453
+ } else if (lowerCookieName.indexOf("__secure-") === 0) {
454
+ if (cookieCfg.secure === false) {
455
+ throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
456
+ }
443
457
  }
444
458
  }
445
459
  }
@@ -151,7 +151,7 @@ function create(opts) {
151
151
  // honor secure-origin report endpoints. Refusing at config-time so
152
152
  // an operator typo (`http://`) surfaces at boot, not as silent
153
153
  // never-fires-in-production.
154
- if (opts.collectorUrl.slice(0, 8) !== "https://") { // string-prefix length, not bytes
154
+ if (opts.collectorUrl.slice(0, 8).toLowerCase() !== "https://") { // RFC 3986 scheme is case-insensitive; string-prefix length, not bytes
155
155
  throw new TypeError(
156
156
  "middleware.nel: opts.collectorUrl must be https:// (browsers " +
157
157
  "ignore non-secure NEL collectors); got " + opts.collectorUrl);
@@ -511,8 +511,12 @@ function create(opts) {
511
511
  if (expHdr) headers["Upload-Expires"] = expHdr;
512
512
 
513
513
  // creation-with-upload: append the body in the same request when
514
- // Content-Type is application/offset+octet-stream.
515
- var contentType = req.headers["content-type"];
514
+ // Content-Type is application/offset+octet-stream. RFC 7231 §3.1.1.1 —
515
+ // the media type is case-insensitive and may carry parameters, so compare
516
+ // the lowercased type/subtype (a compliant `Application/Offset+Octet-Stream`
517
+ // must still take the append path).
518
+ var rawContentType = req.headers["content-type"];
519
+ var contentType = rawContentType ? String(rawContentType).split(";")[0].trim().toLowerCase() : "";
516
520
  if (hasCreationWithBody && contentType === "application/offset+octet-stream") {
517
521
  var chunk;
518
522
  try { chunk = await _readChunk(req, maxChunkSize); }
@@ -97,15 +97,23 @@ function resolve(rawTarget, opts) {
97
97
  try { parsed = safeUrl.parse(rawTarget, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS }); }
98
98
  catch (_e) { return fallback; }
99
99
 
100
+ // parsed.origin / host / hostname come lowercased from the WHATWG URL
101
+ // parser (scheme + host are case-insensitive per RFC 3986). Canonicalize
102
+ // each operator allowlist entry the same way before comparing, so a
103
+ // mixed-case operator entry ("HTTPS://Example.com" / "Example.COM") still
104
+ // matches instead of silently never matching and falling through to the
105
+ // fallback. The attacker-controlled rawTarget is already normalized, so
106
+ // this only makes the operator's intended allowlist work — never widens it.
100
107
  if (baseOrigin !== null && parsed.origin === baseOrigin) return rawTarget;
101
108
  if (allowedOrigins) {
102
109
  for (var i = 0; i < allowedOrigins.length; i += 1) {
103
- if (parsed.origin === allowedOrigins[i]) return rawTarget;
110
+ if (parsed.origin === String(allowedOrigins[i]).toLowerCase()) return rawTarget;
104
111
  }
105
112
  }
106
113
  if (allowedHosts) {
107
114
  for (var j = 0; j < allowedHosts.length; j += 1) {
108
- if (parsed.host === allowedHosts[j] || parsed.hostname === allowedHosts[j]) {
115
+ var allowedHost = String(allowedHosts[j]).toLowerCase();
116
+ if (parsed.host === allowedHost || parsed.hostname === allowedHost) {
109
117
  return rawTarget;
110
118
  }
111
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
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:04849b6a-ae7a-43f3-939a-e67a488f6a49",
5
+ "serialNumber": "urn:uuid:4b79d223-5a7a-4f6d-89bb-07e45cdff81e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T08:07:44.038Z",
8
+ "timestamp": "2026-07-17T10:48:34.153Z",
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.17.1",
22
+ "bom-ref": "@blamejs/core@0.17.3",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.17.1",
25
+ "version": "0.17.3",
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.17.1",
29
+ "purl": "pkg:npm/%40blamejs/core@0.17.3",
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.17.1",
57
+ "ref": "@blamejs/core@0.17.3",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]