@blamejs/core 0.16.11 → 0.16.13

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.16.x
10
10
 
11
+ - v0.16.13 (2026-07-11) — **Make SD-JWT VC verification accept a raw JWK issuer key (its own documented common path), and emit the SMTP command-smuggling audit on a NUL-byte injection — two defects found by covering previously-untested verifier and inbound-server branches.** Covering the uncovered verifier and adversarial-input branches of two more subsystems surfaced two genuine defects, now fixed at the root. b.auth.sdJwtVc.verify rejected a valid credential when the issuerKeyResolver returned a raw JWK object — the very path the code's own comment calls the common one — because the JWK was handed straight to node:crypto.verify, which cannot consume a bare JWK, so verification threw a low-level type error instead of validating; the resolver's JWK is now imported to a key object before verification, matching the holder key-binding path. And the inbound SMTP server refused a command line containing a NUL byte (correct) but never emitted the command-smuggling audit event it emits for bare-CR / bare-LF injection, because it checked for the wrong error code; the audit now fires so a NUL-injection attempt is recorded for forensic triage. A misleading comment in the permissions MFA gate that advertised a non-existent no-freshness-window escape hatch is corrected — the freshness window is always enforced by design. **Fixed:** *b.auth.sdJwtVc.verify accepts a raw JWK from issuerKeyResolver* — When the issuerKeyResolver returned a JWK object — described in the code as the common path — the JWK was passed directly to node:crypto.verify, which requires a key object, so a valid credential failed verification with a raw ERR_INVALID_ARG_TYPE rather than validating. The resolver's JWK is now imported to a public key object (with the existing algorithm/key-type cross-check preserved) before verification, mirroring the holder key-binding JWT path. Verification succeeds for EC and Ed25519 JWK resolvers. · *Corrected a misleading comment in the permissions MFA freshness gate* — A comment in the requireMfa gate described mfaWindowMs: Infinity as an operator escape hatch for a no-freshness-window pass-through. No such escape hatch exists — both the role- and route-level validators reject a non-finite mfaWindowMs, so MFA freshness is always enforced (defaulting to 15 minutes). Enabling it would let a stolen long-lived cookie with a stale mfaAt bypass the gate. The comment now states the freshness window is always enforced; behavior is unchanged. **Security:** *SMTP inbound server records a command-smuggling audit on NUL-byte injection* — The inbound MX server's command handler refused a command line containing a NUL byte with a 500, but the branch meant to emit the mail.server.mx.smtp_smuggling_detected audit checked for the error code guard-smtp-command/nul-byte while the guard actually raises guard-smtp-command/nul. As a result a NUL-injection command was rejected but not recorded, unlike bare-CR / bare-LF smuggling which was audited. The code match is corrected, so a NUL-byte command-smuggling attempt now produces the forensic audit event.
12
+
13
+ - v0.16.12 (2026-07-11) — **Restore the static server's drive-by-execution defense, stop the retention sweep from aborting on subject-scoped rules, make wss:// connections to IP addresses work, and return a verdict instead of throwing on a malformed OpenPGP signature — four defects found by covering previously-untested error and adversarial branches.** Writing behavioral tests for the uncovered error and adversarial branches of the framework's most under-tested files surfaced four genuine defects, now fixed at the root. The static file server's safeAttachmentForRiskyMimes option — the drive-by-execution defense that forces Content-Disposition: attachment for risky inline types (text/html, image/svg+xml, application/javascript) — was silently inert: it was read from a value bag that only carries known-default keys, and this option has no default entry, so it always evaluated to false and the whole defense was dead code. The retention sweep threw and aborted entirely whenever a retention rule with a subjectField met a row with a subject value, because it called a lazily-required module as if it were already resolved. A wss:// connection to an IP-address host threw synchronously and was unusable, because the client set the TLS SNI to the IP literal, which the TLS stack forbids. And b.mail.crypto.pgp.verify threw on a truncated or malformed signature integer instead of returning its documented { ok: false } verdict, so a consumer iterating over untrusted signatures crashed rather than getting a negative result. A static gate now flags any option read from a defaults-applied value bag when that option has no default entry, so the static-server class cannot recur. **Fixed:** *Retention sweep no longer aborts on subject-scoped rules* — A retention rule declared with a subjectField consults the subject-level legal-hold registry when a candidate row carries a subject value. That path called the legal-hold module as though it were already resolved, but it is a lazily-required getter that must be invoked to load the module — so the call threw a TypeError, which the sweep converted into a SWEEP_FAILED and aborted the entire run. The getter is now invoked correctly, so subject-scoped retention sweeps proceed. · *wss:// connections to an IP-address host work* — Connecting the WebSocket client to a wss:// URL whose host is an IP literal threw ERR_INVALID_ARG_VALUE synchronously, because the client set the TLS SNI servername to the IP literal, which RFC 6066 and the Node TLS stack forbid. SNI is now sent only for hostname targets and omitted for IP literals; certificate identity is still verified against the address. **Security:** *Static server's safeAttachmentForRiskyMimes drive-by-execution defense works* — b.staticServe's documented safeAttachmentForRiskyMimes option forces Content-Disposition: attachment for risky inline MIME types (text/html, image/svg+xml, application/javascript) so a browser can't execute an operator-hosted file inline. It was read from the option bag produced by applyDefaults, which strips every key absent from the defaults table — and this option is not a defaults key — so it was always undefined and the defense never engaged. It is now read directly from the caller's options, so enabling it forces the attachment disposition as documented. Operators who set it were relying on a defense that did nothing; it is now active. · *b.mail.crypto.pgp.verify returns a verdict on a malformed signature instead of throwing* — verify() threw MailCryptoError('mail-crypto/pgp/bad-mpi') on a truncated or malformed signature integer, unlike every other malformed-signature path (bad armor, packet parse, hash mismatch), which returns { ok: false, code, reason }. A consumer looping over untrusted signatures and branching on .ok would crash on a crafted input rather than receive a negative verdict. Malformed signature integers now route through the same failure path and return { ok: false }. **Detectors:** *applydefaults-dropped-opt* — A static gate flags any option read from the result of applyDefaults(opts, DEFAULTS) when that option is absent from the DEFAULTS table — the exact shape that silently disabled the static server's attachment defense. The result of applyDefaults carries only the defaults' keys, so such a read is always undefined. The fix is to read the option from the caller's opts directly, or to add it to the defaults table.
14
+
11
15
  - v0.16.11 (2026-07-11) — **Fix a TOML parser bug where every double-quoted key decoded to the empty string — which also let a quoted "__proto__" key slip past the prototype-pollution guard — and return a real 201 from SCIM resource creation, both surfaced by covering previously-untested error and adversarial branches.** Writing behavioral tests for the uncovered error, adversarial, and defensive branches of the framework's most under-tested files surfaced two genuine defects, now fixed at the root. b.parsers.toml decoded every double-quoted (basic) key to the empty string: a document like "host" = "db1" parsed to { "": "db1" }, multiple distinct quoted keys collided onto one empty key, and — the security consequence — a quoted "__proto__" or "constructor" key decoded to the empty string and so slipped past the poisoned-key guard that rejects those names. TOML keys are usually written bare, so the bug was latent since the parser shipped. It is fixed to decode quoted keys correctly, and a quoted "__proto__" / "constructor" is now rejected with toml/poisoned-key. Separately, the SCIM server returned an undefined HTTP status instead of 201 Created when creating a User or Group singleton, because it referenced a status constant that did not exist; it now returns 201 with the created representation. **Fixed:** *b.parsers.toml decodes double-quoted keys correctly* — A basic (double-quoted) key with no escape sequence decoded to the empty string — "host" = "db1" became { "": "db1" }, and multiple distinct quoted keys in one table collided onto a single empty key. The decoder now appends the verbatim key run for both quoted-key kinds, so quoted, literal, dotted, and escaped keys all decode to their intended names. · *SCIM resource creation returns 201 Created* — Creating a User or Group via POST returned an undefined HTTP status because the handler referenced a CREATED status constant that was not defined, so the created representation was written with no valid status code. It now returns 201 with the created resource. **Security:** *b.parsers.toml rejects a quoted __proto__ / constructor key* — The double-quoted-key decoder never appended the key's characters for a basic key, so every double-quoted key resolved to the empty string. A quoted "__proto__" = ... or "constructor" = ... therefore decoded to "", which the poisoned-key guard does not flag, letting those names through. Quoted keys now decode to their real value, and a quoted __proto__ / constructor is rejected with toml/poisoned-key. Bare-key documents were unaffected; only double-quoted keys were mis-decoded.
