@blamejs/core 0.15.50 → 0.15.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.51 (2026-06-29) — **`b.guardOauth` and `b.session.verify` now fail closed when a backing store errors, instead of silently accepting a request whose security check could not be completed.** Two verifiers swallowed an error from a backing store and continued as if the check had passed. b.guardOauth's authorization-code replay defense wrapped the operator's seenCodeStore.hasSeen() call in a silent catch, so a store backend outage skipped the replay check entirely and a replayed authorization code was accepted — even though codeReusePolicy is reject at every profile. A store error now adds a high-severity oauth.code-reuse-unverifiable refusal, so the flow is denied (fail-closed) when reuse cannot be ruled out. b.session.verify enforces a device-fingerprint binding stored in the session's sealed data column; when that column could not be decrypted (key-rotation skew, database corruption, or a tamper of the independently-sealed cell) the failure was swallowed and the entire fingerprint gate — including requireFingerprintMatch and maxAnomalyScore — was skipped, so a strict-mode session was accepted from any device. An unreadable binding under a strict policy is now treated as a failure to prove the binding and the session is refused. **Security:** *OAuth authorization-code replay check fails closed on a store error* — b.guardOauth's code-reuse defense calls the operator-supplied seenCodeStore.hasSeen(code) to refuse a replayed authorization code (RFC 6749 §10.5). The call was wrapped in a drop-silent catch, so when the store backend errored (e.g. a Redis/DB outage) the exception was swallowed, no replay issue was raised, and the flow validated — accepting a code that could not be proven unused, despite codeReusePolicy being reject at every profile. A store error now raises a high-severity oauth.code-reuse-unverifiable issue, so the gate refuses the flow when reuse cannot be ruled out. · *Session verify fails closed when the device-fingerprint binding can't be decrypted* — b.session.verify stores the device-fingerprint binding inside the session's AEAD-sealed data column. When that column failed to decrypt or parse (key-rotation skew, database corruption, or a tamper of the separately-sealed cell), the failure was swallowed and the fingerprint gate was skipped entirely — so a session under a strict binding policy (requireFingerprintMatch:true or a maxAnomalyScore threshold) was accepted from any device, silently voiding the advertised drift-kills-the-session guarantee. A present-but-undecryptable binding column under a strict policy is now treated as a failure to prove the binding and the session is refused; sessions without a binding, and the default non-strict mode, are unchanged.
12
+
11
13
  - v0.15.50 (2026-06-28) — **`b.mail.bimi` closes a VMC certificate authorization bypass, and the host/origin comparisons in `b.ssrfGuard`, `b.middleware.csrfProtect`, and `b.mail.dmarc` now canonicalize both sides so case, trailing-dot, and IDN differences cannot decide a security check.** Four security and correctness decisions compared a host, origin, or domain where one side was normalized and the other was not, so two values that denote the same host in different encodings reached different verdicts. The most serious was in b.mail.bimi.fetchAndVerifyMark: when a VMC/CMC certificate's URI Subject Alternative Name could not be parsed as a URL (for example, a host carrying userinfo), the matcher fell back to a raw substring search of the whole SAN string — so a CA-chained certificate whose real host was attacker-controlled but whose SAN contained the victim domain anywhere (in the userinfo or path) was accepted to vouch for that victim domain. The fallback is removed (an unparseable URI SAN now fails closed) and both the certificate host and the BIMI domain are canonicalized before comparison. b.ssrfGuard allow/deny lists compared the operator's entries verbatim against the URL parser's already-lowercased host, so a mixed-case or trailing-dot deny entry silently failed to block its host; both sides now canonicalize through canonicalizeHost. b.middleware.csrfProtect canonicalized the candidate Origin via the URL parser but built the same-origin baseline by raw concatenation of the Host header, refusing a legitimate same-origin request whose Host was mixed-case or carried an explicit default port; the baseline and allowedOrigins now go through the same canonicalizer. b.mail.dmarc strict alignment compared the From and SPF/DKIM authentication domains with only case-folding, failing an aligned message whose authentication domain carried a trailing dot or an IDN label; both are now canonicalized the same way the relaxed path already was. A new b.publicSuffix.canonicalDomain primitive provides the shared encoding-stable host form. **Added:** *b.publicSuffix.canonicalDomain — encoding-stable host form* — Returns the bare canonical host form of a domain (lowercase, single trailing dot stripped, IDN labels as their A-label/punycode form) for identity comparison, without walking the public-suffix list. Two values that denote the same host in different encodings return the same string; an invalid or hostile host returns the empty string and matches nothing. It is the shared building block for the DMARC-alignment and certificate SAN authorization comparisons above. **Fixed:** *CSRF Origin check no longer refuses a legitimate same-origin request* — b.middleware.csrfProtect canonicalized the incoming Origin/Referer through the URL parser but built the same-origin baseline by concatenating the raw Host header, and compared allowedOrigins verbatim. A legitimate same-origin POST whose Host header was mixed-case or carried an explicit default port (:80/:443) was refused as cross-origin. The baseline and each allowedOrigins entry now pass through the same origin canonicalizer as the candidate. · *DMARC strict alignment canonicalizes the compared domains* — b.mail.dmarc strict alignment (aspf=s / adkim=s) compared the From domain against the SPF/DKIM authentication domain with only case-folding, while the relaxed path already normalized via the public-suffix lookup. An aligned message whose authentication domain carried a trailing dot or an IDN label was wrongly failed. Both domains are now canonicalized identically before the strict comparison. **Security:** *BIMI VMC certificate SubjectAltName authorization bypass closed* — b.mail.bimi.fetchAndVerifyMark binds a verified mark certificate to the BIMI domain via its Subject Alternative Name. When a URI SAN could not be parsed as a URL (e.g. a host with userinfo, or a malformed/homograph URI), the matcher fell back to a raw substring search of the entire SAN string, so a CA-chained certificate whose actual host was attacker-controlled — but whose SAN contained the victim domain as a substring (in the userinfo or path) — was accepted to vouch for the victim domain. The substring fallback is removed: a URI SAN the URL parser refuses now fails closed, and both the certificate host and the BIMI domain are canonicalized (lowercase, trailing-dot strip, IDN A-label) before an exact host comparison. · *SSRF allow/deny lists now match the host case-insensitively* — b.ssrfGuard.createAllowlist compared each operator allow/deny entry verbatim against the URL parser's host, which is already lowercased. A mixed-case or trailing-dot denylist entry therefore failed to match its own host and did not block it. Both the host and each non-CIDR entry now canonicalize through canonicalizeHost before comparison, so a denylisted host is blocked regardless of the case or trailing-dot form the operator wrote.
