@blamejs/core 0.15.51 → 0.15.53

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.15.x
10
10
 
11
+ - v0.15.53 (2026-06-28) — **DKIM (and ARC) verification refuses a body-length-limited (`l=`) signature once content has been appended past the signed octets, closing the append-after-signature attack.** A DKIM signature with an `l=` body-length tag covers only the first `l=` octets of the canonicalized body, so anyone in the delivery path can append arbitrary unsigned content after the signed prefix and the body hash still matches. b.mail.dkim.verify honored such a signature and returned `pass`, with only a non-load-bearing warning that no consumer read — so b.mail.inbound.verify granted an aligned DMARC `pass` to a message whose delivered body diverged from the signed bytes (RFC 6376 §8.2). Verification now refuses an `l=` signature once the delivered body extends beyond the signed octets: the verified prefix no longer authenticates the appended content, so the result is a body-hash `fail` rather than a `pass`. Signatures whose `l=` exactly covers the body (no appended content) are unchanged, and an operator who must accept legacy `l=` senders can opt back in with verify({ acceptBodyLengthLimit: true }). ARC-Message-Signature verification, which reuses the same verifier, inherits the refusal unconditionally. **Security:** *DKIM verify refuses an l= signature with content appended past the signed octets* — The DKIM `l=` tag (RFC 6376 §3.5) limits the body hash to the first `l=` canonicalized octets, leaving any trailing body unsigned — the documented append-after-signature exposure (RFC 6376 §8.2). b.mail.dkim.verify hashed the prefix, matched `bh=`, and returned `pass` for the whole message, even though the recipient received appended content the signer never authenticated; the only signal was a warning string that b.mail.inbound.verify / dmarc.evaluate never inspected, so a forged trailer rode an aligned DMARC `pass`. verify now returns a body-hash `fail` when an `l=` signature leaves delivered body content beyond the signed octets (the framework already refuses `l=` at sign time). A signature whose `l=` covers the entire body still verifies; verify({ acceptBodyLengthLimit: true }) restores the prior accept-with-warning behavior for operators with legacy `l=` senders. · *ARC-Message-Signature verification inherits the same refusal* — ARC chain validation verifies each hop's ARC-Message-Signature through the same DKIM verifier, so an AMS carrying `l=` with appended content is now refused as a body-hash `fail` — failing the chain — rather than validating over a prefix. The ARC path does not expose the acceptBodyLengthLimit opt-out, so it is unconditionally fail-closed for an appended-content `l=`.
12
+
13
+ - v0.15.52 (2026-06-28) — **An email address carrying more than one `@` is now refused instead of having its domain read from the wrong segment, closing a DMARC/SPF alignment bypass and an outbound mis-delivery path.** An RFC 5322 addr-spec has exactly one `@`, but blamejs derived the domain from a multi-@ address inconsistently across sites. For an inbound From like `user@attacker.example@victim.example`, the From-header parser took the RIGHTMOST `@` segment (victim.example) to gate DMARC and set the displayed author, while the DMARC and SPF evaluators re-derived the domain from the LEFTMOST segment (attacker.example) via split("@")[1]. So SPF/DMARC could authenticate a domain the attacker controls while the message displays the victim's domain — and the victim's `_dmarc` policy was never consulted, so a strict `p=reject` author domain could be impersonated. b.mail now refuses any From or MAIL FROM addr-spec with more than one `@` at every domain-derivation site (inbound From extraction, dmarc.evaluate, and spf.verify), and outbound delivery refuses a multi-@ recipient as a permanent bad-address rather than routing to the leftmost segment's MX. **Security:** *A multi-@ From address can no longer split DMARC/SPF alignment from the displayed author* — The inbound From-header parser derived the author domain from the rightmost `@` of a bare addr-spec, while dmarc.evaluate and spf.verify re-derived it from the leftmost `@` (split("@")[1]). A crafted From or MAIL FROM such as `user@attacker.example@victim.example` therefore authenticated attacker.example (which the attacker controls, with a permissive SPF/DMARC posture) while the displayed author was victim.example, whose `_dmarc` record was never queried — a DMARC alignment bypass (CWE-290) against any domain that publishes p=reject. An addr-spec has exactly one `@` (RFC 5322 §3.4.1); a From or MAIL FROM with more than one `@` is now treated as malformed at every derivation site (inbound From extraction yields no author domain and fails closed to reject; dmarc.evaluate and spf.verify refuse it), so the authenticated domain and the displayed domain can no longer diverge. · *Outbound delivery refuses a multi-@ recipient instead of routing to the wrong host* — Outbound SMTP delivery derived the recipient domain with split("@")[1], so a multi-@ recipient like `victim@internal.host@external.com` would have its MX looked up for the leftmost segment (internal.host) and the message routed there — a mis-delivery / exfiltration path when recipients are influenced by untrusted input. A recipient with more than one `@` is now refused as a permanent bad-address before any MX lookup. **Detectors:** *Leftmost-@ email-domain derivation must reject a multi-@ address* — A codebase-patterns detector flags any `str.split("@")[1]` email-domain derivation that is not preceded, within its function, by a single-@ rejection (`str.indexOf("@") !== str.lastIndexOf("@")`). This keeps the leftmost-vs-rightmost `@` divergence from being reintroduced at a new derivation site; a purely informational, non-routing use is marked inline.
14
+
11
15
  - v0.15.51 (2026-06-29) — **`b.guardOauth` and `b.session.verify` now fail closed when a backing store errors, instead of silently accepting a request whose security check could not be completed.** Two verifiers swallowed an error from a backing store and continued as if the check had passed. b.guardOauth's authorization-code replay defense wrapped the operator's seenCodeStore.hasSeen() call in a silent catch, so a store backend outage skipped the replay check entirely and a replayed authorization code was accepted — even though codeReusePolicy is reject at every profile. A store error now adds a high-severity oauth.code-reuse-unverifiable refusal, so the flow is denied (fail-closed) when reuse cannot be ruled out. b.session.verify enforces a device-fingerprint binding stored in the session's sealed data column; when that column could not be decrypted (key-rotation skew, database corruption, or a tamper of the independently-sealed cell) the failure was swallowed and the entire fingerprint gate — including requireFingerprintMatch and maxAnomalyScore — was skipped, so a strict-mode session was accepted from any device. An unreadable binding under a strict policy is now treated as a failure to prove the binding and the session is refused. **Security:** *OAuth authorization-code replay check fails closed on a store error* — b.guardOauth's code-reuse defense calls the operator-supplied seenCodeStore.hasSeen(code) to refuse a replayed authorization code (RFC 6749 §10.5). The call was wrapped in a drop-silent catch, so when the store backend errored (e.g. a Redis/DB outage) the exception was swallowed, no replay issue was raised, and the flow validated — accepting a code that could not be proven unused, despite codeReusePolicy being reject at every profile. A store error now raises a high-severity oauth.code-reuse-unverifiable issue, so the gate refuses the flow when reuse cannot be ruled out. · *Session verify fails closed when the device-fingerprint binding can't be decrypted* — b.session.verify stores the device-fingerprint binding inside the session's AEAD-sealed data column. When that column failed to decrypt or parse (key-rotation skew, database corruption, or a tamper of the separately-sealed cell), the failure was swallowed and the fingerprint gate was skipped entirely — so a session under a strict binding policy (requireFingerprintMatch:true or a maxAnomalyScore threshold) was accepted from any device, silently voiding the advertised drift-kills-the-session guarantee. A present-but-undecryptable binding column under a strict policy is now treated as a failure to prove the binding and the session is refused; sessions without a binding, and the default non-strict mode, are unchanged.
