@blamejs/core 0.9.45 → 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 +16 -0
- package/index.js +9 -0
- package/lib/auth/fal.js +1 -1
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-smtp-command.js +65 -10
- package/lib/mail-server-imap.js +1064 -0
- package/lib/mail-server-mx.js +856 -0
- 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/middleware/protected-resource-metadata.js +1 -1
- package/lib/safe-smtp.js +128 -0
- package/lib/self-update.js +35 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
+
};
|