@blamejs/core 0.17.8 → 0.17.10

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.10 (2026-07-17) — **A CIDR-scoped SMTP relay allowlist is now actually enforced instead of relaying for the whole internet, EU AI-Act social scoring is flagged prohibited for any actor rather than only public authorities, and the X-Wing hybrid KEM stays uniform implicit-rejection on a low-order X25519 point.** The mail server's relay authorization discarded its arguments and returned allowed for every connecting peer whenever an operator configured any relayAllowedFor entry, so a per-CIDR relay allowlist -- intended to scope relay to registered sources -- turned the server into an open relay for the entire internet. The EU AI-Act prohibited-practice classifier gated social scoring on the deployer being a public authority, a limitation that was in the 2021 Commission proposal but dropped from the adopted Regulation (EU) 2024/1689, so a private-actor social-scoring system was under-classified as not-prohibited. The GDPR record-of-processing updater validated a legal-basis change with a truthiness guard, so a falsy-but-invalid value skipped the enum check and could corrupt a required Article 30 field. And the X-Wing hybrid post-quantum KEM threw a raw derivation error on a low-order X25519 point instead of the uniform implicit-rejection its contract and the draft specification require, exposing a decapsulation-oracle distinguisher and a crash for any consumer following the documented no-try/catch advice. **Security:** *SMTP relay allowlist enforces its per-entry CIDR instead of relaying for everyone* — b.mail.server.mx accepts a relayAllowedFor allowlist of { cidr, scope } entries, documented as scoping relay to registered source ranges over the default MX-only, no-relay posture. The relay-authorization check discarded both the peer address and recipient and simply returned allowed whenever the allowlist was non-empty, so an operator restricting relay to, for example, 10.0.0.0/8 actually granted relay to every peer on the internet -- an open relay, directly contradicting the module's advertised open-relay defense. The check now admits relay only when the connecting peer's source address falls inside one of the allowlisted ranges (using the same range arithmetic the HTTP network-allowlist fence uses); a peer outside every range is refused. Each relayAllowedFor entry's cidr is now validated at startup -- a mask is required so a bare IP cannot silently disable an entry, private and reserved ranges are allowed since relay allowlists legitimately name them, and a malformed entry throws at boot rather than mis-scoping relay. · *AI-Act classifier flags social scoring by any actor as prohibited* — b.compliance.aiAct's prohibited-practice classifier implements EU AI-Act Article 5. Its social-scoring gate (Art. 5(1)(c)) required both the purpose to be social scoring and the deployer to be a public authority before flagging the practice as prohibited. The "by public authorities or on their behalf" limitation existed in the 2021 Commission proposal but was removed from the adopted Regulation (EU) 2024/1689, which prohibits social scoring by any actor. A private-actor social-scoring system was therefore under-classified as not-prohibited. The classifier now flags social scoring on the purpose alone, matching the adopted text; the other Article 5 gates (predictive policing based solely on profiling, untargeted facial-recognition scraping, emotion inference in the workplace/education outside medical/safety uses) correctly retain the conjuncts that are part of their statutory definitions rather than over-broad limitations. · *GDPR record-of-processing rejects a falsy-but-invalid legal basis on update* — b.gdpr.ropa records the Article 30 register of processing activities, whose legalBasis field is constrained to the six Article 6(1) bases. register validates the basis against the allowlist, but update validated a legal-basis change with a truthiness guard, so a falsy-but-invalid value (an empty string, and similar) short-circuited past the enum check and was written into the record -- silently corrupting a required Article 30 field. The updater now validates whenever the patch touches legalBasis, keyed on the field being present rather than truthy, so an invalid value is rejected with the same rigor register applies and a genuine change to a valid basis still succeeds. · *X-Wing hybrid KEM stays uniform implicit-rejection on a low-order X25519 point* — b.crypto.xwing implements the X-Wing hybrid post-quantum KEM (ML-KEM-768 combined with X25519), whose contract and the draft-connolly-cfrg-xwing-kem specification require decapsulation to be uniform implicit-rejection: a tampered ciphertext yields a different secret, never an error, so a consumer must not branch on success. But a hostile low-order X25519 point in the ciphertext (or in the recipient's X25519 public half) drove the X25519 derivation to an all-zero shared secret, which the underlying library aborts with a raw derivation error rather than returning the value RFC 7748 X25519 (without the optional abort) yields. That exposed a decapsulation-oracle distinguisher (a low-order point throws while every other ciphertext returns a secret) and a crash for any caller following the documented advice not to wrap decapsulation in a try/catch. The shared X25519 step now translates that abort back into the all-zero shared secret, restoring uniform implicit-rejection; the combiner still binds the X25519 ciphertext and public key and the ML-KEM-768 leg still protects the result, so the hybrid's secure-if-either-holds guarantee is unchanged.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
@@ -51,7 +51,7 @@ var PROHIBITED_PRACTICES = Object.freeze([
51
51
  Object.freeze({
52
52
  id: "social-scoring",
53
53
  article: "Art. 5(1)(c)",
54
- title: "Social scoring by public authorities",
54
+ title: "Social scoring of natural persons",
55
55
  description: "AI systems for the evaluation or classification of natural persons over a certain period of time based on their social behaviour or known, inferred or predicted personal or personality characteristics, leading to detrimental or unfavourable treatment that is unjustified or disproportionate.",
56
56
  examples: Object.freeze([
57
57
  "General-purpose social-credit ranking by a state agency",
@@ -151,9 +151,13 @@ function classify(systemDescription) {
151
151
  if (systemDescription.targetsVulnerableGroup === true) {
152
152
  hits.push("exploit-vulnerabilities");
153
153
  }
154
- // (c) social scoring
155
- if (systemDescription.purpose === "social-scoring" &&
156
- systemDescription.deployerType === "public-authority") {
154
+ // (c) social scoring — Art. 5(1)(c) of the adopted Regulation (EU)
155
+ // 2024/1689 prohibits social scoring by ANY actor. The "by public
156
+ // authorities or on their behalf" limitation from the 2021 Commission
157
+ // proposal was dropped in the final text, so the practice is flagged on the
158
+ // purpose alone, not gated on deployerType (which would under-classify a
159
+ // private-actor social-scoring system as minimal-risk).
160
+ if (systemDescription.purpose === "social-scoring") {
157
161
  hits.push("social-scoring");
158
162
  }
159
163
  // (d) predictive policing on profiling alone
@@ -69,11 +69,29 @@ function _x25519Public(sk) {
69
69
  var spki = nodeCrypto.createPublicKey(key).export({ format: "der", type: "spki" });
70
70
  return spki.subarray(spki.length - X25519_LEN);
71
71
  }
72
+ // draft-connolly-cfrg-xwing-kem uses X25519 exactly as RFC 7748 specifies it,
73
+ // WITHOUT the optional RFC 7748 section 6.1 abort on an all-zero shared secret.
74
+ // Decapsulation must be uniform implicit-rejection: a hostile low-order X25519
75
+ // ephemeral in the ciphertext must NOT be able to signal validity by making
76
+ // decapsulate throw (the documented "yields a different secret rather than an
77
+ // error" contract, and a defense against a decapsulation oracle / crash-on-
78
+ // crafted-input DoS). OpenSSL (the X25519 backing this seam) DOES abort that
79
+ // derivation, so translate its error back into the all-zero shared secret RFC
80
+ // 7748 X25519 yields for a low-order input point. The combiner still binds ctX
81
+ // and pkX and the ML-KEM-768 leg still protects the result, so collapsing the
82
+ // classical leg costs only the classical half of the "secure if either holds"
83
+ // guarantee. A short/malformed key fails earlier at createPublicKey with a
84
+ // different code and still propagates.
72
85
  function _x25519Shared(sk, pk) {
73
- return nodeCrypto.diffieHellman({
74
- privateKey: nodeCrypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, sk]), format: "der", type: "pkcs8" }),
75
- publicKey: nodeCrypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, pk]), format: "der", type: "spki" }),
76
- });
86
+ try {
87
+ return nodeCrypto.diffieHellman({
88
+ privateKey: nodeCrypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, sk]), format: "der", type: "pkcs8" }),
89
+ publicKey: nodeCrypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, pk]), format: "der", type: "spki" }),
90
+ });
91
+ } catch (e) {
92
+ if (e && e.code === "ERR_OSSL_FAILED_DURING_DERIVATION") return Buffer.alloc(X25519_LEN);
93
+ throw e;
94
+ }
77
95
  }