12
16
 
13
17
  - v0.15.50 (2026-06-28) — **`b.mail.bimi` closes a VMC certificate authorization bypass, and the host/origin comparisons in `b.ssrfGuard`, `b.middleware.csrfProtect`, and `b.mail.dmarc` now canonicalize both sides so case, trailing-dot, and IDN differences cannot decide a security check.** Four security and correctness decisions compared a host, origin, or domain where one side was normalized and the other was not, so two values that denote the same host in different encodings reached different verdicts. The most serious was in b.mail.bimi.fetchAndVerifyMark: when a VMC/CMC certificate's URI Subject Alternative Name could not be parsed as a URL (for example, a host carrying userinfo), the matcher fell back to a raw substring search of the whole SAN string — so a CA-chained certificate whose real host was attacker-controlled but whose SAN contained the victim domain anywhere (in the userinfo or path) was accepted to vouch for that victim domain. The fallback is removed (an unparseable URI SAN now fails closed) and both the certificate host and the BIMI domain are canonicalized before comparison. b.ssrfGuard allow/deny lists compared the operator's entries verbatim against the URL parser's already-lowercased host, so a mixed-case or trailing-dot deny entry silently failed to block its host; both sides now canonicalize through canonicalizeHost. b.middleware.csrfProtect canonicalized the candidate Origin via the URL parser but built the same-origin baseline by raw concatenation of the Host header, refusing a legitimate same-origin request whose Host was mixed-case or carried an explicit default port; the baseline and allowedOrigins now go through the same canonicalizer. b.mail.dmarc strict alignment compared the From and SPF/DKIM authentication domains with only case-folding, failing an aligned message whose authentication domain carried a trailing dot or an IDN label; both are now canonicalized the same way the relaxed path already was. A new b.publicSuffix.canonicalDomain primitive provides the shared encoding-stable host form. **Added:** *b.publicSuffix.canonicalDomain — encoding-stable host form* — Returns the bare canonical host form of a domain (lowercase, single trailing dot stripped, IDN labels as their A-label/punycode form) for identity comparison, without walking the public-suffix list. Two values that denote the same host in different encodings return the same string; an invalid or hostile host returns the empty string and matches nothing. It is the shared building block for the DMARC-alignment and certificate SAN authorization comparisons above. **Fixed:** *CSRF Origin check no longer refuses a legitimate same-origin request* — b.middleware.csrfProtect canonicalized the incoming Origin/Referer through the URL parser but built the same-origin baseline by concatenating the raw Host header, and compared allowedOrigins verbatim. A legitimate same-origin POST whose Host header was mixed-case or carried an explicit default port (:80/:443) was refused as cross-origin. The baseline and each allowedOrigins entry now pass through the same origin canonicalizer as the candidate. · *DMARC strict alignment canonicalizes the compared domains* — b.mail.dmarc strict alignment (aspf=s / adkim=s) compared the From domain against the SPF/DKIM authentication domain with only case-folding, while the relaxed path already normalized via the public-suffix lookup. An aligned message whose authentication domain carried a trailing dot or an IDN label was wrongly failed. Both domains are now canonicalized identically before the strict comparison. **Security:** *BIMI VMC certificate SubjectAltName authorization bypass closed* — b.mail.bimi.fetchAndVerifyMark binds a verified mark certificate to the BIMI domain via its Subject Alternative Name. When a URI SAN could not be parsed as a URL (e.g. a host with userinfo, or a malformed/homograph URI), the matcher fell back to a raw substring search of the entire SAN string, so a CA-chained certificate whose actual host was attacker-controlled — but whose SAN contained the victim domain as a substring (in the userinfo or path) — was accepted to vouch for the victim domain. The substring fallback is removed: a URI SAN the URL parser refuses now fails closed, and both the certificate host and the BIMI domain are canonicalized (lowercase, trailing-dot strip, IDN A-label) before an exact host comparison. · *SSRF allow/deny lists now match the host case-insensitively* — b.ssrfGuard.createAllowlist compared each operator allow/deny entry verbatim against the URL parser's host, which is already lowercased. A mixed-case or trailing-dot denylist entry therefore failed to match its own host and did not block it. Both the host and each non-CIDR entry now canonicalize through canonicalizeHost before comparison, so a denylisted host is blocked regardless of the case or trailing-dot form the operator wrote.