12
16
 
13
17
  - v0.16.10 (2026-07-11) — **Restore five APIs that silently didn't work — certificate-fingerprint hashing from PEM, the tus-upload close handler, the mail-delivery factory, the log-stream local-sink env boot, and the CIBA internal-IdP opt — and make single-base guard profile composition work, all surfaced by writing a test for every documented primitive.** Writing a behavioral test for every documented primitive that previously had none surfaced a batch of APIs that were advertised but silently non-working, now fixed at the root. b.crypto.hashCertFingerprint and b.crypto.isCertRevoked threw a TypeError on the PEM-string input their own examples pass (a helper was called on its lazy-require getter instead of the module), so only the raw-DER path worked; b.middleware.tusUpload.close and b.mail.send.deliver.create were documented but never attached to their export, so calling the documented form threw 'not a function'; b.logStream.bootFromEnv advertised a 'local' protocol that threw at init because it wired the sink with the wrong option; and the CIBA client dropped the allowInternal opt on its own backchannel and token POSTs, so an operator who explicitly opted into an internal or loopback identity provider was still SSRF-blocked. Separately, gate-contract profile composition now honors its documented extends: string | string[] contract — a single base profile name passed as a bare string was silently ignored, which made the buildProfile example every guard shares a no-op. The EU AI Act fundamental-rights-impact-assessment and GPAI training-data-summary builders now raise typed ComplianceError refusals with descriptive messages instead of a bare Error whose message was only the error code. Five documentation examples are corrected to their real emitted values (the SRI integrity attribute, two guard rule-ids, a guard issue kind, and the guard-archive profile-composition call). The static gate that requires every documented primitive to be referenced by a test now has an empty backlog register: every documented primitive is exercised. **Added:** *A test for every documented primitive* — The primitive-without-test static gate's backlog register, which listed documented primitives that no test referenced, is now empty: every documented primitive is exercised by a behavioral test through its real consumer path. A new primitive must land with its own test reference; the register accepts an entry only with a specific documented reason coverage is indirect. **Changed:** *Guard profile composition accepts a single base profile as a bare string* — gate-contract profile composition documents extends: string | string[], but a bare-string extends was silently ignored — only the array form composed — which made the buildProfile example every guard shares (buildProfile({ extends: "strict" })) a no-op that returned an empty caps object. A string extends is now normalized to a one-element list, so single-base composition works across every b.guard*.buildProfile. The array form is unchanged. **Fixed:** *b.crypto.hashCertFingerprint / isCertRevoked accept PEM input again* — Both threw 'TypeError: safeBuffer.byteLengthOf is not a function' on a PEM-string certificate — the internal PEM-to-DER step called the byte-length helper on its lazy-require getter rather than the resolved module, so only the pre-parsed DER/Buffer path (which returns before that line) worked. Their documented examples pass a PEM string, so the advertised behavior was silently broken. Fixed at the call site. · *b.middleware.tusUpload.close is reachable* — The middleware export wired tusUpload.create and .memoryStore but never attached the documented .close handler, so b.middleware.tusUpload.close(middleware) threw 'not a function'. It is now attached to the export. · *b.mail.send.deliver.create is reachable* — b.mail.send.deliver was bound directly to the factory function but never carried the documented .create property, so the documented b.mail.send.deliver.create(opts) form resolved to undefined. The .create factory is now attached; the existing collapsed b.mail.send.deliver(opts) callable is unchanged. · *b.logStream.bootFromEnv local protocol works* — BLAMEJS_LOG_STREAM_PROTOCOL=local threw 'log-stream local requires { dir }' at init: bootFromEnv wired the local sink with a path option, but the directory-based local sink requires dir. BLAMEJS_LOG_STREAM_PATH is now mapped to the sink directory, so the advertised local protocol boots and writes end-to-end. · *CIBA client honors allowInternal on its own requests* — The backchannel-authentication and token POSTs of the CIBA client dropped the allowInternal option that create() accepts and threads to the OAuth client for discovery/JWKS, so an operator who explicitly set allowInternal:true for an internal or loopback identity provider was still refused with ssrf-guard/blocked-loopback on exactly those two endpoints. The option is now threaded through per request. The SSRF guard stays on by default; nothing changes unless the operator opts in. · *EU AI Act FRIA / training-data-summary raise typed refusals with real messages* — b.compliance.aiAct.fundamentalRightsImpactAssessment and b.compliance.aiAct.gpai.trainingDataSummary validated required fields by passing the built-in Error as the error class, so the descriptive message was dropped (its slot became the Error options bag) and the thrown message was only the error code. Both now raise a typed ComplianceError with the descriptive message and a stable code, matching the sibling adherenceForm builder. · *Five documentation examples corrected to real values* — The b.crypto.sri example carried a fabricated SHA-384 value (an operator copy-pasting the integrity attribute would get a mismatch); it now shows the correct digest. The b.guardTime.validate example named rule-id time.year-out-of-range (actual time.year-window); b.guardUuid named uuid.nil / uuid.max (actual uuid.nil-uuid / uuid.max-uuid); b.htmlBalance.checkSafe named issue kind event-handler-attribute (actual event-handler). The b.guardArchive.buildProfile example passed { profile: "strict" }, which resolves no caps; it now uses the correct { extends: "strict" } form.
