@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.
@@ -0,0 +1,856 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.server.mx
4
+ * @nav Mail
5
+ * @title Mail MX Server
6
+ * @order 540
7
+ *
8
+ * @intro
9
+ * Inbound SMTP / MX listener. Composes the framework's existing
10
+ * mail-gate substrates (`b.mail.helo`, `b.mail.rbl`,
11
+ * `b.mail.greylist`, `b.guardEnvelope`, `b.mail.auth.dmarc`,
12
+ * `b.safeMime`, `b.guardEmail`, `b.guardSmtpCommand`,
13
+ * `b.mail.agent`) into one operator-facing server that accepts
14
+ * inbound mail per RFC 5321 with PQC-shaped TLS posture, SMTP-
15
+ * smuggling defense baked into the wire-protocol layer, and the
16
+ * gate cascade running at the right phase of the state machine.
17
+ *
18
+ * `create({ ... }).listen()` binds the TCP port; every incoming
19
+ * connection drives the CONNECT → EHLO → [STARTTLS → EHLO] →
20
+ * MAIL → RCPT (×N) → DATA → DATA-body → QUIT state machine. Each
21
+ * phase passes through the operator-supplied gates (defaulting
22
+ * to "no-op" when the operator hasn't wired a gate) and refuses
23
+ * with the appropriate 5xx (permanent) or 4xx (transient) SMTP
24
+ * reply code on gate fail.
25
+ *
26
+ * ## Defenses baked in
27
+ *
28
+ * - **SMTP smuggling** (CVE-2023-51764 / CVE-2024-32178) — every
29
+ * wire line passes through `b.guardSmtpCommand.validate` which
30
+ * refuses bare LF, bare CR, NUL, C0 controls, DEL, and oversize.
31
+ * The DATA body's `\r\n.\r\n` terminator is matched on canonical
32
+ * CRLF only — bare-LF dot-terminators are refused. Together this
33
+ * defends the CVE-2023-51764 class where a hostile sender
34
+ * smuggles a second message past the framework's filter by
35
+ * terminating the first one with `\n.\n` instead of `\r\n.\r\n`.
36
+ *
37
+ * - **Open-relay defense** — RCPT TO non-local refused with 550
38
+ * 5.7.1 Relaying denied unless the operator explicitly registered
39
+ * the destination via `relayAllowedFor: [{ cidr, scope }]`. The
40
+ * default posture is "MX-only, no relay" so a misconfigured boot
41
+ * can't accidentally become an open relay.
42
+ *
43
+ * - **STARTTLS stripping (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot)** —
44
+ * once STARTTLS is advertised + selected, subsequent commands
45
+ * MUST run over the negotiated TLS context. A pre-STARTTLS
46
+ * pipelining attempt (RFC 2920) to inject commands that take
47
+ * effect post-handshake is refused by clearing the command
48
+ * buffer at STARTTLS time and reading fresh from the TLS socket
49
+ * only — defends both the Exim and Dovecot variants of the
50
+ * STARTTLS-injection class.
51
+ *
52
+ * - **Resource exhaustion** — per-command line cap (default
53
+ * 1 KiB), DATA body cap (default 50 MiB per RFC 5321 §4.5.3.1.7),
54
+ * per-recipient cap (default 100 per RFC 5321 §4.5.3.1.8),
55
+ * connection idle timeout (default 5 minutes per RFC 5321
56
+ * §4.5.3.2.7). Operator opts up with explicit bounds.
57
+ *
58
+ * - **TLS posture** — `tlsContext` MUST be supplied (no implicit
59
+ * plaintext-only mode). Operator passes a `b.network.tls.context`
60
+ * output which carries the framework's TLS 1.3 default + OCSP /
61
+ * CT-log posture. Pre-STARTTLS plain commands are limited to
62
+ * EHLO / HELO / STARTTLS / NOOP / QUIT / RSET; MAIL / RCPT /
63
+ * DATA all refused with 530 5.7.0 Must issue a STARTTLS command
64
+ * first.
65
+ *
66
+ * ## Audit lifecycle
67
+ *
68
+ * - `mail.server.mx.connect` — IP, TLS state, FCrDNS hostname
69
+ * - `mail.server.mx.helo` — HELO greeting, helo-gate verdict
70
+ * - `mail.server.mx.mail_from` — sender, SPF verdict, alignment verdict
71
+ * - `mail.server.mx.rcpt_to` — recipient, RBL verdict, greylist verdict
72
+ * - `mail.server.mx.data_accepted` — message size, DKIM verdict, DMARC verdict
73
+ * - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
74
+ * - `mail.server.mx.delivered` — agent.handoff ack
75
+ * - `mail.server.mx.tls_handshake_failed` — handshake error
76
+ * - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
77
+ * - `mail.server.mx.relay_refused` — open-relay attempt
78
+ *
79
+ * ## What v1 does NOT ship
80
+ *
81
+ * - **AUTH / submission auth** — MX listener is inbound from the
82
+ * internet, no authentication. Submission listener (port 587) is
83
+ * a separate slice with SCRAM-SHA-256 / XOAUTH2 / EXTERNAL.
84
+ * - **Sieve filtering** — composes via `b.mail.agent` at delivery
85
+ * time; the MX listener doesn't decide policy itself.
86
+ * - **Outbound DSN generation** — `b.guardDsn` parses inbound DSNs;
87
+ * outbound DSN emission deferred to the submission slice.
88
+ * - **8BITMIME** (RFC 6152, obsoletes RFC 1652) — advertised in
89
+ * the EHLO capabilities since the DATA body parser via
90
+ * `b.safeMime` is octet-clean; no transcoding needed.
91
+ * - **SMTPUTF8** (RFC 6531) + **IDN** (RFC 5891) — the wire-protocol
92
+ * layer here is encoding-agnostic; SMTPUTF8 capability
93
+ * advertisement is a follow-up slice once the operator's
94
+ * downstream (mail-store + delivery agent) accepts Unicode
95
+ * mailbox-local-part bytes. Today the listener does not
96
+ * advertise SMTPUTF8 and refuses non-ASCII in MAIL FROM /
97
+ * RCPT TO via `b.guardSmtpCommand`.
98
+ *
99
+ * ## Composition contract
100
+ *
101
+ * Every gate is a primitive that already exists. The MX slice is a
102
+ * state-machine + wire-protocol coordinator — no new crypto, no
103
+ * new parsing, no new RFC-layer primitives. If a gate isn't ready
104
+ * (e.g. operator hasn't wired `b.mail.auth.dmarc`), the listener
105
+ * skips that phase with an audit note rather than synthesizing a
106
+ * verdict.
107
+ *
108
+ * @card
109
+ * Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
110
+ * smuggling defense baked into the wire-protocol layer (RFC 5321
111
+ * §2.3.8 + CVE-2023-51764 / CVE-2024-32178), open-relay refusal by
112
+ * default, STARTTLS-stripping defense (CVE-2021-38371), and the
113
+ * framework's mail-gate cascade (HELO / RBL / greylist /
114
+ * guardEnvelope / DMARC / safeMime / guardEmail) running at the
115
+ * appropriate phase.
116
+ */
117
+
118
+ var net = require("node:net");
119
+ var nodeTls = require("node:tls");
120
+ var lazyRequire = require("./lazy-require");
121
+ var C = require("./constants");
122
+ var bCrypto = require("./crypto");
123
+ var numericBounds = require("./numeric-bounds");
124
+ var safeAsync = require("./safe-async");
125
+ var safeBuffer = require("./safe-buffer");
126
+ var safeSmtp = require("./safe-smtp");
127
+ var validateOpts = require("./validate-opts");
128
+ var guardSmtpCommand = require("./guard-smtp-command");
129
+ var guardDomain = require("./guard-domain");
130
+ var mailServerRateLimit = require("./mail-server-rate-limit");
131
+ var { defineClass } = require("./framework-error");
132
+
133
+ var audit = lazyRequire(function () { return require("./audit"); });
134
+
135
+ var MailServerMxError = defineClass("MailServerMxError", { alwaysPermanent: true });
136
+
137
+ // RFC 5321 §4.5.3.1 — wire-protocol limits.
138
+ var DEFAULT_MAX_LINE_BYTES = C.BYTES.kib(1);
139
+ var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(50);
140
+ var DEFAULT_MAX_RCPTS_PER_MESSAGE = 100; // allow:raw-byte-literal — RFC 5321 §4.5.3.1.8 recipient cap
141
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
142
+ var DEFAULT_GREETING = "blamejs ESMTP";
143
+
144
+ // SMTP reply-code constants. The framework uses RFC 5321 enhanced
145
+ // status codes per RFC 3463 (`Dclass.Dsubject.Ddetail`) embedded in
146
+ // the reply lines for operator-side observability.
147
+ var REPLY_220_READY = "220";
148
+ var REPLY_221_BYE = "221";
149
+ var REPLY_250_OK = "250";
150
+ var REPLY_354_START_INPUT = "354";
151
+ var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
152
+ var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
153
+ var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
154
+ var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
155
+ var REPLY_501_BAD_ARGS = "501"; // allow:raw-byte-literal — SMTP permanent code
156
+ var REPLY_502_NOT_IMPLEMENTED = "502"; // allow:raw-byte-literal — SMTP permanent code
157
+ var REPLY_503_BAD_SEQUENCE = "503"; // allow:raw-byte-literal — SMTP permanent code
158
+ var REPLY_530_AUTH_REQUIRED = "530"; // allow:raw-byte-literal — SMTP permanent code
159
+ var REPLY_550_MAILBOX_UNAVAIL = "550"; // allow:raw-byte-literal — SMTP permanent code
160
+ var REPLY_552_SIZE_EXCEEDED = "552"; // allow:raw-byte-literal — SMTP permanent code
161
+ var REPLY_554_TRANSACTION_FAILED = "554"; // allow:raw-byte-literal — SMTP permanent code
162
+
163
+ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
164
+ var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
165
+ var RE_SIZE = /SIZE=(\d+)/i;
166
+
167
+ /**
168
+ * @primitive b.mail.server.mx.create
169
+ * @signature b.mail.server.mx.create(opts)
170
+ * @since 0.9.46
171
+ * @status stable
172
+ * @related b.mail.helo.evaluate, b.mail.rbl.create, b.mail.greylist.create, b.guardEnvelope.check, b.mail.agent.create
173
+ *
174
+ * Build the MX listener. Returns `{ listen({ port?, address? }),
175
+ * close({ timeoutMs? }), connectionCount(), _portForTest() }`.
176
+ *
177
+ * @opts
178
+ * tlsContext: TlsContext, // required — b.network.tls.context() output (no implicit plaintext)
179
+ * greeting: string, // default "blamejs ESMTP" — HELO/EHLO 220-line banner
180
+ * helo: b.mail.helo, // optional gate
181
+ * rbl: b.mail.rbl, // optional gate
182
+ * greylist: b.mail.greylist, // optional gate
183
+ * envelope: b.guardEnvelope, // optional gate (SPF/DKIM alignment)
184
+ * dmarc: b.mail.auth.dmarc, // optional gate
185
+ * agent: b.mail.agent, // optional delivery handoff
186
+ * relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
187
+ * localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
188
+ * maxLineBytes: number, // default 1 KiB — per-command line cap
189
+ * maxMessageBytes: number, // default 50 MiB — DATA body cap
190
+ * maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
191
+ * idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
192
+ * profile: "strict" | "balanced" | "permissive", // gate posture cascade
193
+ *
194
+ * @example
195
+ * var tls = b.network.tls.context({ cert: certPem, key: keyPem });
196
+ * var server = b.mail.server.mx.create({
197
+ * tlsContext: tls,
198
+ * greeting: "mx.example.com ESMTP blamejs",
199
+ * helo: b.mail.helo,
200
+ * rbl: b.mail.rbl.create({ providers: ["zen.spamhaus.org"] }),
201
+ * greylist: b.mail.greylist.create({ store: greylistStore }),
202
+ * envelope: b.guardEnvelope,
203
+ * agent: b.mail.agent.create({ store: mailStore }),
204
+ * localDomains: ["example.com"],
205
+ * });
206
+ * await server.listen({ port: 25 });
207
+ */
208
+ function create(opts) {
209
+ validateOpts.requireObject(opts, "mail.server.mx.create",
210
+ MailServerMxError, "mail-server-mx/bad-opts");
211
+ if (!opts.tlsContext) {
212
+ throw new MailServerMxError("mail-server-mx/no-tls-context",
213
+ "mail.server.mx.create: tlsContext is required (no implicit plaintext mode)");
214
+ }
215
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
216
+ ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
217
+ "mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
218
+ if (opts.localDomains !== undefined &&
219
+ (!Array.isArray(opts.localDomains) || opts.localDomains.length === 0)) {
220
+ throw new MailServerMxError("mail-server-mx/bad-opts",
221
+ "mail.server.mx.create: localDomains must be a non-empty array if provided");
222
+ }
223
+ if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
224
+ throw new MailServerMxError("mail-server-mx/bad-opts",
225
+ "mail.server.mx.create: relayAllowedFor must be an array if provided");
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 localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
234
+ var relayAllowedFor = opts.relayAllowedFor || [];
235
+ var profile = opts.profile || "strict";
236
+
237
+ // Default-on per-IP rate limit. Operators pass `rateLimit: false` to
238
+ // disable (only for tests / closed networks), pass a rate-limit
239
+ // handle from b.mail.server.rateLimit.create({...}) to share one
240
+ // budget across multiple listeners, or pass an opts object to
241
+ // override defaults.
242
+ var rateLimit;
243
+ if (opts.rateLimit === false) {
244
+ rateLimit = mailServerRateLimit.create({ disabled: true });
245
+ } else if (opts.rateLimit && typeof opts.rateLimit.admitConnection === "function") {
246
+ rateLimit = opts.rateLimit;
247
+ } else {
248
+ rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
249
+ }
250
+
251
+ // Default-on operator-supplied-domain hardening. opts.localDomains
252
+ // and the HELO / MAIL FROM / RCPT TO domain validations all route
253
+ // through `b.guardDomain` for IDN homograph defense (CVE-2017-5469
254
+ // class), special-use-domain refusal (RFC 6761), label-length cap
255
+ // (RFC 1035 §2.3.4), and bare-IP-as-domain refusal (CVE-2021-22931
256
+ // class). Operators with a closed-network deployment can pass
257
+ // `guardDomain: false` to skip; the default keeps the 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.mx.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
+ // Pre-validate operator-supplied localDomains at boot — the same
282
+ // shape they enforce on RCPT TO must itself pass the validator,
283
+ // otherwise an operator who typed an IDN homograph (or an IP) into
284
+ // their allowlist would silently weaken the gate.
285
+ if (guardDomainProfile) {
286
+ for (var __ldi = 0; __ldi < localDomains.length; __ldi += 1) {
287
+ var __ldVerdict = guardDomain.validate(localDomains[__ldi], guardDomainProfile);
288
+ if (!__ldVerdict.ok) {
289
+ throw new MailServerMxError("mail-server-mx/bad-local-domain",
290
+ "mail.server.mx.create: localDomains[" + __ldi + "] '" + localDomains[__ldi] +
291
+ "' rejected by b.guardDomain (" +
292
+ (__ldVerdict.issues && __ldVerdict.issues[0] && __ldVerdict.issues[0].kind) + ")");
293
+ }
294
+ }
295
+ }
296
+
297
+ var tcpServer = null;
298
+ var listening = false;
299
+ var connections = new Set();
300
+
301
+ function _emit(action, metadata, outcome) {
302
+ try {
303
+ audit().safeEmit({
304
+ action: action,
305
+ outcome: outcome || "success",
306
+ metadata: metadata || {},
307
+ });
308
+ } catch (_e) { /* drop-silent — audit best-effort */ }
309
+ }
310
+
311
+ // ---- Per-connection state machine ---------------------------------------
312
+ function _handleConnection(socket) {
313
+ var remoteAddress = socket.remoteAddress || "0.0.0.0";
314
+ var admit = rateLimit.admitConnection(remoteAddress);
315
+ if (!admit.ok) {
316
+ // 421 4.7.0 — transient refusal; sender retries elsewhere or later.
317
+ // RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
318
+ _emit("mail.server.mx.rate_limit_refused",
319
+ { remoteAddress: remoteAddress, reason: admit.reason }, "denied");
320
+ try {
321
+ socket.write("421 4.7.0 Too many connections from your IP\r\n");
322
+ } catch (_e) { /* socket may already be torn down */ }
323
+ try { socket.destroy(); } catch (_e2) { /* idempotent */ }
324
+ return;
325
+ }
326
+ socket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
327
+
328
+ var connectionId = "mxconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
329
+ connections.add(socket);
330
+
331
+ var state = {
332
+ id: connectionId,
333
+ remoteAddress: remoteAddress,
334
+ remotePort: socket.remotePort || null,
335
+ tls: false,
336
+ stage: "connect", // connect | ehlo | mail | rcpt | data-body | done
337
+ helo: null,
338
+ mailFrom: null,
339
+ rcpts: [],
340
+ messageBytes: 0,
341
+ lastDataByteTime: 0,
342
+ };
343
+
344
+ var lineBuffer = "";
345
+ var bodyCollector = null;
346
+ var inDataBody = false;
347
+
348
+ socket.setTimeout(idleTimeoutMs);
349
+ socket.on("timeout", function () {
350
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.4.2 Idle timeout");
351
+ _closeConnection(socket);
352
+ });
353
+
354
+ socket.on("error", function (err) {
355
+ _emit("mail.server.mx.socket_error",
356
+ { connectionId: state.id, code: (err && err.code) || "unknown", message: err && err.message },
357
+ "warning");
358
+ _closeConnection(socket);
359
+ });
360
+
361
+ socket.on("close", function () {
362
+ connections.delete(socket);
363
+ });
364
+
365
+ _emit("mail.server.mx.connect", {
366
+ connectionId: state.id,
367
+ remoteAddress: state.remoteAddress,
368
+ remotePort: state.remotePort,
369
+ tls: false,
370
+ });
371
+
372
+ // 220 banner — RFC 5321 §3.1.
373
+ _writeReply(socket, REPLY_220_READY, greeting + " ready");
374
+
375
+ socket.on("data", function (chunk) {
376
+ try { _ingestBytes(state, socket, chunk); }
377
+ catch (err) {
378
+ _emit("mail.server.mx.handler_threw",
379
+ { connectionId: state.id, error: (err && err.message) || String(err) },
380
+ "failure");
381
+ try { _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
382
+ catch (_e) { /* socket already gone */ }
383
+ _closeConnection(socket);
384
+ }
385
+ });
386
+
387
+ // ---- Byte-level ingestion --------------------------------------------
388
+ function _ingestBytes(state, socket, chunk) {
389
+ if (inDataBody) {
390
+ // DATA body — accumulate via boundedChunkCollector, watch for
391
+ // canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
392
+ // is the SMTP smuggling shape (CVE-2023-51764); refused.
393
+ try { bodyCollector.push(chunk); }
394
+ catch (_e) {
395
+ _emit("mail.server.mx.data_refused",
396
+ { connectionId: state.id, reason: "body-too-large", maxBytes: maxMessageBytes },
397
+ "denied");
398
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
399
+ "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
400
+ _resetTransaction(state);
401
+ inDataBody = false;
402
+ bodyCollector = null;
403
+ return;
404
+ }
405
+ var collected = bodyCollector.result();
406
+ // Smuggling detector — bare LF dot-line in body before the
407
+ // CRLF dot terminator. Refuse the whole transaction; emit
408
+ // smuggling-detected audit.
409
+ if (guardSmtpCommand.detectBodySmuggling(collected)) {
410
+ _emit("mail.server.mx.smtp_smuggling_detected",
411
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
412
+ "denied");
413
+ _writeReply(socket, REPLY_554_TRANSACTION_FAILED,
414
+ "5.7.0 Bare-LF in DATA body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
415
+ _resetTransaction(state);
416
+ inDataBody = false;
417
+ bodyCollector = null;
418
+ return;
419
+ }
420
+ // Canonical \r\n.\r\n terminator?
421
+ var endIdx = safeSmtp.findDotTerminator(collected);
422
+ if (endIdx !== -1) {
423
+ var body = collected.subarray(0, endIdx);
424
+ _finalizeDataBody(state, socket, body);
425
+ inDataBody = false;
426
+ bodyCollector = null;
427
+ }
428
+ return;
429
+ }
430
+
431
+ // Command phase — line-buffered.
432
+ lineBuffer += chunk.toString("utf8");
433
+ if (lineBuffer.length > maxLineBytes * 4) {
434
+ _writeReply(socket, REPLY_500_SYNTAX,
435
+ "5.5.6 Line too long (>" + maxLineBytes + " bytes)");
436
+ _closeConnection(socket);
437
+ return;
438
+ }
439
+ var crlf;
440
+ while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
441
+ var line = lineBuffer.slice(0, crlf);
442
+ lineBuffer = lineBuffer.slice(crlf + 2);
443
+ _handleCommand(state, socket, line);
444
+ if (inDataBody) return;
445
+ }
446
+ }
447
+
448
+ function _handleCommand(state, socket, line) {
449
+ // Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
450
+ // BEFORE state-machine dispatch.
451
+ try {
452
+ guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
453
+ } catch (err) {
454
+ if (err.code === "guard-smtp-command/bare-lf" ||
455
+ err.code === "guard-smtp-command/bare-cr" ||
456
+ err.code === "guard-smtp-command/nul-byte") {
457
+ _emit("mail.server.mx.smtp_smuggling_detected",
458
+ { connectionId: state.id, code: err.code, line: line.slice(0, 200) }, // allow:raw-byte-literal — audit-log line truncation
459
+ "denied");
460
+ }
461
+ _writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Syntax error (" + (err.code || "bad-line") + ")");
462
+ return;
463
+ }
464
+
465
+ var verb = line.split(/\s+/)[0].toUpperCase();
466
+ switch (verb) {
467
+ case "EHLO":
468
+ case "HELO":
469
+ _handleEhlo(state, socket, line, verb);
470
+ return;
471
+ case "STARTTLS":
472
+ _handleStartTls(state, socket);
473
+ return;
474
+ case "MAIL":
475
+ _handleMailFrom(state, socket, line);
476
+ return;
477
+ case "RCPT":
478
+ _handleRcptTo(state, socket, line);
479
+ return;
480
+ case "DATA":
481
+ _handleData(state, socket);
482
+ return;
483
+ case "NOOP":
484
+ _writeReply(socket, REPLY_250_OK, "2.0.0 OK");
485
+ return;
486
+ case "RSET":
487
+ _resetTransaction(state);
488
+ _writeReply(socket, REPLY_250_OK, "2.0.0 Reset");
489
+ return;
490
+ case "QUIT":
491
+ _writeReply(socket, REPLY_221_BYE, "2.0.0 Bye");
492
+ _closeConnection(socket);
493
+ return;
494
+ case "VRFY":
495
+ case "EXPN":
496
+ // Refuse VRFY/EXPN per modern best practice (information
497
+ // disclosure of internal aliases / valid recipients).
498
+ _writeReply(socket, REPLY_502_NOT_IMPLEMENTED, "5.5.1 Command not implemented");
499
+ return;
500
+ default:
501
+ _writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Unknown command");
502
+ }
503
+ }
504
+
505
+ // ---- EHLO / HELO ------------------------------------------------------
506
+ function _handleEhlo(state, socket, line, verb) {
507
+ var helo = line.slice(verb.length).trim();
508
+ if (!helo) {
509
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
510
+ return;
511
+ }
512
+ // Domain hardening for HELO/EHLO greeting (RFC 5321 §4.1.1.1).
513
+ // Skip when the greeting is an address literal (`[1.2.3.4]` /
514
+ // `[IPv6:...]`) — those are RFC-5321-legitimate non-domain
515
+ // forms; the bracket syntax is already constrained by
516
+ // b.guardSmtpCommand. Bare-IP-as-domain (no brackets) IS
517
+ // refused — that's the CVE-2021-22931 class guardDomain catches.
518
+ if (helo[0] !== "[" && guardDomainProfile) {
519
+ var heloVerdict = _validateDomainHardened(helo, "helo");
520
+ if (!heloVerdict.ok) {
521
+ _writeReply(socket, REPLY_501_BAD_ARGS,
522
+ "5.5.4 " + verb + " domain refused (" +
523
+ (heloVerdict.issues && heloVerdict.issues[0] && heloVerdict.issues[0].kind) + ")");
524
+ return;
525
+ }
526
+ }
527
+ state.helo = helo;
528
+ state.stage = "ehlo";
529
+ // Multi-line 250 capabilities advertisement per RFC 5321 §4.1.1.1.
530
+ if (verb === "EHLO") {
531
+ // EHLO capabilities advertised:
532
+ // - PIPELINING per RFC 2920
533
+ // - SIZE n per RFC 1870 §3 (with the per-server byte cap)
534
+ // - 8BITMIME per RFC 6152 (obsoletes RFC 1652)
535
+ // - STARTTLS per RFC 3207 §2 (only advertised pre-TLS)
536
+ // - ENHANCEDSTATUSCODES per RFC 2034 (RFC 3463 code shape)
537
+ var caps = ["PIPELINING", "SIZE " + maxMessageBytes, "8BITMIME"];
538
+ if (!state.tls) caps.push("STARTTLS");
539
+ caps.push("ENHANCEDSTATUSCODES");
540
+ var lines = [greeting + " greets " + helo];
541
+ for (var i = 0; i < caps.length; i += 1) lines.push(caps[i]);
542
+ _writeMultiline(socket, REPLY_250_OK, lines);
543
+ } else {
544
+ _writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
545
+ }
546
+ _emit("mail.server.mx.helo",
547
+ { connectionId: state.id, verb: verb, helo: helo, tls: state.tls });
548
+ }
549
+
550
+ // ---- STARTTLS ---------------------------------------------------------
551
+ function _handleStartTls(state, socket) {
552
+ if (state.tls) {
553
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 TLS already active");
554
+ return;
555
+ }
556
+ _writeReply(socket, REPLY_220_READY, "2.0.0 Ready to start TLS");
557
+ // STARTTLS-injection defense (CVE-2021-38371 Exim,
558
+ // CVE-2021-33515 Dovecot): clear the command buffer + body
559
+ // collector at upgrade time. Any commands pipelined (RFC 2920)
560
+ // BEFORE the TLS handshake are discarded — only commands sent
561
+ // on the post-handshake TLS socket are honored.
562
+ lineBuffer = "";
563
+ bodyCollector = null;
564
+ inDataBody = false;
565
+ var tlsSocket = new nodeTls.TLSSocket(socket, {
566
+ isServer: true,
567
+ secureContext: opts.tlsContext,
568
+ });
569
+ tlsSocket.on("secure", function () {
570
+ state.tls = true;
571
+ // After the handshake, the state machine restarts at EHLO
572
+ // (per RFC 3207 §4.2 — client MUST re-issue EHLO).
573
+ state.stage = "ehlo";
574
+ state.helo = null;
575
+ });
576
+ tlsSocket.on("error", function (err) {
577
+ _emit("mail.server.mx.tls_handshake_failed",
578
+ { connectionId: state.id, code: (err && err.code) || "unknown",
579
+ message: err && err.message }, "failure");
580
+ _closeConnection(socket);
581
+ });
582
+ tlsSocket.on("data", function (chunk) {
583
+ try { _ingestBytes(state, tlsSocket, chunk); }
584
+ catch (err) {
585
+ _emit("mail.server.mx.handler_threw",
586
+ { connectionId: state.id, error: (err && err.message) || String(err) },
587
+ "failure");
588
+ _closeConnection(tlsSocket);
589
+ }
590
+ });
591
+ }
592
+
593
+ // ---- MAIL FROM --------------------------------------------------------
594
+ function _handleMailFrom(state, socket, line) {
595
+ if (!state.tls && _requiresStartTls()) {
596
+ _writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
597
+ return;
598
+ }
599
+ if (state.stage !== "ehlo" && state.stage !== "mail") {
600
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 EHLO/HELO first");
601
+ return;
602
+ }
603
+ var match = line.match(RE_MAIL_FROM);
604
+ if (!match) {
605
+ _writeReply(socket, REPLY_501_BAD_ARGS,
606
+ "5.5.4 Syntax: MAIL FROM:<address> [SIZE=n]");
607
+ return;
608
+ }
609
+ var mailFrom = match[1].toLowerCase();
610
+ // Domain hardening on MAIL FROM domain. Skip address-literal
611
+ // and empty-reverse-path forms (RFC 5321 §4.5.5 — bounce return
612
+ // path `<>` is legitimate and has no domain).
613
+ var __mfAtIdx = mailFrom.lastIndexOf("@");
614
+ var mailFromDomain = __mfAtIdx === -1 ? "" : mailFrom.slice(__mfAtIdx + 1);
615
+ if (mailFromDomain && mailFromDomain[0] !== "[" && guardDomainProfile) {
616
+ var mfVerdict = _validateDomainHardened(mailFromDomain, "mail_from");
617
+ if (!mfVerdict.ok) {
618
+ _writeReply(socket, REPLY_501_BAD_ARGS,
619
+ "5.5.4 MAIL FROM domain refused (" +
620
+ (mfVerdict.issues && mfVerdict.issues[0] && mfVerdict.issues[0].kind) + ")");
621
+ return;
622
+ }
623
+ }
624
+ var paramStr = match[2] || "";
625
+ var sizeMatch = paramStr.match(RE_SIZE);
626
+ if (sizeMatch) {
627
+ var declaredSize = parseInt(sizeMatch[1], 10);
628
+ if (declaredSize > maxMessageBytes) {
629
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
630
+ "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
631
+ return;
632
+ }
633
+ }
634
+ state.mailFrom = mailFrom;
635
+ state.stage = "rcpt";
636
+ state.rcpts = [];
637
+ _emit("mail.server.mx.mail_from",
638
+ { connectionId: state.id, mailFrom: mailFrom });
639
+ _writeReply(socket, REPLY_250_OK, "2.1.0 Sender OK");
640
+ }
641
+
642
+ // ---- RCPT TO ----------------------------------------------------------
643
+ function _handleRcptTo(state, socket, line) {
644
+ if (state.stage !== "rcpt") {
645
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
646
+ return;
647
+ }
648
+ if (state.rcpts.length >= maxRcptsPerMsg) {
649
+ _writeReply(socket, REPLY_452_INSUFFICIENT_STG,
650
+ "4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
651
+ return;
652
+ }
653
+ var match = line.match(RE_RCPT_TO);
654
+ if (!match) {
655
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 Syntax: RCPT TO:<address>");
656
+ return;
657
+ }
658
+ var rcpt = match[1].toLowerCase();
659
+ // Domain hardening on RCPT TO domain — skip the address-literal
660
+ // form per RFC 5321 §4.1.3 (bracket syntax already constrained
661
+ // by b.guardSmtpCommand). Refuses IDN homograph + special-use
662
+ // domains + bare-IP-as-domain on the un-bracketed form.
663
+ var _atIdx = rcpt.lastIndexOf("@");
664
+ var rcptDomain = _atIdx === -1 ? "" : rcpt.slice(_atIdx + 1);
665
+ if (rcptDomain && rcptDomain[0] !== "[" && guardDomainProfile) {
666
+ var rcptVerdict = _validateDomainHardened(rcptDomain, "rcpt_to");
667
+ if (!rcptVerdict.ok) {
668
+ _writeReply(socket, REPLY_501_BAD_ARGS,
669
+ "5.5.4 RCPT TO domain refused (" +
670
+ (rcptVerdict.issues && rcptVerdict.issues[0] && rcptVerdict.issues[0].kind) + ")");
671
+ return;
672
+ }
673
+ }
674
+ // Local-domain check — refuse non-local recipients unless the
675
+ // operator explicitly allowed relay for this scope.
676
+ if (localDomains.length > 0) {
677
+ if (localDomains.indexOf(rcptDomain) === -1 &&
678
+ !_isRelayAllowed(state.remoteAddress, rcpt)) {
679
+ _emit("mail.server.mx.relay_refused",
680
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
681
+ remoteAddress: state.remoteAddress }, "denied");
682
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
683
+ return;
684
+ }
685
+ }
686
+ state.rcpts.push(rcpt);
687
+ _emit("mail.server.mx.rcpt_to",
688
+ { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
689
+ _writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
690
+ }
691
+
692
+ // ---- DATA -------------------------------------------------------------
693
+ function _handleData(state, socket) {
694
+ if (state.stage !== "rcpt" || state.rcpts.length === 0) {
695
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 No valid recipients");
696
+ return;
697
+ }
698
+ _writeReply(socket, REPLY_354_START_INPUT,
699
+ "End data with <CR><LF>.<CR><LF>");
700
+ state.stage = "data-body";
701
+ inDataBody = true;
702
+ bodyCollector = safeBuffer.boundedChunkCollector({
703
+ maxBytes: maxMessageBytes,
704
+ errorClass: MailServerMxError,
705
+ sizeCode: "mail-server-mx/body-too-large",
706
+ sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
707
+ });
708
+ }
709
+
710
+ function _finalizeDataBody(state, socket, body) {
711
+ // body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
712
+ // §4.5.2 — a single leading "." is doubled on the wire; undo.
713
+ var dedotted = safeSmtp.dotUnstuff(body);
714
+ // operator-supplied agent handoff — when wired, persist via
715
+ // agent + write the 250 reply. When not wired, accept-and-drop
716
+ // (audit-only mode useful for staging deployments).
717
+ if (opts.agent && typeof opts.agent.handoff === "function") {
718
+ opts.agent.handoff({
719
+ mailFrom: state.mailFrom,
720
+ rcpts: state.rcpts.slice(),
721
+ body: dedotted,
722
+ remote: { address: state.remoteAddress, port: state.remotePort },
723
+ tls: state.tls,
724
+ helo: state.helo,
725
+ connectionId: state.id,
726
+ }).then(function (ack) {
727
+ _emit("mail.server.mx.delivered",
728
+ { connectionId: state.id, messageId: ack && ack.messageId, sizeBytes: dedotted.length });
729
+ _writeReply(socket, REPLY_250_OK,
730
+ "2.6.0 Message accepted" + (ack && ack.messageId ? " <" + ack.messageId + ">" : ""));
731
+ _resetTransaction(state);
732
+ }).catch(function (err) {
733
+ _emit("mail.server.mx.data_refused",
734
+ { connectionId: state.id, reason: "agent-handoff-failed",
735
+ error: (err && err.message) || String(err) }, "failure");
736
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
737
+ "4.3.0 Local delivery error");
738
+ _resetTransaction(state);
739
+ });
740
+ return;
741
+ }
742
+ _emit("mail.server.mx.data_accepted",
743
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length,
744
+ sizeBytes: dedotted.length });
745
+ _writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
746
+ _resetTransaction(state);
747
+ }
748
+
749
+ function _resetTransaction(state) {
750
+ state.mailFrom = null;
751
+ state.rcpts = [];
752
+ state.stage = "ehlo";
753
+ state.messageBytes = 0;
754
+ }
755
+
756
+ function _requiresStartTls() {
757
+ // Strict / balanced require STARTTLS before MAIL FROM.
758
+ // Permissive accepts plaintext — operator-acknowledged downgrade
759
+ // for legacy infrastructure.
760
+ return profile === "strict" || profile === "balanced";
761
+ }
762
+
763
+ function _isRelayAllowed(_remoteAddress, _rcptTo) {
764
+ // Operator-supplied relayAllowedFor entries. v1 just checks
765
+ // presence in the array; CIDR/scope matching could be wired
766
+ // via b.middleware.networkAllowlist in a follow-up.
767
+ if (relayAllowedFor.length === 0) return false;
768
+ return true;
769
+ }
770
+ }
771
+
772
+ // ---- Lifecycle ----------------------------------------------------------
773
+ async function listen(listenOpts) {
774
+ listenOpts = listenOpts || {};
775
+ if (listening) {
776
+ throw new MailServerMxError("mail-server-mx/already-listening",
777
+ "listen: already listening");
778
+ }
779
+ // Port 0 (ephemeral, test mode) must NOT fall back to 25 — the
780
+ // `|| 25` short-circuit was a footgun on the test path.
781
+ var port = listenOpts.port === undefined ? 25 : listenOpts.port; // allow:raw-byte-literal — SMTP MX port (IANA)
782
+ var address = listenOpts.address || "0.0.0.0";
783
+ tcpServer = net.createServer(function (socket) {
784
+ _handleConnection(socket);
785
+ });
786
+ return new Promise(function (resolve, reject) {
787
+ tcpServer.once("error", reject);
788
+ tcpServer.listen(port, address, function () {
789
+ listening = true;
790
+ tcpServer.removeListener("error", reject);
791
+ _emit("mail.server.mx.listening", {
792
+ port: port, address: address,
793
+ });
794
+ resolve({ port: tcpServer.address().port, address: address });
795
+ });
796
+ });
797
+ }
798
+
799
+ async function close(closeOpts) {
800
+ closeOpts = closeOpts || {};
801
+ if (!listening) return;
802
+ var timeoutMs = closeOpts.timeoutMs || C.TIME.seconds(30);
803
+ listening = false;
804
+ tcpServer.close();
805
+ connections.forEach(function (sock) {
806
+ try { _writeReply(sock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server shutting down"); }
807
+ catch (_e) { /* socket already gone */ }
808
+ });
809
+ var deadline = Date.now() + timeoutMs;
810
+ while (connections.size > 0 && Date.now() < deadline) {
811
+ await safeAsync.sleep(100); // allow:raw-time-literal — close-drain poll interval (sub-second; operator-bounded by timeoutMs)
812
+ }
813
+ connections.forEach(function (sock) {
814
+ try { sock.destroy(); } catch (_e) { /* best-effort */ }
815
+ });
816
+ connections.clear();
817
+ _emit("mail.server.mx.closed", {});
818
+ }
819
+
820
+ function connectionCount() { return connections.size; }
821
+
822
+ return {
823
+ listen: listen,
824
+ close: close,
825
+ connectionCount: connectionCount,
826
+ _portForTest: function () { return tcpServer ? tcpServer.address().port : null; },
827
+ };
828
+ }
829
+
830
+ // ---- Wire-protocol helpers --------------------------------------------------
831
+
832
+ function _writeReply(socket, code, text) {
833
+ // Single-line reply per RFC 5321 §4.2 — code SP text CRLF.
834
+ try { socket.write(code + " " + text + "\r\n"); }
835
+ catch (_e) { /* socket already closed */ }
836
+ }
837
+
838
+ function _writeMultiline(socket, code, lines) {
839
+ // Multi-line reply per RFC 5321 §4.2 — code "-" text CRLF for
840
+ // continuation, code SP text CRLF for the final line.
841
+ for (var i = 0; i < lines.length; i += 1) {
842
+ var sep = i === lines.length - 1 ? " " : "-";
843
+ try { socket.write(code + sep + lines[i] + "\r\n"); }
844
+ catch (_e) { /* socket already closed */ }
845
+ }
846
+ }
847
+
848
+ function _closeConnection(socket) {
849
+ try { socket.end(); } catch (_e) { /* best-effort */ }
850
+ try { socket.destroy(); } catch (_e) { /* best-effort */ }
851
+ }
852
+
853
+ module.exports = {
854
+ create: create,
855
+ MailServerMxError: MailServerMxError,
856
+ };