@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/NOTICE +5 -5
  3. package/README.md +9 -9
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/audit-sign.js +24 -5
  6. package/lib/audit.js +26 -24
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/chain-writer.js +17 -0
  9. package/lib/db-file-lifecycle.js +14 -3
  10. package/lib/db.js +505 -49
  11. package/lib/guard-filename.js +8 -1
  12. package/lib/guard-html.js +10 -2
  13. package/lib/guard-list-unsubscribe.js +6 -1
  14. package/lib/guard-managesieve-command.js +24 -3
  15. package/lib/guard-smtp-command.js +20 -4
  16. package/lib/guard-svg.js +6 -1
  17. package/lib/http-client.js +17 -3
  18. package/lib/mail-agent.js +6 -4
  19. package/lib/mail-auth.js +59 -2
  20. package/lib/mail-server-imap.js +65 -34
  21. package/lib/mail-server-managesieve.js +65 -42
  22. package/lib/mail-server-mx.js +313 -40
  23. package/lib/mail-server-net.js +155 -1
  24. package/lib/mail-server-pop3.js +16 -19
  25. package/lib/mail-server-rate-limit.js +104 -6
  26. package/lib/mail-server-submission.js +162 -33
  27. package/lib/mail-server-tls.js +71 -11
  28. package/lib/mcp.js +11 -3
  29. package/lib/middleware/csrf-protect.js +37 -20
  30. package/lib/middleware/require-mtls.js +8 -1
  31. package/lib/network-tls.js +18 -0
  32. package/lib/safe-mount-info.js +39 -6
  33. package/lib/safe-smtp.js +96 -1
  34. package/lib/safe-url.js +8 -2
  35. package/lib/self-update.js +4 -1
  36. package/lib/session-stores.js +6 -3
  37. package/lib/vendor/MANIFEST.json +34 -34
  38. package/lib/vendor/blamejs-pki.cjs +396 -37
  39. package/lib/vendor/browser/noble-ciphers.mjs +15 -1
  40. package/lib/vendor/browser/noble-hashes.mjs +12 -4
  41. package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
  42. package/lib/vendor/noble-ciphers.cjs +15 -1
  43. package/lib/vendor/noble-curves.cjs +46 -21
  44. package/lib/vendor/noble-post-quantum.cjs +184 -75
  45. package/lib/watcher.js +31 -6
  46. package/lib/ws-client.js +17 -2
  47. package/package.json +1 -1
  48. package/sbom.cdx.json +6 -6
@@ -157,8 +157,8 @@
157
157
 
158
158
  var net = require("node:net");
159
159
  var lazyRequire = require("./lazy-require");
160
+ var boundedMap = require("./bounded-map");
160
161
  var C = require("./constants");
161
- var bCrypto = require("./crypto");
162
162
  var numericBounds = require("./numeric-bounds");
163
163
  var safeAsync = require("./safe-async");
164
164
  var safeBuffer = require("./safe-buffer");
@@ -322,17 +322,18 @@ function _stripForgedAuthResults(messageBuf, authservId) {
322
322
  * greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
323
323
  * agent: b.mail.agent, // optional delivery handoff
324
324
  * relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
325
- * localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
325
+ * localDomains: [string]|fn, // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1). A function returning the array is answered per RCPT, for a hosted set the operator changes while the server runs; an entry it returns that b.guardDomain refuses is dropped with an audit event rather than throwing on the connection that happened to arrive.
326
326
  * recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
327
327
  * maxLineBytes: number, // default 1 KiB — per-command line cap
328
328
  * maxMessageBytes: number, // default 50 MiB — DATA body cap
329
329
  * maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
330
330
  * idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
331
+ * maxConnections: number, // default 1024 — listener-wide ceiling
331
332
  * profile: "strict" | "balanced" | "permissive", // gate posture cascade
332
333
  * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
333
334
  * mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
334
335
  * onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
335
- * authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
336
+ * authservId?: string, // RFC 8601 authserv-id; defaults to the first local domain, re-read per message when localDomains is a function
336
337
  * dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
337
338
  * maxSignatures?: number, // DKIM verify cap (1-16)
338
339
  * clockSkewMs?: number, // DKIM timestamp skew tolerance
@@ -365,12 +366,22 @@ function create(opts) {
365
366
  "output directly. Cert provisioning lives in b.acme (RFC 8555 + RFC 9773 ARI).");
366
367
  }