78
96
 
79
97
  function _shake256(buf, outLen) { return nodeCrypto.createHash("shake256", { outputLength: outLen }).update(buf).digest(); }
@@ -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
 
package/lib/gdpr-ropa.js CHANGED
@@ -129,7 +129,14 @@ function create(opts) {
129
129
  registeredAt: existing.registeredAt,
130
130
  lastUpdatedAt: now(),
131
131
  });
132
- if (patch.legalBasis && !Object.prototype.hasOwnProperty.call(VALID_LEGAL_BASES, merged.legalBasis)) {
132
+ // Validate whenever the patch TOUCHES legalBasis keyed on own-property
133
+ // presence, not truthiness. A falsy-but-invalid value ("" / 0 / false /
134
+ // undefined) is still an invalid legal basis and must be rejected with the
135
+ // same rigor register() applies; a `patch.legalBasis &&` guard would skip
136
+ // the enum check for those values and silently corrupt a required
137
+ // Article 30 field.
138
+ if (Object.prototype.hasOwnProperty.call(patch, "legalBasis") &&
139
+ !Object.prototype.hasOwnProperty.call(VALID_LEGAL_BASES, merged.legalBasis)) {
133
140
  throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
134
141
  "gdpr.ropa.update: legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
135
142
  }
@@ -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,
@@ -150,6 +150,8 @@ var safeSmtp = require("./safe-smtp");
150
150
  var validateOpts = require("./validate-opts");
151
151
  var guardSmtpCommand = require("./guard-smtp-command");
152
152
  var guardDomain = require("./guard-domain");
153
+ var guardCidr = require("./guard-cidr");
154
+ var ssrfGuard = require("./ssrf-guard");
153
155
  var mailServerRateLimit = require("./mail-server-rate-limit");
154
156
  var mailServerTls = require("./mail-server-tls");
155
157
  var mailServerNet = require("./mail-server-net");
@@ -196,6 +198,42 @@ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
196
198
  var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
197
199
  var RE_SIZE = /SIZE=(\d+)/i;
198
200
 
201
+ // A relayAllowedFor entry's `cidr` must be an `<ip>/<prefix>` range so the
202
+ // relay-authorization decision can match the connecting peer against it via
203
+ // b.ssrfGuard.cidrContains (the same range arithmetic the HTTP
204
+ // b.middleware.networkAllowlist fence uses). Shape-validated by composing
205
+ // b.guardCidr.validate rather than a hand-rolled parse: a mask is REQUIRED
206
+ // (a bare IP never matches in cidrContains, so it is refused at boot rather
207
+ // than silently disabling the entry), reserved / private ranges are ALLOWED
208
+ // (a relay allowlist legitimately names 10.0.0.0/8 and friends), and a
209
+ // non-canonical-but-functional network address (host bits set) is audited,
210
+ // not rejected — cidrContains masks it off at match time.
211
+ var _RELAY_CIDR_OPTS = Object.freeze({
212
+ requireMaskPolicy: "reject-bare-ip",
213
+ reservedRangesPolicy: "allow",
214
+ ipv4MappedIpv6Policy: "allow",
215
+ networkAlignmentPolicy: "audit",
216
+ family: "either",
217
+ });
218
+
219
+ // _normalizeRelayCidr — fold a DOTTED IPv4-mapped IPv6 relay CIDR
220
+ // (::ffff:a.b.c.d/N, N in 96..128) to its plain IPv4 CIDR (a.b.c.d/(N-96)).
221
+ // guardCidr.validate parses hex-group IPv6 but not the dotted-mapped spelling,
222
+ // so an operator naming a mapped range that way would be refused at boot even
223
+ // though cidrContains accepts it. Folding to plain IPv4 both validates AND
224
+ // makes the entry match every peer form: a genuine IPv4 peer directly, and an
225
+ // IPv4-mapped peer via the _isRelayAllowed fold. Storing the mapped CIDR as-is
226
+ // would instead match a mapped peer but NOT a genuine IPv4 peer (the inverse
227
+ // asymmetry). Every other spelling (plain IPv4, hex-group IPv6) is unchanged.
228
+ function _normalizeRelayCidr(cidr) {
229
+ if (typeof cidr !== "string") return cidr;
230
+ var m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,3})$/i.exec(cidr);
231
+ if (!m) return cidr;
232
+ var prefix = parseInt(m[2], 10);
233
+ if (prefix < 96 || prefix > 128) return cidr; // outside the ::ffff:0:0/96 block — a genuine IPv6 range
234
+ return m[1] + "/" + (prefix - 96);
235
+ }
236
+
199
237
  // Map the b.mail.inbound.verify verdict to the DATA-phase gate action.