12
14
 
13
15
  - v0.15.49 (2026-06-28) — **`b.crypto.httpSig` now canonicalizes `@query-param` names and values per RFC 9421 §2.2.8, so its HTTP Message Signatures interoperate with conformant peers.** b.crypto.httpSig built the signature base for a @query-param component from the raw on-wire query bytes — the name was matched with encodeURIComponent and the value was emitted verbatim, with no decode-then-reencode. RFC 9421 §2.2.8 requires both the name and the value to be canonicalized: parsed as application/x-www-form-urlencoded (so a '+' and a %20 both become a space, and hex case is normalized) and then re-encoded, with a space rendered as %20. Because the framework signed and verified with the same raw bytes, blamejs-to-blamejs round-trips still worked, but a signature covering a query parameter whose name or value required encoding (a space, a '+', mixed or lowercase percent-encoding) did not match the base a conformant peer constructs — and an emitted identifier could even carry a literal space that the verifier then could not parse. Sign now emits the canonical percent-encoded name and signs the canonical value, and both sign and verify resolve the value through the same canonicalizer; the framework's base now matches the RFC's own published §2.2.8 example vectors. The whole-query @query component (§2.2.7), which the RFC defines as the raw encoded query, is unchanged, and signatures over plain-ASCII parameter names and values are byte-identical to before. **Fixed:** *HTTP Message Signatures @query-param canonicalization (RFC 9421 §2.2.8)* — b.crypto.httpSig now canonicalizes a @query-param component's name and value per RFC 9421 §2.2.8 — decode as application/x-www-form-urlencoded then re-encode, so a '+'-encoded space becomes %20, a %20 and a '+' resolve identically, and percent-encoding hex case is normalized to uppercase. Previously the name was matched with encodeURIComponent and the value was emitted raw, so a signature covering a query parameter that required encoding did not match the signature base a conformant RFC 9421 peer builds, and an emitted ;name="..." identifier could carry a literal space the verifier could not parse. Sign emits the canonical name and signs the canonical value; verify resolves the value through the same canonicalizer and reproduces the received identifier per §2.5. The framework's signature base now matches the RFC's published §2.2.8 example vectors. The whole-query @query component (§2.2.7) stays the raw encoded query, and signatures over plain-ASCII parameters are byte-identical to before.
