@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.
- package/CHANGELOG.md +15 -0
- package/index.js +5 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/mail-server-imap.js +1064 -0
- package/lib/mail-server-mx.js +124 -4
- package/lib/mail-server-rate-limit.js +256 -0
- package/lib/mail-server-submission.js +986 -0
- package/lib/metrics.js +50 -7
- package/lib/self-update.js +35 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-server-mx.js
CHANGED
|
@@ -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:
|
|
257
|
-
remotePort: socket.remotePort
|
|
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
|
+
};
|