@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.
@@ -52,9 +52,10 @@
52
52
  * `authFailuresPerIpPer15Min` cap applies to USER+PASS / APOP /
53
53
  * AUTH refusals.
54
54
  *
55
- * - **Slow-loris on RETR / TOP** — per-connection `idleTimeoutMs`
56
- * bounds dead connections; `b.mail.server.rateLimit.minBytesPerSecond`
57
- * bounds trickle-receive class.
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 remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
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 (remoteAddress === null) return;
247
- var connectionId = "pop3conn-" + bCrypto.generateToken(8); // connection-id length
248
- var socket = rawSocket;
249
- connections.add(socket);
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 — one
320
- * exposing `admitConnection` — passes through unchanged, and anything else
321
- * is treated as `create()` options. Every mail server (IMAP / POP3 / SMTP
322
- * MX / Submission / ManageSieve) composes this at the top of its `create()`
323
- * so the spec contract is identical across protocols.
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") return spec;
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 remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
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
- ? new nodeTls.TLSSocket(rawSocket, { isServer: true, secureContext: opts.tlsContext })
400
- : rawSocket;
401
- connections.add(socket);
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
- socket.on("data", function (chunk) {
462
- try { _ingestBytes(state, socket, chunk); }
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(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
499
+ try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
467
500
  catch (_e) { /* socket already gone */ }
468
- _closeConnection(socket);
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
- var collected = bodyCollector.result();
537
- if (guardSmtpCommand.detectBodySmuggling(collected)) {
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
- var endIdx = safeSmtp.findDotTerminator(collected);
548
- if (endIdx !== -1) {
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
- try { _ingestBytes(state, tlsSocket, chunk); }
727
- catch (err) {
728
- _emit("mail.server.submission.handler_threw",
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,
@@ -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 = { cert: certPem, key: keyPem };
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
  };