200
238
  // The sender's published DMARC policy drives it (RFC 7489 §6.3 p= /
201
239
  // §6.6.2 disposition): reject → refuse at the wire; quarantine →
@@ -319,6 +357,25 @@ function create(opts) {
319
357
  throw new MailServerMxError("mail-server-mx/bad-opts",
320
358
  "mail.server.mx.create: relayAllowedFor must be an array if provided");
321
359
  }
360
+ // Every relay-allowlist entry MUST carry a valid `<ip>/<prefix>` CIDR:
361
+ // relay is granted only to a peer whose address falls inside an allowlisted
362
+ // range. Refuse a malformed / mask-less entry at boot so an operator typo
363
+ // can't silently leave the relay decision mis-scoped (open relay is the
364
+ // failure this closes — pre-fix any non-empty relayAllowedFor admitted
365
+ // every peer regardless of source address).
366
+ if (Array.isArray(opts.relayAllowedFor)) {
367
+ for (var __ri = 0; __ri < opts.relayAllowedFor.length; __ri += 1) {
368
+ var __re = opts.relayAllowedFor[__ri];
369
+ var __reOk = __re && typeof __re === "object" && !Array.isArray(__re) &&
370
+ guardCidr.validate(_normalizeRelayCidr(__re.cidr), _RELAY_CIDR_OPTS).ok;
371
+ if (!__reOk) {
372
+ throw new MailServerMxError("mail-server-mx/bad-relay-cidr",
373
+ "mail.server.mx.create: relayAllowedFor[" + __ri + "] must be an object with a " +
374
+ "valid CIDR string (e.g. { cidr: \"10.0.0.0/8\", scope: \"internal\" }); relay is " +
375
+ "granted only to peers whose source address falls inside an allowlisted range");
376
+ }
377
+ }
378
+ }
322
379
 
