@blamejs/core 0.17.7 → 0.17.9

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.9 (2026-07-17) — **A prototype-member regime name no longer voids a breach-notification clock into reporting a missed deadline as met, a planted plaintext file can no longer stand in for the sealed keyed-hash MAC key, and content-safety sanitization recomputes the integrity descriptor over the bytes actually delivered.** The incident-report deadline clock resolved its per-regime statutory table by a plain-object lookup with no own-property check, so a regime name equal to an inherited object member (valueOf, toString, constructor) resolved to a prototype function instead of the default deadlines -- every due-by became NaN, and a notification filed long past a statutory wall was recorded on-time, reporting a missed regulatory deadline as met. The retention posture-floor lookup had the same defect. The vault's keyed-hash MAC key reader trusted the vault's documented pass-through of a non-sealed value, so a plaintext file substituted on disk was accepted as the secret MAC key without the vault passphrase. And the static file server and the upload finalizer both replaced the served/stored payload when a content-safety gate sanitized it but kept advertising the ORIGINAL bytes' ETag/SRI (static) and sha3/size descriptor (upload), so a client validating the integrity of the delivered body -- or an operator storing the reported hash as the dedup/integrity key -- got a digest that did not match what was actually delivered. **Security:** *Breach-notification and retention lookups reject a prototype-member name* — b.incident.report's deadline clock resolves the statutory deadline set for an incident's regime (GDPR 72h, NIS2 24h, DORA 4h initial, HIPAA 60 days, ...) by indexing a per-regime table with the operator-supplied regime string. The lookup used a truthiness check with no own-property guard, so a regime equal to an inherited object member -- valueOf, toString, constructor, hasOwnProperty -- resolved to the prototype function instead of falling back to the default deadlines. Every computed due-by then became NaN, and because a late comparison against NaN is never true, a notification filed long past the real statutory wall was recorded on-time (not late) and the clock's late count stayed zero -- a genuinely missed regulatory deadline reported to a regulator as met. b.retention's compliance-posture floor lookup (hipaa, pci-dss, gdpr, soc2) had the identical defect, resolving a prototype-member posture to an inherited function as the retention floor. Both now resolve the name through an own-property check, so a prototype-member name falls back to the default deadlines (incident-report) or is rejected as an unknown posture (retention); real regimes and postures are unaffected. · *Vault refuses a non-sealed file as the keyed-hash MAC key* — b.vault derives a keyed-hash MAC key (used by field-level encryption's keyed derived-hash mode to keep an attacker with disk access from correlating low-entropy plaintexts) and seals it at rest. The reader loaded the sealed file and called the vault's unseal, which by its documented idempotent-read contract returns any value lacking the vault seal prefix verbatim -- so a plaintext 32-byte value planted on disk passed straight through and was accepted as the MAC key. In wrapped-vault mode this let an attacker with disk-write access but no vault passphrase (and therefore unable to forge a genuine seal) inject a known MAC key, downgrading the keyed derived-hash to an attacker-known key. The reader now requires the on-disk value to actually be sealed (carry the vault prefix) before unsealing, refusing a substituted plaintext or otherwise-unsealed file -- the same load-bearing prefix check the database key loader and the key-rotation pipeline already apply to their sealed key material. · *Content-safety sanitization recomputes the served body's integrity descriptor* — When a content-safety gate returns a sanitize action it replaces the payload with the cleaned bytes. b.staticServe still emitted the strong ETag and subresource-integrity (SRI) header derived from the original file on disk, and b.fileUpload's finalizer still handed onFinalize (and recorded in the audit) the sha3 and size of the original assembled bytes -- so the integrity descriptor advertised for the delivered/stored bytes did not match them. A browser performing SRI verification of the sanitized static response would fail the check, a strong-validator or If-None-Match cache would be keyed to a representation the client never receives, and an operator storing the upload's reported sha3 as the integrity or dedup key of the stored (sanitized) file would record a hash that never matches it. Both now recompute the ETag/SRI (static) and the sha3/size descriptor (upload) over the bytes actually delivered whenever a gate sanitized the payload; an unsanitized response keeps the on-disk/reassembly digest, which already describes the delivered bytes.
12
+
13
+ - v0.17.8 (2026-07-17) — **The AI-Act transparency HTML emitters now escape every interpolated value, closing a reflected-XSS through a banner's language attribute and a script-context breakout through the JSON-LD disclosure.** b.compliance.aiAct.transparency.htmlBanner rendered its lang value -- a free-form string that is typically a request locale, Accept-Language, or query parameter when the banner is server-rendered -- into a double-quoted HTML attribute by raw concatenation, so a lang containing a double quote broke out of the attribute and injected active content (reflected XSS). Only the element text was escaped before; the attribute values were not. And b.compliance.aiAct.transparency.jsonLdDisclosure embedded the watermark manifest (operator-supplied strings such as the model id and deployer name) inside a <script type="application/ld+json"> element with raw JSON.stringify, which does not escape </script>; the HTML parser ends a script element at the first </script> regardless of its type, so a manifest value carrying that sequence terminated the block early and injected markup. Both emitters now escape at the sink -- every attribute value through the HTML-entity escaper and the JSON-LD payload through the script-safe serializer -- so no interpolated value can break out of its context. **Security:** *AI-Act transparency banner escapes its attribute values* — b.compliance.aiAct.transparency.htmlBanner builds a status banner whose lang attribute carries a free-form language value -- in a server-rendered banner that value commonly comes from the request (a locale, an Accept-Language header, or a query parameter). The banner escaped its visible text but concatenated the lang, article, and kind values into their double-quoted HTML attributes raw, so a lang value containing a double quote closed the attribute early and let the remainder inject an element or event handler into the page (reflected cross-site scripting, CWE-79). Every attribute value is now passed through the HTML-entity escaper before interpolation, matching the escaping the banner text already had, so a hostile value is rendered as inert text inside the attribute rather than breaking out of it; the escape-at-the-sink handling also covers the article and kind values even though their range is currently constrained. · *AI-Act JSON-LD disclosure cannot break out of its script element* — b.compliance.aiAct.transparency.jsonLdDisclosure emits the watermark manifest as JSON-LD inside a <script type="application/ld+json"> element, serializing operator-supplied manifest strings (the model id, deployer name, prompt hash, and similar). It used raw JSON.stringify, which does not escape the sequence </script>; because an HTML parser terminates a script element at the first </script> regardless of the element's type, a manifest value containing </script> (or an HTML comment opener) ended the disclosure block early and injected arbitrary markup that the browser then parsed and could execute. The disclosure now serializes the manifest with the framework's script-safe serializer, which escapes <, >, & and the U+2028/U+2029 separators to their \uXXXX form so the parsed JSON is unchanged but no substring can break out of the script context.
14
+
11
15
  - v0.17.7 (2026-07-17) — **A histogram exemplar's label name can no longer forge a line into the metrics scrape, the CSP report endpoint bounds how many reports one request can carry, and a cron field of the form N/step now fires on the whole repeating series instead of once.** A histogram exemplar's label VALUES were scrubbed before rendering but its label NAMES were written verbatim into the OpenMetrics exposition, and a Prometheus label name -- unlike a value -- cannot be quoted or escaped, so a name containing a newline forged an entire metric line into every /metrics scrape (the label-name sibling of the exemplar value injection already closed). The CSP report endpoint processed an unbounded number of reports per request: a single unauthenticated POST within the body-size cap could pack well over a thousand tiny reports, each driving a full audit-chain append and report hook, an amplification vector now bounded by a per-request report cap. And the shared cron parser mis-read a field of the form N/step -- e.g. 5/15 -- as the single value N instead of the standard N, N+step, ... through the field maximum, so a job scheduled that way fired once per period instead of on the intended repeating series; a recurring queue job also silently dropped its configured retry limit when it re-enqueued the next occurrence. **Fixed:** *A cron field of the form N/step fires on the full repeating series* — The shared cron parser read a field of the form N/step -- a bare number followed by a step, such as 5/15 in the minute field -- as the single value N, dropping the step entirely, instead of the standard Vixie-cron meaning N, N+step, ... up to the field maximum (5/15 in minutes is 5, 20, 35, 50, the same way */15 is 0, 15, 30, 45). A schedule written that way therefore fired once per period instead of on the intended repeating series -- and a job that runs less often than intended (a rotation, a cleanup, a scan) is a silent operational-safety gap. The parser now anchors the range at the field maximum whenever a step is present, so N/step expands to the full series; a bare number with no step is still the single value N. The cron-recurring queue backends (local and Redis) parse through this shared code and inherit the fix. · *A recurring queue job keeps its configured retry limit across occurrences* — A cron-recurring queue job re-enqueues itself for its next firing time and carried its priority, classification, and trace id forward -- but silently dropped the operator's configured maxAttempts, so every occurrence after the first reverted to the enqueue default retry budget rather than the one the operator set. Both queue backends now carry maxAttempts forward (guarded to a positive finite value, falling back to the enqueue default only when it is unset), so a recurring job's retry limit is stable across every occurrence. **Security:** *Metrics exemplar label names cannot inject a line into the scrape* — b.metrics histogram exemplars carry their own label set, stored through the same redaction step the regular labels use -- but that step scrubbed only the label VALUES (for credential shapes) and passed every label NAME through verbatim to the shared exposition renderer. A regular label name is validated against the Prometheus name grammar at registration and refused if undeclared, but the exemplar path had no equivalent gate, and because a label name cannot be quoted or escaped in the OpenMetrics wire format, an exemplar label name containing a newline (or a quote or brace) forged a complete, attacker-shaped metric line into every /metrics scrape -- reachable wherever an operator routes request-derived data into an exemplar label name (CWE-93). The exemplar redaction step now drops any label name that is not a valid Prometheus label name (length-bounded so a hostile oversized name cannot itself become a denial of service), matching the gate regular labels already get; valid names such as trace_id are unaffected. This closes the label-name sibling of the exemplar value injection fixed earlier. · *CSP report endpoint bounds the number of reports per request* — b.middleware.cspReport accepts a batch of reports in one POST (the Reporting API delivers them batched). The body-size cap bounded the request bytes but not the number of reports inside it, so a single unauthenticated request could carry well over a thousand small report objects, and the handler drove a full audit-chain append (a hash, a seal, and a serialized database insert) plus an operator report hook for every one of them -- a per-request amplification denial-of-service against an endpoint that is public by design. The endpoint now caps the batch length at a configurable maxReports (default 100, generous for a real browser batch): an over-cap batch is refused with 413 and the documented too-many-reports rejection reason, processing none of its reports, so the amplification is bounded while a normal browser report is unaffected.
