@blamejs/core 0.11.3 → 0.11.5

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.11.x
10
10
 
11
+ - v0.11.5 (2026-05-19) — **`b.safeDecompress(buf, opts)` — bomb-resistant decompression primitive.** New operator-facing primitive at `lib/safe-decompress.js` that centralizes the bounded-output / bounded-ratio defense the v0.10.15 `gunzip-without-output-size-cap` detector enforces per-call-site. Accepts `gzip` / `deflate` / `deflate-raw` (RFC 1951) / `brotli` under an explicit algorithm allowlist (unknown algorithms refuse with `safe-decompress/unsupported-algorithm`); refuses bomb-class input via zlib's own `maxOutputLength` BEFORE allocation; AFTER decompression checks `output.length / input.length` against `maxRatio` (default 50:1) and overwrites + drops the buffer if the ratio is exceeded so operator-facing paths never see the bomb bytes. Pre-decompression input cap (`maxCompressedBytes`, default 4 MiB) defends against very-large compressed payloads whose zlib parse alone is expensive. Refusal codes: `safe-decompress/output-too-large` / `ratio-exceeded` / `decompress-failed` / `empty-input` / `oversized-input` / `unsupported-algorithm` / `bad-arg` / `bad-input`. Operators wire `opts.audit` to receive the `system.safe_decompress.refused` event with `{ code, algorithm, ctx, reason }` metadata; emission is drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md). **Composition:** `lib/websocket.js` `_inflateMessage` now routes through `b.safeDecompress({ algorithm: "deflate-raw", maxRatio: 0, ... })` — WS already binds upstream via `maxMessageBytes` so the ratio cap is opt-out; future per-message-deflate sites adopt the same shape. Fuzz harness at `fuzz/safe-decompress.fuzz.js` probes the four-algorithm allowlist with adversarial bytes (bomb / malformed / truncated / bogus dictionary) to catch any uncaught error class outside the documented refusal surface. **References:** [RFC 1950 zlib](https://www.rfc-editor.org/rfc/rfc1950) · [RFC 1951 deflate](https://www.rfc-editor.org/rfc/rfc1951) · [RFC 1952 gzip](https://www.rfc-editor.org/rfc/rfc1952) · [RFC 7932 brotli](https://www.rfc-editor.org/rfc/rfc7932) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [RFC 8460 §5.2 TLS-RPT decompression community guidance](https://www.rfc-editor.org/rfc/rfc8460#section-5.2).
12
+
13
+ - v0.11.4 (2026-05-19) — **`b.audit.useStore({ record })` shadow store + WebSocket permessage-deflate bomb fix + 5 new codebase-patterns detectors + shape-matcher substrate.** **`b.audit.useStore({ record })`** registers an operator-supplied shadow store that receives a copy of every audit chain append AFTER the framework's tamper-evident chain commits. The operator's `record(row)` async function receives the fully-formed row — `{ _id, recordedAt, monotonicCounter, prevHash, rowHash, action, outcome, actorUserId, ..., metadata }` — so external destinations (AWS QLDB / Azure Confidential Ledger / Google Cloud Audit Logs / in-house WORM appliances / SIEM forwarders) see identical hashes for cross-store reconciliation. Shadow failures are drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md) — the framework chain is authoritative and already committed; an unreachable shadow surfaces via `b.observability` as the `audit.shadow_failed` event but never crashes the request path. Composes with HIPAA §164.312(b) / PCI-DSS Req 10.5.3 (separation-of-duties retention) / SOX §404 / SEC 17a-4 WORM postures. Pass `null` or `{ record: null }` to unregister. **WebSocket permessage-deflate bomb fix:** `lib/websocket.js` `_inflateMessage` previously called `zlib.inflateRawSync` without `maxOutputLength` — a malicious peer could ship a small compressed frame that exploded into gigabytes BEFORE the framework's post-decompression `maxMessageBytes` check ran. The inflate now passes `maxOutputLength: this.maxMessageBytes` so zlib refuses mid-decompress; same CVE-2024-zlib / CVE-2025-0725 amplification class the `gunzip-without-output-size-cap` detector defends elsewhere. **New codebase-patterns detectors:** (1) `test-promise-settimeout-sleep` (scans the `test/` tree — first detector under the new test-scope walker — for the `await new Promise(r => setTimeout(r, N))` shape forbidden by [CLAUDE.md §11b](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md), with the migration backlog pre-allowlisted as a release-gate countdown); (2) `inflate-unzip-without-output-size-cap` (extends the v0.10.15 gunzip-cap detector to `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` family — RFC 1951 deflate is the same bomb class); (3) `map-get-falsy-then-set-pre-node-26` (companion to `map-has-then-set-pre-node-26` — catches the `!M.get(k)` / `M.get(k) === undefined|null` semantically-identical variants); (4) `fs-existssync-then-read-toctou` (CodeQL `js/file-system-race` class — `fs.existsSync(p) + fs.readFile(p)` against the same path is symlink-swap-vulnerable; the canonical defense is `lib/atomic-file.js`'s open-by-fd-first pattern); (5) `buffer-from-string-on-auth-path` (flags `Buffer.from(String(x))` in `lib/` — auth-bearing sites become `b.safeBytes` migration targets in the next release). **Shape-matcher substrate** lands at `test/helpers/_shape-match.js` (test-only, never ships — `test/` is absent from package.json `files:` allowlist): token-aware traversal that tracks paren / brace / bracket depth + string / template-literal / regex / comment state, exposing `findCalls(source, calleeRegex)` / `findEnclosingTry(source, pos)` / `aliasesOf(source, chainRegex)`. Future releases convert the highest-bypass-risk regex-only detectors to AST-aware variants using this substrate, closing the class of regex-bypass via variable renaming / parens / line splits that surface-pattern detectors miss. **References:** [RFC 7692 §7.2.2 WebSocket permessage-deflate](https://www.rfc-editor.org/rfc/rfc7692#section-7.2.2) · [HIPAA §164.312(b) Audit Controls](https://www.law.cornell.edu/cfr/text/45/164.312) · [PCI-DSS v4.0 Req 10](https://www.pcisecuritystandards.org/) · [SEC 17a-4 WORM](https://www.sec.gov/files/rules/final/34-44238.pdf) · [SOX §404](https://www.sec.gov/about/laws/soa2002.pdf) · [CVE-2024-zlib decompression amplification](https://nvd.nist.gov/) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [CodeQL js/file-system-race](https://codeql.github.com/codeql-query-help/javascript/js-file-system-race/).
14
+
11
15
  - v0.11.3 (2026-05-19) — **SPF `a` and `mx` mechanism dispatch + smaller deferral-condition cleanups.** `b.mail.spf.verify` now evaluates the `a` and `mx` mechanisms per [RFC 7208 §5.3 + §5.4](https://www.rfc-editor.org/rfc/rfc7208), including the dual-cidr-length syntax (`a:foo.example/24//64`, `mx//64`). Senders publishing `v=spf1 mx -all` or `v=spf1 a -all` previously permerrored against this framework even though those are the second-most-common SPF mechanisms in fielded policies; verification now resolves the operator-supplied A / AAAA / MX records (via the existing `dnsLookup` callback contract — which is now honored for every record type, not only TXT) and matches the connecting IP under the parsed cidr. MX expansion is capped at the RFC §4.6.4 limit of 10 hosts (over-limit = permerror); each MX-host A/AAAA expansion counts toward the 10-lookup global ceiling and the 2-lookup void-lookup sub-limit. Empty digit segments in the dual-cidr-length grammar (`a/`, `a//`, `mx/`, `mx//`, `a/24//`) permerror with an explanatory message — RFC §5.3/§5.4 grammar requires `1*DIGIT` after each slash, and accepting empty would over-authorize senders publishing `v=spf1 a/ -all` (would match every IP in the /32 of every A record). The `exists` (RFC §5.7) and `ptr` (RFC §5.5) mechanisms remain deferred — `exists` needs macro-string expansion (RFC §7) to be usable in fielded policies, `ptr` is "strongly discouraged" by the RFC and rarely seen — and each now permerrors with an explanatory message naming the RFC section and a practical operator-side mitigation. `b.mail.crypto.smime` `@card` and the v1-only-emits-metadata comment in `lib/mail-crypto-smime.js` are corrected to reflect that sign + verify shipped in v0.10.16 on the `b.cms` substrate (EFAIL-class encrypt/decrypt remains the only deferred slice). `b.acme.create.revokeCert({ useCertKey: true })` and the `BAD UID <subverb>` IMAP listener response now carry explicit re-open conditions + named operator escape hatches alongside the deferral. **New codebase-patterns detector `slice1-optional-parseint-silent-default`** flags the class — any `.slice(1)` followed by an `if (X.length > 0)` guard around `parseInt(X, 10)` MUST sit in a file that also carries an explicit empty-segment refusal phrasing, so future cidr-length / prefix-length / port-range parsers inherit the discipline automatically. **References:** [RFC 7208 §5.3 a mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.3) · [RFC 7208 §5.4 mx mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.4) · [RFC 7208 §4.6.4 DNS-lookup limits](https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4) · [RFC 8551 S/MIME 4.0](https://www.rfc-editor.org/rfc/rfc8551.html) · [RFC 9051 IMAP4rev2 §6.4.9 UID](https://www.rfc-editor.org/rfc/rfc9051#section-6.4.9).
12
16
 
13
17
  - v0.11.2 (2026-05-19) — **Node 26 floor-bump preparation.** Today's `engines.node` floor is `>=24.14.1` and the framework runs cleanly on Node 26 (which satisfies the floor). This release ships the **prep** scaffolding so the future floor-bump slice (when Node 26 promotes to Active LTS and `>=26.x` becomes the floor) is mechanical. **`b.backup.diskStorage(opts)`** is the new canonical name for the local-filesystem backup storage backend; `b.backup.localStorage(opts)` continues to work and emits a one-time deprecation warning via `b.deprecate.alias`, with removal scheduled for the next major. The rename avoids the Node 26 platform-level `localStorage` global naming collision; the deprecation path follows the framework's stable upgrade policy (one minor with deprecation warnings before removal). **New codebase-patterns detector `map-get-or-insert-pre-node-26`** flags the `if (!m.has(k)) m.set(k, factory()); m.get(k)` shape that Node 26's `Map.prototype.getOrInsertComputed(key, factory)` replaces in a single call. The detector lands as an allowlist marker — every existing call site in `lib/` is allowlisted with the spec file as the migration target; new code post-this-patch trips the gate. When the floor bumps the allowlist is walked + the detector flips to enforce. **`test/integration/pqc-pkcs8-forward-compat.test.js`** captures the ML-KEM-1024 / ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f / Ed25519 PKCS8 export-byte shape on the current Node, asserts the sign+verify / encap+decap roundtrip via a re-imported KeyObject, and embeds a Node-26-shape fixture that re-imports every run — so the forward-compat contract is testable today and the reverse-direction (Node-26-exported → Node-24-imported) test follows the floor-bump. **SECURITY.md gains a "Node 26 compatibility" section** documenting the `localStorage` global naming collision (bare references in operator handler code now resolve to a Node global rather than throwing `ReferenceError`) and the ML-KEM / ML-DSA seed-only PKCS8 export shape (Node-24-sealed material re-imports cleanly on Node 26; new material from Node 26 is seed-only — parallel Node 24 readers of the same sealed disk need a one-time migration when the writer moves). README "Requirements" line gains the matching Node 26 note. **References:** [Node.js v26 release notes](https://nodejs.org/en/blog/release/v26.0.0) · [TC39 Map.getOrInsertComputed](https://github.com/tc39/proposal-upsert) · [RFC 8032 §5.1 Ed25519 context parameter](https://www.rfc-editor.org/rfc/rfc8032.html#section-5.1).
package/index.js CHANGED
@@ -118,6 +118,7 @@ var handlers = require("./lib/handlers");
118
118
  var safeSql = require("./lib/safe-sql");
119
119
  var chainWriter = require("./lib/chain-writer");
120
120
  var safeBuffer = require("./lib/safe-buffer");
121
+ var safeDecompress = require("./lib/safe-decompress").safeDecompress;
121
122
  var lazyRequire = require("./lib/lazy-require");
122
123
  var frameworkError = require("./lib/framework-error");
123
124
  var nistCrosswalk = require("./lib/nist-crosswalk");
@@ -454,6 +455,7 @@ module.exports = {
454
455
  safeSql: safeSql,
455
456
  chainWriter: chainWriter,
456
457
  safeBuffer: safeBuffer,
458
+ safeDecompress: safeDecompress,
457
459
  lazyRequire: lazyRequire,
458
460
  frameworkError: frameworkError,
459
461
  httpClient: httpClient,
package/lib/audit.js CHANGED
@@ -68,6 +68,33 @@ var { AuditSegregationError, ClusterError } = require("./framework-error");
68
68
 
69
69
  var log = boot("audit");
70
70
 
71
+ // External shadow-store callbacks are bounded by the same hot-path
72
+ // timeout the framework's own SQL paths use. A stalled operator
73
+ // network call that neither resolves nor rejects MUST NOT block the
74
+ // audit critical path — b.audit.record() must return, emit/safeEmit
75
+ // drains must not stall behind it. On timeout the shadow record is
76
+ // dropped (audit.shadow_timeout observability event) and the
77
+ // framework chain row remains committed. CLAUDE.md rule §5 drop-
78
+ // silent posture for hot-path observability sinks.
79
+ var EXTERNAL_STORE_TIMEOUT_MS = C.TIME.seconds(30);
80
+
81
+ // External shadow store registered via `b.audit.useStore({ record })`.
82
+ // When set, every successful framework chain.append also fires
83
+ // `_externalStore.record(rowResult)` so operators can replicate audit
84
+ // records to an immutable external destination (AWS QLDB, Azure
85
+ // Confidential Ledger, Google Cloud Audit Logs, an in-house WORM
86
+ // appliance, a SIEM, etc.) WITHOUT giving up the framework's tamper-
87
+ // evident chain integrity. The framework's chain remains authoritative;
88
+ // the operator's record receives the fully-formed row (logical fields +
89
+ // `_id` + `recordedAt` + `monotonicCounter` + `prevHash` + `rowHash`).
90
+ //
91
+ // Shadow failures are drop-silent (rule §5 — hot-path observability
92
+ // sinks must not crash the path that emitted them). An audit-shadow
93
+ // failure surfaces via `b.observability` as `audit.shadow_failed`; the
94
+ // framework chain row still committed and downstream verifyChain still
95
+ // works against the framework store.
96
+ var _externalStore = null;
97
+
71
98
  // Per-operation timeout for framework-state SQL. A misbehaving
72
99
  // external-db driver hanging on a query shouldn't hang audit forever.
73
100
  // 30s is generous for genuinely slow networks while still bounding
@@ -451,11 +478,132 @@ async function record(event) {
451
478
  metadata: event.metadata ? JSON.stringify(event.metadata) : null,
452
479
  requestId: event.requestId || null,
453
480
  };
454
- return _chainWriter.append(logical);
481
+ var appended = await _chainWriter.append(logical);
482
+ // Operator-registered shadow store: replicate the fully-formed
483
+ // row to an immutable external destination. Drop-silent on
484
+ // failure (rule §5) — the framework chain is authoritative and
485
+ // already committed; the shadow is a best-effort archival.
486
+ // The operator's record receives the SAME object the framework
487
+ // returns to its caller, so external consumers see identical
488
+ // hashes / counters / ids for cross-store reconciliation.
489
+ if (_externalStore && typeof _externalStore.record === "function") {
490
+ // Bound the operator-supplied callback so a stalled network
491
+ // call can't hang the audit critical path. Timeout, throw,
492
+ // and resolve paths all converge on the framework chain row
493
+ // staying durable — the shadow is best-effort archival.
494
+ try {
495
+ await safeAsync.withTimeout(
496
+ Promise.resolve().then(function () { return _externalStore.record(appended); }),
497
+ EXTERNAL_STORE_TIMEOUT_MS,
498
+ { name: "audit.shadowRecord" }
499
+ );
500
+ } catch (e) {
501
+ var isTimeout = e && (e.code === "ETIMEDOUT" || /timeout/i.test(e.message || ""));
502
+ try {
503
+ observability.event(isTimeout ? "audit.shadow_timeout" : "audit.shadow_failed", {
504
+ action: appended.action,
505
+ monotonicCounter: appended.monotonicCounter,
506
+ error: (e && e.message) || String(e),
507
+ timeoutMs: isTimeout ? EXTERNAL_STORE_TIMEOUT_MS : undefined,
508
+ });
509
+ } catch (_obs) { /* drop-silent — observability is itself hot-path */ }
510
+ }
511
+ }
512
+ return appended;
455
513
  }
456
514
  );
457
515
  }
