@blamejs/core 0.18.53 → 0.18.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -0
- package/NOTICE +1 -1
- package/README.md +5 -5
- package/lib/agent-audit.js +27 -2
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +41 -33
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +73 -12
- package/lib/guard-regex.js +3 -5
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/guard-yaml.js +60 -15
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +29 -13
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +134 -22
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +186 -89
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +198 -42
- package/lib/mail-server-mx.js +191 -38
- package/lib/mail-server-net.js +281 -1
- package/lib/mail-server-pop3.js +89 -41
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +183 -35
- package/lib/mail-server-tls.js +48 -3
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/mcp.js +11 -3
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/network-tls.js +18 -0
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +672 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-server-mx.js
CHANGED
|
@@ -75,12 +75,14 @@
|
|
|
75
75
|
* - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
|
|
76
76
|
* - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
|
|
77
77
|
* - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
|
|
78
|
-
* - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC results + action (accept / quarantine / reject / defer) + gate mode
|
|
78
|
+
* - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC/ARC results + action (accept / quarantine / reject / defer) + gate mode
|
|
79
79
|
* - `mail.server.mx.envelope_error` — DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)
|
|
80
80
|
* - `mail.server.mx.delivered` — agent.handoff ack
|
|
81
81
|
* - `mail.server.mx.tls_handshake_failed` — handshake error
|
|
82
82
|
* - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
|
|
83
83
|
* - `mail.server.mx.relay_refused` — open-relay attempt
|
|
84
|
+
* - `mail.server.mx.recipient_refused` — recipientPolicy said the mailbox is unavailable (550 5.1.1)
|
|
85
|
+
* - `mail.server.mx.recipient_policy_threw` — recipientPolicy failed; the recipient is deferred (451 4.7.1)
|
|
84
86
|
*
|
|
85
87
|
* ## What v1 does NOT ship
|
|
86
88
|
*
|
|
@@ -114,11 +116,25 @@
|
|
|
114
116
|
* `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
|
|
115
117
|
* (connecting-IP DNS blocklist, evaluated once per connection) and
|
|
116
118
|
* `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
|
|
117
|
-
* RCPT TO and surface their verdicts on the `rcpt_to` event.
|
|
119
|
+
* RCPT TO and surface their verdicts on the `rcpt_to` event.
|
|
120
|
+
*
|
|
121
|
+
* `opts.recipientPolicy` decides whether a mailbox on a local domain
|
|
122
|
+
* exists, and is the only way to answer "no such user" the way RFC
|
|
123
|
+
* 5321 §3.3 asks. It runs at RCPT TO, so the refusal costs one command
|
|
124
|
+
* rather than a whole message body: `{ ok: false, reason }` becomes
|
|
125
|
+
* 550 5.1.1 with the reason, and a throw becomes 451 4.7.1 (a
|
|
126
|
+
* directory that cannot be reached is not a verdict about the
|
|
127
|
+
* mailbox). Refusals charge the same per-IP recipient-failure budget
|
|
128
|
+
* as a relay refusal, because the 250-vs-550 difference is a
|
|
129
|
+
* mailbox-existence oracle. Unwired, every syntactically valid
|
|
130
|
+
* recipient on a local domain is accepted and the agent handoff is
|
|
131
|
+
* the operator's last chance to reject. The
|
|
132
|
+
|
|
118
133
|
* message-authentication gate (`opts.guardEnvelope`) runs at DATA
|
|
119
134
|
* completion through `b.mail.inbound.verify` — SPF (RFC 7208) on the
|
|
120
135
|
* envelope identity, DKIM (RFC 6376) on the message bytes, DMARC
|
|
121
|
-
* (RFC 9989) policy + alignment on the From-header domain
|
|
136
|
+
* (RFC 9989) policy + alignment on the From-header domain, ARC
|
|
137
|
+
* (RFC 8617) on any chain a forwarder left behind — and in
|
|
122
138
|
* enforce mode refuses before the agent handoff: 550 5.7.26
|
|
123
139
|
* (RFC 7372) when the sender's published policy says reject, 550
|
|
124
140
|
* 5.7.1 on the RFC 9989 §5.3.1 multi-From spoofing shape, 451 4.7.0
|
|
@@ -142,7 +158,6 @@
|
|
|
142
158
|
var net = require("node:net");
|
|
143
159
|
var lazyRequire = require("./lazy-require");
|
|
144
160
|
var C = require("./constants");
|
|
145
|
-
var bCrypto = require("./crypto");
|
|
146
161
|
var numericBounds = require("./numeric-bounds");
|
|
147
162
|
var safeAsync = require("./safe-async");
|
|
148
163
|
var safeBuffer = require("./safe-buffer");
|
|
@@ -307,12 +322,14 @@ function _stripForgedAuthResults(messageBuf, authservId) {
|
|
|
307
322
|
* agent: b.mail.agent, // optional delivery handoff
|
|
308
323
|
* relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
|
|
309
324
|
* localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
|
|
325
|
+
* recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
|
|
310
326
|
* maxLineBytes: number, // default 1 KiB — per-command line cap
|
|
311
327
|
* maxMessageBytes: number, // default 50 MiB — DATA body cap
|
|
312
328
|
* maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
|
|
313
329
|
* idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
|
|
330
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
314
331
|
* profile: "strict" | "balanced" | "permissive", // gate posture cascade
|
|
315
|
-
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC via b.mail.inbound.verify
|
|
332
|
+
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
|
|
316
333
|
* mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
|
|
317
334
|
* onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
|
|
318
335
|
* authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
|
|
@@ -348,12 +365,20 @@ function create(opts) {
|
|
|
348
365
|
"output directly. Cert provisioning lives in b.acme (RFC 8555 + RFC 9773 ARI).");
|
|
349
366
|
}
|
|
350
367
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
351
|
-
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
368
|
+
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
|
|
352
369
|
"mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
|
|
353
|
-
|
|
354
|
-
|
|
370
|
+
// An EMPTY array is accepted and means "this server hosts no domains", which
|
|
371
|
+
// is a real state — a first boot before the first domain is added — and the
|
|
372
|
+
// only honest thing to do with it is refuse every recipient. Refusing it at
|
|
373
|
+
// construction left the operator with one spelling that started a server
|
|
374
|
+
// (omitting the option), and that spelling used to skip the relay check
|
|
375
|
+
// entirely, so the only way to get a listener was to get one that accepted
|
|
376
|
+
// everything. An allowlist that disappears when it is empty is a firewall
|
|
377
|
+
// rule set that opens when the last rule is deleted.
|
|
378
|
+
if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains)) {
|
|
355
379
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
356
|
-
"mail.server.mx.create: localDomains must be
|
|
380
|
+
"mail.server.mx.create: localDomains must be an array of domain strings " +
|
|
381
|
+
"(an empty array means this server hosts no domains and refuses every recipient)");
|
|
357
382
|
}
|
|
358
383
|
if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
|
|
359
384
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
@@ -542,15 +567,17 @@ function create(opts) {
|
|
|
542
567
|
function _handleConnection(socket) {
|
|
543
568
|
// 421 4.7.0 — transient refusal; sender retries elsewhere or later.
|
|
544
569
|
// RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
|
|
545
|
-
var
|
|
570
|
+
var accepted = mailServerNet.acceptConnection(socket, {
|
|
571
|
+
rateLimit: rateLimit,
|
|
572
|
+
connections: connections,
|
|
573
|
+
emit: _emit,
|
|
546
574
|
refusedEvent: "mail.server.mx.rate_limit_refused",
|
|
547
575
|
refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
|
|
576
|
+
idPrefix: "mxconn-",
|
|
548
577
|
});
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
var connectionId = "mxconn-" + bCrypto.generateToken(8); // connection-id length
|
|
553
|
-
connections.add(socket);
|
|
578
|
+
if (accepted === null) return;
|
|
579
|
+
var remoteAddress = accepted.remoteAddress;
|
|
580
|
+
var connectionId = accepted.connectionId;
|
|
554
581
|
|
|
555
582
|
// Backpressure observer — `_writeReply` flips `_bpEmitted` after
|
|
556
583
|
// the first audit emission per socket to bound the audit volume.
|
|
@@ -580,6 +607,15 @@ function create(opts) {
|
|
|
580
607
|
// message. Decode to string only for the per-command line parse.
|
|
581
608
|
var lineBuffer = Buffer.alloc(0);
|
|
582
609
|
var bodyCollector = null;
|
|
610
|
+
// Watches the DATA body for its terminator and the smuggling shape as bytes
|
|
611
|
+
// arrive, so neither screen re-reads what it has already seen. Lives exactly
|
|
612
|
+
// as long as bodyCollector.
|
|
613
|
+
var bodyScanner = null;
|
|
614
|
+
// The slow-loris byte-rate floor, measured over bounded windows so an early
|
|
615
|
+
// burst cannot buy credit for a slow tail.
|
|
616
|
+
var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
|
|
617
|
+
// Every byte this connection has received, counted once at the wire funnel.
|
|
618
|
+
var wireBytes = 0;
|
|
583
619
|
var inDataBody = false;
|
|
584
620
|
// Async command pump: gates (HELO / RBL / greylist / envelope /
|
|
585
621
|
// DMARC) may await DNS or a store, so command handling is async.
|
|
@@ -604,10 +640,9 @@ function create(opts) {
|
|
|
604
640
|
_closeConnection(socket);
|
|
605
641
|
});
|
|
606
642
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
});
|
|
643
|
+
// The set entry and the rate-limit slot are released by trackConnection;
|
|
644
|
+
// this handler carries only the per-transaction flag the drain reads.
|
|
645
|
+
socket.on("close", function () { connClosed = true; });
|
|
611
646
|
|
|
612
647
|
_emit("mail.server.mx.connect", {
|
|
613
648
|
connectionId: state.id,
|
|
@@ -630,6 +665,12 @@ function create(opts) {
|
|
|
630
665
|
// 421 path. `activeSock` is whichever socket is current (plaintext or
|
|
631
666
|
// TLS) so the 421/close lands on the right transport.
|
|
632
667
|
function _feedChunk(activeSock, chunk) {
|
|
668
|
+
// Every wire byte, counted once, on the single funnel both the plaintext
|
|
669
|
+
// and the post-STARTTLS socket feed. The rate window takes its baseline
|
|
670
|
+
// from this, so the measurement is "bytes since the transfer opened"
|
|
671
|
+
// rather than "bytes the body parser happened to see" — the distinction
|
|
672
|
+
// that let the sibling listener's count go flat across a window roll.
|
|
673
|
+
wireBytes += chunk.length;
|
|
633
674
|
pumpChain = pumpChain.then(function () {
|
|
634
675
|
if (connClosed) return undefined;
|
|
635
676
|
return _ingestBytes(state, activeSock, chunk);
|
|
@@ -648,6 +689,27 @@ function create(opts) {
|
|
|
648
689
|
|
|
649
690
|
// ---- Byte-level ingestion --------------------------------------------
|
|
650
691
|
async function _ingestBytes(state, socket, chunk) {
|
|
692
|
+
// The body-rate floor is enforced HERE, on every inbound byte, rather
|
|
693
|
+
// than inside the DATA handler below. A check reached only from a body
|
|
694
|
+
// handler is one the peer chooses whether to reach: on the sibling
|
|
695
|
+
// listener the same floor was skipped first by using BDAT, then by a
|
|
696
|
+
// zero-length chunk, then by interleaving NOOP, each of which resets the
|
|
697
|
+
// socket idle timer without passing through a body handler. What a peer
|
|
698
|
+
// cannot do is hold the connection without sending bytes, and every byte
|
|
699
|
+
// arrives here.
|
|
700
|
+
if (inDataBody && bodyRateWindow.starved(wireBytes, Date.now())) {
|
|
701
|
+
_emit("mail.server.mx.data_refused",
|
|
702
|
+
{ connectionId: state.id, reason: "body-rate-below-floor",
|
|
703
|
+
minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
|
|
704
|
+
_writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
|
|
705
|
+
"4.7.0 Message body arriving below the minimum rate; closing connection");
|
|
706
|
+
_resetTransaction(state);
|
|
707
|
+
inDataBody = false;
|
|
708
|
+
bodyCollector = null;
|
|
709
|
+
bodyScanner = null;
|
|
710
|
+
_closeConnection(socket);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
651
713
|
if (inDataBody) {
|
|
652
714
|
// DATA body — accumulate via boundedChunkCollector, watch for
|
|
653
715
|
// canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
|
|
@@ -662,13 +724,25 @@ function create(opts) {
|
|
|
662
724
|
_resetTransaction(state);
|
|
663
725
|
inDataBody = false;
|
|
664
726
|
bodyCollector = null;
|
|
727
|
+
bodyScanner = null;
|
|
665
728
|
return;
|
|
666
729
|
}
|
|
667
|
-
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
730
|
+
// Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
|
|
731
|
+
// Re-deriving the whole accumulated body per chunk (`result()` is a
|
|
732
|
+
// fresh concat of everything received so far) and scanning it twice
|
|
733
|
+
// made acceptance quadratic in the message size. The byte cap bounds
|
|
734
|
+
// BYTES, not processor time, so a message well inside maxMessageBytes
|
|
735
|
+
// still cost 4949 ms at 8 MiB against 143 ms at 1 MiB — and on this
|
|
736
|
+
// listener that is reachable unauthenticated. `result()` is now called
|
|
737
|
+
// ONCE, when the terminator is found.
|
|
738
|
+
//
|
|
739
|
+
// The slow-loris floor is NOT applied here — it runs at the top of
|
|
740
|
+
// _ingestBytes, where every inbound byte passes whatever command it
|
|
741
|
+
// belongs to.
|
|
742
|
+
var seen = bodyScanner.push(chunk);
|
|
743
|
+
// Smuggling detector — bare LF dot-line in body before the CRLF dot
|
|
744
|
+
// terminator. Refuse the whole transaction; emit a smuggling audit.
|
|
745
|
+
if (seen.smuggling) {
|
|
672
746
|
_emit("mail.server.mx.smtp_smuggling_detected",
|
|
673
747
|
{ connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
|
|
674
748
|
"denied");
|
|
@@ -677,14 +751,15 @@ function create(opts) {
|
|
|
677
751
|
_resetTransaction(state);
|
|
678
752
|
inDataBody = false;
|
|
679
753
|
bodyCollector = null;
|
|
754
|
+
bodyScanner = null;
|
|
680
755
|
return;
|
|
681
756
|
}
|
|
682
757
|
// Canonical \r\n.\r\n terminator?
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
var body = collected.subarray(0, endIdx);
|
|
758
|
+
if (seen.terminatorAt !== -1) {
|
|
759
|
+
var body = bodyCollector.result().subarray(0, seen.terminatorAt);
|
|
686
760
|
inDataBody = false;
|
|
687
761
|
bodyCollector = null;
|
|
762
|
+
bodyScanner = null;
|
|
688
763
|
await _finalizeDataBody(state, socket, body);
|
|
689
764
|
}
|
|
690
765
|
return;
|
|
@@ -851,6 +926,7 @@ function create(opts) {
|
|
|
851
926
|
// shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
|
|
852
927
|
lineBuffer = Buffer.alloc(0);
|
|
853
928
|
bodyCollector = null;
|
|
929
|
+
bodyScanner = null;
|
|
854
930
|
inDataBody = false;
|
|
855
931
|
mailServerTls.upgradeSocket({
|
|
856
932
|
plainSocket: socket,
|
|
@@ -986,17 +1062,21 @@ function create(opts) {
|
|
|
986
1062
|
}
|
|
987
1063
|
// Local-domain check — refuse non-local recipients unless the
|
|
988
1064
|
// operator explicitly allowed relay for this scope.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1065
|
+
//
|
|
1066
|
+
// Run UNCONDITIONALLY. This used to sit inside `if (localDomains.length
|
|
1067
|
+
// > 0)`, so a server hosting no domains ran no check at all and accepted
|
|
1068
|
+
// every recipient. An empty hosted set now refuses everything naturally,
|
|
1069
|
+
// which is what an empty allowlist has to mean; `relayAllowedFor` is
|
|
1070
|
+
// still the way to permit a scope deliberately.
|
|
1071
|
+
if (localDomains.indexOf(rcptDomain) === -1 &&
|
|
1072
|
+
!_isRelayAllowed(state.remoteAddress, rcpt)) {
|
|
1073
|
+
rateLimit.noteRcptFailure(state.remoteAddress);
|
|
1074
|
+
_trackRefusedRcpt(state, rcpt, "relay-denied");
|
|
1075
|
+
_emit("mail.server.mx.relay_refused",
|
|
1076
|
+
{ connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
|
|
1077
|
+
remoteAddress: state.remoteAddress }, "denied");
|
|
1078
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
|
|
1079
|
+
return;
|
|
1000
1080
|
}
|
|
1001
1081
|
// RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
|
|
1002
1082
|
// IP. The verdict is per-connection, so it's evaluated once and
|
|
@@ -1035,6 +1115,60 @@ function create(opts) {
|
|
|
1035
1115
|
return;
|
|
1036
1116
|
}
|
|
1037
1117
|
}
|
|
1118
|
+
// Operator-supplied recipient policy — the only place a listener can
|
|
1119
|
+
// answer "no such mailbox" the way RFC 5321 §3.3 asks. Without it, a
|
|
1120
|
+
// local domain accepted every local part and the application first met
|
|
1121
|
+
// the recipient at agent.handoff, after 354 and after the whole message:
|
|
1122
|
+
// from there the choices were 250 (tell the peer it arrived, then owe a
|
|
1123
|
+
// DSN) or 451 (tell a peer holding a permanent condition to retry
|
|
1124
|
+
// forever). Neither is a refusal. Same shape as the submission
|
|
1125
|
+
// listener's hook: `{ ok: true }` accepts, `{ ok: false, reason }`
|
|
1126
|
+
// refuses. Unwired, every syntactically valid recipient on a local
|
|
1127
|
+
// domain is accepted, exactly as before.
|
|
1128
|
+
if (typeof opts.recipientPolicy === "function") {
|
|
1129
|
+
var rcptVerdictPolicy;
|
|
1130
|
+
try {
|
|
1131
|
+
rcptVerdictPolicy = await opts.recipientPolicy({
|
|
1132
|
+
mailFrom: state.mailFrom,
|
|
1133
|
+
rcptTo: rcpt,
|
|
1134
|
+
connectionId: state.id,
|
|
1135
|
+
remoteAddress: state.remoteAddress,
|
|
1136
|
+
tls: state.tls,
|
|
1137
|
+
heloName: state.heloName || null,
|
|
1138
|
+
});
|
|
1139
|
+
} catch (policyErr) {
|
|
1140
|
+
// The operator's directory being unreachable is not a verdict about
|
|
1141
|
+
// this mailbox. A 550 here would permanently reject mail for a
|
|
1142
|
+
// legitimate recipient because a lookup failed, so it defers.
|
|
1143
|
+
_emit("mail.server.mx.recipient_policy_threw",
|
|
1144
|
+
{ connectionId: state.id, rcptTo: rcpt,
|
|
1145
|
+
error: (policyErr && policyErr.message) || String(policyErr) }, "failure");
|
|
1146
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR,
|
|
1147
|
+
"4.7.1 Recipient policy temporarily unavailable");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (!rcptVerdictPolicy || rcptVerdictPolicy.ok !== true) {
|
|
1151
|
+
// The 250-vs-550 difference is a mailbox-existence oracle, so a
|
|
1152
|
+
// policy refusal charges the same per-IP recipient-failure budget
|
|
1153
|
+
// the relay refusal does. Without that, wiring this hook would hand
|
|
1154
|
+
// a scanner a free enumeration channel the listener did not have.
|
|
1155
|
+
rateLimit.noteRcptFailure(state.remoteAddress);
|
|
1156
|
+
_trackRefusedRcpt(state, rcpt, "recipient-policy");
|
|
1157
|
+
_emit("mail.server.mx.recipient_refused",
|
|
1158
|
+
{ connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
|
|
1159
|
+
reason: (rcptVerdictPolicy && rcptVerdictPolicy.reason) || "policy-refused",
|
|
1160
|
+
remoteAddress: state.remoteAddress }, "denied");
|
|
1161
|
+
// The reason goes onto a line-oriented reply, and a directory wrapper
|
|
1162
|
+
// routinely quotes the address it looked up — which the peer chose.
|
|
1163
|
+
// A CR or LF in it would end the 550 early and let the remainder be
|
|
1164
|
+
// read as a second, forged reply. The refusal still happens; only the
|
|
1165
|
+
// prose falls back.
|
|
1166
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
1167
|
+
"5.1.1 " + mailServerNet.replyTextOrFallback(
|
|
1168
|
+
rcptVerdictPolicy && rcptVerdictPolicy.reason, "Mailbox unavailable"));
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1038
1172
|
state.rcpts.push(rcpt);
|
|
1039
1173
|
_emit("mail.server.mx.rcpt_to",
|
|
1040
1174
|
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
|
|
@@ -1060,6 +1194,8 @@ function create(opts) {
|
|
|
1060
1194
|
sizeCode: "mail-server-mx/body-too-large",
|
|
1061
1195
|
sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
|
|
1062
1196
|
});
|
|
1197
|
+
bodyScanner = safeSmtp.createBodyScanner();
|
|
1198
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1063
1199
|
}
|
|
1064
1200
|
|
|
1065
1201
|
async function _finalizeDataBody(state, socket, body) {
|
|
@@ -1143,6 +1279,12 @@ function create(opts) {
|
|
|
1143
1279
|
spf: inboundVerdict.spf.result,
|
|
1144
1280
|
dkim: dkimSummary,
|
|
1145
1281
|
dmarc: inboundVerdict.dmarc.result,
|
|
1282
|
+
arc: inboundVerdict.arc && inboundVerdict.arc.chainStatus,
|
|
1283
|
+
// The status alone cannot tell an operator reading the audit
|
|
1284
|
+
// whether a chain failed because a seal was bad or because a
|
|
1285
|
+
// resolver was down — the two want opposite responses.
|
|
1286
|
+
arcReason: inboundVerdict.arc && inboundVerdict.arc.reason,
|
|
1287
|
+
arcTransient: !!(inboundVerdict.arc && inboundVerdict.arc.transient),
|
|
1146
1288
|
action: envAction,
|
|
1147
1289
|
mode: envelopeGate.mode,
|
|
1148
1290
|
}, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
|
|
@@ -1185,6 +1327,16 @@ function create(opts) {
|
|
|
1185
1327
|
spf: inboundVerdict.spf,
|
|
1186
1328
|
dkim: inboundVerdict.dkim,
|
|
1187
1329
|
dmarc: inboundVerdict.dmarc,
|
|
1330
|
+
// The ARC chain sits beside the other three because it was
|
|
1331
|
+
// computed with them. It used to reach the delivered message as an
|
|
1332
|
+
// `arc=` token and the audit event as a status, and stop there —
|
|
1333
|
+
// so a consumer wanting to act on it re-parsed a header the
|
|
1334
|
+
// pipeline had just written. The header is also lossier than the
|
|
1335
|
+
// verdict: RFC 8601 has one `arc=fail` token, while the verdict
|
|
1336
|
+
// separates a chain that is structurally incomplete from one whose
|
|
1337
|
+
// seal did not verify, and only the second says anything about the
|
|
1338
|
+
// sender.
|
|
1339
|
+
arc: inboundVerdict.arc,
|
|
1188
1340
|
from: inboundVerdict.from,
|
|
1189
1341
|
action: envAction,
|
|
1190
1342
|
mode: envelopeGate.mode,
|
|
@@ -1298,6 +1450,7 @@ function create(opts) {
|
|
|
1298
1450
|
// explicit 0 (only an OMITTED port falls back to the default).
|
|
1299
1451
|
var _tcpListener = mailServerNet.createTcpListener(net, {
|
|
1300
1452
|
defaultPort: 25, // SMTP MX port (IANA)
|
|
1453
|
+
maxConnections: opts.maxConnections,
|
|
1301
1454
|
handleConnection: _handleConnection,
|
|
1302
1455
|
errorFactory: function (code, message) { return new MailServerMxError("mail-server-mx/" + code, message); },
|
|
1303
1456
|
emit: _emit,
|