12
16
 
13
17
  - v0.17.6 (2026-07-17) — **PDF disarm now refuses JavaScript, launch actions, and polyglots even when an operator opt says allow, a renewed cluster lease no longer stretches its own expiry into the future, and a credential-issuance proof is refused when its replay nonce is absent.** b.guardPdf.sanitize is documented to strip a PDF down to inert content and to refuse -- under every profile -- the JavaScript, launch-action, and polyglot classes it cannot safely neutralize. But its forced-reject override pinned only the exfiltration and encryption policies, omitting the JavaScript, launch-action, and polyglot policies; those default to reject, so the gap was invisible until an operator passed an explicit permissive opt, which then let sanitize hand back a live PDF still carrying JavaScript or a launch action. b.clusterProviderDb.renewLease computed a lease's time-to-live as the span between its acquire time and its expiry, but on renewal advanced only the expiry and left the acquire time frozen, so each renewal re-derived an ever-larger TTL and pushed the expiry unboundedly into the future -- a dead leader's lease then never lapsed and no follower could take over. And b.auth.oid4vci's issuer skipped the credential-proof replay/holder-binding nonce check when the expected nonce was null, the miss sentinel that a Redis-, Map-, or SQL-backed nonce store commonly returns, so a forged proof could mint a credential bound to an attacker-chosen key. **Security:** *PDF disarm refuses active content even against a permissive operator opt* — b.guardPdf.sanitize is the disarm-by-refusal primitive: it guarantees it never returns a PDF that still carries JavaScript, a launch/open action, an embedded file, or encryption, and that the JavaScript, launch-action, and polyglot classes are refused under every profile. To hold that guarantee regardless of the operator's configuration, sanitize builds a forced override that pins the relevant policies to reject -- but the override pinned only the embedded-file, open-action, magic, and encryption policies and omitted the JavaScript, launch-action, and polyglot policies. Because those three already default to reject in every shipped profile, the omission was invisible in normal use; an operator who passed an explicit permissive opt (javascriptPolicy, launchActionPolicy, or polyglotPolicy set to allow or audit) turned that opt back on inside sanitize and received a live PDF still carrying JavaScript or a launch action -- exactly the remote-code-execution and polyglot classes the primitive promises to refuse. The forced override now pins all three, so sanitize refuses them unconditionally; the overridable validate and gate entry points, which document these policies as operator-tunable, are unchanged. · *Cluster lease renewal keeps a bounded expiry so a dead leader can be taken over* — b.clusterProviderDb models a leader lease as a sliding window whose span -- expiry minus acquire time -- equals the configured lease TTL. renewLease recovered the TTL from that span but then advanced only the expiry while leaving the acquire time frozen at the original acquisition, so the next renewal measured a span that had grown by one renewal interval and re-derived an ever-larger TTL, pushing the expiry unboundedly into the future. A leader that renewed even a few times and then died left a lease whose expiry was far beyond the configured TTL, so it never lapsed within the takeover window and no follower could steal it -- the cluster could stall with no active leader. renewLease now slides both ends of the window forward on every renewal (acquire time and expiry both move to now and now-plus-TTL), keeping the span at the configured TTL so a lapsed leader's lease expires on schedule and bounded takeover works. · *Credential-issuance proof is refused when its replay nonce is absent* — b.auth.oid4vci's issuer verifies the wallet's key-binding proof against the c_nonce it minted with the access token -- the challenge that binds the proof to this issuance and prevents replay. The verifier treated three states of the expected nonce differently: a string was compared, an undefined value (the miss sentinel b.cache returns) was refused, and a null value was treated as no check required. But a nonce store fronting Redis, a Map, or a SQL row -- all accepted through the documented store option -- commonly signals a miss with null, which fell straight through the comparison and disabled the replay and holder-binding defense entirely: after the short-lived c_nonce expired (while the access token was still valid, in batch issuance), an attacker holding that token could submit a proof signed by an arbitrary key with any nonce and have a credential minted bound to that attacker-controlled key. The verifier now requires the expected nonce to be a non-empty string and fails closed on any other value, so an absent or expired nonce is refused regardless of the store's miss sentinel.