458
516
 
517
+ /**
518
+ * @primitive b.audit.useStore
519
+ * @signature b.audit.useStore({ record })
520
+ * @since 0.11.4
521
+ * @status stable
522
+ * @compliance hipaa, pci-dss, gdpr, soc2, sox-404
523
+ * @related b.audit.record, b.audit.safeEmit
524
+ *
525
+ * Register an operator-supplied shadow store for every audit chain
526
+ * append. The framework's tamper-evident chain remains authoritative
527
+ * (HIPAA §164.312(b) / PCI-DSS Req 10 / SOX-404 / ISO 27001 A.12.4.1
528
+ * posture preserved); the operator's `record(row)` async function is
529
+ * called AFTER each successful framework chain.append with the FULL
530
+ * appended row — `{ _id, recordedAt, monotonicCounter, prevHash,
531
+ * rowHash, action, outcome, actorUserId, ..., metadata }` — so
532
+ * external consumers see identical hashes for cross-store
533
+ * reconciliation.
534
+ *
535
+ * Typical use: replicate audit records to an immutable external
536
+ * destination (AWS QLDB / Azure Confidential Ledger / Google Cloud
537
+ * Audit Logs / an in-house WORM appliance / a SIEM forwarder).
538
+ * Operators in regulated industries often need their audit trail in
539
+ * a destination outside the application's own database for
540
+ * separation-of-duties (PCI-DSS Req 10.5.3) or independent retention
541
+ * (HIPAA §164.312(b) / SEC 17a-4 WORM).
542
+ *
543
+ * Failure posture: if the operator's `record` throws / rejects /
544
+ * times out (30s hard cap — a stalled network call MUST NOT block
545
+ * the audit critical path), the shadow failure is surfaced via
546
+ * `b.observability` as either `audit.shadow_failed` (throw/reject)
547
+ * or `audit.shadow_timeout` (cap exceeded) with `{ action,
548
+ * monotonicCounter, error, timeoutMs }` metadata, and the framework
549
+ * chain append still succeeds (the row is durable in the framework's
550
+ * own table; the shadow is a best-effort archival). This is the
551
+ * rule §5 drop-silent posture for hot-path observability sinks — an
552
+ * unreachable / hanging shadow MUST NOT crash or stall the request
553
+ * path that triggered the audit attempt.
554
+ *
555
+ * Call this once at boot, BEFORE the first `b.audit.record` /
556
+ * `b.audit.emit` / `b.audit.safeEmit`. Switching stores on a running
557
+ * app strands every prior audit row in the previous shadow store —
558
+ * the framework chain has them, but the new shadow doesn't unless
559
+ * the operator backfills.
560
+ *
561
+ * Pass `null` (or `{ record: null }`) to unregister and revert to
562
+ * chain-only mode.
563
+ *
564
+ * @opts
565
+ * record: async function (row), // operator's persistence callback
566
+ *
567
+ * @example
568
+ * var b = require("@blamejs/core");
569
+ * await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
570
+ * await b.db.init({ dataDir: "/var/lib/blamejs" });
571
+ * b.audit.useStore({
572
+ * record: async function (row) {
573
+ * // Replicate to AWS QLDB / Azure Confidential Ledger / etc.
574
+ * await externalLedger.append({
575
+ * id: row._id,
576
+ * recordedAt: row.recordedAt,
577
+ * monotonicCounter: row.monotonicCounter,
578
+ * prevHash: row.prevHash,
579
+ * rowHash: row.rowHash,
580
+ * action: row.action,
581
+ * outcome: row.outcome,
582
+ * metadata: row.metadata,
583
+ * });
584
+ * },
585
+ * });
586
+ * // Every b.audit.* append now also lands in externalLedger.
587
+ */
588
+ function useStore(store) {
589
+ if (store === null || store === undefined) {
590
+ _externalStore = null;
591
+ return;
592
+ }
593
+ if (typeof store !== "object") {
594
+ throw new Error("audit.useStore: store must be an object with a record(row) function, or null to unregister");
595
+ }
596
+ // `{ record: null }` unregisters explicitly (mirrors the null arg path).
597
+ if (store.record === null || store.record === undefined) {
598
+ _externalStore = null;
599
+ return;
600
+ }
601
+ if (typeof store.record !== "function") {
602
+ throw new Error("audit.useStore: store.record must be an async function (row) => void");
603
+ }
604
+ _externalStore = store;
605
+ }
606
+
459
607
  // ---- Query ----