@@ -510,6 +510,12 @@ async function verify(presentation, opts) {
510
510
  !Buffer.isBuffer(issuerKey) &&
511
511
  typeof issuerKey.kty === "string") {
512
512
  jwtExternal._assertAlgKtyMatch(alg, issuerKey);
513
+ // node:crypto.verify cannot consume a bare JWK object — import it to
514
+ // a KeyObject first, mirroring the holder KB-JWT path below
515
+ // (bCrypto.importPublicJwk). Without this a resolver that returns a
516
+ // JWK (the common path per the CVE-2026-22817 note above) rejects a
517
+ // VALID credential with ERR_INVALID_ARG_TYPE instead of verifying.
518
+ issuerKey = bCrypto.importPublicJwk(issuerKey);
513
519
  }
514
520
  var jwtParsed = _verifyJwt(jwt, issuerKey, alg);
515
521
  // Post-verify header compare. Pre-verify we parsed the
@@ -866,7 +866,13 @@ function verify(opts) {
866
866
 
867
867
  var ok;
868
868
  if (parsed.pubAlg === PUB_ALG_RSA) {
869
- var rsaMpi = _readMpi(parsed.sigMpisBytes, 0);
869
+ // A malformed / truncated signature MPI is untrusted input like any
870
+ // other packet field: return a verdict, don't throw. Every other
871
+ // bad-signature path here routes through _fail; the raw _readMpi
872
+ // reads did not, so a truncated MPI escaped as an exception.
873
+ var rsaMpi;
874
+ try { rsaMpi = _readMpi(parsed.sigMpisBytes, 0); }
875
+ catch (mpiErr) { return _fail(mpiErr.code || "mail-crypto/pgp/bad-mpi", mpiErr.message); }
870
876
  // The signature is an integer in [0, n); when its value has one or more
871
877
  // high zero bytes (~1/256 of signatures) the OpenPGP MPI encoding strips
872
878
  // them (RFC 9580 §3.2), but node's RSA verify requires a signature exactly
@@ -889,9 +895,13 @@ function verify(opts) {
889
895
  }
890
896
  } else {
891
897
  // Ed25519Legacy — two MPIs (R, S) reassemble into the 64-byte raw
892
- // EdDSA signature.
893
- var rMpi = _readMpi(parsed.sigMpisBytes, 0);
894
- var sMpi = _readMpi(parsed.sigMpisBytes, rMpi.next);
898
+ // EdDSA signature. Truncated/malformed MPIs are untrusted input:
899
+ // return a verdict via _fail rather than throwing out of verify().
900
+ var rMpi, sMpi;
901
+ try {
902
+ rMpi = _readMpi(parsed.sigMpisBytes, 0);
903
+ sMpi = _readMpi(parsed.sigMpisBytes, rMpi.next);
904
+ } catch (mpiErrE) { return _fail(mpiErrE.code || "mail-crypto/pgp/bad-mpi", mpiErrE.message); }
895
905
  function _padTo32(buf) {
896
906
  if (buf.length === 32) return buf;
897
907
  if (buf.length > 32) return buf.slice(buf.length - 32);
@@ -658,7 +658,7 @@ function create(opts) {
658
658
  } catch (err) {
659
659
  if (err.code === "guard-smtp-command/bare-lf" ||
660
660
  err.code === "guard-smtp-command/bare-cr" ||
661
- err.code === "guard-smtp-command/nul-byte") {
661
+ err.code === "guard-smtp-command/nul") {
662
662
  _emit("mail.server.mx.smtp_smuggling_detected",
663
663
  { connectionId: state.id, code: err.code, line: line.slice(0, 200) }, // audit-log line truncation
664
664
  "denied");
@@ -572,9 +572,12 @@ function create(opts) {
572
572
  // Window floor — when neither route nor role supplies an
573
573
  // explicit mfaWindowMs, default to 15 minutes. Without this
574
574
  // floor, a stolen long-lived cookie carrying an old `mfaAt`
575
- // walks past every requireMfa: true gate. Operators who want
576
- // an explicit no-window pass-through must say so via
577
- // mfaWindowMs: Infinity (audited reason).
575
+ // walks past every requireMfa: true gate. There is deliberately
576
+ // NO no-window opt-out: the role- and route-level validators
577
+ // reject a non-finite mfaWindowMs, so freshness is always
578
+ // enforced. (The `!== Infinity` guard below is a defensive
579
+ // backstop for a hypothetically-injected value, not a supported
580
+ // config path.)
578
581
  if (enforceWindowMs === null) {
579
582
  enforceWindowMs = C.TIME.minutes(15);
580
583
  }
package/lib/retention.js CHANGED
@@ -492,7 +492,7 @@ function create(opts) {
492
492
  // tables), the central registry is authoritative. Honors
493
493
  // the same skip semantics as the per-row field.
494
494
  if (rule.subjectField && row[rule.subjectField]) {
495
- var holdsRegistry = legalHold._getSingleton();
495
+ var holdsRegistry = legalHold()._getSingleton();
496
496
  if (holdsRegistry && holdsRegistry.isHeld(row[rule.subjectField])) {
497
497
  summary.legalHoldsHonored++;
498
498
  _emit("retention.row.legal_hold_skipped",
package/lib/static.js CHANGED
@@ -845,7 +845,11 @@ function create(opts) {
845
845
  var auditSuccess = cfg.auditSuccess;
846
846
  var auditFailures = cfg.auditFailures;
847
847
  var acceptRanges = cfg.acceptRanges;
848
- var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
848
+ // Read straight from opts: safeAttachmentForRiskyMimes is not a DEFAULTS
849
+ // key, so applyDefaults(opts, DEFAULTS) never carries it into cfg — reading
850
+ // it off cfg silently dropped the opt and left the drive-by-execution
851
+ // defense (Content-Disposition: attachment for risky inline MIMEs) inert.
852
+ var safeAttachment = !!opts.safeAttachmentForRiskyMimes;
849
853
  // forceAttachmentForNonText default follows mountType (v0.15.0): a
850
854
  // mount TYPED "user-content" forces risky inline MIMEs to download by
851
855
  // default (stored-XSS defense for untrusted uploads); a "curated" mount
package/lib/ws-client.js CHANGED
@@ -406,10 +406,24 @@ class WsClient extends EventEmitter {
406
406
  var tlsOpts = Object.assign({
407
407
  host: host,
408
408
  port: port,
409
- servername: host,
410
409
  rejectUnauthorized: true,
411
410
  minVersion: "TLSv1.3",
412
411
  }, dialTlsOpts || {});
412
+ // RFC 6066 §3: the TLS SNI ServerName MUST be a hostname, never an IP
413
+ // literal — Node throws ERR_INVALID_ARG_VALUE when servername is an IP,
414
+ // which made wss:// to any IP-literal target unusable. Send SNI only for
415
+ // hostname targets; for an IP-literal host omit it (cert identity is still
416
+ // verified against the address' iPAddress SAN by the default
417
+ // checkServerIdentity). An operator servername in tlsOpts still wins.
418
+ // Decide via ssrfGuard.canonicalizeHost so an IPv6 literal is recognized:
419
+ // parsed.hostname keeps the brackets (`[::1]`), and net.isIP only accepts
420
+ // the bare form — canonicalizeHost strips the brackets (and normalizes the
421
+ // address) so the IP test is correct. Without it an IPv6 literal would set
422
+ // servername=`[::1]`, a malformed SNI a strict server rejects. Hostnames
423
+ // canonicalize to a non-IP form, so they still get their SNI.
424
+ if (!tlsOpts.servername && !net.isIP(ssrfGuard.canonicalizeHost(host))) {
425
+ tlsOpts.servername = host;
426
+ }
413
427
  if (lookup) tlsOpts.lookup = lookup;
414
428
  try {
415
429
  var pqcShares = networkTls().pqc.getKeyShares();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.11",
3
+ "version": "0.16.13",
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:97a20bb6-c995-41f3-9276-1b4e0fb8e9d2",
5
+ "serialNumber": "urn:uuid:b90a5fae-dd42-4003-9780-e63a6ebf948e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-11T06:28:18.910Z",
8
+ "timestamp": "2026-07-11T13:08:36.250Z",
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.16.11",
22
+ "bom-ref": "@blamejs/core@0.16.13",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.11",
25
+ "version": "0.16.13",
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.16.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.13",
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.16.11",
57
+ "ref": "@blamejs/core@0.16.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]