@blamejs/core 0.9.46 → 0.9.49

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.
@@ -126,6 +126,8 @@ var safeBuffer = require("./safe-buffer");
126
126
  var safeSmtp = require("./safe-smtp");
127
127
  var validateOpts = require("./validate-opts");
128
128
  var guardSmtpCommand = require("./guard-smtp-command");
129
+ var guardDomain = require("./guard-domain");
130
+ var mailServerRateLimit = require("./mail-server-rate-limit");
129
131
  var { defineClass } = require("./framework-error");
130
132
 
131
133
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -232,6 +234,66 @@ function create(opts) {
232
234
  var relayAllowedFor = opts.relayAllowedFor || [];
233
235
  var profile = opts.profile || "strict";
234
236
 
237
+ // Default-on per-IP rate limit. Operators pass `rateLimit: false` to
238
+ // disable (only for tests / closed networks), pass a rate-limit
239
+ // handle from b.mail.server.rateLimit.create({...}) to share one
240
+ // budget across multiple listeners, or pass an opts object to
241
+ // override defaults.
242
+ var rateLimit;
243
+ if (opts.rateLimit === false) {
244
+ rateLimit = mailServerRateLimit.create({ disabled: true });
245
+ } else if (opts.rateLimit && typeof opts.rateLimit.admitConnection === "function") {
246
+ rateLimit = opts.rateLimit;
247
+ } else {
248
+ rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
249
+ }
250
+
251
+ // Default-on operator-supplied-domain hardening. opts.localDomains
252
+ // and the HELO / MAIL FROM / RCPT TO domain validations all route
253
+ // through `b.guardDomain` for IDN homograph defense (CVE-2017-5469
254
+ // class), special-use-domain refusal (RFC 6761), label-length cap
255
+ // (RFC 1035 §2.3.4), and bare-IP-as-domain refusal (CVE-2021-22931
256
+ // class). Operators with a closed-network deployment can pass
257
+ // `guardDomain: false` to skip; the default keeps the protection on.
258
+ var guardDomainProfile;
259
+ if (opts.guardDomain === false) {
260
+ guardDomainProfile = null;
261
+ } else {
262
+ guardDomainProfile = guardDomain.buildProfile({
263
+ profile: opts.guardDomain && typeof opts.guardDomain === "object"
264
+ ? (opts.guardDomain.profile || profile)
265
+ : profile,
266
+ });
267
+ }
268
+ function _validateDomainHardened(d, label) {
269
+ if (!guardDomainProfile) return { ok: true };
270
+ var verdict = guardDomain.validate(d, guardDomainProfile);
271
+ if (!verdict.ok) {
272
+ _emit("mail.server.mx.domain_refused", {
273
+ reason: verdict.issues && verdict.issues[0] && verdict.issues[0].kind,
274
+ domain: d,
275
+ label: label,
276
+ }, "denied");
277
+ }
278
+ return verdict;
279
+ }
280
+
281
+ // Pre-validate operator-supplied localDomains at boot — the same
282
+ // shape they enforce on RCPT TO must itself pass the validator,
283
+ // otherwise an operator who typed an IDN homograph (or an IP) into
284
+ // their allowlist would silently weaken the gate.
285
+ if (guardDomainProfile) {
286
+ for (var __ldi = 0; __ldi < localDomains.length; __ldi += 1) {
287
+ var __ldVerdict = guardDomain.validate(localDomains[__ldi], guardDomainProfile);
288
+ if (!__ldVerdict.ok) {
289
+ throw new MailServerMxError("mail-server-mx/bad-local-domain",
290
+ "mail.server.mx.create: localDomains[" + __ldi + "] '" + localDomains[__ldi] +
291
+ "' rejected by b.guardDomain (" +
292
+ (__ldVerdict.issues && __ldVerdict.issues[0] && __ldVerdict.issues[0].kind) + ")");
293
+ }
294
+ }
295
+ }
296
+
235
297
  var tcpServer = null;
236
298
  var listening = false;
237
299
  var connections = new Set();
@@ -248,19 +310,35 @@ function create(opts) {
248
310
 
249
311
  // ---- Per-connection state machine ---------------------------------------
250
312
  function _handleConnection(socket) {
313
+ var remoteAddress = socket.remoteAddress || "0.0.0.0";
314
+ var admit = rateLimit.admitConnection(remoteAddress);
315
+ if (!admit.ok) {
316
+ // 421 4.7.0 — transient refusal; sender retries elsewhere or later.
317
+ // RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
318
+ _emit("mail.server.mx.rate_limit_refused",
319
+ { remoteAddress: remoteAddress, reason: admit.reason }, "denied");
320
+ try {
321
+ socket.write("421 4.7.0 Too many connections from your IP\r\n");
322
+ } catch (_e) { /* socket may already be torn down */ }
323
+ try { socket.destroy(); } catch (_e2) { /* idempotent */ }
324
+ return;
325
+ }
326
+ socket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
327
+
251
328
  var connectionId = "mxconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
252
329
  connections.add(socket);
253
330
 
254
331
  var state = {
255
332
  id: connectionId,
256
- remoteAddress: socket.remoteAddress || null,
257
- remotePort: socket.remotePort || null,
333
+ remoteAddress: remoteAddress,
334
+ remotePort: socket.remotePort || null,
258
335
  tls: false,
259
336
  stage: "connect", // connect | ehlo | mail | rcpt | data-body | done
260
337
  helo: null,
261
338
  mailFrom: null,
262
339
  rcpts: [],
263
340
  messageBytes: 0,
341
+ lastDataByteTime: 0,
264
342
  };
265
343
 
266
344
  var lineBuffer = "";
@@ -431,6 +509,21 @@ function create(opts) {
431
509
  _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
432
510
  return;
433
511
  }
512
+ // Domain hardening for HELO/EHLO greeting (RFC 5321 §4.1.1.1).
513
+ // Skip when the greeting is an address literal (`[1.2.3.4]` /
514
+ // `[IPv6:...]`) — those are RFC-5321-legitimate non-domain
515
+ // forms; the bracket syntax is already constrained by
516
+ // b.guardSmtpCommand. Bare-IP-as-domain (no brackets) IS
517
+ // refused — that's the CVE-2021-22931 class guardDomain catches.
518
+ if (helo[0] !== "[" && guardDomainProfile) {
519
+ var heloVerdict = _validateDomainHardened(helo, "helo");
520
+ if (!heloVerdict.ok) {
521
+ _writeReply(socket, REPLY_501_BAD_ARGS,
522
+ "5.5.4 " + verb + " domain refused (" +
523
+ (heloVerdict.issues && heloVerdict.issues[0] && heloVerdict.issues[0].kind) + ")");
524
+ return;
525
+ }
526
+ }
434
527
  state.helo = helo;
435
528
  state.stage = "ehlo";
436
529
  // Multi-line 250 capabilities advertisement per RFC 5321 §4.1.1.1.
@@ -514,6 +607,20 @@ function create(opts) {
514
607
  return;
515
608
  }
516
609
  var mailFrom = match[1].toLowerCase();
610
+ // Domain hardening on MAIL FROM domain. Skip address-literal
611
+ // and empty-reverse-path forms (RFC 5321 §4.5.5 — bounce return
612
+ // path `<>` is legitimate and has no domain).
613
+ var __mfAtIdx = mailFrom.lastIndexOf("@");
614
+ var mailFromDomain = __mfAtIdx === -1 ? "" : mailFrom.slice(__mfAtIdx + 1);
615
+ if (mailFromDomain && mailFromDomain[0] !== "[" && guardDomainProfile) {
616
+ var mfVerdict = _validateDomainHardened(mailFromDomain, "mail_from");
617
+ if (!mfVerdict.ok) {
618
+ _writeReply(socket, REPLY_501_BAD_ARGS,
619
+ "5.5.4 MAIL FROM domain refused (" +
620
+ (mfVerdict.issues && mfVerdict.issues[0] && mfVerdict.issues[0].kind) + ")");
621
+ return;
622
+ }
623
+ }
517
624
  var paramStr = match[2] || "";
518
625
  var sizeMatch = paramStr.match(RE_SIZE);
519
626
  if (sizeMatch) {
@@ -549,11 +656,24 @@ function create(opts) {
549
656
  return;
550
657
  }
551
658
  var rcpt = match[1].toLowerCase();
659
+ // Domain hardening on RCPT TO domain — skip the address-literal
660
+ // form per RFC 5321 §4.1.3 (bracket syntax already constrained
661
+ // by b.guardSmtpCommand). Refuses IDN homograph + special-use
662
+ // domains + bare-IP-as-domain on the un-bracketed form.
663
+ var _atIdx = rcpt.lastIndexOf("@");
664
+ var rcptDomain = _atIdx === -1 ? "" : rcpt.slice(_atIdx + 1);
665
+ if (rcptDomain && rcptDomain[0] !== "[" && guardDomainProfile) {
666
+ var rcptVerdict = _validateDomainHardened(rcptDomain, "rcpt_to");
667
+ if (!rcptVerdict.ok) {
668
+ _writeReply(socket, REPLY_501_BAD_ARGS,
669
+ "5.5.4 RCPT TO domain refused (" +
670
+ (rcptVerdict.issues && rcptVerdict.issues[0] && rcptVerdict.issues[0].kind) + ")");
671
+ return;
672
+ }
673
+ }
552
674
  // Local-domain check — refuse non-local recipients unless the
553
675
  // operator explicitly allowed relay for this scope.
554
676
  if (localDomains.length > 0) {
555
- var atIdx = rcpt.lastIndexOf("@");
556
- var rcptDomain = atIdx === -1 ? "" : rcpt.slice(atIdx + 1);
557
677
  if (localDomains.indexOf(rcptDomain) === -1 &&
558
678
  !_isRelayAllowed(state.remoteAddress, rcpt)) {
559
679
  _emit("mail.server.mx.relay_refused",
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.server.rateLimit
4
+ * @nav Mail
5
+ * @title Mail Server Rate Limit
6
+ * @order 544
7
+ *
8
+ * @intro
9
+ * Per-IP DoS defenses shared by `b.mail.server.mx` and
10
+ * `b.mail.server.submission`. Both listeners boot with sensible
11
+ * defaults; operators tighten or relax per-deployment via the
12
+ * `rateLimit` opt on either listener.
13
+ *
14
+ * Defenses:
15
+ *
16
+ * - **Per-IP concurrent connections** — bounded to
17
+ * `maxConcurrentConnectionsPerIp` (default 10). A single hostile
18
+ * peer cannot open thousands of TCP slots and starve legitimate
19
+ * senders. Sliding-window kernel-level limits (iptables connlimit,
20
+ * ELB connection cap) are still recommended upstream — this is
21
+ * the framework's own ceiling for when the kernel limit isn't
22
+ * wired.
23
+ *
24
+ * - **Per-IP connection rate** — bounded to
25
+ * `connectionsPerIpPerMinute` (default 60). Rapid reconnect /
26
+ * scan attacks tripped here; legitimate retry-with-backoff
27
+ * traffic stays under the cap.
28
+ *
29
+ * - **Per-IP AUTH-failure budget** — bounded to
30
+ * `authFailuresPerIpPer15Min` (default 10; submission listener
31
+ * only). Credential-stuffing class — RFC 4954 §6 codes AUTH
32
+ * refusals as 535 5.7.8; we count those per remote IP in a
33
+ * rolling 15-minute window and refuse new AUTH attempts past
34
+ * the cap with 421 4.7.0. The framework's authenticator is
35
+ * unaware of this layer; the rate-limit lives at the wire-
36
+ * protocol boundary so a credential leak past the listener is
37
+ * still bounded.
38
+ *
39
+ * - **Slow-loris / minBytesPerSecond on DATA** — bounded to
40
+ * `minBytesPerSecond` (default 100 bytes/sec) during the DATA-
41
+ * body phase. The state machine's idleTimeoutMs already cuts
42
+ * fully-stalled connections; this floor cuts a hostile peer
43
+ * trickling one byte per minute to hold a connection for hours
44
+ * within the idle window.
45
+ *
46
+ * ## What this module is NOT
47
+ *
48
+ * - **Not an HTTP rate-limiter.** `b.middleware.rateLimit` covers
49
+ * the HTTP request-response shape; this module covers the
50
+ * SMTP-transactional state machine where rate-limits apply at
51
+ * the connection-boundary + the AUTH command + the DATA byte-
52
+ * rate, not per-request.
53
+ * - **Not a replacement for kernel / proxy-level limits.** This
54
+ * module is the in-process belt; iptables / NFTables / ELB /
55
+ * CloudFlare / haproxy / nginx-stream stay the suspenders. A
56
+ * framework-level limiter sees only what reaches the process;
57
+ * the kernel sees the connection floods before they cost an
58
+ * event-loop tick.
59
+ *
60
+ * ## Wire-up
61
+ *
62
+ * ```js
63
+ * var rateLimit = b.mail.server.rateLimit.create({
64
+ * maxConcurrentConnectionsPerIp: 10,
65
+ * connectionsPerIpPerMinute: 60,
66
+ * authFailuresPerIpPer15Min: 10,
67
+ * minBytesPerSecond: 100,
68
+ * });
69
+ *
70
+ * var mx = b.mail.server.mx.create({ tlsContext, rateLimit, ... });
71
+ * ```
72
+ *
73
+ * The listener calls `rateLimit.admitConnection(ip)` in the
74
+ * net.createServer callback and refuses new connections with
75
+ * `421 4.7.0 Too many connections` when the verdict is no. AUTH-
76
+ * failure budgeting (`noteAuthFailure` + `checkAuthAdmit`) is
77
+ * wired in the submission listener's AUTH handler. The slow-loris
78
+ * defense is wired in the DATA-body collector.
79
+ *
80
+ * @card
81
+ * Per-IP DoS defenses for b.mail.server.mx and b.mail.server.submission:
82
+ * concurrent-connection cap, connection-rate cap, AUTH-failure budget
83
+ * (submission), slow-loris min-bytes-per-second on DATA. Belt-and-
84
+ * suspenders to kernel/proxy-level limits.
85
+ */
86
+
87
+ var C = require("./constants");
88
+ var lazyRequire = require("./lazy-require");
89
+ var numericBounds = require("./numeric-bounds");
90
+ var validateOpts = require("./validate-opts");
91
+ var { defineClass } = require("./framework-error");
92
+
93
+ var audit = lazyRequire(function () { return require("./audit"); });
94
+
95
+ var MailServerRateLimitError = defineClass("MailServerRateLimitError", { alwaysPermanent: true });
96
+
97
+ var DEFAULTS = Object.freeze({
98
+ maxConcurrentConnectionsPerIp: 10,
99
+ connectionsPerIpPerMinute: 60, // allow:raw-time-literal — connection count, not a time value
100
+ authFailuresPerIpPer15Min: 10,
101
+ minBytesPerSecond: 100, // allow:raw-byte-literal — slow-loris byte-rate floor
102
+ disabled: false,
103
+ });
104
+
105
+ var CONNECTION_RATE_WINDOW_MS = C.TIME.minutes(1);
106
+ var AUTH_FAILURE_WINDOW_MS = C.TIME.minutes(15);
107
+
108
+ /**
109
+ * @primitive b.mail.server.rateLimit.create
110
+ * @signature b.mail.server.rateLimit.create(opts?)
111
+ * @since 0.9.47
112
+ * @status stable
113
+ * @related b.mail.server.mx.create, b.mail.server.submission.create
114
+ *
115
+ * Build a rate-limit handle. The listeners compose this internally
116
+ * with the framework defaults; operators override caps by passing
117
+ * their own `rateLimit` opt to `b.mail.server.mx.create` or
118
+ * `b.mail.server.submission.create`. Direct construction is for
119
+ * operators sharing one budget across multiple listeners (e.g. an
120
+ * MX + a submission server on the same IP space).
121
+ *
122
+ * @opts
123
+ * maxConcurrentConnectionsPerIp: number, // default 10
124
+ * connectionsPerIpPerMinute: number, // default 60
125
+ * authFailuresPerIpPer15Min: number, // default 10
126
+ * minBytesPerSecond: number, // default 100 (DATA-body slow-loris floor)
127
+ * disabled: boolean, // default false — test escape hatch
128
+ *
129
+ * @example
130
+ * var rl = b.mail.server.rateLimit.create({
131
+ * maxConcurrentConnectionsPerIp: 5,
132
+ * connectionsPerIpPerMinute: 30,
133
+ * });
134
+ * var ok = rl.admitConnection("192.0.2.1");
135
+ * // → { ok: true } or { ok: false, reason: "concurrent-per-ip" | "rate-per-ip" }
136
+ */
137
+ function create(opts) {
138
+ opts = opts || {};
139
+ if (typeof opts !== "object" || Array.isArray(opts)) {
140
+ throw new MailServerRateLimitError("mail-server-rate-limit/bad-opts",
141
+ "b.mail.server.rateLimit.create: opts must be a plain object");
142
+ }
143
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts, [
144
+ "maxConcurrentConnectionsPerIp",
145
+ "connectionsPerIpPerMinute",
146
+ "authFailuresPerIpPer15Min",
147
+ "minBytesPerSecond",
148
+ ], "b.mail.server.rateLimit.create.", MailServerRateLimitError, "mail-server-rate-limit/bad-bound");
149
+ validateOpts.optionalBoolean(opts.disabled,
150
+ "b.mail.server.rateLimit.create: opts.disabled",
151
+ MailServerRateLimitError, "mail-server-rate-limit/bad-disabled");
152
+
153
+ var cfg = {
154
+ maxConcurrentConnectionsPerIp: opts.maxConcurrentConnectionsPerIp === undefined
155
+ ? DEFAULTS.maxConcurrentConnectionsPerIp : opts.maxConcurrentConnectionsPerIp,
156
+ connectionsPerIpPerMinute: opts.connectionsPerIpPerMinute === undefined
157
+ ? DEFAULTS.connectionsPerIpPerMinute : opts.connectionsPerIpPerMinute,
158
+ authFailuresPerIpPer15Min: opts.authFailuresPerIpPer15Min === undefined
159
+ ? DEFAULTS.authFailuresPerIpPer15Min : opts.authFailuresPerIpPer15Min,
160
+ minBytesPerSecond: opts.minBytesPerSecond === undefined
161
+ ? DEFAULTS.minBytesPerSecond : opts.minBytesPerSecond,
162
+ disabled: opts.disabled === true,
163
+ };
164
+
165
+ // Per-IP state. Maps key on the remote IP string; entries are
166
+ // pruned lazily on read (any entry whose window has fully expired
167
+ // is removed instead of returned). Operators with extreme connection
168
+ // counts can wire a periodic gc() externally; the lazy prune keeps
169
+ // memory bounded under normal load.
170
+ var concurrentByIp = new Map(); // ip → integer count
171
+ var connectionTimes = new Map(); // ip → [timestampMs, ...]
172
+ var authFailureTimes = new Map(); // ip → [timestampMs, ...]
173
+
174
+ function _pruneWindow(arr, windowMs) {
175
+ var cutoff = Date.now() - windowMs;
176
+ var i = 0;
177
+ while (i < arr.length && arr[i] < cutoff) i += 1;
178
+ if (i > 0) arr.splice(0, i);
179
+ }
180
+
181
+ function _audit(action, outcome, metadata) {
182
+ try {
183
+ audit().safeEmit({ action: action, outcome: outcome || "denied", metadata: metadata || {} });
184
+ } catch (_e) { /* drop-silent — audit best-effort */ }
185
+ }
186
+
187
+ function admitConnection(ip) {
188
+ if (cfg.disabled) return { ok: true };
189
+ var concurrent = concurrentByIp.get(ip) || 0;
190
+ if (concurrent >= cfg.maxConcurrentConnectionsPerIp) {
191
+ _audit("mail.server.rate_limit.refused", "denied",
192
+ { reason: "concurrent-per-ip", ip: ip, cap: cfg.maxConcurrentConnectionsPerIp });
193
+ return { ok: false, reason: "concurrent-per-ip" };
194
+ }
195
+ var times = connectionTimes.get(ip);
196
+ if (!times) { times = []; connectionTimes.set(ip, times); }
197
+ _pruneWindow(times, CONNECTION_RATE_WINDOW_MS);
198
+ if (times.length >= cfg.connectionsPerIpPerMinute) {
199
+ _audit("mail.server.rate_limit.refused", "denied",
200
+ { reason: "rate-per-ip", ip: ip, cap: cfg.connectionsPerIpPerMinute });
201
+ return { ok: false, reason: "rate-per-ip" };
202
+ }
203
+ times.push(Date.now());
204
+ concurrentByIp.set(ip, concurrent + 1);
205
+ return { ok: true };
206
+ }
207
+
208
+ function releaseConnection(ip) {
209
+ if (cfg.disabled) return;
210
+ var concurrent = concurrentByIp.get(ip) || 0;
211
+ if (concurrent <= 1) concurrentByIp.delete(ip);
212
+ else concurrentByIp.set(ip, concurrent - 1);
213
+ }
214
+
215
+ function checkAuthAdmit(ip) {
216
+ if (cfg.disabled) return { ok: true };
217
+ var times = authFailureTimes.get(ip);
218
+ if (!times) return { ok: true };
219
+ _pruneWindow(times, AUTH_FAILURE_WINDOW_MS);
220
+ if (times.length === 0) {
221
+ authFailureTimes.delete(ip);
222
+ return { ok: true };
223
+ }
224
+ if (times.length >= cfg.authFailuresPerIpPer15Min) {
225
+ _audit("mail.server.rate_limit.auth_refused", "denied",
226
+ { reason: "auth-failures-per-ip", ip: ip, cap: cfg.authFailuresPerIpPer15Min });
227
+ return { ok: false, reason: "auth-failures-per-ip" };
228
+ }
229
+ return { ok: true };
230
+ }
231
+
232
+ function noteAuthFailure(ip) {
233
+ if (cfg.disabled) return;
234
+ var times = authFailureTimes.get(ip);
235
+ if (!times) { times = []; authFailureTimes.set(ip, times); }
236
+ times.push(Date.now());
237
+ }
238
+
239
+ function minBytesPerSecond() { return cfg.disabled ? 0 : cfg.minBytesPerSecond; }
240
+ function isDisabled() { return cfg.disabled; }
241
+
242
+ return {
243
+ admitConnection: admitConnection,
244
+ releaseConnection: releaseConnection,
245
+ checkAuthAdmit: checkAuthAdmit,
246
+ noteAuthFailure: noteAuthFailure,
247
+ minBytesPerSecond: minBytesPerSecond,
248
+ isDisabled: isDisabled,
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ create: create,
254
+ MailServerRateLimitError: MailServerRateLimitError,
255
+ DEFAULTS: DEFAULTS,
256
+ };