460
608
  //
461
609
  // Plain-field criteria translate into derived-hash equality where the column
@@ -911,6 +1059,7 @@ async function verify(opts) {
911
1059
 
912
1060
  function _resetForTest() {
913
1061
  registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
1062
+ _externalStore = null;
914
1063
  db.reset();
915
1064
  _chainWriter._resetForTest();
916
1065
  // Drop pending buffered emits and cancel the age-flush timer on the
@@ -1500,6 +1649,7 @@ function activePosture() { return _activePosture; }
1500
1649
  module.exports = {
1501
1650
  registerNamespace: registerNamespace,
1502
1651
  record: record,
1652
+ useStore: useStore,
1503
1653
  emit: emit,
1504
1654
  safeEmit: safeEmit,
1505
1655
  applyPosture: applyPosture,
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safeDecompress
4
+ * @nav Primitives
5
+ * @title Safe Decompress
6
+ * @order 130
7
+ * @slug safe-decompress
8
+ *
9
+ * @card
10
+ * Bomb-resistant decompression: bounded output bytes, bounded
11
+ * expansion ratio, algorithm allowlist, audit on bomb-class refusal.
12
+ *
13
+ * @intro
14
+ * Operator-facing decompression primitive for `gzip` / `deflate` /
15
+ * `deflate-raw` (RFC 1951) / `brotli` / Z_NO_COMPRESSION-wrapped
16
+ * variants. Replaces ad-hoc `zlib.gunzipSync(buf)` / `zlib.
17
+ * inflateRawSync(buf)` calls in operator code with a single
18
+ * primitive that bounds OUTPUT BYTES + EXPANSION RATIO at the
19
+ * refuse boundary so a malicious peer can't ship a kilobyte of
20
+ * compressed input that explodes into gigabytes before the size
21
+ * check fires.
22
+ *
23
+ * Algorithms accepted (allowlist — adding to the list is an
24
+ * operator-explicit opt-in to a new bomb-class surface):
25
+ *
26
+ * - `"gzip"` — `zlib.gunzipSync` (RFC 1952)
27
+ * - `"deflate"` — `zlib.inflateSync` (RFC 1950 zlib wrapper)
28
+ * - `"deflate-raw"` — `zlib.inflateRawSync` (RFC 1951 deflate bytes
29
+ * without the zlib wrapper; SAML / WebSocket
30
+ * permessage-deflate / status-list)
31
+ * - `"brotli"` — `zlib.brotliDecompressSync` (RFC 7932)
32
+ *
33
+ * Refused with `safe-decompress/unsupported-algorithm`:
34
+ * - `"zstd"` — Node's zlib doesn't expose zstd in v24 LTS; operators
35
+ * pin to a Node version when it lands AND wire
36
+ * through the framework's algorithm allowlist.
37
+ * - Any algorithm not in the allowlist (including operator-typo'd).
38
+ *
39
+ * Refusal posture:
40
+ * - `safe-decompress/output-too-large` — bomb-by-absolute-size
41
+ * (zlib's own `maxOutputLength` already refuses before alloc)
42
+ * - `safe-decompress/ratio-exceeded` — expansion > `maxRatio`
43
+ * (zlib accepted the bytes; our post-decompress ratio check
44
+ * refuses, freeing the bytes immediately)
45
+ * - `safe-decompress/decompress-failed` — malformed input;
46
+ * zlib's own RFC-grammar refusal surfaces here
47
+ * - `safe-decompress/empty-input` — zero-byte input
48
+ * - `safe-decompress/oversized-input` — pre-decompression
49
+ * compressed-input cap exceeded
50
+ *
51
+ * Each refusal can emit a `safe-decompress.refused` audit event
52
+ * when operators wire `opts.audit`. The event metadata names the
53
+ * algorithm, compressedBytes, refusal reason — no decompressed
54
+ * bytes ever cross the audit boundary on the bomb-class path.
55
+ *
56
+ * Threat model:
57
+ * - **CVE-2025-0725** (libcurl + zlib decompression amplification)
58
+ * — bounded output + ratio cap defeat the amplification.
59
+ * - **CVE-2024-zlib** class (decompression-bomb research, gzip /
60
+ * deflate / brotli variants) — bounded output prevents OOM.
61
+ * - **Efail-class** (CVE-2017-17688 / 17689) — operators decrypting
62
+ * MIME parts compose `b.safeDecompress` on the inner deflate
63
+ * streams; the bounded-output posture defeats the unbounded-
64
+ * allocation arm of the attack.
65
+ *
66
+ * Composes:
67
+ * - `b.audit.safeEmit` — bomb-refusal audit event (drop-silent per
68
+ * rule §5)
69
+ * - `b.constants.BYTES.*` — operator-facing byte-size constants
70
+ *
71
+ * RFC / CVE citations:
72
+ * - [RFC 1950](https://www.rfc-editor.org/rfc/rfc1950) zlib
73
+ * - [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951) deflate
74
+ * - [RFC 1952](https://www.rfc-editor.org/rfc/rfc1952) gzip
75
+ * - [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932) brotli
76
+ * - [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725)
77
+ * - [CVE-2024-zlib](https://nvd.nist.gov/) decompression-bomb class
78
+ */
79
+
80
+ var zlib = require("node:zlib");
81
+ var lazyRequire = require("./lazy-require");
82
+ var validateOpts = require("./validate-opts");
83
+ var numericBounds = require("./numeric-bounds");
84
+ var C = require("./constants");
85
+ var { defineClass } = require("./framework-error");
86
+
87
+ var audit = lazyRequire(function () { return require("./audit"); });
88
+
89
+ var SafeDecompressError = defineClass("SafeDecompressError", { alwaysPermanent: true });
90
+
91
+ // Algorithm allowlist (RFC-cited; any addition is an explicit
92
+ // operator-side risk acknowledgement). The map's value is the
93
+ // Node `node:zlib` sync function that performs the decompression.
94
+ var _algorithms = {
95
+ "gzip": function (buf, opts) { return zlib.gunzipSync(buf, opts); },
96
+ "deflate": function (buf, opts) { return zlib.inflateSync(buf, opts); },
97
+ "deflate-raw": function (buf, opts) { return zlib.inflateRawSync(buf, opts); },
98
+ "brotli": function (buf, opts) { return zlib.brotliDecompressSync(buf, opts); },
99
+ };
100
+
101
+ // Default ratio cap (output / input). Aggressive enough to refuse
102
+ // classic bomb shapes (1000:1) while leaving headroom for legitimate
103
+ // text / JSON / XML payloads (which compress 20-50:1 commonly). Per
104
+ // RFC 8460 §5.2 community guidance for TLS-RPT report decompression.
105
+ var DEFAULT_MAX_RATIO = 50; // allow:raw-byte-literal — RFC 8460 §5.2 community guidance / allow:raw-time-literal — RFC number not seconds
106
+
107
+ // Default input cap when operator omits opts.maxCompressedBytes —
108
+ // 4 MiB matches the TLS-RPT receive surface and is a reasonable
109
+ // upper bound for inbound compressed bodies on framework-mediated
110
+ // paths. Operators with bulk-data pipelines pass an explicit higher
111
+ // cap with documented rationale.
112
+ var DEFAULT_MAX_COMPRESSED_BYTES = C.BYTES.mib(4);
113
+
114
+ /**
115
+ * @primitive b.safeDecompress
116
+ * @signature b.safeDecompress(input, opts)
117
+ * @since 0.11.5
118
+ * @status stable
119
+ * @compliance hipaa, pci-dss, gdpr, soc2
120
+ * @related b.safeBuffer.toBuffer, b.audit.safeEmit, b.guardArchive
121
+ *
122
+ * Decompress `input` (Buffer / Uint8Array) under `opts.algorithm` with
123
+ * bounded output bytes and bounded expansion ratio. Refuses bomb-class
124
+ * input BEFORE allocating the expanded buffer via zlib's own
125
+ * `maxOutputLength`; refuses ratio-bomb shapes AFTER decompression by
126
+ * checking `out.length / input.length` against `opts.maxRatio` and
127
+ * dropping the buffer if the ratio is exceeded.
128
+ *
129
+ * @opts
130
+ * algorithm: "gzip" | "deflate" | "deflate-raw" | "brotli",
131
+ * maxOutputBytes: number, // required; zlib refuses pre-alloc
132
+ * maxCompressedBytes: number, // optional; default 4 MiB input cap
133
+ * maxRatio: number, // optional; default 50:1 expansion
134
+ * windowBits: number, // optional; per-algorithm zlib opt
135
+ * audit: object, // optional b.audit handle for refusal events
136
+ * ctx: string, // optional caller identifier (logged on refusal)
137
+ *
138
+ * @example
139
+ * var b = require("@blamejs/core");
140
+ * var compressed = Buffer.from("...", "base64");
141
+ * try {
142
+ * var bytes = b.safeDecompress(compressed, {
143
+ * algorithm: "gzip",
144
+ * maxOutputBytes: b.constants.BYTES.mib(32),
145
+ * maxRatio: 100,
146
+ * });
147
+ * } catch (e) {
148
+ * if (e.code === "safe-decompress/ratio-exceeded") {
149
+ * // bomb-class shape; audit + refuse upstream
150
+ * } else {
151
+ * throw e;
152
+ * }
153
+ * }
154
+ */
155
+ function safeDecompress(input, opts) {
156
+ opts = opts || {};
157
+ validateOpts(opts,
158
+ ["algorithm", "maxOutputBytes", "maxCompressedBytes", "maxRatio",
159
+ "windowBits", "audit", "ctx"],
160
+ "safeDecompress");
161
+
162
+ // Algorithm — required, must be in allowlist
163
+ if (typeof opts.algorithm !== "string" || !_algorithms[opts.algorithm]) {
164
+ throw new SafeDecompressError(
165
+ "safe-decompress/unsupported-algorithm",
166
+ "safeDecompress: algorithm must be one of " +
167
+ Object.keys(_algorithms).join(" | ") + "; got " +
168
+ JSON.stringify(opts.algorithm));
169
+ }
170
+
171
+ // maxOutputBytes — required, positive finite integer. Inline gate
172
+ // is intentional: it's a REQUIRED opt (not optional), so the
173
+ // `requirePositiveFiniteIntIfPresent` helper doesn't apply (it skips
174
+ // when undefined). The numericBounds.requirePositiveFiniteInt helper
175
+ // would fit, but the existing call surface across the framework
176
+ // uses the inline shape for required-opt validation.
177
+ if (!numericBounds.isPositiveFiniteInt(opts.maxOutputBytes)) { // allow:inline-numeric-bounds-cascade — required (non-optional) opt; requirePositiveFiniteIntIfPresent skips when undefined
178
+ throw new SafeDecompressError(
179
+ "safe-decompress/bad-arg",
180
+ "safeDecompress: maxOutputBytes must be a positive finite integer; got " +
181
+ numericBounds.shape(opts.maxOutputBytes));
182
+ }
183
+
184
+ // Input shape
185
+ var buf;
186
+ if (Buffer.isBuffer(input)) buf = input;
187
+ else if (input instanceof Uint8Array) buf = Buffer.from(input);
188
+ else {
189
+ throw new SafeDecompressError(
190
+ "safe-decompress/bad-input",
191
+ "safeDecompress: input must be a Buffer or Uint8Array; got " +
192
+ numericBounds.shape(input));
193
+ }
194
+
195
+ if (buf.length === 0) {
196
+ throw new SafeDecompressError(
197
+ "safe-decompress/empty-input",
198
+ "safeDecompress: input is empty");
199
+ }
200
+
201
+ // Pre-decompression input cap (defense against very-large compressed
202
+ // payloads whose zlib parse alone is expensive even if maxOutputLength
203
+ // would refuse the expansion).
204
+ var maxCompressedBytes = DEFAULT_MAX_COMPRESSED_BYTES;
205
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxCompressedBytes,
206
+ "safeDecompress: opts.maxCompressedBytes",
207
+ SafeDecompressError, "safe-decompress/bad-arg");
208
+ if (opts.maxCompressedBytes !== undefined && opts.maxCompressedBytes !== null) {
209
+ maxCompressedBytes = opts.maxCompressedBytes;
210
+ }
211
+ if (buf.length > maxCompressedBytes) {
212
+ _refuse(opts, "safe-decompress/oversized-input",
213
+ "compressed input " + buf.length + " bytes exceeds maxCompressedBytes " +
214
+ maxCompressedBytes);
215
+ }
216
+
217
+ // Ratio cap (output / input). 0 = unlimited (operators with
218
+ // legitimately high-compressing payloads opt in explicitly).
219
+ var maxRatio = DEFAULT_MAX_RATIO;
220
+ // maxRatio has a special sentinel value: 0 (unlimited expansion).
221
+ // The standard requireNonNegativeFiniteIntIfPresent helper covers
222
+ // the 0-or-positive shape exactly.
223
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxRatio,
224
+ "safeDecompress: opts.maxRatio (0 = unlimited expansion)",
225
+ SafeDecompressError, "safe-decompress/bad-arg");
226
+ if (opts.maxRatio !== undefined && opts.maxRatio !== null) {
227
+ maxRatio = opts.maxRatio;
228
+ }
229
+
230
+ var zlibOpts = { maxOutputLength: opts.maxOutputBytes };
231
+ if (typeof opts.windowBits === "number") zlibOpts.windowBits = opts.windowBits;
232
+
233
+ var out;
234
+ try {
235
+ out = _algorithms[opts.algorithm](buf, zlibOpts);
236
+ } catch (e) {
237
+ // zlib refuses bombs by throwing; surface as a typed error and
238
+ // refuse-emit. The original zlib error message is preserved on
239
+ // .cause for operator debugging.
240
+ var err = new SafeDecompressError(
241
+ "safe-decompress/decompress-failed",
242
+ "safeDecompress: decompression refused (" + opts.algorithm + "): " +
243
+ ((e && e.message) || String(e)));
244
+ err.cause = e;
245
+ _refuse(opts, err.code, err.message, err);
246
+ }
247
+
248
+ // Ratio cap — runs AFTER decompression but BEFORE returning. zlib
249
+ // already enforced maxOutputBytes; the ratio cap catches "bomb that
250
+ // fit under the absolute cap but expanded 1000x." We immediately
251
+ // drop the buffer if the ratio is exceeded so the operator-facing
252
+ // path never sees the bomb bytes.
253
+ if (maxRatio > 0) {
254
+ var ratio = Math.ceil(out.length / buf.length);
255
+ if (ratio > maxRatio) {
256
+ // Zero the buffer before drop — defends against side-channel
257
+ // peek + bug-induced leak. zlib already heap-allocated it; we
258
+ // overwrite + release.
259
+ out.fill(0);
260
+ _refuse(opts, "safe-decompress/ratio-exceeded",
261
+ "expansion ratio " + ratio + ":1 exceeds maxRatio " + maxRatio +
262
+ ":1 (compressed=" + buf.length + " decompressed=" + out.length + ")");
263
+ }
264
+ }
265
+
266
+ return out;
267
+ }
268
+
269
+ // Drop-silent audit emission per rule §5: refuse-emit is best-effort,
270
+ // failures here don't crash the operator's path. Then throw the typed
271
+ // error so the caller's catch block decides downstream.
272
+ function _refuse(opts, code, message, originalError) {
273
+ var auditImpl = opts.audit || (audit() && audit().safeEmit ? audit() : null);
274
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
275
+ try {
276
+ auditImpl.safeEmit({
277
+ action: "system.safe_decompress.refused",
278
+ outcome: "denied",
279
+ metadata: {
280
+ code: code,
281
+ algorithm: opts.algorithm,
282
+ ctx: opts.ctx || null,
283
+ reason: message,
284
+ },
285
+ });
286
+ } catch (_e) { /* drop-silent per rule §5 */ }
287
+ }
288
+ var err = new SafeDecompressError(code, message);
289
+ if (originalError) err.cause = originalError;
290
+ throw err;
291
+ }
292
+
293
+ module.exports = {
294
+ safeDecompress: safeDecompress,
295
+ DEFAULT_MAX_RATIO: DEFAULT_MAX_RATIO,
296
+ SafeDecompressError: SafeDecompressError,
297
+ };
package/lib/websocket.js CHANGED
@@ -76,6 +76,7 @@
76
76
 
77
77
  var nodeCrypto = require("node:crypto");
78
78
  var zlib = require("node:zlib");
79
+ var safeDecompress = require("./safe-decompress").safeDecompress;
79
80
  var { EventEmitter } = require("node:events");
80
81
  var C = require("./constants");
81
82
  var requestHelpers = require("./request-helpers");
@@ -603,10 +604,36 @@ function _deflateMessage(payload, windowBits) {
603
604
  return raw;
604
605
  }
605
606
 
606
- function _inflateMessage(payload, windowBits) {
607
+ function _inflateMessage(payload, windowBits, maxOutputBytes) {
607
608
  // Per RFC 7692 §7.2.2, append the 4-byte trailer before inflating.
609
+ // Routes through `b.safeDecompress` so the bounded-output defense
610
+ // is uniform with every other RFC 1951 deflate site in the
611
+ // framework. `maxRatio: 0` (unlimited expansion) because WS
612
+ // per-message-deflate already binds upstream via the operator's
613
+ // `maxMessageBytes` opt; the absolute cap is the real defense.
614
+ // Streaming WS payloads can legitimately compress > 50:1 on
615
+ // repetitive text (logs, sensor data); operators with a
616
+ // tighter posture set their own maxMessageBytes.
608
617
  var withTrailer = Buffer.concat([payload, DEFLATE_TRAILING]);
609
- return zlib.inflateRawSync(withTrailer, { windowBits: windowBits });
618
+ // `maxCompressedBytes` MUST track the operator's `maxMessageBytes`,
619
+ // not safeDecompress's 4 MiB default. WS operators with high-
620
+ // throughput pipelines legitimately set `maxMessageBytes > 4 MiB`
621
+ // (large file pushes, batched JSON, telemetry); a compressed
622
+ // payload up to that cap is legitimate input. The compressed input
623
+ // is bounded above by the same cap the framework enforces on
624
+ // reassembled-message bytes (RFC 6455 §5.4 fragmented messages are
625
+ // concatenated then decompressed; the operator's `maxMessageBytes`
626
+ // is enforced at FrameParser reassembly), so passing it here keeps
627
+ // safeDecompress aligned with the operator's intent rather than
628
+ // overriding it with the primitive's general-purpose default.
629
+ return safeDecompress(withTrailer, {
630
+ algorithm: "deflate-raw",
631
+ maxOutputBytes: maxOutputBytes,
632
+ maxCompressedBytes: maxOutputBytes,
633
+ maxRatio: 0,
634
+ windowBits: windowBits,
635
+ ctx: "websocket._inflateMessage",
636
+ });
610
637
  }
611
638
 
612
639
  // ---- Frame parser ----
@@ -1065,8 +1092,13 @@ class WebSocketConnection extends EventEmitter {
1065
1092
  // CLOSE_INVALID_PAYLOAD per §5.6 / §6 of RFC 6455.
1066
1093
  if (wasCompressed) {
1067
1094
  try {
1068
- data = _inflateMessage(data, this._permessageDeflate.clientMaxWindowBits);
1095
+ data = _inflateMessage(data, this._permessageDeflate.clientMaxWindowBits,
1096
+ this.maxMessageBytes);
1069
1097
  } catch (e) {
1098
+ // RFC 6455 §7.4.1 / §5.6 — protocol-level decode failure
1099
+ // (including bomb-cap overrun via maxOutputLength) returns
1100
+ // CLOSE_INVALID_PAYLOAD. The over-cap case never allocates the
1101
+ // exploded bytes — zlib's maxOutputLength refuses mid-inflate.
1070
1102
  return this._abort(CLOSE_INVALID_PAYLOAD,
1071
1103
  "permessage-deflate inflate failed: " + ((e && e.message) || String(e)));
1072
1104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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.6",
5
- "serialNumber": "urn:uuid:b5450662-adf2-4f43-baef-731a1c66e80f",
5
+ "serialNumber": "urn:uuid:8b0da807-db01-45a8-b301-744f3623a4e7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-19T17:04:46.477Z",
8
+ "timestamp": "2026-05-19T22:45:04.845Z",
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.11.3",
22
+ "bom-ref": "@blamejs/core@0.11.5",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.11.3",
25
+ "version": "0.11.5",
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.11.3",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.5",
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.11.3",
57
+ "ref": "@blamejs/core@0.11.5",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]