@blamejs/core 0.9.28 → 0.9.39

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.
@@ -0,0 +1,392 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.rbl
4
+ * @nav Mail
5
+ * @title Mail RBL
6
+ * @order 540
7
+ *
8
+ * @intro
9
+ * RFC 5782 DNS-based blocklist (DNSBL) + allowlist (DNSWL) query
10
+ * primitive. Composes `b.network.dns.resolver` for the underlying
11
+ * DNS queries and surfaces a structured `{ listed, allowed,
12
+ * neutral, errors }` shape for the MX listener (v0.9.34) and
13
+ * submission listener (v0.9.35) to consume per-connection.
14
+ *
15
+ * ## Query construction
16
+ *
17
+ * - **IPv4** — octets reversed, blocklist domain suffixed. RFC 5782
18
+ * §2.1: address `192.0.2.99` against `bl.spamcop.net` becomes the
19
+ * query name `99.2.0.192.bl.spamcop.net`.
20
+ * - **IPv6** — nibble-reversed across all 128 bits (32 hex nibbles),
21
+ * blocklist domain suffixed. RFC 5782 §2.4: address
22
+ * `2001:db8::1` against `ugly.example.com` becomes
23
+ * `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ugly.example.com`.
24
+ * - **Domain blocklists** (Spamhaus DBL / SURBL — RFC 5782 §3) —
25
+ * query the domain directly against the list zone, no reverse.
26
+ *
27
+ * ## A-record semantics (RFC 5782 §2.1)
28
+ *
29
+ * The A-record return is a SEMANTIC code, not a routable address.
30
+ * Convention is `127.0.0.x`:
31
+ * - `127.0.0.2` — listed (generic).
32
+ * - `127.0.0.4+` — operator-specific sub-list (Spamhaus uses
33
+ * `127.0.0.4` SBL, `127.0.0.5` XBL, etc.).
34
+ * - `127.255.255.252+` — RFC 5782 §5 test addresses.
35
+ * The primitive exposes the raw bytes so operator's MX policy can
36
+ * inspect the sub-list code.
37
+ *
38
+ * ## TXT-record reason (RFC 5782 §2.2)
39
+ *
40
+ * Many DNSBLs publish a TXT record alongside the A — short prose
41
+ * describing why the IP is listed (often with a URL for delisting).
42
+ * The primitive fetches it lazily — operator opts in via
43
+ * `{ withReason: true }` per-query when they want to render the
44
+ * reason back to the peer via SMTP 550 message.
45
+ *
46
+ * ## DNSWL allowlists
47
+ *
48
+ * `b.mail.rbl.create({ ..., allowlists: [...] })` — operator wires
49
+ * any list as DNSBL (refuse on listed) OR DNSWL (allow on listed).
50
+ * Same query shape; the verdict semantics differ. RFC 5782 §3.2
51
+ * notes TXT records on DNSWLs are operationally less useful since
52
+ * SMTP can't advise the peer WHY they were accepted, but the field
53
+ * is surfaced for audit visibility regardless.
54
+ *
55
+ * ## CVE / threat model
56
+ *
57
+ * - **Blocklist-cache amplification** — each list query goes through
58
+ * `b.network.dns.resolver` so cache + TTL + serve-stale already
59
+ * defend against amplification + flood from a single hostile peer.
60
+ * - **DoS-by-query** — operator-configurable per-connection
61
+ * concurrent-query cap (default 8) and per-IP query timeout
62
+ * (default 5s); a slow / unresponsive list can't stall the MX
63
+ * listener.
64
+ * - **DNS-poisoning** — every response parses through `b.safeDns`
65
+ * (bounded RR counts, bounded TXT length) via the resolver, so
66
+ * a poisoned upstream response can't smuggle oversized rdata.
67
+ *
68
+ * ## Why it exists
69
+ *
70
+ * The MX listener (v0.9.34) needs RBL queries on every accepted
71
+ * connection for SPF / IP-reputation evaluation; the submission
72
+ * listener checks operator's own submission-rate / spam-source
73
+ * lists. Without this primitive each consumer rolls its own
74
+ * reverse-IP construction + A-record sub-code interpretation, and
75
+ * the per-list query timeout / cap is operator-specific instead of
76
+ * framework-shared.
77
+ *
78
+ * @card
79
+ * RFC 5782 DNSBL + DNSWL query primitive. Composes b.network.dns.resolver;
80
+ * reverses IPv4 octets + IPv6 nibbles; surfaces A-record return code
81
+ * and optional TXT reason. Operator-configurable list set + concurrent-
82
+ * query cap + per-list timeout.
83
+ */
84
+
85
+ var C = require("./constants");
86
+ var { defineClass } = require("./framework-error");
87
+ var lazyRequire = require("./lazy-require");
88
+ var ipUtils = require("./ip-utils");
89
+
90
+ var audit = lazyRequire(function () { return require("./audit"); });
91
+
92
+ var MailRblError = defineClass("MailRblError", { alwaysPermanent: true });
93
+
94
+ var IPV4_RE = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; // allow:regex-no-length-cap — anchored + per-octet repeat-cap
95
+ var IPV6_HEX_RE = /^[0-9a-fA-F:]+$/; // allow:regex-no-length-cap — checked by length cap below
96
+ var IPV6_MAX_LEN = 39; // allow:raw-byte-literal — max IPv6 textual length (8 groups × 4 hex + 7 colons)
97
+
98
+ var DEFAULT_TIMEOUT_MS = C.TIME.seconds(5);
99
+ var DEFAULT_CONCURRENCY = 8; // allow:raw-byte-literal — concurrent-query cap, not bytes
100
+ var DEFAULT_PROFILE = "strict";
101
+
102
+ var PROFILES = Object.freeze({
103
+ strict: { maxConcurrent: 8, perListTimeoutMs: C.TIME.seconds(5), maxListsPerQuery: 16 }, // allow:raw-byte-literal — list-count cap
104
+ balanced: { maxConcurrent: 16, perListTimeoutMs: C.TIME.seconds(10), maxListsPerQuery: 32 }, // allow:raw-byte-literal — list-count cap
105
+ permissive: { maxConcurrent: 32, perListTimeoutMs: C.TIME.seconds(20), maxListsPerQuery: 64 }, // allow:raw-byte-literal — list-count cap
106
+ });
107
+
108
+ var COMPLIANCE_POSTURES = Object.freeze({
109
+ hipaa: "strict",
110
+ "pci-dss": "strict",
111
+ gdpr: "strict",
112
+ soc2: "strict",
113
+ });
114
+
115
+ /**
116
+ * @primitive b.mail.rbl.create
117
+ * @signature b.mail.rbl.create(opts)
118
+ * @since 0.9.33
119
+ * @status stable
120
+ * @related b.network.dns.resolver.create, b.safeDns.parseResponse
121
+ *
122
+ * Build an RBL query instance. Returns an object with
123
+ * `.query(ip, opts) → Promise<verdict>` and
124
+ * `.queryDomain(domain, opts) → Promise<verdict>` methods.
125
+ *
126
+ * @opts
127
+ * resolver: b.network.dns.resolver.create() instance, required
128
+ * blocklists: Array<string> — DNS zones (e.g. "bl.spamcop.net")
129
+ * allowlists: Array<string> — DNSWL zones (e.g. "list.dnswl.org")
130
+ * profile: "strict" | "balanced" | "permissive"
131
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2"
132
+ * withReason: boolean — default false; fetch TXT record per A hit
133
+ * audit: b.audit namespace
134
+ *
135
+ * @example
136
+ * var rbl = b.mail.rbl.create({
137
+ * resolver: b.network.dns.resolver.create(),
138
+ * blocklists: ["zen.spamhaus.org", "bl.spamcop.net"],
139
+ * });
140
+ * var verdict = await rbl.query("192.0.2.99", { withReason: true });
141
+ * if (verdict.listed.length) refuseConnection(verdict.listed[0].reason);
142
+ */
143
+ function create(opts) {
144
+ opts = opts || {};
145
+ if (!opts.resolver || typeof opts.resolver.query !== "function") {
146
+ throw new MailRblError("mail-rbl/bad-resolver",
147
+ "create: opts.resolver must be a b.network.dns.resolver.create() instance");
148
+ }
149
+ var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
150
+ if (!PROFILES[profile]) {
151
+ throw new MailRblError("mail-rbl/bad-profile",
152
+ "create: unknown profile '" + profile + "'");
153
+ }
154
+ var caps = PROFILES[profile];
155
+ var blocklists = Array.isArray(opts.blocklists) ? opts.blocklists.slice() : [];
156
+ var allowlists = Array.isArray(opts.allowlists) ? opts.allowlists.slice() : [];
157
+ var withReason = opts.withReason === true;
158
+ var auditImpl = opts.audit || audit();
159
+ if (blocklists.length + allowlists.length === 0) {
160
+ throw new MailRblError("mail-rbl/no-lists",
161
+ "create: must configure at least one blocklist or allowlist");
162
+ }
163
+ if (blocklists.length + allowlists.length > caps.maxListsPerQuery) {
164
+ throw new MailRblError("mail-rbl/too-many-lists",
165
+ "create: " + (blocklists.length + allowlists.length) +
166
+ " lists configured; profile cap is " + caps.maxListsPerQuery);
167
+ }
168
+ _validateZoneNames(blocklists.concat(allowlists));
169
+
170
+ async function query(ip, qopts) {
171
+ qopts = qopts || {};
172
+ if (typeof ip !== "string" || ip.length === 0) {
173
+ throw new MailRblError("mail-rbl/bad-input",
174
+ "query: ip must be a non-empty string");
175
+ }
176
+ var reverse = reverseIp(ip);
177
+ return _walkLists(reverse, qopts);
178
+ }
179
+
180
+ async function queryDomain(domain, qopts) {
181
+ qopts = qopts || {};
182
+ if (typeof domain !== "string" || domain.length === 0) {
183
+ throw new MailRblError("mail-rbl/bad-input",
184
+ "queryDomain: domain must be a non-empty string");
185
+ }
186
+ if (domain.indexOf(".") === -1) {
187
+ throw new MailRblError("mail-rbl/bad-input",
188
+ "queryDomain: domain must contain at least one label separator");
189
+ }
190
+ return _walkLists(domain, qopts);
191
+ }
192
+
193
+ async function _walkLists(prefix, qopts) {
194
+ var perQueryReason = qopts.withReason !== undefined ? qopts.withReason === true : withReason;
195
+ var allLists = blocklists.map(function (z) { return { zone: z, kind: "block" }; })
196
+ .concat(allowlists.map(function (z) { return { zone: z, kind: "allow" }; }));
197
+
198
+ var verdict = { listed: [], allowed: [], neutral: [], errors: [] };
199
+ var inFlight = 0;
200
+ var idx = 0;
201
+
202
+ return new Promise(function (resolve) {
203
+ function _emit() {
204
+ // Schedule up to maxConcurrent at any time.
205
+ while (inFlight < caps.maxConcurrent && idx < allLists.length) {
206
+ var entry = allLists[idx];
207
+ idx += 1;
208
+ inFlight += 1;
209
+ _checkList(prefix, entry, perQueryReason, caps).then(function (rv) {
210
+ inFlight -= 1;
211
+ if (rv.error) {
212
+ verdict.errors.push({ list: rv.list, message: rv.error });
213
+ } else if (rv.listed) {
214
+ if (rv.kind === "allow") verdict.allowed.push(rv);
215
+ else verdict.listed.push(rv);
216
+ } else {
217
+ verdict.neutral.push({ list: rv.list });
218
+ }
219
+ if (idx >= allLists.length && inFlight === 0) resolve(verdict);
220
+ else _emit();
221
+ });
222
+ }
223
+ if (idx >= allLists.length && inFlight === 0) resolve(verdict);
224
+ }
225
+ _emit();
226
+ });
227
+ }
228
+
229
+ return {
230
+ query: query,
231
+ queryDomain: queryDomain,
232
+ blocklists: blocklists,
233
+ allowlists: allowlists,
234
+ profile: profile,
235
+ MailRblError: MailRblError,
236
+ };
237
+
238
+ async function _checkList(prefix, entry, perQueryReason, capsArg) {
239
+ var name = prefix + "." + entry.zone;
240
+ var rv = { list: entry.zone, kind: entry.kind, listed: false };
241
+ try {
242
+ var aResp = await _withTimeout(opts.resolver.queryA(name), capsArg.perListTimeoutMs);
243
+ if (aResp && aResp.rrs && aResp.rrs.length > 0) {
244
+ rv.listed = true;
245
+ rv.returnCode = aResp.rrs[0].decoded;
246
+ if (perQueryReason) {
247
+ try {
248
+ var txtResp = await _withTimeout(opts.resolver.queryTxt(name), capsArg.perListTimeoutMs);
249
+ if (txtResp && txtResp.rrs && txtResp.rrs.length > 0) {
250
+ rv.reason = (txtResp.rrs[0].decoded || []).join("");
251
+ }
252
+ } catch (_e) { /* TXT failure is non-fatal; A bit already set the verdict */ }
253
+ }
254
+ _emitAudit(auditImpl, "mail.rbl." + entry.kind + "_listed", {
255
+ list: entry.zone, returnCode: rv.returnCode,
256
+ });
257
+ }
258
+ } catch (e) {
259
+ // NXDOMAIN is the expected "not listed" response, not an error
260
+ // condition. RFC 5782 §2.1.1 — absence of any A record means
261
+ // "not in list". Resolver surfaces this as resolver/nxdomain-or-
262
+ // error which we treat as the neutral verdict.
263
+ if (e && e.code === "resolver/nxdomain-or-error") {
264
+ // Neutral — not listed; not an error.
265
+ return rv;
266
+ }
267
+ rv.error = (e && e.message) || String(e);
268
+ }
269
+ return rv;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * @primitive b.mail.rbl.reverseIp
275
+ * @signature b.mail.rbl.reverseIp(ip)
276
+ * @since 0.9.33
277
+ * @status stable
278
+ *
279
+ * Build the reverse-DNS query name for an IPv4 or IPv6 address per
280
+ * RFC 5782 §2.1 / §2.4. Pure-functional helper exposed for operator
281
+ * tests and the `b.mail.dnsbl` extension primitive.
282
+ *
283
+ * @example
284
+ * b.mail.rbl.reverseIp("192.0.2.99"); // → "99.2.0.192"
285
+ * b.mail.rbl.reverseIp("2001:db8::1"); // → "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
286
+ */
287
+ function reverseIp(ip) {
288
+ if (typeof ip !== "string" || ip.length === 0) {
289
+ throw new MailRblError("mail-rbl/bad-input",
290
+ "reverseIp: ip must be a non-empty string");
291
+ }
292
+ // IPv4 first.
293
+ if (IPV4_RE.test(ip)) {
294
+ return ip.split(".").reverse().join(".");
295
+ }
296
+ // IPv6 — accept canonical / compressed forms. Expand and nibble-reverse.
297
+ if (ip.length > IPV6_MAX_LEN || !IPV6_HEX_RE.test(ip)) {
298
+ throw new MailRblError("mail-rbl/bad-input",
299
+ "reverseIp: '" + ip + "' is not a valid IPv4 or IPv6 address");
300
+ }
301
+ var expanded = ipUtils.expandIpv6Hex(ip);
302
+ if (!expanded) {
303
+ throw new MailRblError("mail-rbl/bad-input",
304
+ "reverseIp: '" + ip + "' is not a parseable IPv6 address");
305
+ }
306
+ // expanded is 32 hex chars (128 bits / 4 = 32 nibbles); reverse + dot-join.
307
+ var rev = [];
308
+ for (var i = expanded.length - 1; i >= 0; i -= 1) rev.push(expanded[i]);
309
+ return rev.join(".");
310
+ }
311
+
312
+ /**
313
+ * @primitive b.mail.rbl.compliancePosture
314
+ * @signature b.mail.rbl.compliancePosture(posture)
315
+ * @since 0.9.33
316
+ * @status stable
317
+ *
318
+ * Return the effective profile name for a compliance posture, or
319
+ * `null` for unknown posture names.
320
+ *
321
+ * @example
322
+ * b.mail.rbl.compliancePosture("hipaa"); // → "strict"
323
+ */
324
+ function compliancePosture(posture) {
325
+ return COMPLIANCE_POSTURES[posture] || null;
326
+ }
327
+
328
+ function _validateZoneNames(zones) {
329
+ for (var i = 0; i < zones.length; i += 1) {
330
+ var z = zones[i];
331
+ if (typeof z !== "string" || z.length === 0 || z.length > 253) { // allow:raw-byte-literal — RFC 1035 §2.3.4 total name cap
332
+ throw new MailRblError("mail-rbl/bad-zone",
333
+ "list zone '" + z + "' must be a non-empty string under 253 bytes");
334
+ }
335
+ if (z.indexOf("..") !== -1 || z.charAt(0) === "." || z.charAt(z.length - 1) === ".") {
336
+ throw new MailRblError("mail-rbl/bad-zone",
337
+ "list zone '" + z + "' has malformed dots");
338
+ }
339
+ // ASCII label-shape — DNSBL zones are always ASCII (IDN punycode
340
+ // if non-ASCII upstream).
341
+ for (var c = 0; c < z.length; c += 1) {
342
+ var cc = z.charCodeAt(c);
343
+ if (cc < 0x20 || cc === 0x7f || cc > 0x7e) { // allow:raw-byte-literal — RFC 1035 ASCII zone-name shape
344
+ throw new MailRblError("mail-rbl/bad-zone",
345
+ "list zone '" + z + "' contains non-ASCII or control chars");
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ function _withTimeout(promise, timeoutMs) {
352
+ return new Promise(function (resolve, reject) {
353
+ var done = false;
354
+ var t = setTimeout(function () {
355
+ if (done) return;
356
+ done = true;
357
+ reject(new MailRblError("mail-rbl/timeout",
358
+ "list query exceeded " + timeoutMs + "ms"));
359
+ }, timeoutMs);
360
+ promise.then(function (v) {
361
+ if (done) return;
362
+ done = true;
363
+ clearTimeout(t);
364
+ resolve(v);
365
+ }, function (e) {
366
+ if (done) return;
367
+ done = true;
368
+ clearTimeout(t);
369
+ reject(e);
370
+ });
371
+ });
372
+ }
373
+
374
+ function _emitAudit(auditImpl, action, metadata) {
375
+ try {
376
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
377
+ auditImpl.safeEmit({ action: action, outcome: "success", metadata: metadata });
378
+ }
379
+ } catch (_e) { /* drop-silent — audit failures don't break query path */ }
380
+ }
381
+
382
+ void DEFAULT_TIMEOUT_MS; // referenced indirectly via PROFILES.strict.perListTimeoutMs
383
+ void DEFAULT_CONCURRENCY; // referenced indirectly via PROFILES.strict.maxConcurrent
384
+
385
+ module.exports = {
386
+ create: create,
387
+ reverseIp: reverseIp,
388
+ compliancePosture: compliancePosture,
389
+ PROFILES: PROFILES,
390
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
391
+ MailRblError: MailRblError,
392
+ };
package/lib/mail.js CHANGED
@@ -65,6 +65,7 @@ var guardEmail = lazyRequire(function () { return require("./guard-email"); });
65
65
  var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
66
66
  var fileType = lazyRequire(function () { return require("./file-type"); });
67
67
  var dkim = require("./mail-dkim");
68
+ var ipUtils = require("./ip-utils");
68
69
  var mailAuth = require("./mail-auth");
69
70
  var mailBimi = require("./mail-bimi");
70
71
  var mailUnsubscribe = require("./mail-unsubscribe");
@@ -785,7 +786,7 @@ function smtpTransport(opts) {
785
786
  var host = opts.host;
786
787
  var servername = opts.servername;
787
788
  if (servername === undefined) {
788
- servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || (host && host.indexOf(":") !== -1))
789
+ servername = (ipUtils.isIPv4Shape(host) || (host && host.indexOf(":") !== -1))
789
790
  ? undefined : host;
790
791
  }
791
792