@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,473 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.helo
4
+ * @nav Mail
5
+ * @title Mail HELO
6
+ * @order 550
7
+ *
8
+ * @intro
9
+ * RFC 5321 §4.1.1.1 HELO / EHLO hostname validation primitive +
10
+ * forward-confirmed-reverse-DNS (FCrDNS, RFC 8601 §2.7.6) verifier.
11
+ * Composes `b.network.dns.resolver` (v0.9.31) for the rDNS + forward
12
+ * lookup pair; pairs with `b.guardSmtpCommand` (v0.9.32) which gates
13
+ * the command-line SHAPE — this primitive evaluates the SEMANTIC
14
+ * identity claim.
15
+ *
16
+ * The MX listener (v0.9.36) calls `b.mail.helo.evaluate({ ip,
17
+ * claimedName, resolver })` at the EHLO boundary and feeds the
18
+ * verdict into the per-connection policy decision (reject /
19
+ * greylist-anyway / score-tag for downstream SpamAssassin / accept).
20
+ *
21
+ * ## Shape gate (RFC 5321 §4.1.1.1 + §4.1.2)
22
+ *
23
+ * - **Domain form**: LDH labels per RFC 5321 §2.3.5, FQDN with at
24
+ * least one `.` (operator can demand multi-label via profile).
25
+ * Bare hostname (no dots) refused under `strict`; localhost-class
26
+ * claims (`localhost`, `localdomain`) always refused regardless
27
+ * of profile.
28
+ * - **Address-literal**: `[1.2.3.4]` IPv4 or `[IPv6:2001:db8::1]`
29
+ * IPv6 per RFC 5321 §4.1.3 — accepted when matches the connection
30
+ * IP, refused otherwise (RFC 5321 §4.1.1.1 implies the literal
31
+ * should be the actual host).
32
+ * - **Empty / too-long**: refused under all profiles.
33
+ *
34
+ * ## FCrDNS check (RFC 8601 §2.7.6 / RFC 1912 §2.1)
35
+ *
36
+ * With `resolver` provided, `evaluate()` issues:
37
+ *
38
+ * 1. PTR for `<connection-ip>.in-addr.arpa` (IPv4) or `.ip6.arpa`
39
+ * (IPv6) — reverse name.
40
+ * 2. A / AAAA for each PTR result — forward name.
41
+ * 3. Match: at least one forward IP must equal the connection IP
42
+ * (the FCrDNS contract).
43
+ *
44
+ * The returned verdict carries the rDNS name(s) + the per-name
45
+ * forward-match outcome so operator audit pipelines see exactly why
46
+ * FCrDNS passed or failed.
47
+ *
48
+ * ## "Generic rDNS" heuristic (operator-configurable)
49
+ *
50
+ * Many spam sources have FCrDNS-valid rDNS that's CLEARLY a consumer
51
+ * ISP dynamic pool (`pool-xx-xx.dialup.example.com`,
52
+ * `dsl-1234.foo.example.net`, etc.). Operators opt-in via
53
+ * `{ genericRdnsPatterns: [<regex>...] }` and the verdict flags
54
+ * genericRdns: true. Pre-shipped pattern list lives in
55
+ * `b.mail.helo.GENERIC_RDNS_PATTERNS` for the common
56
+ * consumer-ISP shapes; operator extends per-deployment.
57
+ *
58
+ * ## Verdict shape
59
+ *
60
+ * ```js
61
+ * {
62
+ * action: "accept" | "reject-shape" | "soft-fail-fcrdns" |
63
+ * "match-self-refused" | "literal-mismatch",
64
+ * shape: "domain" | "address-literal-v4" |
65
+ * "address-literal-v6" | "bare-host" | "invalid",
66
+ * fcrdns: {
67
+ * checked: boolean,
68
+ * passed: boolean,
69
+ * rdnsNames: string[],
70
+ * forwardIps: string[],
71
+ * matchedIp: string | null,
72
+ * } | null,
73
+ * genericRdns: boolean,
74
+ * reason: string,
75
+ * }
76
+ * ```
77
+ *
78
+ * ## CVE / threat model
79
+ *
80
+ * - **HELO spoofing** — RFC 5321 §4.1.1.1 doesn't require HELO
81
+ * accuracy, but a peer claiming `our-mx-cluster.example.com`
82
+ * when its FCrDNS resolves elsewhere is suspect. Operator's
83
+ * `selfNames` list blocks the self-claim spoof.
84
+ * - **Botnet residential-IP class** — generic-rDNS detection +
85
+ * RBL composition catches consumer-ISP dynamic-pool sources
86
+ * before they reach the DATA phase.
87
+ * - **DNS poisoning of PTR** — composed via `b.network.dns.resolver`,
88
+ * so PTR queries inherit the resolver's `safeDns` caps, AD-bit
89
+ * surface, and CVE coverage (CVE-2008-1447 / 2022-3204 /
90
+ * 2023-50387 / 50868 / 2024-1737).
91
+ *
92
+ * ## When NOT to enforce FCrDNS strict
93
+ *
94
+ * IPv6 PTR records are spotty across consumer ISPs; FCrDNS-strict
95
+ * on IPv6 traffic over-rejects. Operator opts to
96
+ * `{ fcrdnsRequiredFor: ["v4"] }` under `balanced` profile when
97
+ * they need to accept v6 senders without PTR records (common with
98
+ * legitimate cloud / VPS providers that don't auto-publish rDNS).
99
+ *
100
+ * @card
101
+ * RFC 5321 §4.1.1.1 HELO/EHLO validation + RFC 8601 §2.7.6 FCrDNS check. Composes b.network.dns.resolver for PTR + forward lookups. Verdict carries shape + FCrDNS pass/fail + generic-rDNS flag for MX listener policy. Operator-configurable selfNames, genericRdnsPatterns, fcrdnsRequiredFor.
102
+ */
103
+
104
+ var { defineClass } = require("./framework-error");
105
+ var lazyRequire = require("./lazy-require");
106
+ var ipUtils = require("./ip-utils");
107
+
108
+ var audit = lazyRequire(function () { return require("./audit"); });
109
+
110
+ var MailHeloError = defineClass("MailHeloError", { alwaysPermanent: true });
111
+
112
+ // RFC 5321 §2.3.5 LDH label: alphanumeric + hyphen (not leading or
113
+ // trailing); §2.3.5 domain: dot-joined LDH labels; total ≤ 255 octets
114
+ // per RFC 1035 §2.3.4.
115
+ var LDH_LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/; // allow:regex-no-length-cap — anchored + per-label repeat cap matches RFC 5321 §2.3.5
116
+ var ADDR_LIT_V4_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
117
+ var ADDR_LIT_V6_RE = /^\[IPv6:([0-9a-fA-F:.]+)\]$/; // allow:regex-no-length-cap — IPv6 textual bounded by overall maxBytes
118
+
119
+ var DEFAULT_MAX_BYTES = 255; // allow:raw-byte-literal — RFC 1035 §2.3.4 cap
120
+ var DEFAULT_PROFILE = "strict";
121
+
122
+ var PROFILES = Object.freeze({
123
+ // Strict: FQDN (≥1 dot), no localhost, no bare-host, FCrDNS strict
124
+ // for IPv4 + IPv6.
125
+ strict: {
126
+ maxBytes: DEFAULT_MAX_BYTES,
127
+ requireFqdn: true,
128
+ refuseBareHost: true,
129
+ fcrdnsRequiredFor: ["v4", "v6"],
130
+ },
131
+ // Balanced: FQDN required, FCrDNS strict for v4 only (consumer IPv6
132
+ // PTR records are spotty).
133
+ balanced: {
134
+ maxBytes: DEFAULT_MAX_BYTES,
135
+ requireFqdn: true,
136
+ refuseBareHost: true,
137
+ fcrdnsRequiredFor: ["v4"],
138
+ },
139
+ // Permissive: shape only; no FCrDNS gate.
140
+ permissive: {
141
+ maxBytes: DEFAULT_MAX_BYTES,
142
+ requireFqdn: false,
143
+ refuseBareHost: false,
144
+ fcrdnsRequiredFor: [],
145
+ },
146
+ });
147
+
148
+ var COMPLIANCE_POSTURES = Object.freeze({
149
+ hipaa: "strict",
150
+ "pci-dss": "strict",
151
+ gdpr: "strict",
152
+ soc2: "strict",
153
+ });
154
+
155
+ // Operator-extensible default list of generic-rDNS patterns the
156
+ // framework ships. Each is a RegExp — case-insensitive — designed
157
+ // to catch the obvious consumer-ISP dynamic-pool naming shapes.
158
+ // The framework's value-add is the gate primitive, not curating a
159
+ // world-class generic-rDNS list; operators extend per deployment.
160
+ var GENERIC_RDNS_PATTERNS = Object.freeze([
161
+ /dynamic/i, // allow:regex-no-length-cap — case-insensitive partial; input length already capped
162
+ /\bdial-?up\b/i, // allow:regex-no-length-cap
163
+ /\bdsl\b/i, // allow:regex-no-length-cap
164
+ /\bcable\b/i, // allow:regex-no-length-cap
165
+ /\bpool[-_]/i, // allow:regex-no-length-cap
166
+ /\bppp[0-9]/i, // allow:regex-no-length-cap
167
+ /\bbroadband\b/i, // allow:regex-no-length-cap
168
+ /\b[0-9]{1,3}-[0-9]{1,3}-[0-9]{1,3}-[0-9]{1,3}\b/, // allow:regex-no-length-cap — IPv4-in-name shape (no anchor on purpose; runs over capped input)
169
+ ]);
170
+
171
+ // Localhost-class claims always refused regardless of profile.
172
+ var LOCALHOST_REFUSED = Object.freeze({
173
+ "localhost": true,
174
+ "localhost.localdomain": true,
175
+ "localdomain": true,
176
+ });
177
+
178
+ /**
179
+ * @primitive b.mail.helo.evaluate
180
+ * @signature b.mail.helo.evaluate(ctx, opts?)
181
+ * @since 0.9.35
182
+ * @status stable
183
+ * @related b.guardSmtpCommand.validate, b.network.dns.resolver.create
184
+ *
185
+ * Evaluate a peer's HELO / EHLO identity claim. Returns a verdict
186
+ * shape the MX listener consumes to drive accept / reject /
187
+ * score-tag policy.
188
+ *
189
+ * @opts
190
+ * profile: "strict" | "balanced" | "permissive",
191
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
192
+ * selfNames: string[], // operator's MX hostnames; claim of these by a peer refused
193
+ * genericRdnsPatterns: RegExp[], // additional patterns layered onto built-ins
194
+ * fcrdnsRequiredFor: ("v4" | "v6")[], // overrides profile's FCrDNS list
195
+ * audit: b.audit namespace,
196
+ *
197
+ * @example
198
+ * var resolver = b.network.dns.resolver.create();
199
+ * var v = await b.mail.helo.evaluate({
200
+ * ip: "203.0.113.42",
201
+ * claimedName: "mail.example.com",
202
+ * resolver: resolver,
203
+ * }, { profile: "strict" });
204
+ * if (v.action === "reject-shape") return reply(550, v.reason);
205
+ */
206
+ async function evaluate(ctx, opts) {
207
+ opts = opts || {};
208
+ var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
209
+ if (!PROFILES[profile]) {
210
+ throw new MailHeloError("mail-helo/bad-profile",
211
+ "evaluate: unknown profile '" + profile + "'");
212
+ }
213
+ var caps = PROFILES[profile];
214
+ var fcrdnsRequiredFor = Array.isArray(opts.fcrdnsRequiredFor) ? opts.fcrdnsRequiredFor : caps.fcrdnsRequiredFor;
215
+ var selfNames = (opts.selfNames || []).map(function (n) { return String(n).toLowerCase(); });
216
+ var auditImpl = opts.audit || audit();
217
+
218
+ if (!ctx || typeof ctx !== "object") {
219
+ throw new MailHeloError("mail-helo/bad-input",
220
+ "evaluate: ctx must be a plain object");
221
+ }
222
+ if (typeof ctx.claimedName !== "string" || ctx.claimedName.length === 0) {
223
+ throw new MailHeloError("mail-helo/bad-input",
224
+ "evaluate: ctx.claimedName must be a non-empty string");
225
+ }
226
+ if (typeof ctx.ip !== "string" || ctx.ip.length === 0) {
227
+ throw new MailHeloError("mail-helo/bad-input",
228
+ "evaluate: ctx.ip must be a non-empty string");
229
+ }
230
+
231
+ var claimed = ctx.claimedName.trim();
232
+ if (Buffer.byteLength(claimed, "utf8") > caps.maxBytes) {
233
+ return _emit(auditImpl, "reject-shape", {
234
+ shape: "invalid",
235
+ reason: "claimedName exceeds " + caps.maxBytes + " bytes (RFC 1035 §2.3.4)",
236
+ });
237
+ }
238
+
239
+ // Classify shape: address-literal vs domain vs invalid.
240
+ var v4Lit = claimed.match(ADDR_LIT_V4_RE);
241
+ var v6Lit = claimed.match(ADDR_LIT_V6_RE);
242
+ if (v4Lit) {
243
+ if (v4Lit[1] !== ctx.ip) {
244
+ return _emit(auditImpl, "literal-mismatch", {
245
+ shape: "address-literal-v4",
246
+ reason: "address-literal '" + v4Lit[1] + "' does not match connection IP '" + ctx.ip + "' (RFC 5321 §4.1.1.1)",
247
+ });
248
+ }
249
+ return _emit(auditImpl, "accept", {
250
+ shape: "address-literal-v4",
251
+ reason: "address-literal matches connection IP",
252
+ });
253
+ }
254
+ if (v6Lit) {
255
+ // Both sides expanded to canonical form for compare.
256
+ var claimedHex = ipUtils.expandIpv6Hex(v6Lit[1]);
257
+ var ipHex = ipUtils.expandIpv6Hex(ctx.ip);
258
+ if (!claimedHex || !ipHex || claimedHex !== ipHex) {
259
+ return _emit(auditImpl, "literal-mismatch", {
260
+ shape: "address-literal-v6",
261
+ reason: "IPv6 address-literal '" + v6Lit[1] + "' does not match connection IP '" + ctx.ip + "' (RFC 5321 §4.1.3)",
262
+ });
263
+ }
264
+ return _emit(auditImpl, "accept", {
265
+ shape: "address-literal-v6",
266
+ reason: "IPv6 address-literal matches connection IP",
267
+ });
268
+ }
269
+
270
+ // Not an address-literal; must be a domain.
271
+ var lower = claimed.toLowerCase();
272
+ if (LOCALHOST_REFUSED[lower]) {
273
+ return _emit(auditImpl, "reject-shape", {
274
+ shape: "invalid",
275
+ reason: "localhost-class claim '" + lower + "' refused (RFC 6761 §6.3 reserved name)",
276
+ });
277
+ }
278
+ if (selfNames.indexOf(lower) !== -1) {
279
+ return _emit(auditImpl, "match-self-refused", {
280
+ shape: "domain",
281
+ reason: "peer claims our own MX hostname '" + lower + "' (HELO-self spoofing)",
282
+ });
283
+ }
284
+
285
+ var labels = claimed.split(".");
286
+ var isFqdn = labels.length >= 2 && labels.every(function (l) { return l.length > 0; });
287
+ if (!isFqdn && caps.requireFqdn) {
288
+ return _emit(auditImpl, "reject-shape", {
289
+ shape: "bare-host",
290
+ reason: "claimedName not FQDN (no '.'); RFC 5321 §4.1.1.1 requires primary host name",
291
+ });
292
+ }
293
+ if (labels.length === 1 && caps.refuseBareHost) {
294
+ return _emit(auditImpl, "reject-shape", {
295
+ shape: "bare-host",
296
+ reason: "bare host '" + claimed + "' refused; FQDN required",
297
+ });
298
+ }
299
+ for (var i = 0; i < labels.length; i += 1) {
300
+ var l = labels[i];
301
+ if (l.length === 0) {
302
+ return _emit(auditImpl, "reject-shape", {
303
+ shape: "invalid",
304
+ reason: "empty label (consecutive dots)",
305
+ });
306
+ }
307
+ if (!LDH_LABEL_RE.test(l)) { // allow:regex-no-length-cap — claimed already capped at maxBytes; label length-bounded by LDH_LABEL_RE's repeat cap
308
+ return _emit(auditImpl, "reject-shape", {
309
+ shape: "invalid",
310
+ reason: "label '" + l + "' not LDH-shaped (RFC 5321 §2.3.5)",
311
+ });
312
+ }
313
+ }
314
+
315
+ // Shape OK — run FCrDNS if resolver provided and profile requires.
316
+ var ipKind = ipUtils.expandIpv6Hex(ctx.ip) ? "v6" : "v4";
317
+ var needFcrdns = ctx.resolver && fcrdnsRequiredFor.indexOf(ipKind) !== -1;
318
+ if (!needFcrdns) {
319
+ var generic = _checkGenericRdns(opts.genericRdnsPatterns, [claimed]);
320
+ var rv = {
321
+ action: "accept",
322
+ shape: "domain",
323
+ fcrdns: null,
324
+ genericRdns: generic,
325
+ reason: "shape passed; FCrDNS skipped (no resolver or " + ipKind + " not in fcrdnsRequiredFor)",
326
+ };
327
+ _emitAudit(auditImpl, "mail.helo.accept", rv);
328
+ return rv;
329
+ }
330
+
331
+ var fcrdnsResult = await _runFcrdns(ctx.ip, ctx.resolver);
332
+ var rv2 = {
333
+ action: fcrdnsResult.passed ? "accept" : "soft-fail-fcrdns",
334
+ shape: "domain",
335
+ fcrdns: fcrdnsResult,
336
+ genericRdns: _checkGenericRdns(opts.genericRdnsPatterns, fcrdnsResult.rdnsNames.concat([claimed])),
337
+ reason: fcrdnsResult.passed
338
+ ? "FCrDNS verified (rDNS → forward → match connection IP)"
339
+ : "FCrDNS failed (rDNS resolved but forward did not match connection IP)",
340
+ };
341
+ _emitAudit(auditImpl, fcrdnsResult.passed ? "mail.helo.accept" : "mail.helo.fcrdns_failed", rv2);
342
+ return rv2;
343
+ }
344
+
345
+ async function _runFcrdns(ip, resolver) {
346
+ var result = {
347
+ checked: true,
348
+ passed: false,
349
+ rdnsNames: [],
350
+ forwardIps: [],
351
+ matchedIp: null,
352
+ };
353
+ var rev = _reverseName(ip);
354
+ if (!rev) {
355
+ return result; // unparseable IP — caller already rejected
356
+ }
357
+ try {
358
+ var ptr = await resolver.queryPtr(rev);
359
+ if (ptr && ptr.rrs) {
360
+ result.rdnsNames = ptr.rrs.map(function (r) { return r.decoded; }).filter(Boolean);
361
+ }
362
+ } catch (_e) { /* NXDOMAIN or upstream — leave empty so passed stays false */ }
363
+
364
+ for (var i = 0; i < result.rdnsNames.length; i += 1) {
365
+ var name = result.rdnsNames[i];
366
+ try {
367
+ var isV6 = ipUtils.expandIpv6Hex(ip) !== null;
368
+ var fwd = isV6 ? await resolver.queryAaaa(name) : await resolver.queryA(name);
369
+ if (fwd && fwd.rrs) {
370
+ for (var j = 0; j < fwd.rrs.length; j += 1) {
371
+ var fip = fwd.rrs[j].decoded;
372
+ if (fip) {
373
+ result.forwardIps.push(fip);
374
+ if (_ipEqual(fip, ip)) {
375
+ result.passed = true;
376
+ result.matchedIp = fip;
377
+ }
378
+ }
379
+ }
380
+ }
381
+ } catch (_e) { /* per-name fwd failure is non-fatal; check the next */ }
382
+ }
383
+ return result;
384
+ }
385
+
386
+ function _reverseName(ip) {
387
+ if (typeof ip !== "string") return null;
388
+ // Reuse the RFC 5782 reverse-name construction on mail-rbl —
389
+ // appended to in-addr.arpa or ip6.arpa for the PTR query name.
390
+ if (ipUtils.isIPv4Shape(ip)) {
391
+ return ip.split(".").reverse().join(".") + ".in-addr.arpa";
392
+ }
393
+ var hex = ipUtils.expandIpv6Hex(ip);
394
+ if (!hex) return null;
395
+ var rev = "";
396
+ for (var i = hex.length - 1; i >= 0; i -= 1) {
397
+ rev += hex[i];
398
+ if (i > 0) rev += ".";
399
+ }
400
+ return rev + ".ip6.arpa";
401
+ }
402
+
403
+ function _ipEqual(a, b) {
404
+ if (a === b) return true;
405
+ var ha = ipUtils.expandIpv6Hex(a);
406
+ var hb = ipUtils.expandIpv6Hex(b);
407
+ if (ha && hb) return ha === hb;
408
+ return false;
409
+ }
410
+
411
+ function _checkGenericRdns(extraPatterns, names) {
412
+ var patterns = GENERIC_RDNS_PATTERNS.slice();
413
+ if (Array.isArray(extraPatterns)) {
414
+ for (var p = 0; p < extraPatterns.length; p += 1) {
415
+ if (extraPatterns[p] instanceof RegExp) patterns.push(extraPatterns[p]);
416
+ }
417
+ }
418
+ for (var n = 0; n < names.length; n += 1) {
419
+ if (typeof names[n] !== "string") continue;
420
+ for (var q = 0; q < patterns.length; q += 1) {
421
+ if (patterns[q].test(names[n])) return true;
422
+ }
423
+ }
424
+ return false;
425
+ }
426
+
427
+ function _emit(auditImpl, action, partial) {
428
+ var rv = Object.assign({
429
+ action: action,
430
+ fcrdns: null,
431
+ genericRdns: false,
432
+ }, partial);
433
+ _emitAudit(auditImpl, "mail.helo." + action, rv);
434
+ return rv;
435
+ }
436
+
437
+ function _emitAudit(auditImpl, action, metadata) {
438
+ try {
439
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
440
+ auditImpl.safeEmit({
441
+ action: action,
442
+ outcome: "success",
443
+ metadata: metadata,
444
+ });
445
+ }
446
+ } catch (_e) { /* drop-silent — audit emit failure must not block MX accept loop */ }
447
+ }
448
+
449
+ /**
450
+ * @primitive b.mail.helo.compliancePosture
451
+ * @signature b.mail.helo.compliancePosture(posture)
452
+ * @since 0.9.35
453
+ * @status stable
454
+ *
455
+ * Return the effective profile name for a compliance posture, or
456
+ * `null` for unknown posture names.
457
+ *
458
+ * @example
459
+ * b.mail.helo.compliancePosture("hipaa"); // → "strict"
460
+ */
461
+ function compliancePosture(posture) {
462
+ return COMPLIANCE_POSTURES[posture] || null;
463
+ }
464
+
465
+ module.exports = {
466
+ evaluate: evaluate,
467
+ compliancePosture: compliancePosture,
468
+ PROFILES: PROFILES,
469
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
470
+ GENERIC_RDNS_PATTERNS: GENERIC_RDNS_PATTERNS,
471
+ MailHeloError: MailHeloError,
472
+ _reverseName: _reverseName,
473
+ };