@@ -42,6 +42,7 @@
42
42
 
43
43
  var validateOpts = require("./validate-opts");
44
44
  var markupEscape = require("./markup-escape").markupEscape;
45
+ var safeJson = require("./safe-json");
45
46
  var { ComplianceError } = require("./framework-error");
46
47
 
47
48
  var BANNER_KINDS = Object.freeze([
@@ -112,10 +113,17 @@ function _articleFor(kind) {
112
113
 
113
114
  function htmlBanner(opts) {
114
115
  var b = banner(opts);
116
+ // Every interpolated value is entity-escaped for the double-quoted attribute
117
+ // context. `lang` is free-form (a request locale / Accept-Language / query
118
+ // param when the banner is server-rendered), so an unescaped double quote
119
+ // would break out of the attribute and inject active content (reflected
120
+ // XSS). `article` / `kind` are constrained today, but escape-at-the-sink is
121
+ // the invariant — a raw concat is the defect regardless of the current
122
+ // value domain.
115
123
  var attrs =
116
- 'role="status" data-blamejs-aiAct="' + b.article +
117
- '" data-blamejs-kind="' + b.kind +
118
- '" lang="' + b.lang + '"';
124
+ 'role="status" data-blamejs-aiAct="' + _escapeHtml(b.article) +
125
+ '" data-blamejs-kind="' + _escapeHtml(b.kind) +
126
+ '" lang="' + _escapeHtml(b.lang) + '"';
119
127
  return '<div ' + attrs + '>' + _escapeHtml(b.text) + '</div>';
120
128
  }
121
129
 
@@ -159,9 +167,16 @@ function watermark(opts) {
159
167
 
160
168
  function jsonLdDisclosure(opts) {
161
169
  var w = watermark(opts);
170
+ // The manifest carries operator-supplied strings (modelId, deployerName,
171
+ // promptHash, ...). Embedding raw JSON.stringify inside a <script> element
172
+ // lets a value containing "</script>" (or "<!--") terminate the block and
173
+ // inject markup — the HTML parser ends the element at the first "</script>"
174
+ // regardless of the ld+json type. b.safeJson.stringifyForScript escapes
175
+ // < > & (and U+2028/U+2029) to \uXXXX so the parsed JSON is unchanged but no
176
+ // substring can break out of the script context.
162
177
  var script = '<script type="application/ld+json" ' +
163
178
  'data-blamejs-aiAct="Art. 50(2)">' +
164
- JSON.stringify(w) + '</script>';
179
+ safeJson.stringifyForScript(w) + '</script>';
165
180
  return script;
166
181
  }
167
182
 
@@ -1191,7 +1191,11 @@ function create(opts) {
1191
1191
  }
1192
1192
  }
1193
1193
  // Run one gate; throws on refuse / gate-error, mutates bodyBuffer on
1194
- // sanitize so a later gate sees the cleaned bytes.
1194
+ // sanitize so a later gate sees the cleaned bytes. When a gate replaces
1195
+ // the payload, the integrity descriptor (sha3/size) handed to onFinalize
1196
+ // and recorded in the audit must be recomputed over the DELIVERED bytes,
1197
+ // not the pre-sanitize original — flagged here, recomputed after the loop.
1198
+ var contentSanitized = false;
1195
1199
  var _runContentSafetyGate = async function (gate, gateExt) {
1196
1200
  var safetyDecision;
1197
1201
  try {
@@ -1229,6 +1233,7 @@ function create(opts) {
1229
1233
  if (safetyDecision.action === "sanitize" && safetyDecision.sanitized) {
1230
1234
  bodyBuffer = safetyDecision.sanitized;
1231
1235
  bodyStream = null;
1236
+ contentSanitized = true;
1232
1237
  }
1233
1238
  };
1234
1239
  var ranAnyGate = false;
@@ -1271,6 +1276,19 @@ function create(opts) {
1271
1276
  verified.totalBytes);
1272
1277
  }
1273
1278
 
1279
+ // A content-safety gate that returned "sanitize" replaced the payload, so
1280
+ // the integrity descriptor handed to onFinalize + recorded in the audit
1281
+ // must describe the bytes actually DELIVERED — an operator storing
1282
+ // info.sha3 as the integrity / dedup key of the stored (sanitized) bytes
1283
+ // would otherwise get a hash that does not match what they store. When no
1284
+ // sanitize ran, the reassembly digest already describes the delivered bytes.
1285
+ var deliveredSha3 = verified.totalHashHex;
1286
+ var deliveredSize = verified.totalBytes;
1287
+ if (contentSanitized && bodyBuffer) {
1288
+ deliveredSha3 = require("node:crypto").createHash("sha3-512").update(bodyBuffer).digest("hex");
1289
+ deliveredSize = bodyBuffer.length;
1290
+ }
1291
+
1274
1292
  // Hand to operator's onFinalize.
1275
1293
  var rv;
1276
1294
  try {
@@ -1279,13 +1297,13 @@ function create(opts) {
1279
1297
  uploadId: uploadId,
1280
1298
  body: bodyBuffer,
1281
1299
  stream: bodyStream,
1282
- sha3: verified.totalHashHex,
1283
- size: verified.totalBytes,
1300
+ sha3: deliveredSha3,
1301
+ size: deliveredSize,
1284
1302
  actor: actor,
1285
1303
  metadata: meta.metadata,
1286
1304
  });
1287
1305
  } else {
1288
- rv = { ok: true, sha3: verified.totalHashHex, size: verified.totalBytes };
1306
+ rv = { ok: true, sha3: deliveredSha3, size: deliveredSize };
1289
1307
  }
1290
1308
  } catch (e) {
1291
1309
  _emitObs("fileUpload.finalize_failure", 1);
@@ -1294,7 +1312,7 @@ function create(opts) {
1294
1312
  resource: { kind: "fileUpload", id: uploadId },
1295
1313
  outcome: "failure",
1296
1314
  reason: "onfinalize-threw",
1297
- metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
1315
+ metadata: { size: deliveredSize, sha3: deliveredSha3,
1298
1316
  error: (e && e.message) || String(e) },
1299
1317
  });
1300
1318
  throw e;
@@ -1305,12 +1323,12 @@ function create(opts) {
1305
1323
  catch (_e) { /* best-effort */ }
1306
1324
 
1307
1325
  _emitObs("fileUpload.finalize_success", 1);
1308
- _emitObs("fileUpload.finalize_bytes", verified.totalBytes);
1326
+ _emitObs("fileUpload.finalize_bytes", deliveredSize);
1309
1327
  _emitAudit("fileUpload.finalize", {
1310
1328
  actor: requestHelpers.extractActorContext(actor),
1311
1329
  resource: { kind: "fileUpload", id: uploadId },
1312
1330
  outcome: "success",
1313
- metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
1331
+ metadata: { size: deliveredSize, sha3: deliveredSha3,
1314
1332
  mode: useStream ? "stream" : "buffer" },
1315
1333
  });
1316
1334
 
@@ -128,7 +128,14 @@ var REGIME_DEADLINES = Object.freeze({
128
128
  });
129
129
 
130
130
  function _resolveDeadlines(regime, override) {
131
- var base = (regime && REGIME_DEADLINES[regime]) || DEFAULT_DEADLINES;
131
+ // Own-key lookup only: `regime` is operator-supplied and free-form, so a
132
+ // value colliding with an Object.prototype member ("valueOf", "toString",
133
+ // "constructor", ...) must fall back to DEFAULT_DEADLINES, not resolve to the
134
+ // inherited prototype function (which would make every dueBy NaN and report a
135
+ // missed deadline as met). Unknown-but-benign regimes fall back the same way.
136
+ var base = (typeof regime === "string" &&
137
+ Object.prototype.hasOwnProperty.call(REGIME_DEADLINES, regime))
138
+ ? REGIME_DEADLINES[regime] : DEFAULT_DEADLINES;
132
139
  if (!override || typeof override !== "object") return base;
133
140
  return Object.freeze({
134
141
  initial: typeof override.initial === "number" ? override.initial : base.initial,
package/lib/retention.js CHANGED
@@ -641,7 +641,12 @@ function complianceFloor(posture, candidateTtlMs) {
641
641
  "complianceFloor: posture must be a string (pass one, or set the active " +
642
642
  "posture via applyPosture / b.compliance.set), got " + JSON.stringify(posture));
643
643
  }
644
- var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
644
+ // Own-key lookup only: `posture` is operator-supplied, so a value colliding
645
+ // with an Object.prototype member ("valueOf" / "toString" / "constructor" /
646
+ // "hasOwnProperty" / ...) must be treated as unknown and throw below — not
647
+ // resolve to the inherited prototype function and be returned as a "floor".
648
+ var floor = Object.prototype.hasOwnProperty.call(COMPLIANCE_RETENTION_FLOOR_MS, posture)
649
+ ? COMPLIANCE_RETENTION_FLOOR_MS[posture] : undefined;
645
650
  if (floor === undefined) {
646
651
  throw new RetentionError("retention/unknown-posture",
647
652
  "complianceFloor: unknown posture '" + posture + "'; expected one of " +
@@ -691,7 +696,10 @@ function applyPosture(posture) {
691
696
  STATE.activeFloorMs = null;
692
697
  return null;
693
698
  }
694
- var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
699
+ // Own-key lookup only — a proto-member posture name must not inherit a
700
+ // prototype function as the floor (see complianceFloor above).
701
+ var floor = Object.prototype.hasOwnProperty.call(COMPLIANCE_RETENTION_FLOOR_MS, posture)
702
+ ? COMPLIANCE_RETENTION_FLOOR_MS[posture] : undefined;
695
703
  STATE.activePosture = posture;
696
704
  STATE.activeFloorMs = (typeof floor === "number") ? floor : null;
697
705
  return { posture: posture, floorMs: STATE.activeFloorMs };
package/lib/static.js CHANGED
@@ -274,6 +274,24 @@ async function _readMeta(root, candidate) {
274
274
  return entry;
275
275
  }
276
276
 
277
+ // _integrityHeadersForBytes — recompute the strong ETag + SRI integrity for
278
+ // an in-memory buffer, mirroring the digest shape `_readMeta` derives from a
279
+ // file on disk (SHA3-512-truncated ETag for the PQC posture, SHA-384 SRI for
280
+ // the W3C subresource-integrity spec). Used on the content-safety SANITIZE
281
+ // path: the meta cache holds digests of the on-disk ORIGINAL, but the gate
282
+ // replaces the served bytes, so the validators the response advertises must be
283
+ // computed over the bytes actually delivered — otherwise SRI verification of
284
+ // the served body fails and a strong-validator / If-None-Match cache is keyed
285
+ // to a representation the client never receives.
286
+ function _integrityHeadersForBytes(buf) {
287
+ var sha3Hex = nodeCrypto.createHash("sha3-512").update(buf).digest("hex");
288
+ var sriDigest = nodeCrypto.createHash("sha384").update(buf).digest("base64");
289
+ return {
290
+ etag: '"' + sha3Hex.slice(0, ETAG_HEX_PREFIX) + '"',
291
+ integrity: "sha384-" + sriDigest,
292
+ };
293
+ }
294
+
277
295
  function _resolveSafe(root, requestedPath) {
278
296
  if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
279
297
  if (requestedPath.indexOf("\0") !== -1) return null;
@@ -1184,15 +1202,26 @@ function create(opts) {
1184
1202
 
1185
1203
  var cacheControl = _cacheControlFor(urlPath);
1186
1204
 
1205
+ // The validators the response advertises must describe the bytes ACTUALLY
1206
+ // delivered. On the content-safety SANITIZE path the gate replaced the
1207
+ // bytes, so the strong ETag + SRI X-Integrity are recomputed over the
1208
+ // override buffer rather than reused from the meta cache (which hashed the
1209
+ // on-disk original). The conditional-request checks below compare against
1210
+ // this same effective ETag so a strong-validator / If-None-Match cache
1211
+ // revalidates against the representation it was actually served.
1212
+ var served = gateBytesOverride
1213
+ ? _integrityHeadersForBytes(gateBytesOverride)
1214
+ : { etag: meta.etag, integrity: meta.integrity };
1215
+
1187
1216
  var headersIn = req.headers || {};
1188
1217
 
1189
1218
  // Conditional: If-None-Match (304)
1190
1219
  var ifNone = headersIn["if-none-match"];
1191
- if (ifNone && ifNone === meta.etag) {
1220
+ if (ifNone && ifNone === served.etag) {
1192
1221
  stats.etagHits += 1;
1193
1222
  _emitObs("staticServe.etag_hits", 1, { route: urlPath });
1194
1223
  res.writeHead(HTTP.NOT_MODIFIED, {
1195
- "ETag": meta.etag,
1224
+ "ETag": served.etag,
1196
1225
  "Cache-Control": cacheControl,
1197
1226
  "Last-Modified": meta.lastModified,
1198
1227
  });
@@ -1201,7 +1230,7 @@ function create(opts) {
1201
1230
 
1202
1231
  // Conditional: If-Match (412 if no match — strong validator only)
1203
1232
  var ifMatch = headersIn["if-match"];
1204
- if (ifMatch && ifMatch !== "*" && ifMatch !== meta.etag) {
1233
+ if (ifMatch && ifMatch !== "*" && ifMatch !== served.etag) {
1205
1234
  stats.failures += 1;
1206
1235
  _emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-match" });
1207
1236
  return writeErr(res, HTTP.PRECONDITION_FAILED || 412,
@@ -1219,7 +1248,7 @@ function create(opts) {
1219
1248
  stats.etagHits += 1;
1220
1249
  _emitObs("staticServe.if_modified_since_hits", 1, { route: urlPath });
1221
1250
  res.writeHead(HTTP.NOT_MODIFIED, {
1222
- "ETag": meta.etag,
1251
+ "ETag": served.etag,
1223
1252
  "Cache-Control": cacheControl,
1224
1253
  "Last-Modified": meta.lastModified,
1225
1254
  });
@@ -1309,10 +1338,10 @@ function create(opts) {
1309
1338
  var headers = {
1310
1339
  "Content-Type": _contentTypeFor(absPath, contentTypes),
1311
1340
  "Content-Length": sendBytes,
1312
- "ETag": meta.etag,
1341
+ "ETag": served.etag,
1313
1342
  "Cache-Control": cacheControl,
1314
1343
  "Last-Modified": meta.lastModified,
1315
- "X-Integrity": meta.integrity,
1344
+ "X-Integrity": served.integrity,
1316
1345
  };
1317
1346
  // Drive-by-execution defense — when safeAttachmentForRiskyMimes is
1318
1347
  // on, force Content-Disposition: attachment for HTML / JS / SVG /
@@ -1362,6 +1391,15 @@ function create(opts) {
1362
1391
  }
1363
1392
 
1364
1393
  if (req.method === "HEAD") {
1394
+ // A sanitized resource is delivered as the override buffer on GET, so a
1395
+ // HEAD must advertise that same length (and drop Content-Range) — the
1396
+ // meta-cache size describes the on-disk original the client never gets.
1397
+ var headSize = gateBytesOverride ? gateBytesOverride.length : meta.size;
1398
+ if (gateBytesOverride) {
1399
+ headers = Object.assign({}, headers, { "Content-Length": headSize });
1400
+ delete headers["Content-Range"];
1401
+ status = HTTP.OK;
1402
+ }
1365
1403
  res.writeHead(status, headers);
1366
1404
  res.end();
1367
1405
  stats.requestsServed += 1;
@@ -1369,7 +1407,7 @@ function create(opts) {
1369
1407
  if (auditSuccess) {
1370
1408
  emitAudit("staticServe.serve.success", Object.assign({
1371
1409
  outcome: "success", resource: urlPath, method: "HEAD",
1372
- size: meta.size, contentType: headers["Content-Type"],
1410
+ size: headSize, contentType: headers["Content-Type"],
1373
1411
  }, actorCtx));
1374
1412
  }
1375
1413
  return;
@@ -193,6 +193,19 @@ function _readOrCreateDerivedHashMacKey() {
193
193
  }
194
194
  if (nodeFs.existsSync(paths.derivedHashMacKey)) {
195
195
  var sealed = atomicFile.readSync(paths.derivedHashMacKey, { encoding: "utf8" }).trim();
196
+ // A genuine MAC key file is a vault-sealed value (seal() always emits
197
+ // the VAULT_PREFIX). unseal() returns a NON-prefixed value VERBATIM
198
+ // (idempotent read passthrough), so without this load-bearing prefix
199
+ // check a plaintext (or otherwise-unsealed) file substituted on disk
200
+ // would be accepted as the secret MAC key — defeating the sealed-at-
201
+ // rest guarantee a disk-write-only attacker (no vault passphrase, so
202
+ // unable to forge a genuine seal) otherwise cannot bypass (CWE-345).
203
+ // Same load-bearing prefix check db.js applies to db.key.enc.
204
+ if (sealed.indexOf(VAULT_PREFIX) !== 0) {
205
+ throw new VaultError("vault/derived-hash-mac-key-corrupted",
206
+ "vault.derived-hash-mac.sealed is not a sealed value (missing '" +
207
+ VAULT_PREFIX + "' prefix) — refusing to accept it as the MAC key");
208
+ }
196
209
  var b64 = unseal(sealed);
197
210
  var key = Buffer.from(b64, "base64");
198
211
  if (key.length !== 32) { // 32-byte (256-bit) MAC key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
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:f2295071-6b95-4865-918b-7e76ba9682ea",
5
+ "serialNumber": "urn:uuid:ef03d8a8-9729-453c-8187-e08e9d67e6c6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T18:09:14.590Z",
8
+ "timestamp": "2026-07-17T20:32:37.579Z",
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.7",
22
+ "bom-ref": "@blamejs/core@0.17.9",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.17.7",
25
+ "version": "0.17.9",
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.7",
29
+ "purl": "pkg:npm/%40blamejs/core@0.17.9",
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.7",
57
+ "ref": "@blamejs/core@0.17.9",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]