367
368
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
368
- ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
369
+ ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
369
370
  "mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
370
- if (opts.localDomains !== undefined &&
371
- (!Array.isArray(opts.localDomains) || opts.localDomains.length === 0)) {
371
+ // An EMPTY array is accepted and means "this server hosts no domains", which
372
+ // is a real state — a first boot before the first domain is added — and the
373
+ // only honest thing to do with it is refuse every recipient. Refusing it at
374
+ // construction left the operator with one spelling that started a server
375
+ // (omitting the option), and that spelling used to skip the relay check
376
+ // entirely, so the only way to get a listener was to get one that accepted
377
+ // everything. An allowlist that disappears when it is empty is a firewall
378
+ // rule set that opens when the last rule is deleted.
379
+ if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains) &&
380
+ typeof opts.localDomains !== "function") {
372
381
  throw new MailServerMxError("mail-server-mx/bad-opts",
373
- "mail.server.mx.create: localDomains must be a non-empty array if provided");
382
+ "mail.server.mx.create: localDomains must be an array of domain strings, or a " +
383
+ "function returning one when the hosted set changes while the server runs " +
384
+ "(an empty array means this server hosts no domains and refuses every recipient)");
374
385
  }
375
386
  if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
376
387
  throw new MailServerMxError("mail-server-mx/bad-opts",
@@ -401,7 +412,22 @@ function create(opts) {
401
412
  var maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
402
413
  var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
403
414
  var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
404
- var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
415
+ // The hosted-domain set is administrative state, not configuration: operators
416
+ // add and withdraw domains while the process runs, and that is an ordinary
417
+ // act rather than a reconfiguration. Captured once, a withdrawn domain kept
418
+ // drawing 250 at RCPT until a restart — with every management surface
419
+ // agreeing it was gone and nothing telling the operator mail was still
420
+ // arriving for it — while a newly added one drew 550 5.7.1, which a sending
421
+ // queue reads as "never retry" rather than "not yet".
422
+ //
423
+ // So it may be a function, answered per RCPT. The neighbouring
424
+ // recipientPolicy already is, which is what made the frozen half odd: the two
425
+ // parts of one question were answered a line apart with different currency.
426
+ // The array form is unchanged and still validated once at boot.
427
+ var localDomainsFn = typeof opts.localDomains === "function" ? opts.localDomains : null;
428
+ var localDomains = localDomainsFn
429
+ ? []
430
+ : (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
405
431
  var relayAllowedFor = (opts.relayAllowedFor || []).map(function (__e) {
406
432
  return (__e && typeof __e === "object" && !Array.isArray(__e))
407
433
  ? Object.assign({}, __e, { cidr: _normalizeRelayCidr(__e.cidr) })
@@ -493,7 +519,10 @@ function create(opts) {
493
519
  // Authentication-Results header. Defaults to the first local
494
520
  // domain; with neither, the header is skipped (the verdict
495
521
  // still reaches the agent handoff).
496
- authservId: ge.authservId || localDomains[0] || null,
522
+ // A getter, so a listener whose hosted set is a function does not pin the
523
+ // authserv-id to whichever domain happened to be first at boot. Freezing
524
+ // the object prevents redefining this, not invoking it.
525
+ get authservId() { return ge.authservId || _resolveLocalDomains()[0] || null; },
497
526
  dnsLookup: ge.dnsLookup || undefined,
498
527
  maxSignatures: ge.maxSignatures,
499
528
  clockSkewMs: ge.clockSkewMs,
@@ -551,6 +580,165 @@ function create(opts) {
551
580
  }
552
581
  }
553
582
 
583
+ // Resolve the hosted set at the point it is needed.
584
+ //
585
+ // The array form was checked at boot and cannot change, so it is returned as
586
+ // is. The function form is checked when it is read, because a set that can
587
+ // change can acquire a bad entry after boot — an IDN homograph typed into an
588
+ // admin form is exactly the case the boot check exists for, and it would
589
+ // otherwise reach RCPT unexamined.
590
+ //
591
+ // A bad entry is DROPPED rather than thrown on. This is the request path: a
592
+ // throw here would take down the connection that happened to arrive, and the
593
+ // operator would see a mail outage rather than a typo. Dropping refuses mail
594
+ // for the entry that failed and keeps serving the domains that passed, which
595
+ // is what an allowlist with one bad line should do. Each distinct bad value
596
+ // is reported once so it does not become a per-recipient log flood.
597
+ //
598
+ // The cache key is the CONTENT of the returned set, never its identity. An
599
+ // operator holding one array and mutating it — push on add, splice on
600
+ // withdraw — hands back the same object every time, which is the most
601
+ // ordinary way to keep this state. Keyed on identity, that array would be
602
+ // normalized once and then frozen: added domains refused forever and
603
+ // withdrawn ones accepted forever, which is this very bug wearing a hat.
604
+ var _ldSeenKey = null;
605
+ var _ldSeenOut = [];
606
+ // Bounded, because its keys come from the operator's live set and a control
607
+ // plane that churns tenants — or an admin form collecting typos — would
608
+ // otherwise grow this without limit for the life of the process, one entry
609
+ // per distinct bad value ever seen. It exists only to stop a per-recipient
610
+ // log flood, so evicting the oldest costs at most a repeated warning about a
611
+ // domain nobody has mentioned in a long time.
612
+ var _ldWarned = boundedMap.boundedMap({ maxEntries: 256, policy: "evict-oldest" });
613
+
614
+ // Warn once per distinct reason. getOrInsert runs the factory only when the
615
+ // key is absent, which is the whole "first time only" rule — a hand-rolled
616
+ // has-then-set says the same thing in two statements that can drift apart.
617
+ function _warnOnce(key, emitFn) {
618
+ boundedMap.getOrInsert(_ldWarned, key, function () { emitFn(); return true; });
619
+ }
620
+
621
+ // Backstop for the whole resolution, not any one operation inside it.
622
+ //
623
+ // Three separate throws were found here one at a time — the callback itself,
624
+ // serializing its result, coercing an entry — and each was guarded where it
625
+ // stood. The rule is what matters rather than the list: NOTHING in resolving
626
+ // an operator-supplied set may reach the RCPT handler as an exception, since
627
+ // there it ends the connection of whoever happened to be delivering. An
628
+ // unreadable set means no domains are known, and no domains known means every
629
+ // recipient is refused, which is what an empty hosted set already means.
630
+ function _resolveLocalDomains() {
631
+ try { return _resolveLocalDomainsInner(); }
632
+ catch (err) {
633
+ _warnOnce("__resolver", function () {
634
+ _emit("mail.server.mx.local_domains_unavailable",
635
+ { reason: "resolver-threw",
636
+ remark: String((err && err.message) || err).slice(0, 200) },
637
+ "warning");
638
+ });
639
+ return [];
640
+ }
641
+ }
642
+
643
+ function _resolveLocalDomainsInner() {
644
+ if (!localDomainsFn) return localDomains;
645
+ var raw;
646
+ try { raw = localDomainsFn(); }
647
+ catch (err) {
648
+ _warnOnce("__threw", function () {
649
+ _emit("mail.server.mx.local_domains_unavailable",
650
+ { reason: "threw", remark: String((err && err.message) || err).slice(0, 200) },
651
+ "warning");
652
+ });
653
+ return [];
654
+ }
655
+ if (!Array.isArray(raw)) {
656
+ _warnOnce("__shape", function () {
657
+ _emit("mail.server.mx.local_domains_unavailable",
658
+ { reason: "not-an-array", remark: "localDomains() returned " + (typeof raw) },
659
+ "warning");
660
+ });
661
+ return [];
662
+ }
663
+ // Coerce first, and key the cache on the COERCED strings.
664
+ //
665
+ // Keying on the raw array does not work: JSON.stringify is not injective
666
+ // with respect to the String() that follows it. Two objects with different
667
+ // toString() results both serialize as {}, and undefined, null and
668
+ // functions are indistinguishable inside an array — so two genuinely
669
+ // different hosted sets could share a key and the second one would be
670
+ // answered from the first one's cache. A withdrawn domain would stay
671
+ // accepted, which is the bug the callback exists to fix.
672
+ //
673
+ // Coercion is per ENTRY and guarded, because it runs with operator-supplied
674
+ // runtime data inside RCPT handling: an object whose toString throws must
675
+ // drop that entry, not end the connection that happened to arrive.
676
+ var coerced = [];
677
+ for (var i = 0; i < raw.length; i += 1) {
678
+ try {
679
+ coerced.push(String(raw[i]).toLowerCase());
680
+ } catch (entryErr) {
681
+ var entryIndex = i;
682
+ var entryReason = String((entryErr && entryErr.message) || entryErr).slice(0, 160);
683
+ _warnOnce("__entry" + entryIndex, function () {
684
+ _emit("mail.server.mx.local_domain_refused",
685
+ { domain: null, kind: "unreadable",
686
+ remark: "localDomains() entry " + entryIndex + " could not be read (" +
687
+ entryReason +
688
+ "); it is refused and the rest of the set still serves" },
689
+ "warning");
690
+ });
691
+ }
692
+ }
693
+
694
+ // Every element is a string now, so this key is injective over exactly the
695
+ // values the decision below is made from. Coercion is cheap and runs every
696
+ // time; what the cache buys is skipping a b.guardDomain call per entry per
697
+ // recipient, which is the expensive half.
698
+ var contentKey = JSON.stringify(coerced);
699
+ if (contentKey === _ldSeenKey) return _ldSeenOut;
700
+
701
+ // One bad entry drops and the rest of the set still serves, which is the
702
+ // same answer the guard gives for one it rejects on its merits.
703
+ var out = [];
704
+ for (var ci = 0; ci < coerced.length; ci += 1) {
705
+ var d = coerced[ci];
706
+ if (guardDomainProfile) {
707
+ var verdict;
708
+ try { verdict = guardDomain.validate(d, guardDomainProfile); }
709
+ catch (guardErr) {
710
+ var guardReason = String((guardErr && guardErr.message) || guardErr).slice(0, 160);
711
+ _warnOnce("__guard:" + d, function () {
712
+ _emit("mail.server.mx.local_domain_refused",
713
+ { domain: d, kind: "guard-threw",
714
+ remark: "b.guardDomain threw on this entry (" + guardReason +
715
+ "); it is refused and the rest of the set still serves" },
716
+ "warning");
717
+ });
718
+ continue;
719
+ }
720
+ if (!verdict.ok) {
721
+ var refusedDomain = d;
722
+ var refusedKind =
723
+ (verdict.issues && verdict.issues[0] && verdict.issues[0].kind) || null;
724
+ _warnOnce(refusedDomain, function () {
725
+ _emit("mail.server.mx.local_domain_refused",
726
+ { domain: refusedDomain,
727
+ kind: refusedKind,
728
+ remark: "rejected by b.guardDomain; mail for it is refused and the rest " +
729
+ "of the set still serves" },
730
+ "warning");
731
+ });
732
+ continue;
733
+ }
734
+ }
735
+ out.push(d);
736
+ }
737
+ _ldSeenKey = contentKey;
738
+ _ldSeenOut = out;
739
+ return out;
740
+ }
741
+
554
742
  var connections = new Set();
555
743
 
556
744
  var _emit = auditEmit.emit;
@@ -559,15 +747,17 @@ function create(opts) {
559
747
  function _handleConnection(socket) {
560
748
  // 421 4.7.0 — transient refusal; sender retries elsewhere or later.
561
749
  // RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
562
- var remoteAddress = mailServerNet.admitConnection(socket, rateLimit, _emit, {
750
+ var accepted = mailServerNet.acceptConnection(socket, {
751
+ rateLimit: rateLimit,
752
+ connections: connections,
753
+ emit: _emit,
563
754
  refusedEvent: "mail.server.mx.rate_limit_refused",
564
755
  refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
756
+ idPrefix: "mxconn-",
565
757
  });
566
- if (remoteAddress === null) return;
567
- socket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
568
-
569
- var connectionId = "mxconn-" + bCrypto.generateToken(8); // connection-id length
570
- connections.add(socket);
758
+ if (accepted === null) return;
759
+ var remoteAddress = accepted.remoteAddress;
760
+ var connectionId = accepted.connectionId;
571
761
 
572
762
  // Backpressure observer — `_writeReply` flips `_bpEmitted` after
573
763
  // the first audit emission per socket to bound the audit volume.
@@ -597,6 +787,15 @@ function create(opts) {
597
787
  // message. Decode to string only for the per-command line parse.
598
788
  var lineBuffer = Buffer.alloc(0);
599
789
  var bodyCollector = null;
790
+ // Watches the DATA body for its terminator and the smuggling shape as bytes
791
+ // arrive, so neither screen re-reads what it has already seen. Lives exactly
792
+ // as long as bodyCollector.
793
+ var bodyScanner = null;
794
+ // The slow-loris byte-rate floor, measured over bounded windows so an early
795
+ // burst cannot buy credit for a slow tail.
796
+ var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
797
+ // Every byte this connection has received, counted once at the wire funnel.
798
+ var wireBytes = 0;
600
799
  var inDataBody = false;
601
800
  // Async command pump: gates (HELO / RBL / greylist / envelope /
602
801
  // DMARC) may await DNS or a store, so command handling is async.
@@ -621,10 +820,9 @@ function create(opts) {
621
820
  _closeConnection(socket);
622
821
  });
623
822
 
624
- socket.on("close", function () {
625
- connClosed = true;
626
- connections.delete(socket);
627
- });
823
+ // The set entry and the rate-limit slot are released by trackConnection;
824
+ // this handler carries only the per-transaction flag the drain reads.
825
+ socket.on("close", function () { connClosed = true; });
628
826
 
629
827
  _emit("mail.server.mx.connect", {
630
828
  connectionId: state.id,
@@ -647,6 +845,12 @@ function create(opts) {
647
845
  // 421 path. `activeSock` is whichever socket is current (plaintext or
648
846
  // TLS) so the 421/close lands on the right transport.
649
847
  function _feedChunk(activeSock, chunk) {
848
+ // Every wire byte, counted once, on the single funnel both the plaintext
849
+ // and the post-STARTTLS socket feed. The rate window takes its baseline
850
+ // from this, so the measurement is "bytes since the transfer opened"
851
+ // rather than "bytes the body parser happened to see" — the distinction
852
+ // that let the sibling listener's count go flat across a window roll.
853
+ wireBytes += chunk.length;
650
854
  pumpChain = pumpChain.then(function () {
651
855
  if (connClosed) return undefined;
652
856
  return _ingestBytes(state, activeSock, chunk);
@@ -665,6 +869,27 @@ function create(opts) {
665
869
 
666
870
  // ---- Byte-level ingestion --------------------------------------------
667
871
  async function _ingestBytes(state, socket, chunk) {
872
+ // The body-rate floor is enforced HERE, on every inbound byte, rather
873
+ // than inside the DATA handler below. A check reached only from a body
874
+ // handler is one the peer chooses whether to reach: on the sibling
875
+ // listener the same floor was skipped first by using BDAT, then by a
876
+ // zero-length chunk, then by interleaving NOOP, each of which resets the
877
+ // socket idle timer without passing through a body handler. What a peer
878
+ // cannot do is hold the connection without sending bytes, and every byte
879
+ // arrives here.
880
+ if (inDataBody && bodyRateWindow.starved(wireBytes, Date.now())) {
881
+ _emit("mail.server.mx.data_refused",
882
+ { connectionId: state.id, reason: "body-rate-below-floor",
883
+ minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
884
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
885
+ "4.7.0 Message body arriving below the minimum rate; closing connection");
886
+ _resetTransaction(state);
887
+ inDataBody = false;
888
+ bodyCollector = null;
889
+ bodyScanner = null;
890
+ _closeConnection(socket);
891
+ return;
892
+ }
668
893
  if (inDataBody) {
669
894
  // DATA body — accumulate via boundedChunkCollector, watch for
670
895
  // canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
@@ -679,13 +904,25 @@ function create(opts) {
679
904
  _resetTransaction(state);
680
905
  inDataBody = false;
681
906
  bodyCollector = null;
907
+ bodyScanner = null;
682
908
  return;
683
909
  }
684
- var collected = bodyCollector.result();
685
- // Smuggling detector bare LF dot-line in body before the
686
- // CRLF dot terminator. Refuse the whole transaction; emit
687
- // smuggling-detected audit.
688
- if (guardSmtpCommand.detectBodySmuggling(collected)) {
910
+ // Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
911
+ // Re-deriving the whole accumulated body per chunk (`result()` is a
912
+ // fresh concat of everything received so far) and scanning it twice
913
+ // made acceptance quadratic in the message size. The byte cap bounds
914
+ // BYTES, not processor time, so a message well inside maxMessageBytes
915
+ // still cost 4949 ms at 8 MiB against 143 ms at 1 MiB — and on this
916
+ // listener that is reachable unauthenticated. `result()` is now called
917
+ // ONCE, when the terminator is found.
918
+ //
919
+ // The slow-loris floor is NOT applied here — it runs at the top of
920
+ // _ingestBytes, where every inbound byte passes whatever command it
921
+ // belongs to.
922
+ var seen = bodyScanner.push(chunk);
923
+ // Smuggling detector — bare LF dot-line in body before the CRLF dot
924
+ // terminator. Refuse the whole transaction; emit a smuggling audit.
925
+ if (seen.smuggling) {
689
926
  _emit("mail.server.mx.smtp_smuggling_detected",
690
927
  { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
691
928
  "denied");
@@ -694,14 +931,15 @@ function create(opts) {
694
931
  _resetTransaction(state);
695
932
  inDataBody = false;
696
933
  bodyCollector = null;
934
+ bodyScanner = null;
697
935
  return;
698
936
  }
699
937
  // Canonical \r\n.\r\n terminator?
700
- var endIdx = safeSmtp.findDotTerminator(collected);
701
- if (endIdx !== -1) {
702
- var body = collected.subarray(0, endIdx);
938
+ if (seen.terminatorAt !== -1) {
939
+ var body = bodyCollector.result().subarray(0, seen.terminatorAt);
703
940
  inDataBody = false;
704
941
  bodyCollector = null;
942
+ bodyScanner = null;
705
943
  await _finalizeDataBody(state, socket, body);
706
944
  }
707
945
  return;
@@ -868,6 +1106,7 @@ function create(opts) {
868
1106
  // shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
869
1107
  lineBuffer = Buffer.alloc(0);
870
1108
  bodyCollector = null;
1109
+ bodyScanner = null;
871
1110
  inDataBody = false;
872
1111
  mailServerTls.upgradeSocket({
873
1112
  plainSocket: socket,
@@ -1003,17 +1242,21 @@ function create(opts) {
1003
1242
  }
1004
1243
  // Local-domain check — refuse non-local recipients unless the
1005
1244
  // operator explicitly allowed relay for this scope.
1006
- if (localDomains.length > 0) {
1007
- if (localDomains.indexOf(rcptDomain) === -1 &&
1008
- !_isRelayAllowed(state.remoteAddress, rcpt)) {
1009
- rateLimit.noteRcptFailure(state.remoteAddress);
1010
- _trackRefusedRcpt(state, rcpt, "relay-denied");
1011
- _emit("mail.server.mx.relay_refused",
1012
- { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1013
- remoteAddress: state.remoteAddress }, "denied");
1014
- _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
1015
- return;
1016
- }
1245
+ //
1246
+ // Run UNCONDITIONALLY. This used to sit inside `if (localDomains.length
1247
+ // > 0)`, so a server hosting no domains ran no check at all and accepted
1248
+ // every recipient. An empty hosted set now refuses everything naturally,
1249
+ // which is what an empty allowlist has to mean; `relayAllowedFor` is
1250
+ // still the way to permit a scope deliberately.
1251
+ if (_resolveLocalDomains().indexOf(rcptDomain) === -1 &&
1252
+ !_isRelayAllowed(state.remoteAddress, rcpt)) {
1253
+ rateLimit.noteRcptFailure(state.remoteAddress);
1254
+ _trackRefusedRcpt(state, rcpt, "relay-denied");
1255
+ _emit("mail.server.mx.relay_refused",
1256
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1257
+ remoteAddress: state.remoteAddress }, "denied");
1258
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
1259
+ return;
1017
1260
  }
1018
1261
  // RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
1019
1262
  // IP. The verdict is per-connection, so it's evaluated once and
@@ -1131,6 +1374,8 @@ function create(opts) {
1131
1374
  sizeCode: "mail-server-mx/body-too-large",
1132
1375
  sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
1133
1376
  });
1377
+ bodyScanner = safeSmtp.createBodyScanner();
1378
+ bodyRateWindow.start(Date.now(), wireBytes);
1134
1379
  }
1135
1380
 
1136
1381
  async function _finalizeDataBody(state, socket, body) {
@@ -1165,6 +1410,18 @@ function create(opts) {
1165
1410
  var inboundAuth = null;
1166
1411
  if (envelopeGate) {
1167
1412
  var inboundVerdict = null;
1413
+ // Resolve the authserv-id ONCE for this message and use that value for
1414
+ // both the header written and the forged headers stripped.
1415
+ //
1416
+ // It can now come from a hosted-domain callback, so two reads either
1417
+ // side of an await can disagree — and these two reads are exactly the
1418
+ // pair that must not. The strip removes sender-attached
1419
+ // Authentication-Results claiming this receiver's identity (RFC 8601
1420
+ // §5) before the computed one is prepended. Strip under the old id and
1421
+ // write under the new one, and a forged header claiming the id the
1422
+ // trusted verdict was written under survives next to it, which is the
1423
+ // shadowing this defense exists to prevent.
1424
+ var messageAuthservId = envelopeGate.authservId;
1168
1425
  try {
1169
1426
  // Wall-clock ceiling around the whole pipeline — a message
1170
1427
  // stuffed with signatures pointing at slow resolvers must
@@ -1180,7 +1437,7 @@ function create(opts) {
1180
1437
  maxSignatures: envelopeGate.maxSignatures,
1181
1438
  clockSkewMs: envelopeGate.clockSkewMs,
1182
1439
  minRsaBits: envelopeGate.minRsaBits,
1183
- authservId: envelopeGate.authservId || undefined,
1440
+ authservId: messageAuthservId || undefined,
1184
1441
  }),
1185
1442
  envelopeGate.timeoutMs,
1186
1443
  { name: "mail.server.mx.guardEnvelope" });
@@ -1215,6 +1472,11 @@ function create(opts) {
1215
1472
  dkim: dkimSummary,
1216
1473
  dmarc: inboundVerdict.dmarc.result,
1217
1474
  arc: inboundVerdict.arc && inboundVerdict.arc.chainStatus,
1475
+ // The status alone cannot tell an operator reading the audit
1476
+ // whether a chain failed because a seal was bad or because a
1477
+ // resolver was down — the two want opposite responses.
1478
+ arcReason: inboundVerdict.arc && inboundVerdict.arc.reason,
1479
+ arcTransient: !!(inboundVerdict.arc && inboundVerdict.arc.transient),
1218
1480
  action: envAction,
1219
1481
  mode: envelopeGate.mode,
1220
1482
  }, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
@@ -1247,7 +1509,7 @@ function create(opts) {
1247
1509
  // RFC 8601 §5 — strip any sender-attached A-R header
1248
1510
  // claiming this receiver's authserv-id before prepending
1249
1511
  // the computed one (forged-verdict shadowing defense).
1250
- dedotted = _stripForgedAuthResults(dedotted, envelopeGate.authservId);
1512
+ dedotted = _stripForgedAuthResults(dedotted, messageAuthservId);
1251
1513
  dedotted = Buffer.concat([
1252
1514
  Buffer.from(inboundVerdict.authResults + "\r\n", "utf8"),
1253
1515
  dedotted,
@@ -1257,6 +1519,16 @@ function create(opts) {
1257
1519
  spf: inboundVerdict.spf,
1258
1520
  dkim: inboundVerdict.dkim,
1259
1521
  dmarc: inboundVerdict.dmarc,
1522
+ // The ARC chain sits beside the other three because it was
1523
+ // computed with them. It used to reach the delivered message as an
1524
+ // `arc=` token and the audit event as a status, and stop there —
1525
+ // so a consumer wanting to act on it re-parsed a header the
1526
+ // pipeline had just written. The header is also lossier than the
1527
+ // verdict: RFC 8601 has one `arc=fail` token, while the verdict
1528
+ // separates a chain that is structurally incomplete from one whose
1529
+ // seal did not verify, and only the second says anything about the
1530
+ // sender.
1531
+ arc: inboundVerdict.arc,
1260
1532
  from: inboundVerdict.from,
1261
1533
  action: envAction,
1262
1534
  mode: envelopeGate.mode,
@@ -1370,6 +1642,7 @@ function create(opts) {
1370
1642
  // explicit 0 (only an OMITTED port falls back to the default).
1371
1643
  var _tcpListener = mailServerNet.createTcpListener(net, {
1372
1644
  defaultPort: 25, // SMTP MX port (IANA)
1645
+ maxConnections: opts.maxConnections,
1373
1646
  handleConnection: _handleConnection,
1374
1647
  errorFactory: function (code, message) { return new MailServerMxError("mail-server-mx/" + code, message); },
1375
1648
  emit: _emit,