package/lib/mail-auth.js CHANGED
@@ -807,8 +807,24 @@ async function spfVerify(opts) {
807
807
  throw new MailAuthError("mail-auth/spf-bad-ip",
808
808
  "spf.verify: ip must be a string");
809
809
  }
810
+ var mailFromStr = opts.mailFrom ? String(opts.mailFrom) : "";
811
+ // A MAIL FROM addr-spec has exactly one '@' (RFC 5322 §3.4.1). split("@")[1]
812
+ // on a multi-@ string takes the LEFTMOST segment, so the SPF check would
813
+ // authorize a domain the attacker controls rather than the envelope sender's
814
+ // real domain (CWE-290). A multi-@ MAIL FROM is malformed — return a permanent
815
+ // SPF error (a RESULT, not a throw). spfVerify runs inside b.mail.inbound.verify,
816
+ // whose contract is that message-derived faults surface as a permerror/temperror
817
+ // verdict, never a throw: a throw here is caught by mail-server-mx as a pipeline
818
+ // temperror, which onTemperror:"accept" would let through — skipping SPF/DMARC
819
+ // gating for the spoofed From. Returning permerror (domain null → SPF cannot
820
+ // align) keeps the pipeline running so DMARC still gates the From header.
821
+ if (mailFromStr && mailFromStr.indexOf("@") !== mailFromStr.lastIndexOf("@")) {
822
+ return { result: "permerror", domain: null,
823
+ explanation: "mailFrom has more than one '@' (not a valid addr-spec)",
824
+ lookupCount: 0 };
825
+ }
810
826
  var domain = opts.mailFrom
