@blamejs/core 0.9.45 → 0.9.49
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 +16 -0
- package/index.js +9 -0
- package/lib/auth/fal.js +1 -1
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-smtp-command.js +65 -10
- package/lib/mail-server-imap.js +1064 -0
- package/lib/mail-server-mx.js +856 -0
- package/lib/mail-server-rate-limit.js +256 -0
- package/lib/mail-server-submission.js +986 -0
- package/lib/metrics.js +50 -7
- package/lib/middleware/protected-resource-metadata.js +1 -1
- package/lib/safe-smtp.js +128 -0
- package/lib/self-update.js +35 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.server.submission
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail Submission Server
|
|
6
|
+
* @order 542
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Outbound SMTP submission listener per RFC 6409 (port 587) and
|
|
10
|
+
* RFC 8314 implicit-TLS submissions (port 465). Where the MX
|
|
11
|
+
* listener (`b.mail.server.mx`) accepts inbound mail from the
|
|
12
|
+
* internet to local mailboxes, the submission listener accepts
|
|
13
|
+
* outbound mail from authenticated MUAs / app-side mail-senders
|
|
14
|
+
* and routes it to upstream MXs via `b.mail.send`.
|
|
15
|
+
*
|
|
16
|
+
* Differences from the MX listener:
|
|
17
|
+
*
|
|
18
|
+
* - **AUTH required** — operator-supplied authenticator validates
|
|
19
|
+
* SASL credentials (PLAIN / LOGIN / SCRAM-SHA-256 / EXTERNAL /
|
|
20
|
+
* XOAUTH2). MAIL FROM is refused until AUTH succeeds.
|
|
21
|
+
*
|
|
22
|
+
* - **Identity binding** — under strict profile, `MAIL FROM:<x@y>`
|
|
23
|
+
* MUST match the authenticated actor's mailbox set; refused with
|
|
24
|
+
* 553 5.7.1 Sender address rejected. Permissive logs the
|
|
25
|
+
* mismatch but allows.
|
|
26
|
+
*
|
|
27
|
+
* - **TLS required for AUTH** (RFC 4954 §4) — pre-STARTTLS AUTH
|
|
28
|
+
* refused with 538 5.7.11 Encryption required for AUTH
|
|
29
|
+
* mechanism. Permissive profile allows plaintext AUTH for
|
|
30
|
+
* legacy operator-acknowledged downgrade.
|
|
31
|
+
*
|
|
32
|
+
* - **Implicit-TLS mode** — `implicitTls: true` wraps every
|
|
33
|
+
* connection in TLS from the SYN (port 465 per RFC 8314); no
|
|
34
|
+
* STARTTLS advertised because the connection is already secure.
|
|
35
|
+
*
|
|
36
|
+
* - **Outbound routing** — successful DATA hands off to the
|
|
37
|
+
* operator-supplied `agent.handoff({ ... })` for relay through
|
|
38
|
+
* `b.mail.send` to upstream MXs. The listener doesn't perform
|
|
39
|
+
* MX lookup or outbound delivery itself.
|
|
40
|
+
*
|
|
41
|
+
* ## Wire-protocol defenses (inherited from MX listener pattern)
|
|
42
|
+
*
|
|
43
|
+
* - SMTP smuggling (CVE-2023-51764 / -51765 / -51766 / 2024-32178 /
|
|
44
|
+
* RFC 5321 §2.3.8): every wire line through
|
|
45
|
+
* `b.guardSmtpCommand.validate`; DATA-body terminator scan
|
|
46
|
+
* through `b.safeSmtp.findDotTerminator` (strict-CRLF);
|
|
47
|
+
* smuggling shape detected via
|
|
48
|
+
* `b.guardSmtpCommand.detectBodySmuggling`.
|
|
49
|
+
*
|
|
50
|
+
* - STARTTLS-injection (CVE-2021-38371 Exim, CVE-2021-33515
|
|
51
|
+
* Dovecot): command buffer cleared at upgrade time.
|
|
52
|
+
*
|
|
53
|
+
* - Resource exhaustion: per-command line cap (1 KiB), DATA body
|
|
54
|
+
* cap (50 MiB per RFC 5321 §4.5.3.1.7), per-message recipient
|
|
55
|
+
* cap (100 per RFC 5321 §4.5.3.1.8), idle timeout (5 minutes
|
|
56
|
+
* per RFC 5321 §4.5.3.2.7).
|
|
57
|
+
*
|
|
58
|
+
* ## SMTP AUTH (RFC 4954)
|
|
59
|
+
*
|
|
60
|
+
* - Mechanisms negotiated per RFC 4422 (SASL) — the operator
|
|
61
|
+
* opts the list `auth.mechanisms` into the EHLO advertisement.
|
|
62
|
+
* - Initial-response variant `AUTH MECH <base64>` (RFC 4954 §4)
|
|
63
|
+
* supported.
|
|
64
|
+
* - Failed AUTH emits `mail.server.submission.auth_failed` with
|
|
65
|
+
* mechanism + reason; operator's rate-limit wired via
|
|
66
|
+
* `auth.rateLimit` (composes `b.middleware.rateLimit`) trips
|
|
67
|
+
* 421 4.7.0 Too many failed AUTH after the operator-configured
|
|
68
|
+
* budget.
|
|
69
|
+
*
|
|
70
|
+
* ## Audit lifecycle (in addition to the MX listener's)
|
|
71
|
+
*
|
|
72
|
+
* - `mail.server.submission.auth_attempt` — mechanism, actor-hash, remote
|
|
73
|
+
* - `mail.server.submission.auth_success` — mechanism, tenantId, scopes
|
|
74
|
+
* - `mail.server.submission.auth_failed` — mechanism, reason
|
|
75
|
+
* - `mail.server.submission.identity_mismatch` — auth identity vs MAIL FROM
|
|
76
|
+
* - `mail.server.submission.outbound_routed` — delivery agent ack
|
|
77
|
+
*
|
|
78
|
+
* ## What v1 does NOT ship
|
|
79
|
+
*
|
|
80
|
+
* - **DKIM signing pre-relay** — operator wires `b.mail.dkim.sign`
|
|
81
|
+
* in their outbound agent.
|
|
82
|
+
* - **CHUNKING (BDAT) extension** — RFC 3030 BDAT not yet
|
|
83
|
+
* supported on submission; clients use DATA instead.
|
|
84
|
+
* - **Per-actor outbound quota** — operator implements via
|
|
85
|
+
* `b.dailyByteQuota` against the authenticated actor.
|
|
86
|
+
*
|
|
87
|
+
* ## Composition contract
|
|
88
|
+
*
|
|
89
|
+
* Every gate is a primitive that already exists. Submission listener
|
|
90
|
+
* composes `b.guardSmtpCommand` (wire-protocol gate + smuggling
|
|
91
|
+
* defense), `b.safeSmtp` (wire-protocol parser), the operator's
|
|
92
|
+
* authenticator (SASL verify), `b.mail.send` (outbound MX routing),
|
|
93
|
+
* and the framework's TLS posture via `b.network.tls.context`.
|
|
94
|
+
*
|
|
95
|
+
* @card
|
|
96
|
+
* Outbound SMTP submission listener (RFC 6409 / RFC 8314). AUTH-
|
|
97
|
+
* required before MAIL FROM; identity-binding under strict profile;
|
|
98
|
+
* TLS-required-for-AUTH (RFC 4954 §4); implicit-TLS mode for
|
|
99
|
+
* port 465. Composes b.guardSmtpCommand + b.safeSmtp + operator
|
|
100
|
+
* SASL authenticator + b.mail.send for outbound routing.
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
var net = require("node:net");
|
|
104
|
+
var nodeTls = require("node:tls");
|
|
105
|
+
var lazyRequire = require("./lazy-require");
|
|
106
|
+
var C = require("./constants");
|
|
107
|
+
var bCrypto = require("./crypto");
|
|
108
|
+
var numericBounds = require("./numeric-bounds");
|
|
109
|
+
var safeAsync = require("./safe-async");
|
|
110
|
+
var safeBuffer = require("./safe-buffer");
|
|
111
|
+
var safeSmtp = require("./safe-smtp");
|
|
112
|
+
var validateOpts = require("./validate-opts");
|
|
113
|
+
var guardSmtpCommand = require("./guard-smtp-command");
|
|
114
|
+
var guardDomain = require("./guard-domain");
|
|
115
|
+
var mailServerRateLimit = require("./mail-server-rate-limit");
|
|
116
|
+
var { defineClass } = require("./framework-error");
|
|
117
|
+
|
|
118
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
119
|
+
|
|
120
|
+
var MailServerSubmissionError = defineClass("MailServerSubmissionError", { alwaysPermanent: true });
|
|
121
|
+
|
|
122
|
+
var DEFAULT_MAX_LINE_BYTES = C.BYTES.kib(1);
|
|
123
|
+
var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(50);
|
|
124
|
+
var DEFAULT_MAX_RCPTS_PER_MESSAGE = 100; // allow:raw-byte-literal — RFC 5321 §4.5.3.1.8 recipient cap
|
|
125
|
+
var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
|
|
126
|
+
var DEFAULT_GREETING = "blamejs Submission";
|
|
127
|
+
var DEFAULT_AUTH_MECHANISMS = Object.freeze(["PLAIN", "LOGIN"]);
|
|
128
|
+
|
|
129
|
+
var REPLY_220_READY = "220";
|
|
130
|
+
var REPLY_221_BYE = "221";
|
|
131
|
+
var REPLY_235_AUTH_OK = "235"; // allow:raw-byte-literal — SMTP AUTH success code
|
|
132
|
+
var REPLY_250_OK = "250";
|
|
133
|
+
var REPLY_334_AUTH_CHALLENGE = "334"; // allow:raw-byte-literal — SMTP AUTH challenge code
|
|
134
|
+
var REPLY_354_START_INPUT = "354";
|
|
135
|
+
var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
|
|
136
|
+
var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
|
|
137
|
+
var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
|
|
138
|
+
var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
|
|
139
|
+
var REPLY_501_BAD_ARGS = "501"; // allow:raw-byte-literal — SMTP permanent code
|
|
140
|
+
var REPLY_502_NOT_IMPLEMENTED = "502"; // allow:raw-byte-literal — SMTP permanent code
|
|
141
|
+
var REPLY_503_BAD_SEQUENCE = "503"; // allow:raw-byte-literal — SMTP permanent code
|
|
142
|
+
var REPLY_530_AUTH_REQUIRED = "530"; // allow:raw-byte-literal — SMTP permanent code
|
|
143
|
+
var REPLY_535_AUTH_FAILED = "535"; // allow:raw-byte-literal — RFC 4954 §6 AUTH refusal
|
|
144
|
+
var REPLY_538_AUTH_NEEDS_TLS = "538"; // allow:raw-byte-literal — RFC 4954 §4 AUTH-needs-TLS
|
|
145
|
+
var REPLY_550_MAILBOX_UNAVAIL = "550"; // allow:raw-byte-literal — SMTP permanent code (recipient-policy refusal shape)
|
|
146
|
+
var REPLY_552_SIZE_EXCEEDED = "552"; // allow:raw-byte-literal — SMTP permanent code
|
|
147
|
+
var REPLY_553_SENDER_REJECTED = "553"; // allow:raw-byte-literal — identity-binding mismatch
|
|
148
|
+
var REPLY_554_TRANSACTION_FAILED = "554"; // allow:raw-byte-literal — SMTP permanent code
|
|
149
|
+
|
|
150
|
+
var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
|
|
151
|
+
var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
|
|
152
|
+
var RE_SIZE = /SIZE=(\d+)/i;
|
|
153
|
+
var RE_AUTH = /^AUTH\s+([A-Za-z0-9_-]{1,32})(?:\s+(.*))?$/i;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @primitive b.mail.server.submission.create
|
|
157
|
+
* @signature b.mail.server.submission.create(opts)
|
|
158
|
+
* @since 0.9.47
|
|
159
|
+
* @status stable
|
|
160
|
+
* @related b.mail.server.mx.create, b.guardSmtpCommand.detectBodySmuggling, b.safeSmtp.findDotTerminator
|
|
161
|
+
*
|
|
162
|
+
* Build the submission listener. Returns
|
|
163
|
+
* `{ listen({ port?, address? }), close({ timeoutMs? }),
|
|
164
|
+
* connectionCount(), _portForTest() }`.
|
|
165
|
+
*
|
|
166
|
+
* @opts
|
|
167
|
+
* tlsContext: TlsContext, // required — b.network.tls.context() output
|
|
168
|
+
* implicitTls: boolean, // wrap connection in TLS from the SYN (port 465); default false
|
|
169
|
+
* greeting: string, // EHLO/220 banner; default "blamejs Submission"
|
|
170
|
+
* auth: object, // SASL config (required unless permissive profile)
|
|
171
|
+
* mechanisms: string[], // SASL mechs to advertise; default ["PLAIN","LOGIN"]
|
|
172
|
+
* verify: function, // async (mechanism, credentials) => { ok, actor }
|
|
173
|
+
* rateLimit: object, // optional b.middleware.rateLimit instance for failure budget
|
|
174
|
+
* agent: object, // outbound delivery handoff (handoff({ ... }) → ack)
|
|
175
|
+
* identityBinding: "strict" | "permissive", // MAIL FROM must match auth identity (default strict)
|
|
176
|
+
* maxLineBytes: number, // default 1 KiB
|
|
177
|
+
* maxMessageBytes: number, // default 50 MiB
|
|
178
|
+
* maxRcptsPerMessage: number, // default 100
|
|
179
|
+
* idleTimeoutMs: number, // default 5 minutes
|
|
180
|
+
* profile: string, // "strict" | "balanced" | "permissive"; default "strict"
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* var tls = b.network.tls.context({ cert: certPem, key: keyPem });
|
|
184
|
+
* var server = b.mail.server.submission.create({
|
|
185
|
+
* tlsContext: tls,
|
|
186
|
+
* greeting: "smtp.example.com Submission blamejs",
|
|
187
|
+
* auth: {
|
|
188
|
+
* mechanisms: ["PLAIN", "SCRAM-SHA-256"],
|
|
189
|
+
* verify: async function (mech, creds) {
|
|
190
|
+
* var actor = await myAuthService.verify(mech, creds);
|
|
191
|
+
* return actor ? { ok: true, actor: actor } : { ok: false };
|
|
192
|
+
* },
|
|
193
|
+
* },
|
|
194
|
+
* agent: b.mail.agent.create({ outboundSend: b.mail.send }),
|
|
195
|
+
* });
|
|
196
|
+
* await server.listen({ port: 587 });
|
|
197
|
+
*/
|
|
198
|
+
function create(opts) {
|
|
199
|
+
validateOpts.requireObject(opts, "mail.server.submission.create",
|
|
200
|
+
MailServerSubmissionError, "mail-server-submission/bad-opts");
|
|
201
|
+
if (!opts.tlsContext) {
|
|
202
|
+
throw new MailServerSubmissionError("mail-server-submission/no-tls-context",
|
|
203
|
+
"mail.server.submission.create: tlsContext is required");
|
|
204
|
+
}
|
|
205
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
206
|
+
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
207
|
+
"mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
|
|
208
|
+
|
|
209
|
+
var profile = opts.profile || "strict";
|
|
210
|
+
|
|
211
|
+
if (profile !== "permissive" && !opts.auth) {
|
|
212
|
+
throw new MailServerSubmissionError("mail-server-submission/no-auth",
|
|
213
|
+
"mail.server.submission.create: opts.auth required under strict / balanced profiles " +
|
|
214
|
+
"(submission listener is authenticated by design; opt down to 'permissive' for legacy plaintext)");
|
|
215
|
+
}
|
|
216
|
+
if (opts.auth) {
|
|
217
|
+
if (typeof opts.auth.verify !== "function") {
|
|
218
|
+
throw new MailServerSubmissionError("mail-server-submission/bad-auth",
|
|
219
|
+
"mail.server.submission.create: opts.auth.verify must be an async function (mechanism, credentials) => { ok, actor }");
|
|
220
|
+
}
|
|
221
|
+
if (opts.auth.mechanisms !== undefined &&
|
|
222
|
+
(!Array.isArray(opts.auth.mechanisms) || opts.auth.mechanisms.length === 0)) {
|
|
223
|
+
throw new MailServerSubmissionError("mail-server-submission/bad-auth",
|
|
224
|
+
"mail.server.submission.create: opts.auth.mechanisms must be a non-empty array if provided");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
var greeting = opts.greeting || DEFAULT_GREETING;
|
|
229
|
+
var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
|
|
230
|
+
var maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
|
|
231
|
+
var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
|
|
232
|
+
var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
|
|
233
|
+
var authConfig = opts.auth || null;
|
|
234
|
+
var authMechanisms = authConfig && authConfig.mechanisms
|
|
235
|
+
? authConfig.mechanisms.map(function (m) { return String(m).toUpperCase(); })
|
|
236
|
+
: DEFAULT_AUTH_MECHANISMS.slice();
|
|
237
|
+
var identityBinding = opts.identityBinding || "strict";
|
|
238
|
+
var implicitTls = opts.implicitTls === true;
|
|
239
|
+
|
|
240
|
+
// Default-on per-IP rate limit (see lib/mail-server-rate-limit.js).
|
|
241
|
+
// Operators pass `rateLimit: false` to disable, a rate-limit handle
|
|
242
|
+
// to share across listeners, or an opts object to override defaults.
|
|
243
|
+
var rateLimit;
|
|
244
|
+
if (opts.rateLimit === false) {
|
|
245
|
+
rateLimit = mailServerRateLimit.create({ disabled: true });
|
|
246
|
+
} else if (opts.rateLimit && typeof opts.rateLimit.admitConnection === "function") {
|
|
247
|
+
rateLimit = opts.rateLimit;
|
|
248
|
+
} else {
|
|
249
|
+
rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Default-on guardDomain hardening for HELO / MAIL FROM / RCPT TO.
|
|
253
|
+
// Same posture as mail-server-mx — IDN homograph (CVE-2017-5469
|
|
254
|
+
// class), special-use-domain refusal (RFC 6761), label-length cap
|
|
255
|
+
// (RFC 1035 §2.3.4), bare-IP-as-domain refusal (CVE-2021-22931
|
|
256
|
+
// class). Operators with a closed-network deployment pass
|
|
257
|
+
// `guardDomain: false` to skip; the default keeps protection on.
|
|
258
|
+
var guardDomainProfile;
|
|
259
|
+
if (opts.guardDomain === false) {
|
|
260
|
+
guardDomainProfile = null;
|
|
261
|
+
} else {
|
|
262
|
+
guardDomainProfile = guardDomain.buildProfile({
|
|
263
|
+
profile: opts.guardDomain && typeof opts.guardDomain === "object"
|
|
264
|
+
? (opts.guardDomain.profile || profile)
|
|
265
|
+
: profile,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function _validateDomainHardened(d, label) {
|
|
269
|
+
if (!guardDomainProfile) return { ok: true };
|
|
270
|
+
var verdict = guardDomain.validate(d, guardDomainProfile);
|
|
271
|
+
if (!verdict.ok) {
|
|
272
|
+
_emit("mail.server.submission.domain_refused", {
|
|
273
|
+
reason: verdict.issues && verdict.issues[0] && verdict.issues[0].kind,
|
|
274
|
+
domain: d,
|
|
275
|
+
label: label,
|
|
276
|
+
}, "denied");
|
|
277
|
+
}
|
|
278
|
+
return verdict;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
var tcpServer = null;
|
|
282
|
+
var listening = false;
|
|
283
|
+
var connections = new Set();
|
|
284
|
+
|
|
285
|
+
function _emit(action, metadata, outcome) {
|
|
286
|
+
try {
|
|
287
|
+
audit().safeEmit({
|
|
288
|
+
action: action,
|
|
289
|
+
outcome: outcome || "success",
|
|
290
|
+
metadata: metadata || {},
|
|
291
|
+
});
|
|
292
|
+
} catch (_e) { /* drop-silent */ }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _handleConnection(rawSocket) {
|
|
296
|
+
var remoteAddress = rawSocket.remoteAddress || "0.0.0.0";
|
|
297
|
+
var admit = rateLimit.admitConnection(remoteAddress);
|
|
298
|
+
if (!admit.ok) {
|
|
299
|
+
// 421 4.7.0 — transient; sender retries elsewhere.
|
|
300
|
+
_emit("mail.server.submission.rate_limit_refused",
|
|
301
|
+
{ remoteAddress: remoteAddress, reason: admit.reason }, "denied");
|
|
302
|
+
try {
|
|
303
|
+
rawSocket.write("421 4.7.0 Too many connections from your IP\r\n");
|
|
304
|
+
} catch (_e) { /* socket may already be torn down */ }
|
|
305
|
+
try { rawSocket.destroy(); } catch (_e2) { /* idempotent */ }
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
|
|
309
|
+
|
|
310
|
+
var connectionId = "submitconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
|
|
311
|
+
var socket = implicitTls
|
|
312
|
+
? new nodeTls.TLSSocket(rawSocket, { isServer: true, secureContext: opts.tlsContext })
|
|
313
|
+
: rawSocket;
|
|
314
|
+
connections.add(socket);
|
|
315
|
+
|
|
316
|
+
var state = {
|
|
317
|
+
id: connectionId,
|
|
318
|
+
remoteAddress: remoteAddress,
|
|
319
|
+
remotePort: rawSocket.remotePort || null,
|
|
320
|
+
tls: implicitTls,
|
|
321
|
+
stage: "connect",
|
|
322
|
+
helo: null,
|
|
323
|
+
authenticated: false,
|
|
324
|
+
actor: null,
|
|
325
|
+
mailFrom: null,
|
|
326
|
+
rcpts: [],
|
|
327
|
+
// Pending AUTH state (multi-step mechanisms).
|
|
328
|
+
authPending: null,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
var lineBuffer = "";
|
|
332
|
+
var bodyCollector = null;
|
|
333
|
+
var inDataBody = false;
|
|
334
|
+
|
|
335
|
+
socket.setTimeout(idleTimeoutMs);
|
|
336
|
+
socket.on("timeout", function () {
|
|
337
|
+
_writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.4.2 Idle timeout");
|
|
338
|
+
_closeConnection(socket);
|
|
339
|
+
});
|
|
340
|
+
socket.on("error", function (err) {
|
|
341
|
+
_emit("mail.server.submission.socket_error",
|
|
342
|
+
{ connectionId: state.id, code: (err && err.code) || "unknown" }, "warning");
|
|
343
|
+
_closeConnection(socket);
|
|
344
|
+
});
|
|
345
|
+
socket.on("close", function () { connections.delete(socket); });
|
|
346
|
+
|
|
347
|
+
_emit("mail.server.submission.connect", {
|
|
348
|
+
connectionId: state.id,
|
|
349
|
+
remoteAddress: state.remoteAddress,
|
|
350
|
+
remotePort: state.remotePort,
|
|
351
|
+
tls: state.tls,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
_writeReply(socket, REPLY_220_READY, greeting + " ready");
|
|
355
|
+
|
|
356
|
+
socket.on("data", function (chunk) {
|
|
357
|
+
try { _ingestBytes(state, socket, chunk); }
|
|
358
|
+
catch (err) {
|
|
359
|
+
_emit("mail.server.submission.handler_threw",
|
|
360
|
+
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
361
|
+
try { _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
|
|
362
|
+
catch (_e) { /* socket already gone */ }
|
|
363
|
+
_closeConnection(socket);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
function _ingestBytes(state, socket, chunk) {
|
|
368
|
+
if (inDataBody) {
|
|
369
|
+
try { bodyCollector.push(chunk); }
|
|
370
|
+
catch (_e) {
|
|
371
|
+
_emit("mail.server.submission.data_refused",
|
|
372
|
+
{ connectionId: state.id, reason: "body-too-large", maxBytes: maxMessageBytes },
|
|
373
|
+
"denied");
|
|
374
|
+
_writeReply(socket, REPLY_552_SIZE_EXCEEDED,
|
|
375
|
+
"5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
|
|
376
|
+
_resetTransaction(state);
|
|
377
|
+
inDataBody = false; bodyCollector = null;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
var collected = bodyCollector.result();
|
|
381
|
+
if (guardSmtpCommand.detectBodySmuggling(collected)) {
|
|
382
|
+
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
383
|
+
{ connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
|
|
384
|
+
"denied");
|
|
385
|
+
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
386
|
+
"5.7.0 Bare-LF in DATA body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
|
|
387
|
+
_resetTransaction(state);
|
|
388
|
+
inDataBody = false; bodyCollector = null;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
var endIdx = safeSmtp.findDotTerminator(collected);
|
|
392
|
+
if (endIdx !== -1) {
|
|
393
|
+
var body = collected.subarray(0, endIdx);
|
|
394
|
+
_finalizeDataBody(state, socket, body);
|
|
395
|
+
inDataBody = false; bodyCollector = null;
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lineBuffer += chunk.toString("utf8");
|
|
401
|
+
if (lineBuffer.length > maxLineBytes * 4) {
|
|
402
|
+
_writeReply(socket, REPLY_500_SYNTAX,
|
|
403
|
+
"5.5.6 Line too long (>" + maxLineBytes + " bytes)");
|
|
404
|
+
_closeConnection(socket);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
var crlf;
|
|
408
|
+
while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
|
|
409
|
+
var line = lineBuffer.slice(0, crlf);
|
|
410
|
+
lineBuffer = lineBuffer.slice(crlf + 2);
|
|
411
|
+
_handleCommand(state, socket, line);
|
|
412
|
+
if (inDataBody) return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function _handleCommand(state, socket, line) {
|
|
417
|
+
// Pending multi-step AUTH challenge — operator-supplied
|
|
418
|
+
// mechanism may need additional roundtrips. We delegate to
|
|
419
|
+
// authConfig.verify with the new client response.
|
|
420
|
+
if (state.authPending) {
|
|
421
|
+
return _continueAuthExchange(state, socket, line);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// guardSmtpCommand check (smuggling + shape).
|
|
425
|
+
try {
|
|
426
|
+
guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (err.code === "guard-smtp-command/bare-lf" ||
|
|
429
|
+
err.code === "guard-smtp-command/bare-cr" ||
|
|
430
|
+
err.code === "guard-smtp-command/nul-byte") {
|
|
431
|
+
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
432
|
+
{ connectionId: state.id, code: err.code, line: line.slice(0, 200) }, // allow:raw-byte-literal — audit-log line truncation
|
|
433
|
+
"denied");
|
|
434
|
+
}
|
|
435
|
+
_writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Syntax error (" + (err.code || "bad-line") + ")");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
var verb = line.split(/\s+/)[0].toUpperCase();
|
|
440
|
+
switch (verb) {
|
|
441
|
+
case "EHLO":
|
|
442
|
+
case "HELO":
|
|
443
|
+
return _handleEhlo(state, socket, line, verb);
|
|
444
|
+
case "STARTTLS":
|
|
445
|
+
return _handleStartTls(state, socket);
|
|
446
|
+
case "AUTH":
|
|
447
|
+
return _handleAuth(state, socket, line);
|
|
448
|
+
case "MAIL":
|
|
449
|
+
return _handleMailFrom(state, socket, line);
|
|
450
|
+
case "RCPT":
|
|
451
|
+
return _handleRcptTo(state, socket, line);
|
|
452
|
+
case "DATA":
|
|
453
|
+
return _handleData(state, socket);
|
|
454
|
+
case "NOOP":
|
|
455
|
+
return _writeReply(socket, REPLY_250_OK, "2.0.0 OK");
|
|
456
|
+
case "RSET":
|
|
457
|
+
_resetTransaction(state);
|
|
458
|
+
return _writeReply(socket, REPLY_250_OK, "2.0.0 Reset");
|
|
459
|
+
case "QUIT":
|
|
460
|
+
_writeReply(socket, REPLY_221_BYE, "2.0.0 Bye");
|
|
461
|
+
return _closeConnection(socket);
|
|
462
|
+
case "VRFY":
|
|
463
|
+
case "EXPN":
|
|
464
|
+
return _writeReply(socket, REPLY_502_NOT_IMPLEMENTED, "5.5.1 Command not implemented");
|
|
465
|
+
default:
|
|
466
|
+
_writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Unknown command");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function _handleEhlo(state, socket, line, verb) {
|
|
471
|
+
var helo = line.slice(verb.length).trim();
|
|
472
|
+
if (!helo) {
|
|
473
|
+
_writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// Skip guardDomain on address literals (RFC 5321 §4.1.3 valid
|
|
477
|
+
// bracket-form; already constrained by b.guardSmtpCommand).
|
|
478
|
+
// Bare-IP refused — CVE-2021-22931 class.
|
|
479
|
+
if (helo[0] !== "[" && guardDomainProfile) {
|
|
480
|
+
var __heloVerdict = _validateDomainHardened(helo, "helo");
|
|
481
|
+
if (!__heloVerdict.ok) {
|
|
482
|
+
_writeReply(socket, REPLY_501_BAD_ARGS,
|
|
483
|
+
"5.5.4 " + verb + " domain refused (" +
|
|
484
|
+
(__heloVerdict.issues && __heloVerdict.issues[0] && __heloVerdict.issues[0].kind) + ")");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
state.helo = helo;
|
|
489
|
+
state.stage = "ehlo";
|
|
490
|
+
if (verb === "EHLO") {
|
|
491
|
+
var caps = ["PIPELINING", "SIZE " + maxMessageBytes, "8BITMIME", "ENHANCEDSTATUSCODES"];
|
|
492
|
+
// STARTTLS advertised only on explicit-STARTTLS port (587),
|
|
493
|
+
// not on implicit-TLS (465 already wrapped). RFC 8314 §3.3.
|
|
494
|
+
if (!state.tls && !implicitTls) caps.unshift("STARTTLS");
|
|
495
|
+
// AUTH advertised only when authConfig wired AND we're on a
|
|
496
|
+
// TLS-protected connection (or operator opted to permissive).
|
|
497
|
+
if (authConfig && (state.tls || profile === "permissive")) {
|
|
498
|
+
caps.push("AUTH " + authMechanisms.join(" "));
|
|
499
|
+
}
|
|
500
|
+
var lines = [greeting + " greets " + helo];
|
|
501
|
+
for (var i = 0; i < caps.length; i += 1) lines.push(caps[i]);
|
|
502
|
+
_writeMultiline(socket, REPLY_250_OK, lines);
|
|
503
|
+
} else {
|
|
504
|
+
_writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
|
|
505
|
+
}
|
|
506
|
+
_emit("mail.server.submission.helo",
|
|
507
|
+
{ connectionId: state.id, verb: verb, helo: helo, tls: state.tls });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function _handleStartTls(state, socket) {
|
|
511
|
+
if (state.tls) {
|
|
512
|
+
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 TLS already active");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (implicitTls) {
|
|
516
|
+
_writeReply(socket, REPLY_502_NOT_IMPLEMENTED,
|
|
517
|
+
"5.5.1 STARTTLS not available on implicit-TLS port (RFC 8314)");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
_writeReply(socket, REPLY_220_READY, "2.0.0 Ready to start TLS");
|
|
521
|
+
// CVE-2021-38371 / CVE-2021-33515 defense: clear pre-handshake
|
|
522
|
+
// buffer at upgrade time.
|
|
523
|
+
lineBuffer = ""; bodyCollector = null; inDataBody = false;
|
|
524
|
+
var tlsSocket = new nodeTls.TLSSocket(socket, {
|
|
525
|
+
isServer: true, secureContext: opts.tlsContext,
|
|
526
|
+
});
|
|
527
|
+
tlsSocket.on("secure", function () {
|
|
528
|
+
state.tls = true; state.stage = "ehlo"; state.helo = null;
|
|
529
|
+
// Authenticated state SURVIVES STARTTLS upgrade — credentials
|
|
530
|
+
// verified pre-STARTTLS under permissive remain valid post-
|
|
531
|
+
// STARTTLS. Operator opts down to permissive only with this
|
|
532
|
+
// tradeoff acknowledged.
|
|
533
|
+
});
|
|
534
|
+
tlsSocket.on("error", function (err) {
|
|
535
|
+
_emit("mail.server.submission.tls_handshake_failed",
|
|
536
|
+
{ connectionId: state.id, code: (err && err.code) || "unknown" }, "failure");
|
|
537
|
+
_closeConnection(socket);
|
|
538
|
+
});
|
|
539
|
+
tlsSocket.on("data", function (chunk) {
|
|
540
|
+
try { _ingestBytes(state, tlsSocket, chunk); }
|
|
541
|
+
catch (err) {
|
|
542
|
+
_emit("mail.server.submission.handler_threw",
|
|
543
|
+
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
544
|
+
_closeConnection(tlsSocket);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function _handleAuth(state, socket, line) {
|
|
550
|
+
if (!authConfig) {
|
|
551
|
+
_writeReply(socket, REPLY_502_NOT_IMPLEMENTED, "5.5.1 AUTH not configured on this listener");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!state.tls && profile !== "permissive") {
|
|
555
|
+
// RFC 4954 §4 — AUTH MUST NOT be advertised or accepted on
|
|
556
|
+
// unencrypted connections (strict + balanced enforce; permissive
|
|
557
|
+
// opts down).
|
|
558
|
+
_writeReply(socket, REPLY_538_AUTH_NEEDS_TLS,
|
|
559
|
+
"5.7.11 Encryption required for AUTH (RFC 4954 §4)");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (state.authenticated) {
|
|
563
|
+
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 Already authenticated");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// Per-IP AUTH-failure budget — credential-stuffing class
|
|
567
|
+
// defense. Refuse new AUTH attempts when the rolling 15-min
|
|
568
|
+
// failure count for this IP has tripped the cap. 421 4.7.0 is
|
|
569
|
+
// transient; the sender either backs off or retries from a
|
|
570
|
+
// different IP (the desired behavior on a stuffing attack —
|
|
571
|
+
// shifts the attacker workload onto IP rotation).
|
|
572
|
+
var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
|
|
573
|
+
if (!authAdmit.ok) {
|
|
574
|
+
_emit("mail.server.submission.auth_rate_limit_refused",
|
|
575
|
+
{ connectionId: state.id, remoteAddress: state.remoteAddress,
|
|
576
|
+
reason: authAdmit.reason }, "denied");
|
|
577
|
+
_writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
|
|
578
|
+
"4.7.0 Too many AUTH failures from your IP");
|
|
579
|
+
_closeConnection(socket);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
var match = line.match(RE_AUTH);
|
|
583
|
+
if (!match) {
|
|
584
|
+
_writeReply(socket, REPLY_501_BAD_ARGS,
|
|
585
|
+
"5.5.4 Syntax: AUTH <SASL-mechanism> [<initial-response>] (RFC 4954)");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
var mech = match[1].toUpperCase();
|
|
589
|
+
var initial = match[2] || null;
|
|
590
|
+
if (authMechanisms.indexOf(mech) === -1) {
|
|
591
|
+
_writeReply(socket, REPLY_535_AUTH_FAILED,
|
|
592
|
+
"5.7.8 Mechanism '" + mech + "' not advertised");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
_emit("mail.server.submission.auth_attempt",
|
|
596
|
+
{ connectionId: state.id, mechanism: mech, remoteAddress: state.remoteAddress });
|
|
597
|
+
|
|
598
|
+
// For PLAIN / LOGIN / EXTERNAL the verify call is single-step.
|
|
599
|
+
// SCRAM-SHA-256 / GS2-* family use multi-step challenges; the
|
|
600
|
+
// operator's verify returns { ok, actor, challenge, pending }
|
|
601
|
+
// — when `pending: true` we send 334 + the challenge and wait
|
|
602
|
+
// for the client response.
|
|
603
|
+
state.authPending = { mechanism: mech, step: 0 };
|
|
604
|
+
_runAuthStep(state, socket, initial);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function _continueAuthExchange(state, socket, line) {
|
|
608
|
+
_runAuthStep(state, socket, line.trim());
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function _runAuthStep(state, socket, clientResponse) {
|
|
612
|
+
Promise.resolve()
|
|
613
|
+
.then(function () {
|
|
614
|
+
return authConfig.verify(state.authPending.mechanism, {
|
|
615
|
+
step: state.authPending.step,
|
|
616
|
+
clientResponse: clientResponse,
|
|
617
|
+
tls: state.tls,
|
|
618
|
+
remoteAddress: state.remoteAddress,
|
|
619
|
+
});
|
|
620
|
+
})
|
|
621
|
+
.then(function (result) {
|
|
622
|
+
state.authPending.step += 1;
|
|
623
|
+
if (result && result.pending && typeof result.challenge === "string") {
|
|
624
|
+
_writeReply(socket, REPLY_334_AUTH_CHALLENGE, result.challenge);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (result && result.ok === true && result.actor) {
|
|
628
|
+
state.authenticated = true;
|
|
629
|
+
state.actor = result.actor;
|
|
630
|
+
state.authPending = null;
|
|
631
|
+
_emit("mail.server.submission.auth_success", {
|
|
632
|
+
connectionId: state.id,
|
|
633
|
+
mechanism: state.authPending && state.authPending.mechanism,
|
|
634
|
+
tenantId: result.actor.tenantId || null,
|
|
635
|
+
scopes: Array.isArray(result.actor.scopes) ? result.actor.scopes : [],
|
|
636
|
+
});
|
|
637
|
+
_writeReply(socket, REPLY_235_AUTH_OK, "2.7.0 Authentication successful");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
state.authPending = null;
|
|
641
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
642
|
+
_emit("mail.server.submission.auth_failed", {
|
|
643
|
+
connectionId: state.id, reason: (result && result.reason) || "verify-returned-fail",
|
|
644
|
+
}, "denied");
|
|
645
|
+
_writeReply(socket, REPLY_535_AUTH_FAILED, "5.7.8 Authentication credentials invalid");
|
|
646
|
+
})
|
|
647
|
+
.catch(function (err) {
|
|
648
|
+
state.authPending = null;
|
|
649
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
650
|
+
_emit("mail.server.submission.auth_failed", {
|
|
651
|
+
connectionId: state.id, reason: (err && err.message) || String(err),
|
|
652
|
+
}, "failure");
|
|
653
|
+
_writeReply(socket, REPLY_535_AUTH_FAILED, "5.7.8 Authentication failed");
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function _handleMailFrom(state, socket, line) {
|
|
658
|
+
if (!state.tls && profile !== "permissive") {
|
|
659
|
+
_writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (!state.authenticated && profile !== "permissive") {
|
|
663
|
+
_writeReply(socket, REPLY_530_AUTH_REQUIRED,
|
|
664
|
+
"5.7.0 Authentication required (submission listener requires AUTH per RFC 6409)");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (state.stage !== "ehlo" && state.stage !== "mail") {
|
|
668
|
+
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 EHLO/HELO first");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
var match = line.match(RE_MAIL_FROM);
|
|
672
|
+
if (!match) {
|
|
673
|
+
_writeReply(socket, REPLY_501_BAD_ARGS,
|
|
674
|
+
"5.5.4 Syntax: MAIL FROM:<address> [SIZE=n]");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
var mailFrom = match[1].toLowerCase();
|
|
678
|
+
// Domain hardening on MAIL FROM. Skip address-literal + empty
|
|
679
|
+
// reverse-path (RFC 5321 §4.5.5).
|
|
680
|
+
var __mfAt = mailFrom.lastIndexOf("@");
|
|
681
|
+
var mailFromDomain = __mfAt === -1 ? "" : mailFrom.slice(__mfAt + 1);
|
|
682
|
+
if (mailFromDomain && mailFromDomain[0] !== "[" && guardDomainProfile) {
|
|
683
|
+
var __mfVerdict = _validateDomainHardened(mailFromDomain, "mail_from");
|
|
684
|
+
if (!__mfVerdict.ok) {
|
|
685
|
+
_writeReply(socket, REPLY_501_BAD_ARGS,
|
|
686
|
+
"5.5.4 MAIL FROM domain refused (" +
|
|
687
|
+
(__mfVerdict.issues && __mfVerdict.issues[0] && __mfVerdict.issues[0].kind) + ")");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
var paramStr = match[2] || "";
|
|
692
|
+
var sizeMatch = paramStr.match(RE_SIZE);
|
|
693
|
+
if (sizeMatch) {
|
|
694
|
+
var declaredSize = parseInt(sizeMatch[1], 10);
|
|
695
|
+
if (declaredSize > maxMessageBytes) {
|
|
696
|
+
_writeReply(socket, REPLY_552_SIZE_EXCEEDED,
|
|
697
|
+
"5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Identity binding — under strict profile, MAIL FROM MUST match
|
|
703
|
+
// an entry in the authenticated actor's mailbox set.
|
|
704
|
+
if (state.authenticated && identityBinding === "strict") {
|
|
705
|
+
var allowed = _actorMailboxes(state.actor);
|
|
706
|
+
if (allowed.length > 0 && allowed.indexOf(mailFrom) === -1) {
|
|
707
|
+
_emit("mail.server.submission.identity_mismatch", {
|
|
708
|
+
connectionId: state.id, authIdentity: state.actor.id || null,
|
|
709
|
+
mailFrom: mailFrom, allowed: allowed,
|
|
710
|
+
}, "denied");
|
|
711
|
+
_writeReply(socket, REPLY_553_SENDER_REJECTED,
|
|
712
|
+
"5.7.1 Sender address rejected: not owned by authenticated identity");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
state.mailFrom = mailFrom;
|
|
718
|
+
state.stage = "rcpt";
|
|
719
|
+
state.rcpts = [];
|
|
720
|
+
// Track in-flight async recipientPolicy verdicts so the cap-check
|
|
721
|
+
// counts BOTH committed + in-flight against `maxRcptsPerMsg`. Under
|
|
722
|
+
// SMTP PIPELINING (RFC 2920) a client can send many RCPT TO commands
|
|
723
|
+
// back-to-back; without this counter each one sees `state.rcpts.length`
|
|
724
|
+
// == 0 because the prior pushes haven't landed inside the .then() yet,
|
|
725
|
+
// so the cap-check passes for every command and `state.rcpts` grows
|
|
726
|
+
// past the limit once the verdicts resolve.
|
|
727
|
+
state.rcptsPending = 0;
|
|
728
|
+
_emit("mail.server.submission.mail_from",
|
|
729
|
+
{ connectionId: state.id, mailFrom: mailFrom,
|
|
730
|
+
actor: state.actor && state.actor.id });
|
|
731
|
+
_writeReply(socket, REPLY_250_OK, "2.1.0 Sender OK");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function _handleRcptTo(state, socket, line) {
|
|
735
|
+
if (state.stage !== "rcpt") {
|
|
736
|
+
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// Cap-check counts BOTH committed (state.rcpts.length) AND in-flight
|
|
740
|
+
// (state.rcptsPending) — under PIPELINING (RFC 2920) the prior
|
|
741
|
+
// commands haven't pushed yet by the time the next cap-check runs.
|
|
742
|
+
if ((state.rcpts.length + (state.rcptsPending || 0)) >= maxRcptsPerMsg) {
|
|
743
|
+
_writeReply(socket, REPLY_452_INSUFFICIENT_STG,
|
|
744
|
+
"4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
var match = line.match(RE_RCPT_TO);
|
|
748
|
+
if (!match) {
|
|
749
|
+
_writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 Syntax: RCPT TO:<address>");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
var rcpt = match[1].toLowerCase();
|
|
753
|
+
|
|
754
|
+
// Domain hardening on RCPT TO. Skip address-literal form.
|
|
755
|
+
var __rcptAt = rcpt.lastIndexOf("@");
|
|
756
|
+
var __rcptDomain = __rcptAt === -1 ? "" : rcpt.slice(__rcptAt + 1);
|
|
757
|
+
if (__rcptDomain && __rcptDomain[0] !== "[" && guardDomainProfile) {
|
|
758
|
+
var __rcptVerdict = _validateDomainHardened(__rcptDomain, "rcpt_to");
|
|
759
|
+
if (!__rcptVerdict.ok) {
|
|
760
|
+
_writeReply(socket, REPLY_501_BAD_ARGS,
|
|
761
|
+
"5.5.4 RCPT TO domain refused (" +
|
|
762
|
+
(__rcptVerdict.issues && __rcptVerdict.issues[0] && __rcptVerdict.issues[0].kind) + ")");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Operator-supplied recipient policy — async predicate that
|
|
768
|
+
// decides whether the authenticated actor may send to this
|
|
769
|
+
// destination. Wires policy decisions like "block *.gov from
|
|
770
|
+
// this tenant" / "this actor's outbound budget is exhausted" /
|
|
771
|
+
// "destination is in the operator's deny list". Returns
|
|
772
|
+
// `{ ok: true }` on accept OR `{ ok: false, reason }` on refuse.
|
|
773
|
+
// When not wired, every syntactically-valid RCPT TO is accepted
|
|
774
|
+
// — the agent.handoff is the operator's last chance to reject.
|
|
775
|
+
if (typeof opts.recipientPolicy === "function") {
|
|
776
|
+
state.rcptsPending = (state.rcptsPending || 0) + 1;
|
|
777
|
+
Promise.resolve()
|
|
778
|
+
.then(function () {
|
|
779
|
+
return opts.recipientPolicy({
|
|
780
|
+
actor: state.actor,
|
|
781
|
+
mailFrom: state.mailFrom,
|
|
782
|
+
rcptTo: rcpt,
|
|
783
|
+
connectionId: state.id,
|
|
784
|
+
remoteAddress: state.remoteAddress,
|
|
785
|
+
tls: state.tls,
|
|
786
|
+
});
|
|
787
|
+
})
|
|
788
|
+
.then(function (verdict) {
|
|
789
|
+
state.rcptsPending -= 1;
|
|
790
|
+
if (verdict && verdict.ok === true) {
|
|
791
|
+
// Re-check the cap before commit — under PIPELINING the
|
|
792
|
+
// verdict may resolve after other in-flight RCPT TO have
|
|
793
|
+
// pushed, so the previously-reserved slot could already
|
|
794
|
+
// be over-committed. Defense-in-depth on top of the
|
|
795
|
+
// in-flight-aware cap-check above.
|
|
796
|
+
if (state.rcpts.length >= maxRcptsPerMsg) {
|
|
797
|
+
_emit("mail.server.submission.recipient_refused", {
|
|
798
|
+
connectionId: state.id, rcptTo: rcpt,
|
|
799
|
+
reason: "cap-exceeded-post-policy",
|
|
800
|
+
actor: state.actor && state.actor.id,
|
|
801
|
+
}, "denied");
|
|
802
|
+
_writeReply(socket, REPLY_452_INSUFFICIENT_STG,
|
|
803
|
+
"4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
state.rcpts.push(rcpt);
|
|
807
|
+
_emit("mail.server.submission.rcpt_to",
|
|
808
|
+
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
|
|
809
|
+
_writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
_emit("mail.server.submission.recipient_refused", {
|
|
813
|
+
connectionId: state.id, rcptTo: rcpt,
|
|
814
|
+
reason: (verdict && verdict.reason) || "policy-refused",
|
|
815
|
+
actor: state.actor && state.actor.id,
|
|
816
|
+
}, "denied");
|
|
817
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
818
|
+
"5.7.1 " + ((verdict && verdict.reason) || "Recipient policy refused"));
|
|
819
|
+
})
|
|
820
|
+
.catch(function (err) {
|
|
821
|
+
state.rcptsPending -= 1;
|
|
822
|
+
_emit("mail.server.submission.recipient_policy_threw", {
|
|
823
|
+
connectionId: state.id, rcptTo: rcpt,
|
|
824
|
+
error: (err && err.message) || String(err),
|
|
825
|
+
}, "failure");
|
|
826
|
+
// Recipient-policy hook failure is treated as transient
|
|
827
|
+
// (the operator's policy engine may be temporarily
|
|
828
|
+
// unavailable); 451 4.7.1 lets the sender retry.
|
|
829
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR,
|
|
830
|
+
"4.7.1 Recipient policy temporarily unavailable");
|
|
831
|
+
});
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
state.rcpts.push(rcpt);
|
|
836
|
+
_emit("mail.server.submission.rcpt_to",
|
|
837
|
+
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
|
|
838
|
+
_writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function _handleData(state, socket) {
|
|
842
|
+
if (state.stage !== "rcpt" || state.rcpts.length === 0) {
|
|
843
|
+
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 No valid recipients");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
_writeReply(socket, REPLY_354_START_INPUT, "End data with <CR><LF>.<CR><LF>");
|
|
847
|
+
state.stage = "data-body";
|
|
848
|
+
inDataBody = true;
|
|
849
|
+
bodyCollector = safeBuffer.boundedChunkCollector({
|
|
850
|
+
maxBytes: maxMessageBytes,
|
|
851
|
+
errorClass: MailServerSubmissionError,
|
|
852
|
+
sizeCode: "mail-server-submission/body-too-large",
|
|
853
|
+
sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function _finalizeDataBody(state, socket, body) {
|
|
858
|
+
var dedotted = safeSmtp.dotUnstuff(body);
|
|
859
|
+
if (opts.agent && typeof opts.agent.handoff === "function") {
|
|
860
|
+
opts.agent.handoff({
|
|
861
|
+
mailFrom: state.mailFrom,
|
|
862
|
+
rcpts: state.rcpts.slice(),
|
|
863
|
+
body: dedotted,
|
|
864
|
+
actor: state.actor,
|
|
865
|
+
remote: { address: state.remoteAddress, port: state.remotePort },
|
|
866
|
+
tls: state.tls,
|
|
867
|
+
helo: state.helo,
|
|
868
|
+
connectionId: state.id,
|
|
869
|
+
direction: "outbound",
|
|
870
|
+
}).then(function (ack) {
|
|
871
|
+
_emit("mail.server.submission.outbound_routed", {
|
|
872
|
+
connectionId: state.id, messageId: ack && ack.messageId,
|
|
873
|
+
sizeBytes: dedotted.length, actor: state.actor && state.actor.id,
|
|
874
|
+
});
|
|
875
|
+
_writeReply(socket, REPLY_250_OK,
|
|
876
|
+
"2.6.0 Message accepted" + (ack && ack.messageId ? " <" + ack.messageId + ">" : ""));
|
|
877
|
+
_resetTransaction(state);
|
|
878
|
+
}).catch(function (err) {
|
|
879
|
+
_emit("mail.server.submission.data_refused",
|
|
880
|
+
{ connectionId: state.id, reason: "agent-handoff-failed",
|
|
881
|
+
error: (err && err.message) || String(err) }, "failure");
|
|
882
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR, "4.3.0 Local delivery error");
|
|
883
|
+
_resetTransaction(state);
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
_emit("mail.server.submission.data_accepted",
|
|
888
|
+
{ connectionId: state.id, mailFrom: state.mailFrom,
|
|
889
|
+
rcptCount: state.rcpts.length, sizeBytes: dedotted.length });
|
|
890
|
+
_writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
|
|
891
|
+
_resetTransaction(state);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function _resetTransaction(state) {
|
|
895
|
+
state.mailFrom = null;
|
|
896
|
+
state.rcpts = [];
|
|
897
|
+
state.rcptsPending = 0;
|
|
898
|
+
state.stage = "ehlo";
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function listen(listenOpts) {
|
|
903
|
+
listenOpts = listenOpts || {};
|
|
904
|
+
if (listening) {
|
|
905
|
+
throw new MailServerSubmissionError("mail-server-submission/already-listening",
|
|
906
|
+
"listen: already listening");
|
|
907
|
+
}
|
|
908
|
+
// Port 0 (ephemeral, test mode) must NOT fall back to the protocol
|
|
909
|
+
// default — the `|| <default>` short-circuit was a footgun on the
|
|
910
|
+
// test path.
|
|
911
|
+
var defaultPort = implicitTls ? 465 : 587; // allow:raw-byte-literal — RFC 8314 implicit-TLS / RFC 6409 submission ports
|
|
912
|
+
var port = listenOpts.port === undefined ? defaultPort : listenOpts.port;
|
|
913
|
+
var address = listenOpts.address || "0.0.0.0";
|
|
914
|
+
tcpServer = net.createServer(function (socket) { _handleConnection(socket); });
|
|
915
|
+
return new Promise(function (resolve, reject) {
|
|
916
|
+
tcpServer.once("error", reject);
|
|
917
|
+
tcpServer.listen(port, address, function () {
|
|
918
|
+
listening = true;
|
|
919
|
+
tcpServer.removeListener("error", reject);
|
|
920
|
+
_emit("mail.server.submission.listening",
|
|
921
|
+
{ port: port, address: address, implicitTls: implicitTls });
|
|
922
|
+
resolve({ port: tcpServer.address().port, address: address });
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function close(closeOpts) {
|
|
928
|
+
closeOpts = closeOpts || {};
|
|
929
|
+
if (!listening) return;
|
|
930
|
+
var timeoutMs = closeOpts.timeoutMs || C.TIME.seconds(30);
|
|
931
|
+
listening = false;
|
|
932
|
+
tcpServer.close();
|
|
933
|
+
connections.forEach(function (sock) {
|
|
934
|
+
try { _writeReply(sock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server shutting down"); }
|
|
935
|
+
catch (_e) { /* socket already gone */ }
|
|
936
|
+
});
|
|
937
|
+
var deadline = Date.now() + timeoutMs;
|
|
938
|
+
while (connections.size > 0 && Date.now() < deadline) {
|
|
939
|
+
await safeAsync.sleep(100); // allow:raw-time-literal — sub-second drain poll
|
|
940
|
+
}
|
|
941
|
+
connections.forEach(function (sock) {
|
|
942
|
+
try { sock.destroy(); } catch (_e) { /* best-effort */ }
|
|
943
|
+
});
|
|
944
|
+
connections.clear();
|
|
945
|
+
_emit("mail.server.submission.closed", {});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function connectionCount() { return connections.size; }
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
listen: listen,
|
|
952
|
+
close: close,
|
|
953
|
+
connectionCount: connectionCount,
|
|
954
|
+
_portForTest: function () { return tcpServer ? tcpServer.address().port : null; },
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function _actorMailboxes(actor) {
|
|
959
|
+
if (!actor) return [];
|
|
960
|
+
if (Array.isArray(actor.mailboxes)) return actor.mailboxes.map(function (m) { return String(m).toLowerCase(); });
|
|
961
|
+
if (typeof actor.mailbox === "string") return [actor.mailbox.toLowerCase()];
|
|
962
|
+
return [];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function _writeReply(socket, code, text) {
|
|
966
|
+
try { socket.write(code + " " + text + "\r\n"); }
|
|
967
|
+
catch (_e) { /* socket already closed */ }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function _writeMultiline(socket, code, lines) {
|
|
971
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
972
|
+
var sep = i === lines.length - 1 ? " " : "-";
|
|
973
|
+
try { socket.write(code + sep + lines[i] + "\r\n"); }
|
|
974
|
+
catch (_e) { /* socket already closed */ }
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function _closeConnection(socket) {
|
|
979
|
+
try { socket.end(); } catch (_e) { /* best-effort */ }
|
|
980
|
+
try { socket.destroy(); } catch (_e) { /* best-effort */ }
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
module.exports = {
|
|
984
|
+
create: create,
|
|
985
|
+
MailServerSubmissionError: MailServerSubmissionError,
|
|
986
|
+
};
|