323
380
  var greeting = opts.greeting || DEFAULT_GREETING;
324
381
  var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
@@ -326,7 +383,11 @@ function create(opts) {
326
383
  var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
327
384
  var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
328
385
  var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
329
- var relayAllowedFor = opts.relayAllowedFor || [];
386
+ var relayAllowedFor = (opts.relayAllowedFor || []).map(function (__e) {
387
+ return (__e && typeof __e === "object" && !Array.isArray(__e))
388
+ ? Object.assign({}, __e, { cidr: _normalizeRelayCidr(__e.cidr) })
389
+ : __e;
390
+ });
330
391
  var profile = opts.profile || "strict";
331
392
  // SMTPUTF8 (RFC 6531) — single switch threaded end-to-end. The MX
332
393
  // listener doesn't advertise SMTPUTF8 to the peer regardless, so
@@ -1195,12 +1256,37 @@ function create(opts) {
1195
1256
  return profile === "strict" || profile === "balanced";
1196
1257
  }
1197
1258
 
1198
- function _isRelayAllowed(_remoteAddress, _rcptTo) {
1199
- // Operator-supplied relayAllowedFor entries. v1 just checks
1200
- // presence in the array; CIDR/scope matching could be wired
1201
- // via b.middleware.networkAllowlist in a follow-up.
1259
+ function _isRelayAllowed(remoteAddress, _rcptTo) {
1260
+ // Relay is admitted ONLY when the connecting peer's source address
1261
+ // falls inside one of the operator's allowlisted CIDR ranges the
1262
+ // same range arithmetic (b.ssrfGuard.cidrContains) the HTTP
1263
+ // b.middleware.networkAllowlist fence uses. Every entry's `cidr` was
1264
+ // shape-validated at create() time; a peer outside every range (or a
1265
+ // non-string / empty peer address) is refused, so a misconfigured
1266
+ // relayAllowedFor fails closed instead of turning the listener into an
1267
+ // open relay. `scope` is an operator-facing annotation on the entry;
1268
+ // the network boundary is the authorization control.
1202
1269
  if (relayAllowedFor.length === 0) return false;
1203
- return true;
1270
+ if (typeof remoteAddress !== "string" || remoteAddress.length === 0) return false;
1271
+ // Node reports an IPv4 client as an IPv4-mapped IPv6 address
1272
+ // (::ffff:a.b.c.d) when the listener binds the IPv6 wildcard `::` (the
1273
+ // common dual-stack deployment). cidrContains refuses a mixed-family
1274
+ // compare, so a documented IPv4 relay CIDR (10.0.0.0/8) would deny every
1275
+ // intended IPv4 client on that listener. Fold the mapped form to its
1276
+ // IPv4 dotted address (ssrfGuard.canonicalizeHost, which folds only the
1277
+ // ::ffff:0:0/96 block) and match EITHER the peer as reported OR the
1278
+ // folded form — so an IPv4 CIDR matches a mapped peer and an IPv6 CIDR
1279
+ // still matches a genuine IPv6 peer.
1280
+ var canonPeer;
1281
+ try { canonPeer = ssrfGuard.canonicalizeHost(remoteAddress); }
1282
+ catch (_e) { canonPeer = remoteAddress; }
1283
+ for (var i = 0; i < relayAllowedFor.length; i += 1) {
1284
+ var entry = relayAllowedFor[i];
1285
+ if (!entry || typeof entry !== "object") continue;
1286
+ if (ssrfGuard.cidrContains(entry.cidr, remoteAddress)) return true;
1287
+ if (canonPeer !== remoteAddress && ssrfGuard.cidrContains(entry.cidr, canonPeer)) return true;
1288
+ }
1289
+ return false;
1204
1290
  }
1205
1291
  }
1206
1292
 
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.8",
3
+ "version": "0.17.10",
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:628c839a-3725-4405-b9d5-cd446477675a",
5
+ "serialNumber": "urn:uuid:f1f0285b-f0c7-4750-93f1-1e0098be1f94",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T19:11:48.442Z",
8
+ "timestamp": "2026-07-18T00:02:49.577Z",
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.8",
22
+ "bom-ref": "@blamejs/core@0.17.10",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.17.8",
25
+ "version": "0.17.10",
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.8",
29
+ "purl": "pkg:npm/%40blamejs/core@0.17.10",
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.8",
57
+ "ref": "@blamejs/core@0.17.10",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]