@@ -296,7 +296,18 @@ function _detectIssues(flow, opts) {
296
296
  "(RFC 6749 §10.5)",
297
297
  });
298
298
  }
299
- } catch (_e) { /* drop-silent — operator-supplied store */ }
299
+ } catch (_e) {
300
+ // A replay-store error means we cannot prove the authorization code is
301
+ // unused — that is a denial, not "the code is fresh". codeReusePolicy is
302
+ // "reject" at every profile, so the replay check is unconditional: fail
303
+ // CLOSED with a high-severity refusal rather than silently skipping it.
304
+ issues.push({
305
+ kind: "code-reuse-unverifiable", severity: "high",
306
+ ruleId: "oauth.code-reuse-unverifiable",
307
+ snippet: "replay store (seenCodeStore.hasSeen) errored — cannot prove " +
308
+ "the authorization code is unused; refusing (fail-closed, RFC 6749 §10.5)",
309
+ });
310
+ }
300
311
  }
301
312
 
302
313
  return issues;
package/lib/session.js CHANGED
@@ -581,6 +581,12 @@ async function verify(token, verifyOpts) {
581
581
  var unsealed = cryptoField.unsealRow(SESSION_TABLE, row);
582
582
  var data = null;
583
583
  var storedFingerprint = null;
584
+ // The sealed `data` cell carries the device-fingerprint binding. A cell that
585
+ // EXISTS on the row but does not decrypt (key-rotation skew, DB corruption, or
586
+ // a tamper of the independently-AEAD-sealed column) means the binding is
587
+ // UNREADABLE — distinct from a session that legitimately carries no binding.
588
+ // Under a strict binding policy that must FAIL CLOSED below, not be skipped.
589
+ var bindingUnreadable = (row.data != null && row.data !== "" && unsealed.data == null);
584
590
  if (unsealed.data) {
585
591
  try {
586
592
  data = safeJson.parse(unsealed.data);
@@ -599,6 +605,7 @@ async function verify(token, verifyOpts) {
599
605
  // operator notices empty-`data` flows. data stays null so the
600
606
  // session remains usable for non-data flows.
601
607
  data = null;
608
+ bindingUnreadable = true; // decrypted but unparseable — binding unreadable
602
609
  try {
603
610
  audit.safeEmit({
604
611
  action: "auth.session.data_unparseable",
@@ -618,6 +625,21 @@ async function verify(token, verifyOpts) {
618
625
  // login-from-Tokyo-then-immediately-from-Brazil pattern is not).
619
626
  var fingerprintDrift = false;
620
627
  var fingerprintAnomalyScore = null;
628
+ // A strict binding policy (requireFingerprintMatch / maxAnomalyScore) cannot be
629
+ // satisfied when the binding is UNREADABLE — we can't prove it matches, so fail
630
+ // CLOSED rather than silently skipping the gate (the pre-fix fail-open).
631
+ if (bindingUnreadable && verifyOpts.req &&
632
+ (verifyOpts.requireFingerprintMatch === true ||
633
+ typeof verifyOpts.maxAnomalyScore === "number")) {
634
+ try {
635
+ audit.safeEmit({
636
+ action: "auth.session.binding_unreadable",
637
+ outcome: "failure",
638
+ metadata: { hasUserId: !!unsealed.userId },
639
+ });
640
+ } catch (_ig) { /* audit best-effort */ }
641
+ return null;
642
+ }
621
643
  if (storedFingerprint && verifyOpts.req) {
622
644
  var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
623
645
  ? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.50",
3
+ "version": "0.15.51",
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:74c051d6-47fc-4a6b-a557-b70f7fd7afdf",
5
+ "serialNumber": "urn:uuid:28e40053-c854-460c-bb90-e65917309220",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T02:26:06.809Z",
8
+ "timestamp": "2026-06-29T03:31:54.302Z",
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.15.50",
22
+ "bom-ref": "@blamejs/core@0.15.51",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.50",
25
+ "version": "0.15.51",
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.15.50",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.51",
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.15.50",
57
+ "ref": "@blamejs/core@0.15.51",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]