@blamejs/core 0.18.54 → 0.18.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -0
- package/NOTICE +5 -5
- package/README.md +9 -9
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/audit.js +26 -24
- package/lib/auth/passkey.js +4 -1
- package/lib/chain-writer.js +17 -0
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +65 -42
- package/lib/mail-server-mx.js +313 -40
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +71 -11
- package/lib/mcp.js +11 -3
- package/lib/middleware/csrf-protect.js +37 -20
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/session-stores.js +6 -3
- package/lib/vendor/MANIFEST.json +34 -34
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/vendor/browser/noble-ciphers.mjs +15 -1
- package/lib/vendor/browser/noble-hashes.mjs +12 -4
- package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
- package/lib/vendor/noble-ciphers.cjs +15 -1
- package/lib/vendor/noble-curves.cjs +46 -21
- package/lib/vendor/noble-post-quantum.cjs +184 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-server-mx.js
CHANGED
|
@@ -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],
|
|
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;
|
|
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
|
-
|
|
371
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
567
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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:
|
|
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,
|
|
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,
|