811
- ? String(opts.mailFrom).split("@")[1]
827
+ ? mailFromStr.split("@")[1]
812
828
  : opts.helo;
813
829
  if (typeof domain !== "string" || domain.length === 0) {
814
830
  throw new MailAuthError("mail-auth/spf-bad-domain",
@@ -1209,6 +1225,15 @@ async function dmarcEvaluate(opts) {
1209
1225
  throw new MailAuthError("mail-auth/dmarc-bad-from",
1210
1226
  "dmarc.evaluate: opts.from must be the From-header email address");
1211
1227
  }
1228
+ // An addr-spec has exactly one '@' (RFC 5322 §3.4.1). split("@")[1] on a
1229
+ // multi-@ string (x@attacker@victim) silently takes the LEFTMOST segment
1230
+ // (attacker), which can diverge from the rightmost-@ domain the inbound From
1231
+ // parser gated on — so DMARC would authorize a different domain than the one
1232
+ // displayed. Refuse a multi-@ From as malformed (CWE-290).
1233
+ if (opts.from.indexOf("@") !== opts.from.lastIndexOf("@")) {
1234
+ throw new MailAuthError("mail-auth/dmarc-bad-from",
1235
+ "dmarc.evaluate: opts.from has more than one '@' (not a valid addr-spec)");
1236
+ }
1212
1237
  var fromDomain = opts.from.split("@")[1];
1213
1238
  if (!fromDomain) {
1214
1239
  throw new MailAuthError("mail-auth/dmarc-bad-from",
@@ -2253,6 +2278,14 @@ function _extractFromHeaders(headerBlock) {
2253
2278
  // not a single bare addr-spec.
2254
2279
  if (/[\s,;:<>]/.test(address)) address = null;
2255
2280
  }
2281
+ // An RFC 5322 addr-spec has EXACTLY ONE '@' (local-part "@" domain). A second
2282
+ // '@' (x@attacker@victim) is malformed: the rightmost-@ derivation below reads
2283
+ // victim as the author domain, while a downstream leftmost-@ parser
2284
+ // (dmarc.evaluate's split("@")[1]) reads attacker — so DMARC could authorize a
2285
+ // domain the attacker controls while the displayed From is the victim's
2286
+ // (CWE-290). Treat any address with more than one '@' as unparsable so the
2287
+ // caller fails closed (permerror → reject).
2288
+ if (address && address.indexOf("@") !== address.lastIndexOf("@")) address = null;
2256
2289
  var at = address ? address.lastIndexOf("@") : -1;
2257
2290
  var domain = (at > 0 && address && at < address.length - 1)
2258
2291
  ? address.slice(at + 1).toLowerCase()
package/lib/mail-dkim.js CHANGED
@@ -751,6 +751,21 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
751
751
  if (actualBh !== expectedBh) {
752
752
  return { result: "fail", errors: ["body hash mismatch"] };
753
753
  }
754
+ // RFC 6376 §8.2 — l= signs only the first lcap canonicalized octets, so any
755
+ // body delivered beyond lcap is UNSIGNED: the body hash above matched the
756
+ // prefix, but the recipient receives appended content the signer never
757
+ // authenticated (verified bytes ≠ delivered bytes, CWE-345). The framework
758
+ // already refuses l= at sign-time; on verify, a signature whose l= leaves
759
+ // appended content unsigned must not pass by default. An operator who must
760
+ // accept legacy l= senders opts in via verify({ acceptBodyLengthLimit: true }).
761
+ if (lcap !== undefined && !verifyOpts.acceptBodyLengthLimit) {
762
+ var fullCanon = canonBody === "simple" ? _canonBodySimple(body) : _canonBodyRelaxed(body);
763
+ if (lcap < Buffer.byteLength(fullCanon, "utf8")) {
764
+ return { result: "fail",
765
+ errors: ["DKIM-Signature l= leaves appended body content unsigned " +
766
+ "(RFC 6376 §8.2 append-after-signature)"] };
767
+ }
768
+ }
754
769
 
755
770
  // 2. Canonicalize the headers in h= order, then the DKIM-Signature
756
771
  // header itself with the b= value emptied (per §3.7).
@@ -876,7 +891,7 @@ async function verify(rfc822, opts) {
876
891
  }
877
892
  opts = opts || {};
878
893
  validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
879
- "minRsaBits"], "mail.dkim.verify");
894
+ "minRsaBits", "acceptBodyLengthLimit"], "mail.dkim.verify");
880
895
  var auditOn = opts.audit !== false;
