@blamejs/core 0.18.54 → 0.18.55
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 +110 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- 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 +50 -37
- package/lib/mail-server-mx.js +115 -34
- 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 +48 -3
- package/lib/mcp.js +11 -3
- 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/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +396 -37
- 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-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
|
};
|
|
@@ -106,7 +106,6 @@
|
|
|
106
106
|
var net = require("node:net");
|
|
107
107
|
var nodeTls = require("node:tls");
|
|
108
108
|
var C = require("./constants");
|
|
109
|
-
var bCrypto = require("./crypto");
|
|
110
109
|
var numericBounds = require("./numeric-bounds");
|
|
111
110
|
var safeAsync = require("./safe-async");
|
|
112
111
|
var safeBuffer = require("./safe-buffer");
|
|
@@ -251,6 +250,7 @@ function _actorDomain(actor, mailFrom) {
|
|
|
251
250
|
* maxMessageBytes: number, // default 50 MiB
|
|
252
251
|
* maxRcptsPerMessage: number, // default 100
|
|
253
252
|
* idleTimeoutMs: number, // default 5 minutes
|
|
253
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
254
254
|
* profile: string, // "strict" | "balanced" | "permissive"; default "strict"
|
|
255
255
|
*
|
|
256
256
|
* @example
|
|
@@ -290,7 +290,7 @@ function create(opts) {
|
|
|
290
290
|
"create: opts.tenantScope requires opts.agentTenantId");
|
|
291
291
|
}
|
|
292
292
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
293
|
-
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
293
|
+
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
|
|
294
294
|
"mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
|
|
295
295
|
|
|
296
296
|
var profile = opts.profile || "strict";
|
|
@@ -384,21 +384,26 @@ function create(opts) {
|
|
|
384
384
|
|
|
385
385
|
function _handleConnection(rawSocket) {
|
|
386
386
|
// 421 4.7.0 — transient; sender retries elsewhere.
|
|
387
|
-
var
|
|
387
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
388
|
+
rateLimit: rateLimit,
|
|
389
|
+
connections: connections,
|
|
390
|
+
emit: _emit,
|
|
388
391
|
refusedEvent: "mail.server.submission.rate_limit_refused",
|
|
389
392
|
refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
|
|
390
|
-
|
|
391
|
-
if (remoteAddress === null) return;
|
|
392
|
-
rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
|
|
393
|
-
|
|
394
|
-
var connectionId = "submitconn-" + bCrypto.generateToken(8); // connection-id length
|
|
395
|
-
var socket = implicitTls
|
|
393
|
+
idPrefix: "submitconn-",
|
|
396
394
|
// Certificate compression is configured on the secure CONTEXT by
|
|
397
395
|
// b.mail.server.tls.context — a TLSSocket wrapping a pre-built context
|
|
398
396
|
// ignores the option, so setting it here would be inert.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
wrap: implicitTls
|
|
398
|
+
? function (raw) {
|
|
399
|
+
return new nodeTls.TLSSocket(raw, { isServer: true, secureContext: opts.tlsContext });
|
|
400
|
+
}
|
|
401
|
+
: null,
|
|
402
|
+
});
|
|
403
|
+
if (accepted === null) return;
|
|
404
|
+
var remoteAddress = accepted.remoteAddress;
|
|
405
|
+
var connectionId = accepted.connectionId;
|
|
406
|
+
var socket = accepted.socket;
|
|
402
407
|
|
|
403
408
|
var state = {
|
|
404
409
|
id: connectionId,
|
|
@@ -424,6 +429,13 @@ function create(opts) {
|
|
|
424
429
|
// string only for the per-command parse.
|
|
425
430
|
var lineBuffer = Buffer.alloc(0);
|
|
426
431
|
var bodyCollector = null;
|
|
432
|
+
// Watches the DATA body for its terminator and the smuggling shape as bytes
|
|
433
|
+
// arrive, so neither screen re-reads what it has already seen. Lives exactly
|
|
434
|
+
// as long as bodyCollector.
|
|
435
|
+
var bodyScanner = null;
|
|
436
|
+
// The slow-loris byte-rate floor, measured over bounded windows so an early
|
|
437
|
+
// burst cannot buy credit for a slow tail.
|
|
438
|
+
var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
|
|
427
439
|
var inDataBody = false;
|
|
428
440
|
// RFC 3030 CHUNKING — state for the BDAT command. `bdatCollector`
|
|
429
441
|
// accumulates the message body across multiple BDAT chunks; it lives
|
|
@@ -432,6 +444,15 @@ function create(opts) {
|
|
|
432
444
|
// bytes still owed by the current BDAT chunk; `bdatIsLast` flags
|
|
433
445
|
// whether the current chunk is the terminator.
|
|
434
446
|
var inBdatChunk = false;
|
|
447
|
+
// Whether the rate window has been opened for the BDAT sequence in flight.
|
|
448
|
+
// Explicit rather than inferred from the byte count, because a sequence of
|
|
449
|
+
// zero-length chunks leaves that count at zero.
|
|
450
|
+
var bdatRateStarted = false;
|
|
451
|
+
// Every byte this connection has received, counted once at the wire. The
|
|
452
|
+
// rate window takes its baseline from this rather than from a per-transfer
|
|
453
|
+
// counter, so nothing the parser does downstream — including re-feeding a
|
|
454
|
+
// chunk's tail through itself — can credit a byte twice.
|
|
455
|
+
var wireBytes = 0;
|
|
435
456
|
var bdatRemaining = 0;
|
|
436
457
|
var bdatIsLast = false;
|
|
437
458
|
var bdatCollector = null;
|
|
@@ -447,7 +468,6 @@ function create(opts) {
|
|
|
447
468
|
{ connectionId: state.id, code: (err && err.code) || "unknown" }, "warning");
|
|
448
469
|
_closeConnection(socket);
|
|
449
470
|
});
|
|
450
|
-
socket.on("close", function () { connections.delete(socket); });
|
|
451
471
|
|
|
452
472
|
_emit("mail.server.submission.connect", {
|
|
453
473
|
connectionId: state.id,
|
|
@@ -458,18 +478,67 @@ function create(opts) {
|
|
|
458
478
|
|
|
459
479
|
_writeReply(socket, REPLY_220_READY, greeting + " ready");
|
|
460
480
|
|
|
461
|
-
|
|
462
|
-
|
|
481
|
+
// The one funnel both transports feed. Counted HERE and nowhere else:
|
|
482
|
+
// _ingestBytes re-feeds the tail of a chunk through itself when a BDAT
|
|
483
|
+
// payload and the next command arrive in one packet, so a counter kept
|
|
484
|
+
// inside it credits those bytes twice — a peer pipelining a one-byte
|
|
485
|
+
// payload with its next command would draw roughly double the rate it was
|
|
486
|
+
// really sending.
|
|
487
|
+
//
|
|
488
|
+
// `activeSock` is whichever socket is current, because STARTTLS replaces
|
|
489
|
+
// it. Counting on the plaintext `data` listener alone stopped counting the
|
|
490
|
+
// moment a connection upgraded, which is the moment it becomes the shape
|
|
491
|
+
// operators actually deploy: the reading froze, and after the grace period
|
|
492
|
+
// a client sending well above the floor was judged to have sent nothing.
|
|
493
|
+
function _feedChunk(activeSock, chunk) {
|
|
494
|
+
wireBytes += chunk.length;
|
|
495
|
+
try { _ingestBytes(state, activeSock, chunk); }
|
|
463
496
|
catch (err) {
|
|
464
497
|
_emit("mail.server.submission.handler_threw",
|
|
465
498
|
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
466
|
-
try { _writeReply(
|
|
499
|
+
try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
|
|
467
500
|
catch (_e) { /* socket already gone */ }
|
|
468
|
-
_closeConnection(
|
|
501
|
+
_closeConnection(activeSock);
|
|
469
502
|
}
|
|
470
|
-
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
socket.on("data", function (chunk) { _feedChunk(socket, chunk); });
|
|
471
506
|
|
|
472
507
|
function _ingestBytes(state, socket, chunk) {
|
|
508
|
+
// The body-rate floor is enforced HERE, on every inbound byte, rather
|
|
509
|
+
// than inside the DATA and BDAT handlers.
|
|
510
|
+
//
|
|
511
|
+
// A check reached only from a body handler is one the peer chooses
|
|
512
|
+
// whether to reach. Enforced on the DATA path, a client used BDAT; moved
|
|
513
|
+
// onto BDAT payloads, a client used `BDAT 0`; keyed off the byte count, a
|
|
514
|
+
// client interleaved NOOP, which resets the socket idle timer without
|
|
515
|
+
// ever passing through a body handler. Each fix closed the path it was
|
|
516
|
+
// written for and left the next one open, because the peer picks the
|
|
517
|
+
// command stream.
|
|
518
|
+
//
|
|
519
|
+
// What the peer CANNOT do is hold the connection without sending bytes:
|
|
520
|
+
// stop sending and idleTimeoutMs cuts it. So every byte is measured, from
|
|
521
|
+
// the moment a body transfer opens until it closes, whatever the bytes
|
|
522
|
+
// happen to spell. No timer, and nothing to leak on teardown.
|
|
523
|
+
if (bdatRateStarted || inDataBody) {
|
|
524
|
+
// Read, never incremented: the wire handler owns the counter. The
|
|
525
|
+
// window measures against the baseline it took when the transfer
|
|
526
|
+
// opened, so this is "every byte since then", command bytes included.
|
|
527
|
+
// Counting only BODY bytes made the number go flat — and, with an
|
|
528
|
+
// interleaved command, backwards — across a window roll, which reads as
|
|
529
|
+
// no progress and refuses a client that is in fact sending steadily.
|
|
530
|
+
if (bodyRateWindow.starved(wireBytes, Date.now())) {
|
|
531
|
+
_emit("mail.server.submission.data_refused",
|
|
532
|
+
{ connectionId: state.id, reason: "body-rate-below-floor",
|
|
533
|
+
minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
|
|
534
|
+
_writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
|
|
535
|
+
"4.7.0 Message body arriving below the minimum rate; closing connection");
|
|
536
|
+
_resetTransaction(state);
|
|
537
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
538
|
+
_closeConnection(socket);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
473
542
|
// RFC 3030 — when a BDAT chunk is in progress we consume exactly
|
|
474
543
|
// `bdatRemaining` bytes off the wire, no dot-stuffing, no end-of-
|
|
475
544
|
// data marker. Any excess bytes in the chunk after the BDAT
|
|
@@ -507,6 +576,7 @@ function create(opts) {
|
|
|
507
576
|
var bdatBody = bdatCollector.result();
|
|
508
577
|
bdatCollector = null;
|
|
509
578
|
bdatTotalBytes = 0;
|
|
579
|
+
if (_refuseSmuggledBdatBody(state, socket, bdatBody)) return;
|
|
510
580
|
_finalizeAcceptedBody(state, socket, bdatBody, "BDAT");
|
|
511
581
|
} else {
|
|
512
582
|
// Non-final chunk — per-chunk acknowledgement only.
|
|
@@ -530,27 +600,37 @@ function create(opts) {
|
|
|
530
600
|
_writeReply(socket, REPLY_552_SIZE_EXCEEDED,
|
|
531
601
|
"5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
|
|
532
602
|
_resetTransaction(state);
|
|
533
|
-
inDataBody = false; bodyCollector = null;
|
|
603
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
534
604
|
return;
|
|
535
605
|
}
|
|
536
|
-
|
|
537
|
-
|
|
606
|
+
// Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
|
|
607
|
+
// Re-deriving the whole accumulated body per chunk (`result()` is a
|
|
608
|
+
// fresh concat of everything received) and scanning it twice made
|
|
609
|
+
// acceptance quadratic in the message size: the byte cap still held,
|
|
610
|
+
// but a message inside the cap cost 4949 ms at 8 MiB against 143 ms at
|
|
611
|
+
// 1 MiB. `result()` is now called ONCE, when the terminator is found.
|
|
612
|
+
//
|
|
613
|
+
// The slow-loris floor is NOT applied here. It lives at the top of
|
|
614
|
+
// _ingestBytes, where every inbound byte passes regardless of which
|
|
615
|
+
// command it belongs to — a check reached only from this handler is one
|
|
616
|
+
// a peer skips by sending anything else.
|
|
617
|
+
var seen = bodyScanner.push(chunk);
|
|
618
|
+
if (seen.smuggling) {
|
|
538
619
|
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
539
620
|
{ connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
|
|
540
621
|
"denied");
|
|
541
622
|
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
542
623
|
"5.7.0 Bare-LF in DATA body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
|
|
543
624
|
_resetTransaction(state);
|
|
544
|
-
inDataBody = false; bodyCollector = null;
|
|
625
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
545
626
|
return;
|
|
546
627
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
var body = collected.subarray(0, endIdx);
|
|
628
|
+
if (seen.terminatorAt !== -1) {
|
|
629
|
+
var body = bodyCollector.result().subarray(0, seen.terminatorAt);
|
|
550
630
|
// DATA path dot-unstuffs here; BDAT path skips this step.
|
|
551
631
|
var dedotted = safeSmtp.dotUnstuff(body);
|
|
552
632
|
_finalizeAcceptedBody(state, socket, dedotted, "DATA");
|
|
553
|
-
inDataBody = false; bodyCollector = null;
|
|
633
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
554
634
|
}
|
|
555
635
|
return;
|
|
556
636
|
}
|
|
@@ -705,12 +785,13 @@ function create(opts) {
|
|
|
705
785
|
// body collector AND strip the plain-socket "data" listener
|
|
706
786
|
// before wrapping in TLSSocket so bytes the peer pipelined
|
|
707
787
|
// pre-handshake cannot reach the post-TLS state machine.
|
|
708
|
-
lineBuffer = Buffer.alloc(0); bodyCollector = null; inDataBody = false;
|
|
788
|
+
lineBuffer = Buffer.alloc(0); bodyCollector = null; bodyScanner = null; inDataBody = false;
|
|
709
789
|
// BDAT-side state cleared on STARTTLS upgrade too — same threat
|
|
710
790
|
// model as CVE-2021-38371 (Exim) / CVE-2021-33515 (Dovecot):
|
|
711
791
|
// pre-handshake bytes the peer pipelined MUST NOT reach the
|
|
712
792
|
// post-TLS state machine via the BDAT collector either.
|
|
713
793
|
inBdatChunk = false; bdatRemaining = 0; bdatCollector = null; bdatTotalBytes = 0;
|
|
794
|
+
bdatRateStarted = false;
|
|
714
795
|
mailServerTls.upgradeSocket({
|
|
715
796
|
plainSocket: socket,
|
|
716
797
|
secureContext: opts.tlsContext,
|
|
@@ -723,12 +804,9 @@ function create(opts) {
|
|
|
723
804
|
// tradeoff acknowledged.
|
|
724
805
|
},
|
|
725
806
|
onData: function (tlsSocket, chunk) {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
730
|
-
_closeConnection(tlsSocket);
|
|
731
|
-
}
|
|
807
|
+
// Through the SAME funnel as the plaintext path, so the byte count
|
|
808
|
+
// the rate window measures against does not stop at the upgrade.
|
|
809
|
+
_feedChunk(tlsSocket, chunk);
|
|
732
810
|
},
|
|
733
811
|
onError: function (err) {
|
|
734
812
|
_emit("mail.server.submission.tls_handshake_failed",
|
|
@@ -1133,6 +1211,32 @@ function create(opts) {
|
|
|
1133
1211
|
sizeCode: "mail-server-submission/body-too-large",
|
|
1134
1212
|
sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
|
|
1135
1213
|
});
|
|
1214
|
+
bodyScanner = safeSmtp.createBodyScanner();
|
|
1215
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// The DATA branch's smuggling screen, for the BDAT paths. Both BDAT exits
|
|
1219
|
+
// — the sized LAST chunk and the zero-length LAST that terminates a
|
|
1220
|
+
// previous one — reach the agent, so both need it, and both call HERE
|
|
1221
|
+
// rather than carrying a copy. A screen that exists on one framing and not
|
|
1222
|
+
// its sibling is what this fixes; a second copy is how that happens again.
|
|
1223
|
+
//
|
|
1224
|
+
// BDAT counts its octets, so a dot-line cannot end THIS transfer early. It
|
|
1225
|
+
// matters because the body is relayed onward and the next hop is usually
|
|
1226
|
+
// DATA, where it can. The question is what the body CONTAINS, not how it
|
|
1227
|
+
// arrived: framing changes downstream, content does not.
|
|
1228
|
+
//
|
|
1229
|
+
// Returns true when the transaction was refused and the caller must stop.
|
|
1230
|
+
function _refuseSmuggledBdatBody(state, socket, body) {
|
|
1231
|
+
if (!guardSmtpCommand.detectBodySmuggling(body)) return false;
|
|
1232
|
+
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
1233
|
+
{ connectionId: state.id, mailFrom: state.mailFrom,
|
|
1234
|
+
rcptCount: state.rcpts.length, framing: "BDAT" },
|
|
1235
|
+
"denied");
|
|
1236
|
+
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
1237
|
+
"5.7.0 Bare-LF in BDAT body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
|
|
1238
|
+
_resetTransaction(state);
|
|
1239
|
+
return true;
|
|
1136
1240
|
}
|
|
1137
1241
|
|
|
1138
1242
|
function _finalizeAcceptedBody(state, socket, dedotted, source) {
|
|
@@ -1279,6 +1383,21 @@ function create(opts) {
|
|
|
1279
1383
|
});
|
|
1280
1384
|
}
|
|
1281
1385
|
state.stage = "bdat";
|
|
1386
|
+
// Open the rate window on the FIRST BDAT of a sequence, the way the DATA
|
|
1387
|
+
// prompt opens it on that path, and judge EVERY BDAT command against it —
|
|
1388
|
+
// including a zero-length one.
|
|
1389
|
+
//
|
|
1390
|
+
// Keyed on an explicit flag rather than on the byte count: `BDAT 0`
|
|
1391
|
+
// without LAST leaves the count at zero, so a count-based test restarted
|
|
1392
|
+
// the window on every command while the zero-length path returned before
|
|
1393
|
+
// reaching any measurement. One such command before each idle timeout
|
|
1394
|
+
// then held the connection indefinitely without ever meeting the floor —
|
|
1395
|
+
// the same slow-loris this check exists to stop, wearing a different
|
|
1396
|
+
// command.
|
|
1397
|
+
if (!bdatRateStarted) {
|
|
1398
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1399
|
+
bdatRateStarted = true;
|
|
1400
|
+
}
|
|
1282
1401
|
bdatRemaining = sizeN;
|
|
1283
1402
|
bdatIsLast = isLast;
|
|
1284
1403
|
// size=0 + LAST is a valid sequence — finalises the message
|
|
@@ -1288,8 +1407,13 @@ function create(opts) {
|
|
|
1288
1407
|
// _finalizeAcceptedBody for size=0 LAST.
|
|
1289
1408
|
if (sizeN === 0) {
|
|
1290
1409
|
if (isLast) {
|
|
1410
|
+
// NOT necessarily empty: this carries everything the prior chunks
|
|
1411
|
+
// accumulated, so it needs the same screen as the sized-LAST path
|
|
1412
|
+
// above. A smuggled body sent as chunk one and terminated with
|
|
1413
|
+
// `BDAT 0 LAST` reaches the agent through here.
|
|
1291
1414
|
var emptyBody = bdatCollector ? bdatCollector.result() : Buffer.alloc(0);
|
|
1292
1415
|
bdatCollector = null; bdatTotalBytes = 0;
|
|
1416
|
+
if (_refuseSmuggledBdatBody(state, socket, emptyBody)) return;
|
|
1293
1417
|
_finalizeAcceptedBody(state, socket, emptyBody, "BDAT");
|
|
1294
1418
|
} else {
|
|
1295
1419
|
_writeReply(socket, REPLY_250_OK, "2.0.0 0 octets received");
|
|
@@ -1312,6 +1436,10 @@ function create(opts) {
|
|
|
1312
1436
|
bdatIsLast = false;
|
|
1313
1437
|
bdatCollector = null;
|
|
1314
1438
|
bdatTotalBytes = 0;
|
|
1439
|
+
// A new transaction opens a new rate window rather than inheriting the
|
|
1440
|
+
// last one's elapsed time, which would refuse the first chunk of a
|
|
1441
|
+
// perfectly fast sender that happened to follow a slow one.
|
|
1442
|
+
bdatRateStarted = false;
|
|
1315
1443
|
}
|
|
1316
1444
|
}
|
|
1317
1445
|
|
|
@@ -1321,6 +1449,7 @@ function create(opts) {
|
|
|
1321
1449
|
// listening event reports implicitTls so an operator can confirm the wire mode.
|
|
1322
1450
|
var _tcpListener = mailServerNet.createTcpListener(net, {
|
|
1323
1451
|
defaultPort: implicitTls ? 465 : 587, // RFC 8314 implicit-TLS / RFC 6409 submission ports
|
|
1452
|
+
maxConnections: opts.maxConnections,
|
|
1324
1453
|
handleConnection: _handleConnection,
|
|
1325
1454
|
errorFactory: function (code, message) { return new MailServerSubmissionError("mail-server-submission/" + code, message); },
|
|
1326
1455
|
emit: _emit,
|
package/lib/mail-server-tls.js
CHANGED
|
@@ -100,6 +100,9 @@ var validateOpts = require("./validate-opts");
|
|
|
100
100
|
var { defineClass } = require("./framework-error");
|
|
101
101
|
|
|
102
102
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
103
|
+
// Lazy — network-tls pulls the posture machinery, and only the context build
|
|
104
|
+
// needs it.
|
|
105
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
103
106
|
|
|
104
107
|
var MailServerTlsError = defineClass("MailServerTlsError", { alwaysPermanent: true });
|
|
105
108
|
|
|
@@ -144,6 +147,44 @@ var DEFAULT_POLL_MS = C.TIME.seconds(30);
|
|
|
144
147
|
* // ... later, on shutdown:
|
|
145
148
|
* tls.stop(); // clears the poll timer
|
|
146
149
|
*/
|
|
150
|
+
|
|
151
|
+
// The createSecureContext options, assembled in ONE place so the live build and
|
|
152
|
+
// the test hook cannot diverge. Module scope is what makes that claim true: a
|
|
153
|
+
// copy nested inside context() is unreachable from the hook, which then has no
|
|
154
|
+
// way to answer the question except by re-deriving the options itself — and a
|
|
155
|
+
// re-derivation agrees with the build right up until someone changes one.
|
|
156
|
+
//
|
|
157
|
+
// The key-agreement preference comes from `b.network.tls.keyAgreementGroups`,
|
|
158
|
+
// which is where the framework's PQC-first policy lives — the ML-KEM hybrids
|
|
159
|
+
// with a classical X25519 fallback — along with the reasoning about which key
|
|
160
|
+
// node reads it under: a `groups` key is accepted and silently ignored while a
|
|
161
|
+
// malformed `ecdhCurve` throws, so the list has to land under `ecdhCurve`. This
|
|
162
|
+
// built `{ cert, key }` and set no group list at all, so the listener that
|
|
163
|
+
// speaks STARTTLS to the public internet negotiated whatever the runtime
|
|
164
|
+
// defaulted to, and an `ecdhCurve` a consumer passed was accepted and dropped —
|
|
165
|
+
// a failed attempt to set a policy looked exactly like a successful one.
|
|
166
|
+
//
|
|
167
|
+
// RFC 8879 certificate compression belongs here too, on the CONTEXT: a
|
|
168
|
+
// TLSSocket wrapping a pre-built context ignores the option, so setting it at
|
|
169
|
+
// the wrap site is inert and the server keeps writing the full uncompressed
|
|
170
|
+
// chain. A mail certificate is the same ML-DSA-87 chain as the HTTP listener's,
|
|
171
|
+
// and the same dominant share of the handshake.
|
|
172
|
+
//
|
|
173
|
+
// Deliberately NOT `applyToContext`: that also merges the framework trust store
|
|
174
|
+
// into the context, and a `ca` list on a SERVER context changes how it treats
|
|
175
|
+
// client certificates. This listener asked for a group policy, not a
|
|
176
|
+
// verification posture.
|
|
177
|
+
function _contextOptions(sourceOpts, certPem, keyPem) {
|
|
178
|
+
var base = { cert: certPem, key: keyPem };
|
|
179
|
+
var groups = networkTls().keyAgreementGroups(
|
|
180
|
+
sourceOpts ? sourceOpts.ecdhCurve : undefined,
|
|
181
|
+
"b.mail.server.tls.context: opts.ecdhCurve");
|
|
182
|
+
if (groups) base.ecdhCurve = groups;
|
|
183
|
+
var certCompression = C.TLS_CERT_COMPRESSION();
|
|
184
|
+
if (certCompression.length > 0) base.certificateCompression = certCompression;
|
|
185
|
+
return base;
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
function context(opts) {
|
|
148
189
|
validateOpts.requireObject(opts, "b.mail.server.tls.context",
|
|
149
190
|
MailServerTlsError, "mail-server-tls/bad-opts");
|
|
@@ -227,9 +268,7 @@ function context(opts) {
|
|
|
227
268
|
// it at the wrap site is inert and the server keeps writing the full
|
|
228
269
|
// uncompressed chain. A mail certificate is the same ML-DSA-87 chain as
|
|
229
270
|
// the HTTP listener's and the same dominant share of the handshake.
|
|
230
|
-
var ctxOpts =
|
|
231
|
-
var certCompression = C.TLS_CERT_COMPRESSION();
|
|
232
|
-
if (certCompression.length > 0) ctxOpts.certificateCompression = certCompression;
|
|
271
|
+
var ctxOpts = _contextOptions(opts, certPem, keyPem);
|
|
233
272
|
ctx = nodeTls.createSecureContext(ctxOpts);
|
|
234
273
|
} catch (e) {
|
|
235
274
|
throw new MailServerTlsError("mail-server-tls/secure-context-failed",
|
|
@@ -569,4 +608,10 @@ module.exports = {
|
|
|
569
608
|
upgradeSocket: upgradeSocket,
|
|
570
609
|
upgradeLineProtocol: upgradeLineProtocol,
|
|
571
610
|
MailServerTlsError: MailServerTlsError,
|
|
611
|
+
// Test hook: the resolved createSecureContext options, so the group-policy
|
|
612
|
+
// assertions read what the context is actually built with rather than
|
|
613
|
+
// re-deriving it.
|
|
614
|
+
_contextOptionsForTest: function (opts) {
|
|
615
|
+
return _contextOptions(opts, "<cert>", "<key>");
|
|
616
|
+
},
|
|
572
617
|
};
|