@blamejs/core 0.11.2 → 0.11.3
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 +2 -0
- package/MIGRATING.md +9 -2
- package/lib/acme.js +21 -6
- package/lib/mail-auth.js +342 -30
- package/lib/mail-crypto-smime.js +8 -4
- package/lib/mail-server-imap.js +23 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.3 (2026-05-19) — **SPF `a` and `mx` mechanism dispatch + smaller deferral-condition cleanups.** `b.mail.spf.verify` now evaluates the `a` and `mx` mechanisms per [RFC 7208 §5.3 + §5.4](https://www.rfc-editor.org/rfc/rfc7208), including the dual-cidr-length syntax (`a:foo.example/24//64`, `mx//64`). Senders publishing `v=spf1 mx -all` or `v=spf1 a -all` previously permerrored against this framework even though those are the second-most-common SPF mechanisms in fielded policies; verification now resolves the operator-supplied A / AAAA / MX records (via the existing `dnsLookup` callback contract — which is now honored for every record type, not only TXT) and matches the connecting IP under the parsed cidr. MX expansion is capped at the RFC §4.6.4 limit of 10 hosts (over-limit = permerror); each MX-host A/AAAA expansion counts toward the 10-lookup global ceiling and the 2-lookup void-lookup sub-limit. Empty digit segments in the dual-cidr-length grammar (`a/`, `a//`, `mx/`, `mx//`, `a/24//`) permerror with an explanatory message — RFC §5.3/§5.4 grammar requires `1*DIGIT` after each slash, and accepting empty would over-authorize senders publishing `v=spf1 a/ -all` (would match every IP in the /32 of every A record). The `exists` (RFC §5.7) and `ptr` (RFC §5.5) mechanisms remain deferred — `exists` needs macro-string expansion (RFC §7) to be usable in fielded policies, `ptr` is "strongly discouraged" by the RFC and rarely seen — and each now permerrors with an explanatory message naming the RFC section and a practical operator-side mitigation. `b.mail.crypto.smime` `@card` and the v1-only-emits-metadata comment in `lib/mail-crypto-smime.js` are corrected to reflect that sign + verify shipped in v0.10.16 on the `b.cms` substrate (EFAIL-class encrypt/decrypt remains the only deferred slice). `b.acme.create.revokeCert({ useCertKey: true })` and the `BAD UID <subverb>` IMAP listener response now carry explicit re-open conditions + named operator escape hatches alongside the deferral. **New codebase-patterns detector `slice1-optional-parseint-silent-default`** flags the class — any `.slice(1)` followed by an `if (X.length > 0)` guard around `parseInt(X, 10)` MUST sit in a file that also carries an explicit empty-segment refusal phrasing, so future cidr-length / prefix-length / port-range parsers inherit the discipline automatically. **References:** [RFC 7208 §5.3 a mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.3) · [RFC 7208 §5.4 mx mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.4) · [RFC 7208 §4.6.4 DNS-lookup limits](https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4) · [RFC 8551 S/MIME 4.0](https://www.rfc-editor.org/rfc/rfc8551.html) · [RFC 9051 IMAP4rev2 §6.4.9 UID](https://www.rfc-editor.org/rfc/rfc9051#section-6.4.9).
|
|
12
|
+
|
|
11
13
|
- v0.11.2 (2026-05-19) — **Node 26 floor-bump preparation.** Today's `engines.node` floor is `>=24.14.1` and the framework runs cleanly on Node 26 (which satisfies the floor). This release ships the **prep** scaffolding so the future floor-bump slice (when Node 26 promotes to Active LTS and `>=26.x` becomes the floor) is mechanical. **`b.backup.diskStorage(opts)`** is the new canonical name for the local-filesystem backup storage backend; `b.backup.localStorage(opts)` continues to work and emits a one-time deprecation warning via `b.deprecate.alias`, with removal scheduled for the next major. The rename avoids the Node 26 platform-level `localStorage` global naming collision; the deprecation path follows the framework's stable upgrade policy (one minor with deprecation warnings before removal). **New codebase-patterns detector `map-get-or-insert-pre-node-26`** flags the `if (!m.has(k)) m.set(k, factory()); m.get(k)` shape that Node 26's `Map.prototype.getOrInsertComputed(key, factory)` replaces in a single call. The detector lands as an allowlist marker — every existing call site in `lib/` is allowlisted with the spec file as the migration target; new code post-this-patch trips the gate. When the floor bumps the allowlist is walked + the detector flips to enforce. **`test/integration/pqc-pkcs8-forward-compat.test.js`** captures the ML-KEM-1024 / ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f / Ed25519 PKCS8 export-byte shape on the current Node, asserts the sign+verify / encap+decap roundtrip via a re-imported KeyObject, and embeds a Node-26-shape fixture that re-imports every run — so the forward-compat contract is testable today and the reverse-direction (Node-26-exported → Node-24-imported) test follows the floor-bump. **SECURITY.md gains a "Node 26 compatibility" section** documenting the `localStorage` global naming collision (bare references in operator handler code now resolve to a Node global rather than throwing `ReferenceError`) and the ML-KEM / ML-DSA seed-only PKCS8 export shape (Node-24-sealed material re-imports cleanly on Node 26; new material from Node 26 is seed-only — parallel Node 24 readers of the same sealed disk need a one-time migration when the writer moves). README "Requirements" line gains the matching Node 26 note. **References:** [Node.js v26 release notes](https://nodejs.org/en/blog/release/v26.0.0) · [TC39 Map.getOrInsertComputed](https://github.com/tc39/proposal-upsert) · [RFC 8032 §5.1 Ed25519 context parameter](https://www.rfc-editor.org/rfc/rfc8032.html#section-5.1).
|
|
12
14
|
|
|
13
15
|
- v0.11.1 (2026-05-19) — **Integration suite hardening + live coverage for the v0.11.0 surface.** **`b.httpClient.request`** now skips the local SSRF DNS lookup when a proxy is configured AND the operator passes `allowInternal: true`. The proxy resolves the destination hostname in its own network context, so requiring local resolution refused legitimate intranet / docker-service-name targets routed through the proxy. The SSRF gate still runs when `allowInternal` is false / array-form (the proxy's freedom to reach internal IPs is not a blanket license; the explicit opt-in is still required). **`b.mtlsCa`** integration tests now compose with the `caKeySealedMode: "disabled"` opt for fixture purposes; production deployments continue to wire `opts.vault` for sealed-at-rest CA-key storage. **`b.mail.crypto.smime.verify`** return shape gains a `chainVerified: boolean` field reflecting whether `opts.trustAnchorCertsPem` was supplied and the leaf-to-root chain walk completed. **Integration coverage added for v0.11.0 primitives:** new `test/integration/mail-crypto-smime.test.js` round-trips S/MIME sign + verify with a real X.509 chain issued by `b.mtlsCa` (CA → leaf cert → ML-DSA-65 signer), exercises tamper / wrong-key / untrusted-anchor refusal paths, and validates the `chainVerified` return field. `test/integration/federation-auth.test.js` extends to cover SAML SLO (`buildLogoutRequest` against Keycloak's `/protocol/saml` SLO endpoint with the wire-format-parse assertion) and RFC 7592 Dynamic Client Registration Management (`registerClient` / `readClient` / `updateClient` / `deleteClient` against Keycloak's DCR endpoint).
|
package/MIGRATING.md
CHANGED
|
@@ -4,9 +4,16 @@ Operator-facing migration recipes per breaking change. The bulk of this file is
|
|
|
4
4
|
|
|
5
5
|
**Out-of-band breaking changes** (schema breaks, config-shape changes, on-disk format breaks) cannot be expressed as `deprecate()` calls because there's no in-process runtime to warn from. They're hardcoded in the OUT_OF_BAND_BREAKS table inside `scripts/gen-migrating.js` so the operator sees the full upgrade path here without needing to grep CHANGELOG.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Removed in v0.x
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### `localStorage`
|
|
10
|
+
|
|
11
|
+
- **Since:** 0.11.2
|
|
12
|
+
- **Removed in:** 0.12.0
|
|
13
|
+
- **Defined at:** [`lib/backup/index.js`](lib/backup/index.js)
|
|
14
|
+
- **Renamed to:** `diskStorage`
|
|
15
|
+
|
|
16
|
+
b.backup.localStorage was renamed to b.backup.diskStorage — the Node 26 `localStorage` global doesn't clash today, but the rename keeps the operator-facing surface unambiguous. Update the call site; removal lands in the next major.
|
|
10
17
|
|
|
11
18
|
---
|
|
12
19
|
|
package/lib/acme.js
CHANGED
|
@@ -822,13 +822,28 @@ function create(opts) {
|
|
|
822
822
|
if (typeof ropts.reason === "number") payload.reason = ropts.reason;
|
|
823
823
|
var signedOpts = { useJwk: false }; // account-key signed by default
|
|
824
824
|
if (ropts.useCertKey === true) {
|
|
825
|
-
// RFC 8555 §7.6 alternate: certificate's own
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
825
|
+
// RFC 8555 §7.6 alternate signer: the certificate's own private
|
|
826
|
+
// key signs the revocation JWS (bypassing the account-key
|
|
827
|
+
// requirement). Useful when the account that originally issued
|
|
828
|
+
// the cert is lost / compromised but the cert's private key is
|
|
829
|
+
// still under operator control.
|
|
830
|
+
//
|
|
831
|
+
// Re-open condition: operator surfaces a CA whose only accepted
|
|
832
|
+
// revocation path is cert-key signing (rare — Let's Encrypt /
|
|
833
|
+
// ZeroSSL / Google CA all accept account-key signing as the
|
|
834
|
+
// primary path), OR the operator's account-recovery posture
|
|
835
|
+
// demands cert-key as a break-glass route. Track via a new opt
|
|
836
|
+
// shape: `ropts.certPrivateKey: <PEM string>` would route
|
|
837
|
+
// through a dedicated signed-post that detaches from
|
|
838
|
+
// state.accountUrl.
|
|
839
|
+
//
|
|
840
|
+
// Operator escape hatch today: account-key signing covers every
|
|
841
|
+
// mainstream public CA. Operators in the rare cert-key-only
|
|
842
|
+
// scenario reach the CA directly via the CA's own revocation
|
|
843
|
+
// portal (web UI / out-of-band API) until this lights up.
|
|
830
844
|
throw _err("acme/revoke-cert-key-not-implemented",
|
|
831
|
-
"revokeCert: cert-key signing path
|
|
845
|
+
"revokeCert: cert-key signing path (RFC 8555 §7.6 alternate) is deferred; " +
|
|
846
|
+
"use account-key signing (the default, omit useCertKey)", true);
|
|
832
847
|
}
|
|
833
848
|
var rsp = await _signedPost(state.directory.revokeCert, payload, signedOpts);
|
|
834
849
|
if (rsp.statusCode !== 200) {
|
package/lib/mail-auth.js
CHANGED
|
@@ -13,11 +13,21 @@
|
|
|
13
13
|
* b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup }) → result
|
|
14
14
|
* b.mail.arc.verify(rfc822, opts) → chain status
|
|
15
15
|
*
|
|
16
|
-
* SPF (RFC 7208) —
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* SPF (RFC 7208) — ip4 / ip6 / a / mx / include / all / redirect=
|
|
17
|
+
* mechanisms.
|
|
18
|
+
* Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4 (with the
|
|
19
|
+
* void-lookup sub-limit at 2). The `a` and `mx` arms honor RFC
|
|
20
|
+
* §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`).
|
|
21
|
+
*
|
|
22
|
+
* Deferred mechanisms (each carries an explicit Re-open condition
|
|
23
|
+
* in the dispatch arm in this file):
|
|
24
|
+
* - exists: requires macro-string expansion (§7) to be useful;
|
|
25
|
+
* re-opens when macros land OR an operator surfaces a
|
|
26
|
+
* real macro-less `exists:` policy.
|
|
27
|
+
* - ptr: "strongly discouraged" by §5.5; re-opens when an
|
|
28
|
+
* operator surfaces a legitimate ptr-only sender.
|
|
29
|
+
* - macro-string expansion (§7) itself — separate slice tracked
|
|
30
|
+
* under blamejs-roadmap.md.
|
|
21
31
|
*
|
|
22
32
|
* DMARC (RFC 7489) — TXT record at _dmarc.<domain>; alignment check
|
|
23
33
|
* between From-header domain and DKIM-d / SPF-from-domain;
|
|
@@ -27,9 +37,9 @@
|
|
|
27
37
|
* List).
|
|
28
38
|
*
|
|
29
39
|
* ARC (RFC 8617) — chain-of-custody verification. The framework parses
|
|
30
|
-
* the existing chain headers
|
|
31
|
-
*
|
|
32
|
-
*
|
|
40
|
+
* the existing chain headers, recomputes the per-hop signatures, and
|
|
41
|
+
* reports validity by composing `lib/mail-dkim.js` (which carries
|
|
42
|
+
* the actual signature-verification surface).
|
|
33
43
|
*/
|
|
34
44
|
|
|
35
45
|
var zlib = require("node:zlib");
|
|
@@ -76,16 +86,21 @@ var SPF_RECORD_MAX_BYTES = 450;
|
|
|
76
86
|
// alone tripped.
|
|
77
87
|
var SPF_REDIRECT_DEPTH_LIMIT = 10; // allow:raw-byte-literal — same shape as RFC 7208 §4.6.4 lookup ceiling
|
|
78
88
|
|
|
79
|
-
// Shared safe-DNS TXT/A/AAAA/PTR lookup. Operator-supplied
|
|
80
|
-
// `dnsLookup
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
+
// Shared safe-DNS TXT/A/AAAA/MX/PTR lookup. Operator-supplied
|
|
90
|
+
// `dnsLookup(qname, type)` is honored for every type when present:
|
|
91
|
+
// TXT → [[ "v=spf1 ...", ... ], ...] (array of TXT-string-arrays)
|
|
92
|
+
// A → [ "192.0.2.1", ... ] (flat IPv4 string array)
|
|
93
|
+
// AAAA → [ "2001:db8::1", ... ] (flat IPv6 string array)
|
|
94
|
+
// MX → [ { exchange, preference }, ...] (or [ "mx1.example.", ... ]
|
|
95
|
+
// when operator omits preference)
|
|
96
|
+
// PTR → [ "host.example.", ... ] (flat PTR-name array)
|
|
97
|
+
// When no operator callback is supplied, requests route through
|
|
98
|
+
// `b.network.dns.resolver` (DoH by default per v0.7.23). CVE-2008-1447
|
|
99
|
+
// (Kaminsky) + CVE-2022-3204 (NRDelegationAttack) class — the encrypted
|
|
100
|
+
// DoH transport plus b.safeDns parse caps defend transport and parse-
|
|
101
|
+
// side. Earlier shape fell back to `node:dns.promises.resolveTxt`
|
|
102
|
+
// directly, which sent plaintext UDP/53 to whatever the system
|
|
103
|
+
// resolver was — every downstream finding inherited that exposure.
|
|
89
104
|
var _defaultResolver = null;
|
|
90
105
|
function _getDefaultResolver() {
|
|
91
106
|
if (_defaultResolver) return _defaultResolver;
|
|
@@ -111,7 +126,21 @@ async function _safeResolveTxt(qname, operatorLookup) {
|
|
|
111
126
|
return out;
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
async function _safeResolveA(qname, family /* 4|6
|
|
129
|
+
async function _safeResolveA(qname, family /* 4|6 */, operatorLookup) {
|
|
130
|
+
// Pre-v0.11.3 the operatorLookup parameter wasn't threaded here, so
|
|
131
|
+
// the documented `dnsLookup` shape for A/AAAA was unhonored — SPF a/
|
|
132
|
+
// mx mechanism tests had no operator-mockable path. The function
|
|
133
|
+
// signature now matches the docstring contract above. Operator
|
|
134
|
+
// returns a flat string array of IP literals.
|
|
135
|
+
if (operatorLookup) {
|
|
136
|
+
var resp = await operatorLookup(qname, family === 6 ? "AAAA" : "A");
|
|
137
|
+
if (!Array.isArray(resp) || resp.length === 0) {
|
|
138
|
+
var aerr = new Error("no " + (family === 6 ? "AAAA" : "A") + " records for " + qname);
|
|
139
|
+
aerr.code = "ENODATA";
|
|
140
|
+
throw aerr;
|
|
141
|
+
}
|
|
142
|
+
return resp.map(function (x) { return String(x); });
|
|
143
|
+
}
|
|
115
144
|
var r = await _getDefaultResolver().query(qname, family === 6 ? "AAAA" : "A");
|
|
116
145
|
var out = [];
|
|
117
146
|
for (var i = 0; i < r.rrs.length; i += 1) {
|
|
@@ -127,6 +156,50 @@ async function _safeResolveA(qname, family /* 4|6 */) {
|
|
|
127
156
|
return out;
|
|
128
157
|
}
|
|
129
158
|
|
|
159
|
+
// RFC 1035 §3.3.9 MX record: { preference, exchange }. Returns array of
|
|
160
|
+
// exchange hostnames sorted by preference (lowest first). Operator-
|
|
161
|
+
// supplied dnsLookup callback may return either:
|
|
162
|
+
// - [ { exchange, preference }, ... ] — full shape (preferred)
|
|
163
|
+
// - [ "mx1.example.", ... ] — exchanges only (preference
|
|
164
|
+
// treated as 0 → first-served)
|
|
165
|
+
async function _safeResolveMx(qname, operatorLookup) {
|
|
166
|
+
if (operatorLookup) {
|
|
167
|
+
var resp = await operatorLookup(qname, "MX");
|
|
168
|
+
if (!Array.isArray(resp) || resp.length === 0) {
|
|
169
|
+
var merr = new Error("no MX records for " + qname);
|
|
170
|
+
merr.code = "ENODATA";
|
|
171
|
+
throw merr;
|
|
172
|
+
}
|
|
173
|
+
var normalized = resp.map(function (entry) {
|
|
174
|
+
if (typeof entry === "string") return { exchange: entry.replace(/\.$/, ""), preference: 0 };
|
|
175
|
+
var ex = entry && entry.exchange;
|
|
176
|
+
var pref = (entry && typeof entry.preference === "number") ? entry.preference : 0;
|
|
177
|
+
return { exchange: String(ex || "").replace(/\.$/, ""), preference: pref };
|
|
178
|
+
}).filter(function (e) { return e.exchange.length > 0; });
|
|
179
|
+
normalized.sort(function (a, b) { return a.preference - b.preference; });
|
|
180
|
+
return normalized.map(function (e) { return e.exchange; });
|
|
181
|
+
}
|
|
182
|
+
var r = await _getDefaultResolver().query(qname, "MX");
|
|
183
|
+
var entries = [];
|
|
184
|
+
for (var i = 0; i < r.rrs.length; i += 1) {
|
|
185
|
+
var rr = r.rrs[i];
|
|
186
|
+
if (rr && rr.type === 15) { // allow:raw-byte-literal — IANA DNS qtype MX
|
|
187
|
+
var d = rr.decoded || {};
|
|
188
|
+
if (d.exchange) {
|
|
189
|
+
entries.push({ exchange: String(d.exchange).replace(/\.$/, ""),
|
|
190
|
+
preference: typeof d.preference === "number" ? d.preference : 0 });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (entries.length === 0) {
|
|
195
|
+
var err = new Error("no MX records for " + qname);
|
|
196
|
+
err.code = "ENODATA";
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
entries.sort(function (a, b) { return a.preference - b.preference; });
|
|
200
|
+
return entries.map(function (e) { return e.exchange; });
|
|
201
|
+
}
|
|
202
|
+
|
|
130
203
|
async function _safeReverse(ip) {
|
|
131
204
|
// PTR query against the reverse-arpa name. IPv4: a.b.c.d.in-addr.arpa
|
|
132
205
|
// (reversed octets); IPv6: nibble-reversed under ip6.arpa.
|
|
@@ -273,7 +346,13 @@ function _parseSpfRecord(text) {
|
|
|
273
346
|
? colonAt : slashAt;
|
|
274
347
|
var mech = sep === -1 ? p : p.slice(0, sep);
|
|
275
348
|
var arg = sep === -1 ? null : p.slice(sep + 1);
|
|
276
|
-
|
|
349
|
+
// `raw` preserves the full mechanism+arg token after qualifier-
|
|
350
|
+
// strip. The a/mx dispatch arm reparses this directly because
|
|
351
|
+
// RFC 7208 §5.3/§5.4 allow `dual-cidr-length` after the optional
|
|
352
|
+
// domain-spec (e.g. `a:example.com/24//64`); the simple `arg`
|
|
353
|
+
// field above splits on the first separator and loses the
|
|
354
|
+
// information about whether that separator was `:` or `/`.
|
|
355
|
+
mechanisms.push({ qualifier: qualifier, mechanism: mech.toLowerCase(), arg: arg, raw: p });
|
|
277
356
|
}
|
|
278
357
|
// Surface modifiers via a non-enumerable property so callers that
|
|
279
358
|
// don't expect them don't see them in JSON-serialized records but
|
|
@@ -322,9 +401,188 @@ async function _fetchSpfRecord(domain, dnsLookup) {
|
|
|
322
401
|
return { kind: "found", record: matches[0] };
|
|
323
402
|
}
|
|
324
403
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
404
|
+
// RFC 7208 §5.3 / §5.4 — `a [ ":" domain-spec ] [ dual-cidr-length ]`
|
|
405
|
+
// and `mx [ ":" domain-spec ] [ dual-cidr-length ]`. dual-cidr-length
|
|
406
|
+
// is `[ "/" ip4-cidr ] [ "//" ip6-cidr ]`. Returns the parsed target
|
|
407
|
+
// domain plus per-family prefix lengths (32 / 128 when omitted).
|
|
408
|
+
//
|
|
409
|
+
// `raw` is the post-qualifier token (e.g. "a", "a:foo.com", "a/24",
|
|
410
|
+
// "a//64", "a:foo.com/24//64"). Throws MailAuthError on bad cidr.
|
|
411
|
+
function _parseADualCidr(raw, mech, defaultDomain) {
|
|
412
|
+
var rest = raw.slice(mech.length);
|
|
413
|
+
var domain = defaultDomain;
|
|
414
|
+
var v4Mask = 32; // allow:raw-byte-literal — IPv4 max prefix
|
|
415
|
+
var v6Mask = 128; // allow:raw-byte-literal — IPv6 max prefix
|
|
416
|
+
|
|
417
|
+
if (rest.charAt(0) === ":") {
|
|
418
|
+
rest = rest.slice(1);
|
|
419
|
+
var slashAt = rest.indexOf("/");
|
|
420
|
+
if (slashAt === -1) { domain = rest; rest = ""; }
|
|
421
|
+
else { domain = rest.slice(0, slashAt); rest = rest.slice(slashAt); }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (rest.length > 0) {
|
|
425
|
+
// rest is now "" | "/v4" | "//v6" | "/v4//v6".
|
|
426
|
+
var dblSlash = rest.indexOf("//");
|
|
427
|
+
var v4Part = "";
|
|
428
|
+
var v6Part = "";
|
|
429
|
+
if (dblSlash !== -1) {
|
|
430
|
+
v4Part = rest.slice(0, dblSlash); // "" or "/24"
|
|
431
|
+
v6Part = rest.slice(dblSlash + 2); // "64"
|
|
432
|
+
} else {
|
|
433
|
+
v4Part = rest; // "/24"
|
|
434
|
+
}
|
|
435
|
+
if (v4Part.length > 0) {
|
|
436
|
+
if (v4Part.charAt(0) !== "/") {
|
|
437
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
438
|
+
"SPF " + mech + " dual-cidr malformed: " + JSON.stringify(raw));
|
|
439
|
+
}
|
|
440
|
+
var v4Str = v4Part.slice(1);
|
|
441
|
+
// RFC 7208 §5.3 / §5.4 — `ip4-cidr-length = "/" 1*DIGIT`. An
|
|
442
|
+
// empty digit segment (`a/`, `mx/`) is malformed grammar; the
|
|
443
|
+
// receiver MUST permerror. Pre-fix this silently kept the
|
|
444
|
+
// default /32 and would authorize the connecting IP under any
|
|
445
|
+
// A record of the target, which can over-authorize senders
|
|
446
|
+
// publishing `v=spf1 a/ -all` (would match every IP in the
|
|
447
|
+
// /32 of every A record).
|
|
448
|
+
if (v4Str.length === 0) {
|
|
449
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
450
|
+
"SPF " + mech + " v4 cidr-length is empty (RFC 7208 §5.3/§5.4 grammar requires 1*DIGIT): " +
|
|
451
|
+
JSON.stringify(raw));
|
|
452
|
+
}
|
|
453
|
+
var v4n = parseInt(v4Str, 10);
|
|
454
|
+
if (!isFinite(v4n) || v4n < 0 || v4n > 32 || String(v4n) !== v4Str) { // allow:raw-byte-literal — IPv4 max prefix
|
|
455
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
456
|
+
"SPF " + mech + " v4 cidr-length invalid: " + JSON.stringify(raw));
|
|
457
|
+
}
|
|
458
|
+
v4Mask = v4n;
|
|
459
|
+
}
|
|
460
|
+
// RFC 7208 §5.3 / §5.4 — `ip6-cidr-length = "/" 1*DIGIT` (after
|
|
461
|
+
// the "//" separator). When the `//` separator IS present (i.e.
|
|
462
|
+
// the raw token contained `//`) the digit segment MUST be 1*DIGIT.
|
|
463
|
+
// Empty (`a//`, `a/24//`, `mx//`) is malformed grammar; permerror.
|
|
464
|
+
if (dblSlash !== -1) {
|
|
465
|
+
if (v6Part.length === 0) {
|
|
466
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
467
|
+
"SPF " + mech + " v6 cidr-length is empty (RFC 7208 §5.3/§5.4 grammar requires 1*DIGIT): " +
|
|
468
|
+
JSON.stringify(raw));
|
|
469
|
+
}
|
|
470
|
+
var v6n = parseInt(v6Part, 10);
|
|
471
|
+
if (!isFinite(v6n) || v6n < 0 || v6n > 128 || String(v6n) !== v6Part) { // allow:raw-byte-literal — IPv6 max prefix
|
|
472
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
473
|
+
"SPF " + mech + " v6 cidr-length invalid: " + JSON.stringify(raw));
|
|
474
|
+
}
|
|
475
|
+
v6Mask = v6n;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!domain || domain.length === 0) {
|
|
480
|
+
throw new MailAuthError("mail-auth/spf-bad-cidr",
|
|
481
|
+
"SPF " + mech + " has no target domain (current-domain unavailable)");
|
|
482
|
+
}
|
|
483
|
+
return { domain: domain.toLowerCase(), v4Mask: v4Mask, v6Mask: v6Mask };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// RFC 7208 §5.3 / §5.4 — `a` and `mx` mechanism evaluation. Both
|
|
487
|
+
// resolve the target domain (or the current SPF-evaluating domain when
|
|
488
|
+
// arg omitted) to a set of IP addresses; the connecting IP matches if
|
|
489
|
+
// it falls inside any of those addresses under the parsed cidr prefix.
|
|
490
|
+
//
|
|
491
|
+
// Lookup accounting per §4.6.4:
|
|
492
|
+
// - `a`: the outer evaluator has already counted this as one DNS-
|
|
493
|
+
// touching mechanism. The single A/AAAA query is THAT one
|
|
494
|
+
// lookup; no additional increment here.
|
|
495
|
+
// - `mx`: the outer evaluator has counted the MX query itself.
|
|
496
|
+
// EACH MX hostname's A/AAAA expansion adds an additional
|
|
497
|
+
// lookup; total expansion is capped at 10 MX hostnames per
|
|
498
|
+
// §4.6.4 (the explicit "MX limit"). Crossing the global
|
|
499
|
+
// 10-lookup ceiling at any expansion step permerrors.
|
|
500
|
+
//
|
|
501
|
+
// Returns one of:
|
|
502
|
+
// { match: true } — connecting IP matched
|
|
503
|
+
// { match: false } — no IP matched / record absent
|
|
504
|
+
// { error: "temperror", reason: "..." } — transient DNS failure
|
|
505
|
+
// { error: "permerror", reason: "..." } — over-limit / bad CIDR / bad MX count
|
|
506
|
+
async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups) {
|
|
507
|
+
var parsed;
|
|
508
|
+
try { parsed = _parseADualCidr(raw, mech, defaultDomain); }
|
|
509
|
+
catch (e) { return { error: "permerror", reason: e.message }; }
|
|
510
|
+
|
|
511
|
+
var mask = isIpv6 ? parsed.v6Mask : parsed.v4Mask;
|
|
512
|
+
var family = isIpv6 ? 6 : 4; // allow:raw-byte-literal — IP family marker
|
|
513
|
+
|
|
514
|
+
var targetIps = [];
|
|
515
|
+
if (mech === "a") {
|
|
516
|
+
try { targetIps = await _safeResolveA(parsed.domain, family, dnsLookup); }
|
|
517
|
+
catch (e) {
|
|
518
|
+
var code = e && e.code;
|
|
519
|
+
if (code === "ENOTFOUND" || code === "ENODATA") return { match: false };
|
|
520
|
+
return { error: "temperror",
|
|
521
|
+
reason: "SPF a:" + parsed.domain + " lookup failed: " +
|
|
522
|
+
((e && e.message) || String(e)) };
|
|
523
|
+
}
|
|
524
|
+
} else { // mech === "mx"
|
|
525
|
+
var mxHosts;
|
|
526
|
+
try { mxHosts = await _safeResolveMx(parsed.domain, dnsLookup); }
|
|
527
|
+
catch (e) {
|
|
528
|
+
var mcode = e && e.code;
|
|
529
|
+
if (mcode === "ENOTFOUND" || mcode === "ENODATA") return { match: false };
|
|
530
|
+
return { error: "temperror",
|
|
531
|
+
reason: "SPF mx:" + parsed.domain + " MX lookup failed: " +
|
|
532
|
+
((e && e.message) || String(e)) };
|
|
533
|
+
}
|
|
534
|
+
// RFC 7208 §4.6.4 — the MX expansion is capped at 10 hostnames.
|
|
535
|
+
// Crossing this is a permerror; receivers MUST NOT silently
|
|
536
|
+
// truncate, since a misconfigured sender publishing 20 MX hosts
|
|
537
|
+
// would otherwise have only the first 10 contribute to authz.
|
|
538
|
+
if (mxHosts.length > 10) { // allow:raw-byte-literal — RFC 7208 §4.6.4 MX limit
|
|
539
|
+
return { error: "permerror",
|
|
540
|
+
reason: "SPF mx:" + parsed.domain + " resolved " + mxHosts.length +
|
|
541
|
+
" MX hosts (RFC 7208 §4.6.4 caps at 10)" };
|
|
542
|
+
}
|
|
543
|
+
for (var mi = 0; mi < mxHosts.length; mi += 1) {
|
|
544
|
+
lookups.count += 1;
|
|
545
|
+
if (lookups.count > lookups.limit) {
|
|
546
|
+
return { error: "permerror",
|
|
547
|
+
reason: "DNS lookup limit exceeded (RFC 7208 §4.6.4) during mx:" +
|
|
548
|
+
parsed.domain + " expansion" };
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
var hostIps = await _safeResolveA(mxHosts[mi], family, dnsLookup);
|
|
552
|
+
for (var hi = 0; hi < hostIps.length; hi += 1) targetIps.push(hostIps[hi]);
|
|
553
|
+
} catch (e) {
|
|
554
|
+
var hcode = e && e.code;
|
|
555
|
+
if (hcode === "ENOTFOUND" || hcode === "ENODATA") {
|
|
556
|
+
// Void lookup — counts toward §4.6.4 ceiling for the MX
|
|
557
|
+
// expansion (the MX hostname has no A/AAAA in the relevant
|
|
558
|
+
// family). Some hosts are v4-only and won't have AAAA; we
|
|
559
|
+
// skip the host but charge the void slot.
|
|
560
|
+
lookups.void = (lookups.void || 0) + 1;
|
|
561
|
+
if (lookups.void > SPF_VOID_LOOKUP_LIMIT) {
|
|
562
|
+
return { error: "permerror",
|
|
563
|
+
reason: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4) during mx expansion" };
|
|
564
|
+
}
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
return { error: "temperror",
|
|
568
|
+
reason: "SPF mx host " + mxHosts[mi] + " A/AAAA lookup failed: " +
|
|
569
|
+
((e && e.message) || String(e)) };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (var ti = 0; ti < targetIps.length; ti += 1) {
|
|
575
|
+
var cidr = targetIps[ti] + "/" + mask;
|
|
576
|
+
if (isIpv6) { if (_ipv6InCidr(ip, cidr)) return { match: true }; }
|
|
577
|
+
else { if (_ipv4InCidr(ip, cidr)) return { match: true }; }
|
|
578
|
+
}
|
|
579
|
+
return { match: false };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// SPF verify — recursive include resolution + ip4 / ip6 / a / mx /
|
|
583
|
+
// include / all / redirect=. The `exists` and `ptr` mechanisms +
|
|
584
|
+
// macro-string expansion remain deferred (see the mechanism dispatch
|
|
585
|
+
// arm for the Re-open condition + operator escape hatch).
|
|
328
586
|
async function spfVerify(opts) {
|
|
329
587
|
opts = opts || {};
|
|
330
588
|
validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
|
|
@@ -427,15 +685,69 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
427
685
|
return { verdict: "permerror",
|
|
428
686
|
explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
|
|
429
687
|
}
|
|
430
|
-
} else if (m.mechanism === "a" || m.mechanism === "mx"
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
//
|
|
688
|
+
} else if (m.mechanism === "a" || m.mechanism === "mx") {
|
|
689
|
+
// RFC 7208 §5.3 / §5.4. The mechanism itself counts as one DNS
|
|
690
|
+
// lookup per §4.6.4 (already incremented by the outer loop's
|
|
691
|
+
// `lookups.count += 1` for non-initial domains; ip4/ip6/all are
|
|
692
|
+
// overcounted as a result, but only by mechanisms whose lookup
|
|
693
|
+
// budget the spec doesn't care about — they're not DNS-touching).
|
|
694
|
+
// The `a` / `mx` arms additionally expand per RFC §4.6.4 (each
|
|
695
|
+
// MX hostname adds another lookup); the helper handles that
|
|
696
|
+
// accounting.
|
|
697
|
+
lookups.count += 1;
|
|
698
|
+
if (lookups.count > lookups.limit) {
|
|
699
|
+
return { verdict: "permerror",
|
|
700
|
+
explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4) at " +
|
|
701
|
+
m.mechanism };
|
|
702
|
+
}
|
|
703
|
+
var amRes = await _spfMatchAMx(m.mechanism, m.raw, ip, isIpv6,
|
|
704
|
+
domain, dnsLookup, lookups);
|
|
705
|
+
if (amRes.error === "permerror") {
|
|
706
|
+
return { verdict: "permerror", explanation: amRes.reason };
|
|
707
|
+
}
|
|
708
|
+
if (amRes.error === "temperror") {
|
|
709
|
+
return { verdict: "temperror", explanation: amRes.reason };
|
|
710
|
+
}
|
|
711
|
+
if (amRes.match) match = true;
|
|
712
|
+
} else if (m.mechanism === "exists" || m.mechanism === "ptr") {
|
|
713
|
+
// RFC 7208 §5.7 (exists) + §5.5 (ptr) — deferred from v0.11.3.
|
|
714
|
+
//
|
|
715
|
+
// exists: requires macro-string expansion (RFC 7208 §7) to be
|
|
716
|
+
// useful in practice; almost every published `exists:` policy
|
|
717
|
+
// uses macros like `exists:%{l}.%{d}._spf.example.com` to do
|
|
718
|
+
// per-recipient or per-IP lookups. A non-macro `exists:` is
|
|
719
|
+
// technically valid but vanishingly rare in published policies.
|
|
720
|
+
//
|
|
721
|
+
// ptr: RFC 7208 §5.5 explicitly says "use of this mechanism
|
|
722
|
+
// is strongly discouraged" — the receiver does reverse-DNS +
|
|
723
|
+
// forward-confirm per query, doubling DNS load and tying the
|
|
724
|
+
// sender's authz to whoever controls their PTR zone. Despite
|
|
725
|
+
// this discouragement, a small minority of legacy senders
|
|
726
|
+
// still publish `+ptr -all` policies as their only SPF stance.
|
|
727
|
+
//
|
|
728
|
+
// Re-open conditions:
|
|
729
|
+
// - exists: macro-string expansion lands in the framework (a
|
|
730
|
+
// standalone slice; tracked under blamejs-roadmap.md), OR an
|
|
731
|
+
// operator surfaces a real `exists:` policy without macros
|
|
732
|
+
// and asks for the simple A-existence form.
|
|
733
|
+
// - ptr: an operator surfaces a legitimate sender whose
|
|
734
|
+
// ONLY SPF stance is `ptr` and needs the framework to
|
|
735
|
+
// evaluate it (rather than the operator's MTA already doing
|
|
736
|
+
// iprev via `b.mail.auth.iprev`).
|
|
737
|
+
//
|
|
738
|
+
// Operator escape hatch today:
|
|
739
|
+
// - exists: senders almost universally have a non-`exists:`
|
|
740
|
+
// mechanism alongside; the framework returns "permerror"
|
|
741
|
+
// here, surfacing the gap, but legitimate mail flow that
|
|
742
|
+
// ALSO carries a passing ip4/ip6/include path is unaffected.
|
|
743
|
+
// - ptr: operators evaluating a ptr-only sender wire
|
|
744
|
+
// `b.mail.auth.iprev(ip)` and treat fcrdns=true the same as
|
|
745
|
+
// SPF pass for that domain.
|
|
434
746
|
return {
|
|
435
747
|
verdict: "permerror",
|
|
436
|
-
explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented
|
|
437
|
-
"
|
|
438
|
-
"
|
|
748
|
+
explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented (RFC 7208 §" +
|
|
749
|
+
(m.mechanism === "exists" ? "5.7 + §7 macros" : "5.5") +
|
|
750
|
+
"); senders typically publish ip4 / ip6 / a / mx / include alongside",
|
|
439
751
|
};
|
|
440
752
|
}
|
|
441
753
|
if (match) {
|
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* @slug mail-crypto-smime
|
|
8
8
|
*
|
|
9
9
|
* @card
|
|
10
|
-
* S/MIME 4.0
|
|
11
|
-
*
|
|
10
|
+
* S/MIME 4.0 sign + verify (PQC-first ML-DSA / SLH-DSA signers) on
|
|
11
|
+
* the b.cms substrate. RFC 8551 multipart/signed with RFC 5652
|
|
12
|
+
* SignedData; EFAIL-class encrypt/decrypt deferred until the AAD-
|
|
13
|
+
* binding posture lands.
|
|
12
14
|
*
|
|
13
15
|
* @intro
|
|
14
16
|
* S/MIME 4.0 (RFC 8551, replacing RFC 5751) `multipart/signed;
|
|
@@ -110,8 +112,10 @@ var ALLOWED_HASHES = ["sha256", "sha384", "sha512"];
|
|
|
110
112
|
var REFUSED_HASHES = ["md5", "sha1"]; // allow:raw-byte-literal — CVE-2017-9006-class
|
|
111
113
|
|
|
112
114
|
// PROFILES + COMPLIANCE_POSTURES — the framework's standard cross-
|
|
113
|
-
// primitive contract.
|
|
114
|
-
//
|
|
115
|
+
// primitive contract. sign() and verify() (live since v0.10.16) read
|
|
116
|
+
// these to determine which hash + RSA-bit floors apply per operator
|
|
117
|
+
// posture; encrypt() / decrypt() (deferred per the @intro EFAIL note)
|
|
118
|
+
// will compose the same set when they land.
|
|
115
119
|
var PROFILES = ["strict", "balanced", "permissive"];
|
|
116
120
|
var COMPLIANCE_POSTURES = {
|
|
117
121
|
hipaa: "strict",
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -1251,7 +1251,29 @@ function create(opts) {
|
|
|
1251
1251
|
var subArgs = sub[2];
|
|
1252
1252
|
if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
|
|
1253
1253
|
if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
|
|
1254
|
-
|
|
1254
|
+
// RFC 9051 §6.4.9 also defines UID SEARCH / UID COPY / UID MOVE /
|
|
1255
|
+
// UID EXPUNGE; deferred from the initial listener slice.
|
|
1256
|
+
//
|
|
1257
|
+
// SEARCH: composes with the existing _handleSearch path; needs
|
|
1258
|
+
// the searchRange path threaded through `useUid: true`.
|
|
1259
|
+
// COPY: composes with the existing _handleCopy path; needs
|
|
1260
|
+
// the mailStore.copyRange opt accepted.
|
|
1261
|
+
// MOVE: RFC 6851; same shape as COPY plus an atomic-delete
|
|
1262
|
+
// step on the source mailbox.
|
|
1263
|
+
// EXPUNGE: RFC 4315 UIDPLUS; expunges by uid-set instead of by
|
|
1264
|
+
// \Deleted-flag scan.
|
|
1265
|
+
//
|
|
1266
|
+
// Re-open condition: operator surfaces a real IMAP client that
|
|
1267
|
+
// refuses to fall back to seq-number variants (most modern
|
|
1268
|
+
// clients — mutt / Thunderbird / Apple Mail / Outlook — already
|
|
1269
|
+
// use the seq-number forms when UID variants are unavailable).
|
|
1270
|
+
//
|
|
1271
|
+
// Operator escape hatch today: clients that issue these UID
|
|
1272
|
+
// sub-commands receive `BAD` and retry against the seq-number
|
|
1273
|
+
// variant (SEARCH / COPY / MOVE / EXPUNGE) which the listener
|
|
1274
|
+
// does serve.
|
|
1275
|
+
_writeTagged(socket, tag, "BAD UID " + subVerb +
|
|
1276
|
+
" is not yet implemented; client may retry with the seq-number form");
|
|
1255
1277
|
}
|
|
1256
1278
|
|
|
1257
1279
|
function _handleIdle(state, socket, tag) {
|
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.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:b5450662-adf2-4f43-baef-731a1c66e80f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-19T17:04:46.477Z",
|
|
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.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.3",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.3",
|
|
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.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.3",
|
|
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.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.3",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|