881
896
 
882
897
  // Bounded clock skew: refuse non-numeric / negative / infinite /
@@ -918,7 +933,8 @@ async function verify(rfc822, opts) {
918
933
  }
919
934
  maxSignatures = Math.floor(opts.maxSignatures);
920
935
  }
921
- var verifyOpts = { minRsaBits: opts.minRsaBits };
936
+ var verifyOpts = { minRsaBits: opts.minRsaBits,
937
+ acceptBodyLengthLimit: opts.acceptBodyLengthLimit === true };
922
938
  // Framework-internal ARC reuse: the key is a Symbol owned by
923
939
  // mail-arc-reuse-token (exported from no public surface), so only framework
924
940
  // code that requires that module can set it. When present, the i= tag is an
@@ -143,6 +143,10 @@ function _buildDsnMessage(opts) {
143
143
  "--" + boundary + "\r\n" +
144
144
  "Content-Type: message/delivery-status\r\n" +
145
145
  "\r\n" +
146
+ // Reporting-MTA is an informational DSN header naming our own reporting MTA
147
+ // (it falls back to the bounce-from's domain); it drives no auth decision or
148
+ // delivery routing, so the leftmost-@ segment is acceptable here.
149
+ // allow:leftmost-domain-informational
146
150
  "Reporting-MTA: dns; " + (opts.reportingMta || from.split("@")[1] || "") + "\r\n" +
147
151
  "Arrival-Date: " + nowIso + "\r\n" +
148
152
  "\r\n" +
@@ -300,6 +304,15 @@ async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
300
304
  }
301
305
 
302
306
  async function _deliverOne(envelope, recipient, ctx) {
307
+ // A recipient addr-spec has exactly one '@' (RFC 5322 §3.4.1). split("@")[1]
308
+ // on a multi-@ string (victim@internal.host@external.com) takes the LEFTMOST
309
+ // segment, so the MX lookup + delivery would route to a domain other than the
310
+ // intended one — a mis-delivery / exfiltration vector. Refuse a multi-@
311
+ // recipient as a permanent bad-address rather than route to the wrong host.
312
+ if (recipient.indexOf("@") !== recipient.lastIndexOf("@")) {
313
+ return { recipient: recipient, outcome: "permanent",
314
+ reason: "bad-address", reasonCode: "5.1.3" };
315
+ }
303
316
  var domain = recipient.split("@")[1];
304
317
  if (!domain) {
305
318
  return { recipient: recipient, outcome: "permanent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.51",
3
+ "version": "0.15.53",
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:28e40053-c854-460c-bb90-e65917309220",
5
+ "serialNumber": "urn:uuid:3c37e813-c979-4c20-b8c7-3c496401d86c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T03:31:54.302Z",
8
+ "timestamp": "2026-06-29T06:09:51.919Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.51",
22
+ "bom-ref": "@blamejs/core@0.15.53",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.51",
25
+ "version": "0.15.53",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.51",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.53",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.51",
57
+ "ref": "@blamejs/core@0.15.53",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]