@blamejs/core 0.18.54 → 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 +110 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +50 -37
- package/lib/mail-server-mx.js +115 -34
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +48 -3
- package/lib/mcp.js +11 -3
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -133,7 +133,6 @@ var safeBuffer = require("./safe-buffer");
|
|
|
133
133
|
var mailServerTls = require("./mail-server-tls");
|
|
134
134
|
var mailServerNet = require("./mail-server-net");
|
|
135
135
|
var C = require("./constants");
|
|
136
|
-
var bCrypto = require("./crypto");
|
|
137
136
|
var numericBounds = require("./numeric-bounds");
|
|
138
137
|
var validateOpts = require("./validate-opts");
|
|
139
138
|
var guardManageSieveCommand = require("./guard-managesieve-command");
|
|
@@ -172,6 +171,7 @@ var ERR_CLAMP = 200;
|
|
|
172
171
|
* greeting: string, // default "blamejs ManageSieve"
|
|
173
172
|
* maxLineBytes: number, // default 8192
|
|
174
173
|
* idleTimeoutMs: number, // default 5 min
|
|
174
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
175
175
|
* profile: "strict" | "balanced" | "permissive", // default "strict"
|
|
176
176
|
* auth: {
|
|
177
177
|
* mechanisms: ["SCRAM-SHA-256", "OAUTHBEARER", ...], // SASL mechs to advertise
|
|
@@ -212,7 +212,7 @@ function create(opts) {
|
|
|
212
212
|
"operator-supplied backend)");
|
|
213
213
|
}
|
|
214
214
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
215
|
-
["maxLineBytes", "idleTimeoutMs"],
|
|
215
|
+
["maxLineBytes", "idleTimeoutMs", "maxConnections"],
|
|
216
216
|
"mail.server.managesieve.", MailServerManageSieveError, "mail-server-managesieve/bad-bound");
|
|
217
217
|
|
|
218
218
|
var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
|
|
@@ -236,22 +236,18 @@ function create(opts) {
|
|
|
236
236
|
var _emit = auditEmit.emit;
|
|
237
237
|
|
|
238
238
|
function _handleConnection(rawSocket) {
|
|
239
|
-
var
|
|
239
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
240
|
+
rateLimit: rateLimit,
|
|
241
|
+
connections: connections,
|
|
242
|
+
emit: _emit,
|
|
240
243
|
refusedEvent: "mail.server.managesieve.rate_limit_refused",
|
|
241
244
|
refusalLine: 'NO "Too many connections from your IP"\r\n',
|
|
245
|
+
idPrefix: "msvconn-",
|
|
242
246
|
});
|
|
243
|
-
if (
|
|
244
|
-
var
|
|
245
|
-
var
|
|
246
|
-
|
|
247
|
-
// Single close handler covers BOTH operator-driven `_close(socket)`
|
|
248
|
-
// and client-initiated disconnects (TCP FIN / RST). Releases the
|
|
249
|
-
// rate-limit slot AND removes the socket from the tracking set so
|
|
250
|
-
// long-lived deployments don't accumulate stale entries.
|
|
251
|
-
rawSocket.once("close", function () {
|
|
252
|
-
rateLimit.releaseConnection(remoteAddress);
|
|
253
|
-
connections.delete(socket);
|
|
254
|
-
});
|
|
247
|
+
if (accepted === null) return;
|
|
248
|
+
var remoteAddress = accepted.remoteAddress;
|
|
249
|
+
var connectionId = accepted.connectionId;
|
|
250
|
+
var socket = accepted.socket;
|
|
255
251
|
|
|
256
252
|
var state = {
|
|
257
253
|
id: connectionId,
|
|
@@ -302,6 +298,31 @@ function create(opts) {
|
|
|
302
298
|
// AUTHENTICATE with a non-synchronizing initial-response), the next
|
|
303
299
|
// N bytes are the literal-payload; accumulate them before resuming
|
|
304
300
|
// line-mode dispatch.
|
|
301
|
+
// The CRLF that ends the LINE a literal sat on. RFC 5804 §4 puts it after
|
|
302
|
+
// the payload, outside the declared octet count, so every literal owes one
|
|
303
|
+
// and no literal's size includes it.
|
|
304
|
+
//
|
|
305
|
+
// Consumed here when it is already buffered, and otherwise awaited: it may
|
|
306
|
+
// arrive in a later segment, and read as a line it is an EMPTY line. On the
|
|
307
|
+
// SASL path an empty line is a second response and the pipelining guard
|
|
308
|
+
// fails the exchange for it; on the PUTSCRIPT path it draws a spurious
|
|
309
|
+
// "empty command line" refusal AFTER the script was accepted, so one command
|
|
310
|
+
// is answered twice and a client that reads one reply per command spends the
|
|
311
|
+
// rest of the session attributing each answer to the command before it.
|
|
312
|
+
//
|
|
313
|
+
// A peek alone is not enough, because whether the terminator has arrived yet
|
|
314
|
+
// is a property of the network rather than of the protocol. Shared by both
|
|
315
|
+
// literal paths: one of them used to do this and the other did not, which is
|
|
316
|
+
// the entire defect.
|
|
317
|
+
function _consumeLiteralTerminator(state) {
|
|
318
|
+
if (state.lineBuffer.length >= 2 &&
|
|
319
|
+
state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
|
|
320
|
+
state.lineBuffer = state.lineBuffer.subarray(2);
|
|
321
|
+
} else {
|
|
322
|
+
state.awaitingLiteralTerminator = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
305
326
|
function _drainBuffer(state, socket) {
|
|
306
327
|
while (true) {
|
|
307
328
|
if (state.pendingLiteral) {
|
|
@@ -314,6 +335,7 @@ function create(opts) {
|
|
|
314
335
|
}
|
|
315
336
|
pl.body = Buffer.concat([pl.body, state.lineBuffer.subarray(0, need)]);
|
|
316
337
|
state.lineBuffer = state.lineBuffer.subarray(need);
|
|
338
|
+
_consumeLiteralTerminator(state);
|
|
317
339
|
state.pendingLiteral = null;
|
|
318
340
|
_completePutscript(state, socket, pl);
|
|
319
341
|
if (state.stage === "closed") return;
|
|
@@ -329,26 +351,7 @@ function create(opts) {
|
|
|
329
351
|
}
|
|
330
352
|
pa.irBody = Buffer.concat([pa.irBody, state.lineBuffer.subarray(0, needA)]);
|
|
331
353
|
state.lineBuffer = state.lineBuffer.subarray(needA);
|
|
332
|
-
|
|
333
|
-
// buffer, so the next pass read it as an empty LINE — harmless while
|
|
334
|
-
// nothing was in flight, and not harmless during a multi-step SASL
|
|
335
|
-
// exchange, where an empty line is a second response and the pipelining
|
|
336
|
-
// guard fails the exchange for it. The bug only appeared when the two
|
|
337
|
-
// writes arrived as separate TCP segments, which is why the host run
|
|
338
|
-
// was green and the container run was not.
|
|
339
|
-
// The CRLF that ends the line the literal sat on. Consumed here when it
|
|
340
|
-
// is already buffered, and otherwise awaited: it may arrive in a later
|
|
341
|
-
// segment, and read as a line it is an EMPTY line. That was harmless
|
|
342
|
-
// while nothing was in flight, and during a multi-step SASL exchange it
|
|
343
|
-
// is a second response — the pipelining guard fails the exchange for
|
|
344
|
-
// it. A peek alone is not enough, because whether the terminator has
|
|
345
|
-
// arrived yet is a property of the network, not of the protocol.
|
|
346
|
-
if (state.lineBuffer.length >= 2 &&
|
|
347
|
-
state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
|
|
348
|
-
state.lineBuffer = state.lineBuffer.subarray(2);
|
|
349
|
-
} else {
|
|
350
|
-
state.awaitingLiteralTerminator = true;
|
|
351
|
-
}
|
|
354
|
+
_consumeLiteralTerminator(state);
|
|
352
355
|
pa.irBytes = null;
|
|
353
356
|
_completeAuthenticate(state, socket);
|
|
354
357
|
if (state.stage === "closed") return;
|
|
@@ -625,10 +628,19 @@ function create(opts) {
|
|
|
625
628
|
mech: mech,
|
|
626
629
|
irBytes: parsed.literalBytes,
|
|
627
630
|
irPlus: parsed.literalPlus,
|
|
628
|
-
|
|
631
|
+
// RFC 5804 §2.1 lets the initial response come inline as a quoted string
|
|
632
|
+
// instead of a literal. It arrives complete on this line, so there is
|
|
633
|
+
// nothing to wait for — the exchange runs now, with the response the
|
|
634
|
+
// client actually sent. This used to fall through to the no-initial-
|
|
635
|
+
// response branch and answer a conforming client as though it had said
|
|
636
|
+
// nothing.
|
|
637
|
+
irBody: parsed.initialResponse === null || parsed.initialResponse === undefined
|
|
638
|
+
? Buffer.alloc(0)
|
|
639
|
+
: Buffer.from(parsed.initialResponse, "utf8"),
|
|
629
640
|
};
|
|
630
641
|
if (parsed.literalBytes === null) {
|
|
631
|
-
//
|
|
642
|
+
// Either an inline initial response, complete above, or none at all —
|
|
643
|
+
// in which case verify is called with an empty client response.
|
|
632
644
|
_completeAuthenticate(state, socket);
|
|
633
645
|
return;
|
|
634
646
|
}
|
|
@@ -987,6 +999,7 @@ function create(opts) {
|
|
|
987
999
|
// ---- Lifecycle ----------------------------------------------------------
|
|
988
1000
|
return mailServerNet.createStoreServer(net, {
|
|
989
1001
|
defaultPort: DEFAULT_PORT,
|
|
1002
|
+
maxConnections: opts.maxConnections,
|
|
990
1003
|
handleConnection: _handleConnection,
|
|
991
1004
|
errorClass: MailServerManageSieveError,
|
|
992
1005
|
errorCodePrefix: "mail-server-managesieve/",
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -158,7 +158,6 @@
|
|
|
158
158
|
var net = require("node:net");
|
|
159
159
|
var lazyRequire = require("./lazy-require");
|
|
160
160
|
var C = require("./constants");
|
|
161
|
-
var bCrypto = require("./crypto");
|
|
162
161
|
var numericBounds = require("./numeric-bounds");
|
|
163
162
|
var safeAsync = require("./safe-async");
|
|
164
163
|
var safeBuffer = require("./safe-buffer");
|
|
@@ -328,6 +327,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
|
|
|
328
327
|
* maxMessageBytes: number, // default 50 MiB — DATA body cap
|
|
329
328
|
* maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
|
|
330
329
|
* idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
|
|
330
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
331
331
|
* profile: "strict" | "balanced" | "permissive", // gate posture cascade
|
|
332
332
|
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
|
|
333
333
|
* mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
|
|
@@ -365,12 +365,20 @@ function create(opts) {
|
|
|
365
365
|
"output directly. Cert provisioning lives in b.acme (RFC 8555 + RFC 9773 ARI).");
|
|
366
366
|
}
|
|
367
367
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
368
|
-
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
368
|
+
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
|
|
369
369
|
"mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
|
|
370
|
-
|
|
371
|
-
|
|
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)) {
|
|
372
379
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
373
|
-
"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)");
|
|
374
382
|
}
|
|
375
383
|
if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
|
|
376
384
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
@@ -559,15 +567,17 @@ function create(opts) {
|
|
|
559
567
|
function _handleConnection(socket) {
|
|
560
568
|
// 421 4.7.0 — transient refusal; sender retries elsewhere or later.
|
|
561
569
|
// RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
|
|
562
|
-
var
|
|
570
|
+
var accepted = mailServerNet.acceptConnection(socket, {
|
|
571
|
+
rateLimit: rateLimit,
|
|
572
|
+
connections: connections,
|
|
573
|
+
emit: _emit,
|
|
563
574
|
refusedEvent: "mail.server.mx.rate_limit_refused",
|
|
564
575
|
refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
|
|
576
|
+
idPrefix: "mxconn-",
|
|
565
577
|
});
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
var connectionId = "mxconn-" + bCrypto.generateToken(8); // connection-id length
|
|
570
|
-
connections.add(socket);
|
|
578
|
+
if (accepted === null) return;
|
|
579
|
+
var remoteAddress = accepted.remoteAddress;
|
|
580
|
+
var connectionId = accepted.connectionId;
|
|
571
581
|
|
|
572
582
|
// Backpressure observer — `_writeReply` flips `_bpEmitted` after
|
|
573
583
|
// the first audit emission per socket to bound the audit volume.
|
|
@@ -597,6 +607,15 @@ function create(opts) {
|
|
|
597
607
|
// message. Decode to string only for the per-command line parse.
|
|
598
608
|
var lineBuffer = Buffer.alloc(0);
|
|
599
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;
|
|
600
619
|
var inDataBody = false;
|
|
601
620
|
// Async command pump: gates (HELO / RBL / greylist / envelope /
|
|
602
621
|
// DMARC) may await DNS or a store, so command handling is async.
|
|
@@ -621,10 +640,9 @@ function create(opts) {
|
|
|
621
640
|
_closeConnection(socket);
|
|
622
641
|
});
|
|
623
642
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
});
|
|
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; });
|
|
628
646
|
|
|
629
647
|
_emit("mail.server.mx.connect", {
|
|
630
648
|
connectionId: state.id,
|
|
@@ -647,6 +665,12 @@ function create(opts) {
|
|
|
647
665
|
// 421 path. `activeSock` is whichever socket is current (plaintext or
|
|
648
666
|
// TLS) so the 421/close lands on the right transport.
|
|
649
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;
|
|
650
674
|
pumpChain = pumpChain.then(function () {
|
|
651
675
|
if (connClosed) return undefined;
|
|
652
676
|
return _ingestBytes(state, activeSock, chunk);
|
|
@@ -665,6 +689,27 @@ function create(opts) {
|
|
|
665
689
|
|
|
666
690
|
// ---- Byte-level ingestion --------------------------------------------
|
|
667
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
|
+
}
|
|
668
713
|
if (inDataBody) {
|
|
669
714
|
// DATA body — accumulate via boundedChunkCollector, watch for
|
|
670
715
|
// canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
|
|
@@ -679,13 +724,25 @@ function create(opts) {
|
|
|
679
724
|
_resetTransaction(state);
|
|
680
725
|
inDataBody = false;
|
|
681
726
|
bodyCollector = null;
|
|
727
|
+
bodyScanner = null;
|
|
682
728
|
return;
|
|
683
729
|
}
|
|
684
|
-
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
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) {
|
|
689
746
|
_emit("mail.server.mx.smtp_smuggling_detected",
|
|
690
747
|
{ connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
|
|
691
748
|
"denied");
|
|
@@ -694,14 +751,15 @@ function create(opts) {
|
|
|
694
751
|
_resetTransaction(state);
|
|
695
752
|
inDataBody = false;
|
|
696
753
|
bodyCollector = null;
|
|
754
|
+
bodyScanner = null;
|
|
697
755
|
return;
|
|
698
756
|
}
|
|
699
757
|
// Canonical \r\n.\r\n terminator?
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
var body = collected.subarray(0, endIdx);
|
|
758
|
+
if (seen.terminatorAt !== -1) {
|
|
759
|
+
var body = bodyCollector.result().subarray(0, seen.terminatorAt);
|
|
703
760
|
inDataBody = false;
|
|
704
761
|
bodyCollector = null;
|
|
762
|
+
bodyScanner = null;
|
|
705
763
|
await _finalizeDataBody(state, socket, body);
|
|
706
764
|
}
|
|
707
765
|
return;
|
|
@@ -868,6 +926,7 @@ function create(opts) {
|
|
|
868
926
|
// shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
|
|
869
927
|
lineBuffer = Buffer.alloc(0);
|
|
870
928
|
bodyCollector = null;
|
|
929
|
+
bodyScanner = null;
|
|
871
930
|
inDataBody = false;
|
|
872
931
|
mailServerTls.upgradeSocket({
|
|
873
932
|
plainSocket: socket,
|
|
@@ -1003,17 +1062,21 @@ function create(opts) {
|
|
|
1003
1062
|
}
|
|
1004
1063
|
// Local-domain check — refuse non-local recipients unless the
|
|
1005
1064
|
// operator explicitly allowed relay for this scope.
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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;
|
|
1017
1080
|
}
|
|
1018
1081
|
// RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
|
|
1019
1082
|
// IP. The verdict is per-connection, so it's evaluated once and
|
|
@@ -1131,6 +1194,8 @@ function create(opts) {
|
|
|
1131
1194
|
sizeCode: "mail-server-mx/body-too-large",
|
|
1132
1195
|
sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
|
|
1133
1196
|
});
|
|
1197
|
+
bodyScanner = safeSmtp.createBodyScanner();
|
|
1198
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1134
1199
|
}
|
|
1135
1200
|
|
|
1136
1201
|
async function _finalizeDataBody(state, socket, body) {
|
|
@@ -1215,6 +1280,11 @@ function create(opts) {
|
|
|
1215
1280
|
dkim: dkimSummary,
|
|
1216
1281
|
dmarc: inboundVerdict.dmarc.result,
|
|
1217
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),
|
|
1218
1288
|
action: envAction,
|
|
1219
1289
|
mode: envelopeGate.mode,
|
|
1220
1290
|
}, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
|
|
@@ -1257,6 +1327,16 @@ function create(opts) {
|
|
|
1257
1327
|
spf: inboundVerdict.spf,
|
|
1258
1328
|
dkim: inboundVerdict.dkim,
|
|
1259
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,
|
|
1260
1340
|
from: inboundVerdict.from,
|
|
1261
1341
|
action: envAction,
|
|
1262
1342
|
mode: envelopeGate.mode,
|
|
@@ -1370,6 +1450,7 @@ function create(opts) {
|
|
|
1370
1450
|
// explicit 0 (only an OMITTED port falls back to the default).
|
|
1371
1451
|
var _tcpListener = mailServerNet.createTcpListener(net, {
|
|
1372
1452
|
defaultPort: 25, // SMTP MX port (IANA)
|
|
1453
|
+
maxConnections: opts.maxConnections,
|
|
1373
1454
|
handleConnection: _handleConnection,
|
|
1374
1455
|
errorFactory: function (code, message) { return new MailServerMxError("mail-server-mx/" + code, message); },
|
|
1375
1456
|
emit: _emit,
|
package/lib/mail-server-net.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
5
|
var codepointClass = require("./codepoint-class");
|
|
6
|
+
var bCrypto = require("./crypto");
|
|
6
7
|
|
|
7
8
|
// mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
|
|
8
9
|
// servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
|
|
@@ -16,6 +17,14 @@ var codepointClass = require("./codepoint-class");
|
|
|
16
17
|
// EACCES) rejects the listen promise instead of crashing the process. That, plus
|
|
17
18
|
// the listening/server state, is what createTcpListener owns.
|
|
18
19
|
|
|
20
|
+
// The listener's own ceiling when an operator names none. Every mail listener
|
|
21
|
+
// shares it, so a deployment that raises or lowers it does so in one place and
|
|
22
|
+
// the five listeners cannot drift apart on what "too many" means. Set well
|
|
23
|
+
// above any single-host mail deployment's working set and far below what an
|
|
24
|
+
// unbounded accept loop will take: the point is that a ceiling EXISTS, since
|
|
25
|
+
// the per-address cap alone leaves the total at (cap x source addresses).
|
|
26
|
+
var DEFAULT_MAX_CONNECTIONS = 1024;
|
|
27
|
+
|
|
19
28
|
// createTcpListener(net, cfg) — build a listener lifecycle.
|
|
20
29
|
// cfg.defaultPort port used when listenOpts.port is omitted (an explicit
|
|
21
30
|
// 0 is honored, for an ephemeral test bind).
|
|
@@ -26,6 +35,15 @@ var codepointClass = require("./codepoint-class");
|
|
|
26
35
|
// cfg.listeningEvent the "...listening" audit action.
|
|
27
36
|
// cfg.listeningExtra optional () => object merged onto the listening event
|
|
28
37
|
// payload (Submission reports implicitTls).
|
|
38
|
+
// cfg.maxConnections the listener's own ceiling on concurrently accepted
|
|
39
|
+
// sockets. The per-address cap in b.mail.server.rateLimit
|
|
40
|
+
// bounds ONE peer; it says nothing about how many peers
|
|
41
|
+
// there are, so the process-wide total was the per-address
|
|
42
|
+
// cap times however many source addresses the caller could
|
|
43
|
+
// speak from — a number a botnet, a NAT pool or a single
|
|
44
|
+
// v6 /64 makes large. Enforced by the runtime, which closes
|
|
45
|
+
// the excess socket before handleConnection ever sees it,
|
|
46
|
+
// so a refusal costs no descriptor and no state machine.
|
|
29
47
|
// Returns { listen, getServer, isListening, markClosed } — the server wires its
|
|
30
48
|
// own close() through getServer()/isListening()/markClosed().
|
|
31
49
|
function createTcpListener(net, cfg) {
|
|
@@ -40,6 +58,7 @@ function createTcpListener(net, cfg) {
|
|
|
40
58
|
var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
|
|
41
59
|
var address = listenOpts.address || "0.0.0.0";
|
|
42
60
|
server = net.createServer(function (socket) { cfg.handleConnection(socket); });
|
|
61
|
+
server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
|
|
43
62
|
return new Promise(function (resolve, reject) {
|
|
44
63
|
server.once("error", reject);
|
|
45
64
|
server.listen(port, address, function () {
|
|
@@ -110,6 +129,7 @@ function createStoreServer(net, cfg) {
|
|
|
110
129
|
var ErrorClass = cfg.errorClass;
|
|
111
130
|
var listener = createTcpListener(net, {
|
|
112
131
|
defaultPort: cfg.defaultPort,
|
|
132
|
+
maxConnections: cfg.maxConnections,
|
|
113
133
|
handleConnection: cfg.handleConnection,
|
|
114
134
|
errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
|
|
115
135
|
emit: cfg.emit,
|
|
@@ -122,7 +142,137 @@ function createStoreServer(net, cfg) {
|
|
|
122
142
|
closedEvent: cfg.eventBase + ".closed",
|
|
123
143
|
});
|
|
124
144
|
}
|
|
125
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
listen: listener.listen,
|
|
147
|
+
close: close,
|
|
148
|
+
connectionCount: function () { return cfg.connections.size; },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// trackConnection(socket, cfg) — the other half of admitConnection, and the
|
|
153
|
+
// reason it is here rather than written out per listener.
|
|
154
|
+
//
|
|
155
|
+
// A connection occupies two ledgers: the rate limiter's per-address count, and
|
|
156
|
+
// the listener's live-socket set that shutdown drains. Both are released by the
|
|
157
|
+
// SAME event — the socket closing — and it does not matter who closed it. Five
|
|
158
|
+
// listeners each wrote that pairing by hand and one of them registered only the
|
|
159
|
+
// release, so a peer that opened a connection, took the greeting and dropped TCP
|
|
160
|
+
// freed its rate-limit slot (and could reconnect at once) while its set entry
|
|
161
|
+
// stayed forever. Nothing authenticates before that point, so the growth was
|
|
162
|
+
// unauthenticated and unbounded.
|
|
163
|
+
//
|
|
164
|
+
// Registering both in one handler is what makes the pair impossible to
|
|
165
|
+
// half-write.
|
|
166
|
+
// cfg.connections the listener's live-socket Set.
|
|
167
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
168
|
+
// cfg.remoteAddress the address admitConnection returned.
|
|
169
|
+
// cfg.closeSource optional socket whose "close" drives teardown, when the
|
|
170
|
+
// tracked socket is a wrapper (Submission's implicit-TLS
|
|
171
|
+
// path tracks the TLSSocket but the raw socket is the one
|
|
172
|
+
// that carries the FIN).
|
|
173
|
+
function trackConnection(socket, cfg) {
|
|
174
|
+
cfg.connections.add(socket);
|
|
175
|
+
var source = cfg.closeSource || socket;
|
|
176
|
+
source.once("close", function () {
|
|
177
|
+
cfg.rateLimit.releaseConnection(cfg.remoteAddress);
|
|
178
|
+
cfg.connections.delete(socket);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
|
|
183
|
+
// windows instead of the whole transfer.
|
|
184
|
+
//
|
|
185
|
+
// A lifetime average lets an early burst pay for an arbitrarily slow tail: at
|
|
186
|
+
// the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
|
|
187
|
+
// six, which a peer spends holding a connection and its slot in the per-address
|
|
188
|
+
// cap while sending a byte at a time. The floor was enforced and still
|
|
189
|
+
// bypassable, which is the worse of the two states — it reads as covered.
|
|
190
|
+
//
|
|
191
|
+
// Each window has to meet the floor on its own, so nothing sent earlier pays
|
|
192
|
+
// for what is sent now. A window only rolls forward once it has run long enough
|
|
193
|
+
// to be a real judgement; inside that stretch no verdict is reached, so a
|
|
194
|
+
// sender pausing to read from its own spool is not cut off for the pause, and a
|
|
195
|
+
// fast chunk cannot keep resetting the clock to dodge the next measurement.
|
|
196
|
+
//
|
|
197
|
+
// `now` is a parameter rather than a call to the clock so the whole thing can
|
|
198
|
+
// be driven across days of simulated time without waiting for them.
|
|
199
|
+
// rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
|
|
200
|
+
function createBodyRateWindow(rateLimit) {
|
|
201
|
+
var windowStart = 0;
|
|
202
|
+
var windowBytes = 0;
|
|
203
|
+
return {
|
|
204
|
+
// Open the first window. `bytesSeen` is the caller's running total AT THIS
|
|
205
|
+
// MOMENT, which becomes the baseline every later reading is measured
|
|
206
|
+
// against — so the caller is free to keep one connection-lifetime counter
|
|
207
|
+
// incremented at the wire boundary rather than a per-transfer one. That
|
|
208
|
+
// matters where a listener re-feeds part of a chunk through its own parser:
|
|
209
|
+
// a counter maintained inside the parser credits those bytes twice, and a
|
|
210
|
+
// peer that pipelines a one-byte payload with the next command gets roughly
|
|
211
|
+
// double the rate it is actually sending.
|
|
212
|
+
start: function (now, bytesSeen) {
|
|
213
|
+
windowStart = now;
|
|
214
|
+
windowBytes = bytesSeen || 0;
|
|
215
|
+
},
|
|
216
|
+
// `bytesSeen` is the running total for the whole body; the window's own
|
|
217
|
+
// count is derived, so the caller keeps one counter rather than two.
|
|
218
|
+
starved: function (bytesSeen, now) {
|
|
219
|
+
var elapsed = now - windowStart;
|
|
220
|
+
if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
|
|
221
|
+
// The limiter says how long a window must run before rolling, because the
|
|
222
|
+
// limiter is what decides when a measurement is old enough to mean
|
|
223
|
+
// something. Rolling on a number held HERE would ask a limiter that
|
|
224
|
+
// judges over a longer stretch only ever before it can answer: every call
|
|
225
|
+
// returning "too early", the window resetting underneath it, and its rate
|
|
226
|
+
// protection silently disabled while looking wired.
|
|
227
|
+
if (elapsed >= rateLimit.bodyRateWindowMs()) {
|
|
228
|
+
windowStart = now;
|
|
229
|
+
windowBytes = bytesSeen;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
|
|
237
|
+
// accept and its own state machine: gate the address, mint the connection id,
|
|
238
|
+
// wrap the socket if the protocol starts in TLS, and enter both ledgers.
|
|
239
|
+
//
|
|
240
|
+
// Every listener performed these four steps in the same order, and the parts
|
|
241
|
+
// that differ between them are three strings and an optional wrap. Keeping the
|
|
242
|
+
// order in one place is what stops a fifth listener from being written with one
|
|
243
|
+
// of the steps missing, which is how the tracking-set entry and the rate-limit
|
|
244
|
+
// slot came apart in the first place.
|
|
245
|
+
//
|
|
246
|
+
// Returns null when the address was refused — the caller returns immediately;
|
|
247
|
+
// the refusal line and teardown are already done.
|
|
248
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
249
|
+
// cfg.connections the listener's live-socket Set.
|
|
250
|
+
// cfg.emit the listener's audit emitter.
|
|
251
|
+
// cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
|
|
252
|
+
// cfg.refusalLine the protocol's refusal bytes.
|
|
253
|
+
// cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
|
|
254
|
+
// cfg.wrap optional (rawSocket) => socket, for a protocol that
|
|
255
|
+
// starts inside TLS. The RAW socket still drives teardown:
|
|
256
|
+
// a handshake that never completes closes it without the
|
|
257
|
+
// wrapper ever being established.
|
|
258
|
+
function acceptConnection(rawSocket, cfg) {
|
|
259
|
+
var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
|
|
260
|
+
refusedEvent: cfg.refusedEvent,
|
|
261
|
+
refusalLine: cfg.refusalLine,
|
|
262
|
+
});
|
|
263
|
+
if (remoteAddress === null) return null;
|
|
264
|
+
var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
|
|
265
|
+
trackConnection(socket, {
|
|
266
|
+
connections: cfg.connections,
|
|
267
|
+
rateLimit: cfg.rateLimit,
|
|
268
|
+
remoteAddress: remoteAddress,
|
|
269
|
+
closeSource: socket === rawSocket ? null : rawSocket,
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
socket: socket,
|
|
273
|
+
remoteAddress: remoteAddress,
|
|
274
|
+
connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
|
|
275
|
+
};
|
|
126
276
|
}
|
|
127
277
|
|
|
128
278
|
// admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
|
|
@@ -299,6 +449,10 @@ module.exports = {
|
|
|
299
449
|
runSaslStep: runSaslStep,
|
|
300
450
|
createStoreServer: createStoreServer,
|
|
301
451
|
admitConnection: admitConnection,
|
|
452
|
+
trackConnection: trackConnection,
|
|
453
|
+
acceptConnection: acceptConnection,
|
|
454
|
+
createBodyRateWindow: createBodyRateWindow,
|
|
455
|
+
DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
|
|
302
456
|
validateDomainHardened: validateDomainHardened,
|
|
303
457
|
saslChallengeOrNull: saslChallengeOrNull,
|
|
304
458
|
replyTextOrFallback: replyTextOrFallback,
|