@blamejs/core 0.18.54 → 0.18.56
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 +146 -0
- package/NOTICE +5 -5
- package/README.md +9 -9
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/audit.js +26 -24
- package/lib/auth/passkey.js +4 -1
- package/lib/chain-writer.js +17 -0
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +65 -42
- package/lib/mail-server-mx.js +313 -40
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +71 -11
- package/lib/mcp.js +11 -3
- package/lib/middleware/csrf-protect.js +37 -20
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/session-stores.js +6 -3
- package/lib/vendor/MANIFEST.json +34 -34
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/vendor/browser/noble-ciphers.mjs +15 -1
- package/lib/vendor/browser/noble-hashes.mjs +12 -4
- package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
- package/lib/vendor/noble-ciphers.cjs +15 -1
- package/lib/vendor/noble-curves.cjs +46 -21
- package/lib/vendor/noble-post-quantum.cjs +184 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-server-net.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
5
|
var codepointClass = require("./codepoint-class");
|
|
6
|
+
var bCrypto = require("./crypto");
|
|
6
7
|
|
|
7
8
|
// mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
|
|
8
9
|
// servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
|
|
@@ -16,6 +17,14 @@ var codepointClass = require("./codepoint-class");
|
|
|
16
17
|
// EACCES) rejects the listen promise instead of crashing the process. That, plus
|
|
17
18
|
// the listening/server state, is what createTcpListener owns.
|
|
18
19
|
|
|
20
|
+
// The listener's own ceiling when an operator names none. Every mail listener
|
|
21
|
+
// shares it, so a deployment that raises or lowers it does so in one place and
|
|
22
|
+
// the five listeners cannot drift apart on what "too many" means. Set well
|
|
23
|
+
// above any single-host mail deployment's working set and far below what an
|
|
24
|
+
// unbounded accept loop will take: the point is that a ceiling EXISTS, since
|
|
25
|
+
// the per-address cap alone leaves the total at (cap x source addresses).
|
|
26
|
+
var DEFAULT_MAX_CONNECTIONS = 1024;
|
|
27
|
+
|
|
19
28
|
// createTcpListener(net, cfg) — build a listener lifecycle.
|
|
20
29
|
// cfg.defaultPort port used when listenOpts.port is omitted (an explicit
|
|
21
30
|
// 0 is honored, for an ephemeral test bind).
|
|
@@ -26,6 +35,15 @@ var codepointClass = require("./codepoint-class");
|
|
|
26
35
|
// cfg.listeningEvent the "...listening" audit action.
|
|
27
36
|
// cfg.listeningExtra optional () => object merged onto the listening event
|
|
28
37
|
// payload (Submission reports implicitTls).
|
|
38
|
+
// cfg.maxConnections the listener's own ceiling on concurrently accepted
|
|
39
|
+
// sockets. The per-address cap in b.mail.server.rateLimit
|
|
40
|
+
// bounds ONE peer; it says nothing about how many peers
|
|
41
|
+
// there are, so the process-wide total was the per-address
|
|
42
|
+
// cap times however many source addresses the caller could
|
|
43
|
+
// speak from — a number a botnet, a NAT pool or a single
|
|
44
|
+
// v6 /64 makes large. Enforced by the runtime, which closes
|
|
45
|
+
// the excess socket before handleConnection ever sees it,
|
|
46
|
+
// so a refusal costs no descriptor and no state machine.
|
|
29
47
|
// Returns { listen, getServer, isListening, markClosed } — the server wires its
|
|
30
48
|
// own close() through getServer()/isListening()/markClosed().
|
|
31
49
|
function createTcpListener(net, cfg) {
|
|
@@ -40,6 +58,7 @@ function createTcpListener(net, cfg) {
|
|
|
40
58
|
var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
|
|
41
59
|
var address = listenOpts.address || "0.0.0.0";
|
|
42
60
|
server = net.createServer(function (socket) { cfg.handleConnection(socket); });
|
|
61
|
+
server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
|
|
43
62
|
return new Promise(function (resolve, reject) {
|
|
44
63
|
server.once("error", reject);
|
|
45
64
|
server.listen(port, address, function () {
|
|
@@ -110,6 +129,7 @@ function createStoreServer(net, cfg) {
|
|
|
110
129
|
var ErrorClass = cfg.errorClass;
|
|
111
130
|
var listener = createTcpListener(net, {
|
|
112
131
|
defaultPort: cfg.defaultPort,
|
|
132
|
+
maxConnections: cfg.maxConnections,
|
|
113
133
|
handleConnection: cfg.handleConnection,
|
|
114
134
|
errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
|
|
115
135
|
emit: cfg.emit,
|
|
@@ -122,7 +142,137 @@ function createStoreServer(net, cfg) {
|
|
|
122
142
|
closedEvent: cfg.eventBase + ".closed",
|
|
123
143
|
});
|
|
124
144
|
}
|
|
125
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
listen: listener.listen,
|
|
147
|
+
close: close,
|
|
148
|
+
connectionCount: function () { return cfg.connections.size; },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// trackConnection(socket, cfg) — the other half of admitConnection, and the
|
|
153
|
+
// reason it is here rather than written out per listener.
|
|
154
|
+
//
|
|
155
|
+
// A connection occupies two ledgers: the rate limiter's per-address count, and
|
|
156
|
+
// the listener's live-socket set that shutdown drains. Both are released by the
|
|
157
|
+
// SAME event — the socket closing — and it does not matter who closed it. Five
|
|
158
|
+
// listeners each wrote that pairing by hand and one of them registered only the
|
|
159
|
+
// release, so a peer that opened a connection, took the greeting and dropped TCP
|
|
160
|
+
// freed its rate-limit slot (and could reconnect at once) while its set entry
|
|
161
|
+
// stayed forever. Nothing authenticates before that point, so the growth was
|
|
162
|
+
// unauthenticated and unbounded.
|
|
163
|
+
//
|
|
164
|
+
// Registering both in one handler is what makes the pair impossible to
|
|
165
|
+
// half-write.
|
|
166
|
+
// cfg.connections the listener's live-socket Set.
|
|
167
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
168
|
+
// cfg.remoteAddress the address admitConnection returned.
|
|
169
|
+
// cfg.closeSource optional socket whose "close" drives teardown, when the
|
|
170
|
+
// tracked socket is a wrapper (Submission's implicit-TLS
|
|
171
|
+
// path tracks the TLSSocket but the raw socket is the one
|
|
172
|
+
// that carries the FIN).
|
|
173
|
+
function trackConnection(socket, cfg) {
|
|
174
|
+
cfg.connections.add(socket);
|
|
175
|
+
var source = cfg.closeSource || socket;
|
|
176
|
+
source.once("close", function () {
|
|
177
|
+
cfg.rateLimit.releaseConnection(cfg.remoteAddress);
|
|
178
|
+
cfg.connections.delete(socket);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
|
|
183
|
+
// windows instead of the whole transfer.
|
|
184
|
+
//
|
|
185
|
+
// A lifetime average lets an early burst pay for an arbitrarily slow tail: at
|
|
186
|
+
// the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
|
|
187
|
+
// six, which a peer spends holding a connection and its slot in the per-address
|
|
188
|
+
// cap while sending a byte at a time. The floor was enforced and still
|
|
189
|
+
// bypassable, which is the worse of the two states — it reads as covered.
|
|
190
|
+
//
|
|
191
|
+
// Each window has to meet the floor on its own, so nothing sent earlier pays
|
|
192
|
+
// for what is sent now. A window only rolls forward once it has run long enough
|
|
193
|
+
// to be a real judgement; inside that stretch no verdict is reached, so a
|
|
194
|
+
// sender pausing to read from its own spool is not cut off for the pause, and a
|
|
195
|
+
// fast chunk cannot keep resetting the clock to dodge the next measurement.
|
|
196
|
+
//
|
|
197
|
+
// `now` is a parameter rather than a call to the clock so the whole thing can
|
|
198
|
+
// be driven across days of simulated time without waiting for them.
|
|
199
|
+
// rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
|
|
200
|
+
function createBodyRateWindow(rateLimit) {
|
|
201
|
+
var windowStart = 0;
|
|
202
|
+
var windowBytes = 0;
|
|
203
|
+
return {
|
|
204
|
+
// Open the first window. `bytesSeen` is the caller's running total AT THIS
|
|
205
|
+
// MOMENT, which becomes the baseline every later reading is measured
|
|
206
|
+
// against — so the caller is free to keep one connection-lifetime counter
|
|
207
|
+
// incremented at the wire boundary rather than a per-transfer one. That
|
|
208
|
+
// matters where a listener re-feeds part of a chunk through its own parser:
|
|
209
|
+
// a counter maintained inside the parser credits those bytes twice, and a
|
|
210
|
+
// peer that pipelines a one-byte payload with the next command gets roughly
|
|
211
|
+
// double the rate it is actually sending.
|
|
212
|
+
start: function (now, bytesSeen) {
|
|
213
|
+
windowStart = now;
|
|
214
|
+
windowBytes = bytesSeen || 0;
|
|
215
|
+
},
|
|
216
|
+
// `bytesSeen` is the running total for the whole body; the window's own
|
|
217
|
+
// count is derived, so the caller keeps one counter rather than two.
|
|
218
|
+
starved: function (bytesSeen, now) {
|
|
219
|
+
var elapsed = now - windowStart;
|
|
220
|
+
if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
|
|
221
|
+
// The limiter says how long a window must run before rolling, because the
|
|
222
|
+
// limiter is what decides when a measurement is old enough to mean
|
|
223
|
+
// something. Rolling on a number held HERE would ask a limiter that
|
|
224
|
+
// judges over a longer stretch only ever before it can answer: every call
|
|
225
|
+
// returning "too early", the window resetting underneath it, and its rate
|
|
226
|
+
// protection silently disabled while looking wired.
|
|
227
|
+
if (elapsed >= rateLimit.bodyRateWindowMs()) {
|
|
228
|
+
windowStart = now;
|
|
229
|
+
windowBytes = bytesSeen;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
|
|
237
|
+
// accept and its own state machine: gate the address, mint the connection id,
|
|
238
|
+
// wrap the socket if the protocol starts in TLS, and enter both ledgers.
|
|
239
|
+
//
|
|
240
|
+
// Every listener performed these four steps in the same order, and the parts
|
|
241
|
+
// that differ between them are three strings and an optional wrap. Keeping the
|
|
242
|
+
// order in one place is what stops a fifth listener from being written with one
|
|
243
|
+
// of the steps missing, which is how the tracking-set entry and the rate-limit
|
|
244
|
+
// slot came apart in the first place.
|
|
245
|
+
//
|
|
246
|
+
// Returns null when the address was refused — the caller returns immediately;
|
|
247
|
+
// the refusal line and teardown are already done.
|
|
248
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
249
|
+
// cfg.connections the listener's live-socket Set.
|
|
250
|
+
// cfg.emit the listener's audit emitter.
|
|
251
|
+
// cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
|
|
252
|
+
// cfg.refusalLine the protocol's refusal bytes.
|
|
253
|
+
// cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
|
|
254
|
+
// cfg.wrap optional (rawSocket) => socket, for a protocol that
|
|
255
|
+
// starts inside TLS. The RAW socket still drives teardown:
|
|
256
|
+
// a handshake that never completes closes it without the
|
|
257
|
+
// wrapper ever being established.
|
|
258
|
+
function acceptConnection(rawSocket, cfg) {
|
|
259
|
+
var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
|
|
260
|
+
refusedEvent: cfg.refusedEvent,
|
|
261
|
+
refusalLine: cfg.refusalLine,
|
|
262
|
+
});
|
|
263
|
+
if (remoteAddress === null) return null;
|
|
264
|
+
var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
|
|
265
|
+
trackConnection(socket, {
|
|
266
|
+
connections: cfg.connections,
|
|
267
|
+
rateLimit: cfg.rateLimit,
|
|
268
|
+
remoteAddress: remoteAddress,
|
|
269
|
+
closeSource: socket === rawSocket ? null : rawSocket,
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
socket: socket,
|
|
273
|
+
remoteAddress: remoteAddress,
|
|
274
|
+
connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
|
|
275
|
+
};
|
|
126
276
|
}
|
|
127
277
|
|
|
128
278
|
// admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
|
|
@@ -299,6 +449,10 @@ module.exports = {
|
|
|
299
449
|
runSaslStep: runSaslStep,
|
|
300
450
|
createStoreServer: createStoreServer,
|
|
301
451
|
admitConnection: admitConnection,
|
|
452
|
+
trackConnection: trackConnection,
|
|
453
|
+
acceptConnection: acceptConnection,
|
|
454
|
+
createBodyRateWindow: createBodyRateWindow,
|
|
455
|
+
DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
|
|
302
456
|
validateDomainHardened: validateDomainHardened,
|
|
303
457
|
saslChallengeOrNull: saslChallengeOrNull,
|
|
304
458
|
replyTextOrFallback: replyTextOrFallback,
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -52,9 +52,10 @@
|
|
|
52
52
|
* `authFailuresPerIpPer15Min` cap applies to USER+PASS / APOP /
|
|
53
53
|
* AUTH refusals.
|
|
54
54
|
*
|
|
55
|
-
* - **Slow-loris
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* - **Slow-loris** — per-connection `idleTimeoutMs` bounds a peer that
|
|
56
|
+
* stops making progress, in either direction: a client that sends no
|
|
57
|
+
* further command, and one that stops taking a RETR / TOP response.
|
|
58
|
+
* `maxLineBytes` bounds a command line that never ends.
|
|
58
59
|
*
|
|
59
60
|
* ## Audit lifecycle
|
|
60
61
|
*
|
|
@@ -107,7 +108,6 @@
|
|
|
107
108
|
var net = require("node:net");
|
|
108
109
|
var safeBuffer = require("./safe-buffer");
|
|
109
110
|
var C = require("./constants");
|
|
110
|
-
var bCrypto = require("./crypto");
|
|
111
111
|
var numericBounds = require("./numeric-bounds");
|
|
112
112
|
var validateOpts = require("./validate-opts");
|
|
113
113
|
var guardPop3Command = require("./guard-pop3-command");
|
|
@@ -152,6 +152,7 @@ var ERR_CLAMP = 200;
|
|
|
152
152
|
* greeting: string, // default "blamejs POP3"
|
|
153
153
|
* maxLineBytes: number, // default 1024
|
|
154
154
|
* idleTimeoutMs: number, // default 10 min
|
|
155
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
155
156
|
* commitTimeoutMs: number, // default 30 s (UPDATE-state mailStore.commitPop3Drop cap)
|
|
156
157
|
* profile: "strict" | "balanced" | "permissive",
|
|
157
158
|
* auth: {
|
|
@@ -189,7 +190,7 @@ function create(opts) {
|
|
|
189
190
|
"getMessage/listMessages/markDelete; compose b.mailStore.create or operator-supplied backend)");
|
|
190
191
|
}
|
|
191
192
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
192
|
-
["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs"],
|
|
193
|
+
["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs", "maxConnections"],
|
|
193
194
|
"mail.server.pop3.", MailServerPop3Error, "mail-server-pop3/bad-bound");
|
|
194
195
|
|
|
195
196
|
var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
|
|
@@ -239,23 +240,18 @@ function create(opts) {
|
|
|
239
240
|
var _emit = auditEmit.emit;
|
|
240
241
|
|
|
241
242
|
function _handleConnection(rawSocket) {
|
|
242
|
-
var
|
|
243
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
244
|
+
rateLimit: rateLimit,
|
|
245
|
+
connections: connections,
|
|
246
|
+
emit: _emit,
|
|
243
247
|
refusedEvent: "mail.server.pop3.rate_limit_refused",
|
|
244
248
|
refusalLine: "-ERR Too many connections from your IP\r\n",
|
|
249
|
+
idPrefix: "pop3conn-",
|
|
245
250
|
});
|
|
246
|
-
if (
|
|
247
|
-
var
|
|
248
|
-
var
|
|
249
|
-
|
|
250
|
-
// Single close handler covers BOTH operator-driven `_close(socket)`
|
|
251
|
-
// and client-initiated disconnects (TCP FIN / RST without a
|
|
252
|
-
// server-side close call) — releases the rate-limit slot AND
|
|
253
|
-
// removes the socket from the tracking set so it can't accumulate
|
|
254
|
-
// stale entries across long-lived deployments.
|
|
255
|
-
rawSocket.once("close", function () {
|
|
256
|
-
rateLimit.releaseConnection(remoteAddress);
|
|
257
|
-
connections.delete(socket);
|
|
258
|
-
});
|
|
251
|
+
if (accepted === null) return;
|
|
252
|
+
var remoteAddress = accepted.remoteAddress;
|
|
253
|
+
var connectionId = accepted.connectionId;
|
|
254
|
+
var socket = accepted.socket;
|
|
259
255
|
|
|
260
256
|
var state = {
|
|
261
257
|
id: connectionId,
|
|
@@ -900,6 +896,7 @@ function create(opts) {
|
|
|
900
896
|
// ---- Lifecycle ----------------------------------------------------------
|
|
901
897
|
return mailServerNet.createStoreServer(net, {
|
|
902
898
|
defaultPort: 110, // RFC 1939 POP3 port (IANA)
|
|
899
|
+
maxConnections: opts.maxConnections,
|
|
903
900
|
handleConnection: _handleConnection,
|
|
904
901
|
errorClass: MailServerPop3Error,
|
|
905
902
|
errorCodePrefix: "mail-server-pop3/",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
* trickling one byte per minute to hold a connection for hours
|
|
46
46
|
* within the idle window.
|
|
47
47
|
*
|
|
48
|
+
* It measures bytes ARRIVING, so it applies to the two listeners
|
|
49
|
+
* that take a message body: `b.mail.server.mx` and
|
|
50
|
+
* `b.mail.server.submission`. A peer that stops taking a response
|
|
51
|
+
* is a different condition, and idleTimeoutMs is what bounds it.
|
|
52
|
+
*
|
|
48
53
|
* ## What this module is NOT
|
|
49
54
|
*
|
|
50
55
|
* - **Not an HTTP rate-limiter.** `b.middleware.rateLimit` covers
|
|
@@ -114,6 +119,14 @@ var DEFAULTS = Object.freeze({
|
|
|
114
119
|
});
|
|
115
120
|
|
|
116
121
|
var CONNECTION_RATE_WINDOW_MS = C.TIME.minutes(1);
|
|
122
|
+
// How long a DATA body is given before its byte rate is judged. Below this the
|
|
123
|
+
// measurement means nothing: the first chunk arrives with almost no elapsed
|
|
124
|
+
// time, so any rate computed from it is noise, and a sender that pauses briefly
|
|
125
|
+
// to read from its own spool would be cut off for it. Ten seconds at the
|
|
126
|
+
// default 100 B/s floor means a peer must have delivered a kilobyte before it
|
|
127
|
+
// is asked to justify itself, which no real MTA fails and a one-byte-per-minute
|
|
128
|
+
// trickler cannot reach.
|
|
129
|
+
var BODY_RATE_GRACE_MS = C.TIME.seconds(10);
|
|
117
130
|
var AUTH_FAILURE_WINDOW_MS = C.TIME.minutes(15);
|
|
118
131
|
var RCPT_FAILURE_WINDOW_MS = C.TIME.minutes(1);
|
|
119
132
|
|
|
@@ -294,8 +307,45 @@ function create(opts) {
|
|
|
294
307
|
}
|
|
295
308
|
|
|
296
309
|
function minBytesPerSecond() { return cfg.disabled ? 0 : cfg.minBytesPerSecond; }
|
|
310
|
+
|
|
311
|
+
// How long one body-rate window must run before a verdict from it means
|
|
312
|
+
// anything, and therefore how often the caller rolls its window forward.
|
|
313
|
+
//
|
|
314
|
+
// It lives on the limiter because the limiter is what decides when a
|
|
315
|
+
// measurement is old enough to judge. A caller holding its own number would
|
|
316
|
+
// ask a limiter with a longer interval only ever before it can answer, and
|
|
317
|
+
// that limiter's protection would be off while appearing to be wired.
|
|
318
|
+
function bodyRateWindowMs() { return BODY_RATE_GRACE_MS; }
|
|
297
319
|
function isDisabled() { return cfg.disabled; }
|
|
298
320
|
|
|
321
|
+
// Is a DATA body arriving below the configured floor?
|
|
322
|
+
//
|
|
323
|
+
// The module has documented this floor since it shipped and nothing enforced
|
|
324
|
+
// it: the value was validated, defaulted and exposed as a getter that no
|
|
325
|
+
// listener ever called. `idleTimeoutMs` cuts a fully stalled connection, but
|
|
326
|
+
// a peer trickling a byte every few seconds resets that timer forever and
|
|
327
|
+
// holds a connection — and its slot in the per-address cap — for as long as
|
|
328
|
+
// it likes. This is the check that ends it.
|
|
329
|
+
//
|
|
330
|
+
// Both listeners reach it, so the policy lives here with the number rather
|
|
331
|
+
// than in two copies that can drift. Nothing is stored, so a connection needs
|
|
332
|
+
// no teardown to release it.
|
|
333
|
+
//
|
|
334
|
+
// `bytes` and `elapsedMs` describe ONE WINDOW, not the whole transfer. The
|
|
335
|
+
// difference is the whole defence: measured across the lifetime of a body, an
|
|
336
|
+
// early burst pays for an arbitrarily slow tail — at the default floor an
|
|
337
|
+
// 8 MiB burst buys about a day and 50 MiB buys six. The caller owns the
|
|
338
|
+
// window because the window is per-connection state and this limiter is
|
|
339
|
+
// shared across every connection; b.mail.server.net's createBodyRateWindow is
|
|
340
|
+
// what both listeners use to keep those windows identical.
|
|
341
|
+
function bodyRateStarved(bytes, elapsedMs) {
|
|
342
|
+
if (cfg.disabled) return false;
|
|
343
|
+
if (cfg.minBytesPerSecond <= 0) return false;
|
|
344
|
+
if (!(elapsedMs >= BODY_RATE_GRACE_MS)) return false; // too early to judge
|
|
345
|
+
var seconds = elapsedMs / C.TIME.seconds(1);
|
|
346
|
+
return (bytes / seconds) < cfg.minBytesPerSecond;
|
|
347
|
+
}
|
|
348
|
+
|
|
299
349
|
return {
|
|
300
350
|
admitConnection: admitConnection,
|
|
301
351
|
releaseConnection: releaseConnection,
|
|
@@ -304,6 +354,8 @@ function create(opts) {
|
|
|
304
354
|
checkRcptAdmit: checkRcptAdmit,
|
|
305
355
|
noteRcptFailure: noteRcptFailure,
|
|
306
356
|
minBytesPerSecond: minBytesPerSecond,
|
|
357
|
+
bodyRateStarved: bodyRateStarved,
|
|
358
|
+
bodyRateWindowMs: bodyRateWindowMs,
|
|
307
359
|
isDisabled: isDisabled,
|
|
308
360
|
};
|
|
309
361
|
}
|
|
@@ -316,20 +368,61 @@ function create(opts) {
|
|
|
316
368
|
* @related b.mail.server.rateLimit.create
|
|
317
369
|
*
|
|
318
370
|
* Resolve a rate-limit `spec` into a limiter. `false` disables limiting
|
|
319
|
-
* (a disabled limiter that always admits), an already-built limiter
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
371
|
+
* (a disabled limiter that always admits), an already-built limiter passes
|
|
372
|
+
* through unchanged, and anything else is treated as `create()` options.
|
|
373
|
+
* Every mail server (IMAP / POP3 / SMTP MX / Submission / ManageSieve)
|
|
374
|
+
* composes this at the top of its `create()` so the spec contract is
|
|
375
|
+
* identical across protocols.
|
|
376
|
+
*
|
|
377
|
+
* A custom limiter is recognised by `admitConnection` and must implement the
|
|
378
|
+
* whole interface the listeners call — `admitConnection`, `releaseConnection`,
|
|
379
|
+
* `checkAuthAdmit`, `noteAuthFailure`, `checkRcptAdmit`, `noteRcptFailure`,
|
|
380
|
+
* `minBytesPerSecond`, `bodyRateStarved`, `bodyRateWindowMs` — or resolve
|
|
381
|
+
* refuses at config time
|
|
382
|
+
* and names what is missing. A partial object used to be accepted here and
|
|
383
|
+
* fail later from inside a connection handler, on whichever request first
|
|
384
|
+
* reached a method it did not have. The supported way to customise part of the
|
|
385
|
+
* behaviour is to build `create({...})` and override what you want to change,
|
|
386
|
+
* so a method added in a later release cannot silently go unimplemented.
|
|
324
387
|
*
|
|
325
388
|
* @example
|
|
326
389
|
* var b = require("blamejs");
|
|
327
390
|
* var rl = b.mail.server.rateLimit.resolve(false); // disabled
|
|
328
391
|
* // → a limiter whose admitConnection always admits
|
|
329
392
|
*/
|
|
393
|
+
// What a listener actually calls on a limiter. `admitConnection` alone used to
|
|
394
|
+
// be the whole sniff, so an operator-supplied object carrying only that was
|
|
395
|
+
// accepted here and then failed later, from inside a connection handler, on the
|
|
396
|
+
// first request that reached one of the other calls — a TypeError mid-request
|
|
397
|
+
// rather than an error at boot. `releaseConnection` has been called on every
|
|
398
|
+
// socket close for as long as the listeners have tracked connections, so an
|
|
399
|
+
// incomplete limiter was already breaking before the body-rate floor was added;
|
|
400
|
+
// the floor only made it break sooner and more visibly.
|
|
401
|
+
//
|
|
402
|
+
// Config-time refusal naming the gap is the right tier for this: the operator
|
|
403
|
+
// learns at boot, not from a message that died mid-transaction.
|
|
404
|
+
var LIMITER_INTERFACE = Object.freeze([
|
|
405
|
+
"admitConnection", "releaseConnection",
|
|
406
|
+
"checkAuthAdmit", "noteAuthFailure",
|
|
407
|
+
"checkRcptAdmit", "noteRcptFailure",
|
|
408
|
+
"minBytesPerSecond", "bodyRateStarved", "bodyRateWindowMs",
|
|
409
|
+
]);
|
|
410
|
+
|
|
330
411
|
function resolve(spec) {
|
|
331
412
|
if (spec === false) return create({ disabled: true });
|
|
332
|
-
if (spec && typeof spec.admitConnection === "function")
|
|
413
|
+
if (spec && typeof spec.admitConnection === "function") {
|
|
414
|
+
var missing = LIMITER_INTERFACE.filter(function (m) {
|
|
415
|
+
return typeof spec[m] !== "function";
|
|
416
|
+
});
|
|
417
|
+
if (missing.length > 0) {
|
|
418
|
+
throw new MailServerRateLimitError("mail-server-rate-limit/incomplete-limiter",
|
|
419
|
+
"mail.server.rateLimit.resolve: a custom limiter must implement the whole " +
|
|
420
|
+
"interface the listeners call; missing: " + missing.join(", ") + ". Wrap " +
|
|
421
|
+
"b.mail.server.rateLimit.create({...}) and override the parts you want to " +
|
|
422
|
+
"change, so a method added later cannot silently go unimplemented.");
|
|
423
|
+
}
|
|
424
|
+
return spec;
|
|
425
|
+
}
|
|
333
426
|
return create(spec || {});
|
|
334
427
|
}
|
|
335
428
|
|
|
@@ -338,4 +431,9 @@ module.exports = {
|
|
|
338
431
|
resolve: resolve,
|
|
339
432
|
MailServerRateLimitError: MailServerRateLimitError,
|
|
340
433
|
DEFAULTS: DEFAULTS,
|
|
434
|
+
// How long a body-rate measurement must run before it means anything.
|
|
435
|
+
// Exported because the listener side owns the WINDOW — this limiter answers
|
|
436
|
+
// "is this rate below the floor", and the caller decides which stretch of
|
|
437
|
+
// the transfer to ask about.
|
|
438
|
+
BODY_RATE_GRACE_MS: BODY_RATE_GRACE_MS,
|
|
341
439
|
};
|