@blamejs/core 0.15.52 → 0.15.54
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 +4 -0
- package/lib/auth/saml.js +38 -2
- package/lib/mail-dkim.js +18 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.54 (2026-06-28) — **SAML federation-metadata (MDQ) verification binds the signature to the consumed EntityDescriptor and refuses signature-wrapping, closing an IdP trust-anchor takeover.** b.auth.saml.fetchMdq validates signed federation metadata with the same XML-dsig verifier the assertion path uses, but discarded the verified reference and never bound it to the EntityDescriptor the operator consumes — unlike verifyResponse, which already enforces that the signature covers the element it trusts. Combined with a first-child Signature lookup, an attacker controlling or interposing on the MDQ source could wrap a genuinely federation-signed EntityDescriptor under a forged container (or bury it in an EntityDescriptor whose own ID and signing certificate are attacker-chosen): the signature still validated over the buried element, fetchMdq returned the whole document, and the operator extracted the attacker's certificate as the IdP signing key — a full SAML authentication bypass (the CVE-2024-45409 / ruby-saml signature-wrapping class). fetchMdq now requires the metadata root to be a single EntityDescriptor, binds the verified reference to that root's ID (mirroring verifyResponse), confirms the signed entityID is the one requested, and refuses a duplicate top-level Signature. **Security:** *fetchMdq binds the federation signature to the consumed EntityDescriptor (XML signature-wrapping defense)* — On the MDQ trust-bootstrap path, fetchMdq verified the federation signature but discarded the reference it covered, so the signature did not have to cover the EntityDescriptor whose signing certificate the operator subsequently trusts. An attacker with a malicious or interposed MDQ responder could pair a genuine federation signature over a buried entity with a forged outer/sibling EntityDescriptor carrying their own signing certificate; fetchMdq accepted and returned the wrapped document, and the operator installed the attacker certificate as the IdP trust anchor — forging arbitrary assertions thereafter. fetchMdq now refuses a non-EntityDescriptor root (the EntitiesDescriptor wrapper), refuses a duplicate top-level Signature, binds the verified reference to the document-root EntityDescriptor's ID, and confirms the signed entityID matches the requested one — mirroring the wrapping defenses verifyResponse already applies to assertions. New error codes: auth-saml/mdq-not-entity-descriptor, auth-saml/mdq-duplicate-signature, auth-saml/mdq-entity-mismatch (plus the existing auth-saml/signed-different-element for a buried-reference root). Verification of legitimately signed single-entity metadata is unchanged.
|
|
12
|
+
|
|
13
|
+
- 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=`.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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.
|
package/lib/auth/saml.js
CHANGED
|
@@ -2128,12 +2128,48 @@ async function fetchMdq(opts) {
|
|
|
2128
2128
|
if (opts.trustCertPem) {
|
|
2129
2129
|
var c14n = xmlC14n();
|
|
2130
2130
|
var root = c14n.parse(xml);
|
|
2131
|
-
|
|
2131
|
+
// The element the operator consumes is the document-root EntityDescriptor
|
|
2132
|
+
// (fetchMdq queries a single entityID; an EntitiesDescriptor aggregate is
|
|
2133
|
+
// not what a per-entity MDQ lookup returns). Refusing the wrapper closes the
|
|
2134
|
+
// XML-signature-wrapping vector where a genuine federation signature over a
|
|
2135
|
+
// buried entity is paired with a forged sibling/outer entity carrying the
|
|
2136
|
+
// attacker's signing cert (CVE-2024-45409 class).
|
|
2137
|
+
var rootColon = root.name.indexOf(":");
|
|
2138
|
+
var rootLocal = rootColon !== -1 ? root.name.substring(rootColon + 1) : root.name;
|
|
2139
|
+
if (rootLocal !== "EntityDescriptor") {
|
|
2140
|
+
throw new AuthError("auth-saml/mdq-not-entity-descriptor",
|
|
2141
|
+
"fetchMdq: metadata root must be a single EntityDescriptor, got " + rootLocal +
|
|
2142
|
+
" (signature-wrapping defense)");
|
|
2143
|
+
}
|
|
2144
|
+
// Refuse a duplicate top-level Signature (XSW multi-signature shape), the
|
|
2145
|
+
// way verifyResponse refuses duplicate security-critical children.
|
|
2146
|
+
var sigChildren = _findAllChildren(root, "Signature", null);
|
|
2147
|
+
if (sigChildren.length > 1) {
|
|
2148
|
+
throw new AuthError("auth-saml/mdq-duplicate-signature",
|
|
2149
|
+
"fetchMdq: EntityDescriptor has multiple Signature children (XSW shape refused)");
|
|
2150
|
+
}
|
|
2151
|
+
var sig = sigChildren[0] || null;
|
|
2132
2152
|
if (!sig) {
|
|
2133
2153
|
throw new AuthError("auth-saml/mdq-unsigned",
|
|
2134
2154
|
"fetchMdq: metadata is unsigned but trustCertPem was supplied");
|
|
2135
2155
|
}
|
|
2136
|
-
_verifyXmldsig(xml, sig, opts.trustCertPem);
|
|
2156
|
+
var signed = _verifyXmldsig(xml, sig, opts.trustCertPem);
|
|
2157
|
+
// Bind the verified bytes to the consumed element: the federation signature
|
|
2158
|
+
// MUST cover the document-root EntityDescriptor, not some inner element a
|
|
2159
|
+
// wrapper buried beside a forged one. Mirrors verifyResponse's
|
|
2160
|
+
// signed.refId === _attr(root/assertion, "ID") guard.
|
|
2161
|
+
if (signed.refId !== _attr(root, "ID")) {
|
|
2162
|
+
throw new AuthError("auth-saml/signed-different-element",
|
|
2163
|
+
"fetchMdq: metadata signature references a different element than the " +
|
|
2164
|
+
"EntityDescriptor root (signature-wrapping defense)");
|
|
2165
|
+
}
|
|
2166
|
+
// And the signed root must be the entity that was requested, so a signed
|
|
2167
|
+
// descriptor for one entity cannot be served for another.
|
|
2168
|
+
if (_attr(root, "entityID") !== opts.entityId) {
|
|
2169
|
+
throw new AuthError("auth-saml/mdq-entity-mismatch",
|
|
2170
|
+
"fetchMdq: signed EntityDescriptor entityID '" + _attr(root, "entityID") +
|
|
2171
|
+
"' does not match requested '" + opts.entityId + "'");
|
|
2172
|
+
}
|
|
2137
2173
|
}
|
|
2138
2174
|
_emitAudit("mdq_fetched", "success", { entityId: opts.entityId });
|
|
2139
2175
|
_emitMetric("mdq-fetched");
|
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
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:00e2fd44-d9a2-420d-b06b-b0f089269b4b",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T07:08:19.605Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.54",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.54",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.54",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.15.54",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|