@blamejs/core 0.18.54 → 0.18.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -0
- package/NOTICE +5 -5
- package/README.md +9 -9
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/audit.js +26 -24
- package/lib/auth/passkey.js +4 -1
- package/lib/chain-writer.js +17 -0
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +65 -42
- package/lib/mail-server-mx.js +313 -40
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +71 -11
- package/lib/mcp.js +11 -3
- package/lib/middleware/csrf-protect.js +37 -20
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/session-stores.js +6 -3
- package/lib/vendor/MANIFEST.json +34 -34
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/vendor/browser/noble-ciphers.mjs +15 -1
- package/lib/vendor/browser/noble-hashes.mjs +12 -4
- package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
- package/lib/vendor/noble-ciphers.cjs +15 -1
- package/lib/vendor/noble-curves.cjs +46 -21
- package/lib/vendor/noble-post-quantum.cjs +184 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -106,7 +106,6 @@
|
|
|
106
106
|
var net = require("node:net");
|
|
107
107
|
var nodeTls = require("node:tls");
|
|
108
108
|
var C = require("./constants");
|
|
109
|
-
var bCrypto = require("./crypto");
|
|
110
109
|
var numericBounds = require("./numeric-bounds");
|
|
111
110
|
var safeAsync = require("./safe-async");
|
|
112
111
|
var safeBuffer = require("./safe-buffer");
|
|
@@ -251,6 +250,7 @@ function _actorDomain(actor, mailFrom) {
|
|
|
251
250
|
* maxMessageBytes: number, // default 50 MiB
|
|
252
251
|
* maxRcptsPerMessage: number, // default 100
|
|
253
252
|
* idleTimeoutMs: number, // default 5 minutes
|
|
253
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
254
254
|
* profile: string, // "strict" | "balanced" | "permissive"; default "strict"
|
|
255
255
|
*
|
|
256
256
|
* @example
|
|
@@ -290,7 +290,7 @@ function create(opts) {
|
|
|
290
290
|
"create: opts.tenantScope requires opts.agentTenantId");
|
|
291
291
|
}
|
|
292
292
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
293
|
-
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
293
|
+
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
|
|
294
294
|
"mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
|
|
295
295
|
|
|
296
296
|
var profile = opts.profile || "strict";
|
|
@@ -384,21 +384,26 @@ function create(opts) {
|
|
|
384
384
|
|
|
385
385
|
function _handleConnection(rawSocket) {
|
|
386
386
|
// 421 4.7.0 — transient; sender retries elsewhere.
|
|
387
|
-
var
|
|
387
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
388
|
+
rateLimit: rateLimit,
|
|
389
|
+
connections: connections,
|
|
390
|
+
emit: _emit,
|
|
388
391
|
refusedEvent: "mail.server.submission.rate_limit_refused",
|
|
389
392
|
refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
|
|
390
|
-
|
|
391
|
-
if (remoteAddress === null) return;
|
|
392
|
-
rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
|
|
393
|
-
|
|
394
|
-
var connectionId = "submitconn-" + bCrypto.generateToken(8); // connection-id length
|
|
395
|
-
var socket = implicitTls
|
|
393
|
+
idPrefix: "submitconn-",
|
|
396
394
|
// Certificate compression is configured on the secure CONTEXT by
|
|
397
395
|
// b.mail.server.tls.context — a TLSSocket wrapping a pre-built context
|
|
398
396
|
// ignores the option, so setting it here would be inert.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
wrap: implicitTls
|
|
398
|
+
? function (raw) {
|
|
399
|
+
return new nodeTls.TLSSocket(raw, { isServer: true, secureContext: opts.tlsContext });
|
|
400
|
+
}
|
|
401
|
+
: null,
|
|
402
|
+
});
|
|
403
|
+
if (accepted === null) return;
|
|
404
|
+
var remoteAddress = accepted.remoteAddress;
|
|
405
|
+
var connectionId = accepted.connectionId;
|
|
406
|
+
var socket = accepted.socket;
|
|
402
407
|
|
|
403
408
|
var state = {
|
|
404
409
|
id: connectionId,
|
|
@@ -424,6 +429,13 @@ function create(opts) {
|
|
|
424
429
|
// string only for the per-command parse.
|
|
425
430
|
var lineBuffer = Buffer.alloc(0);
|
|
426
431
|
var bodyCollector = null;
|
|
432
|
+
// Watches the DATA body for its terminator and the smuggling shape as bytes
|
|
433
|
+
// arrive, so neither screen re-reads what it has already seen. Lives exactly
|
|
434
|
+
// as long as bodyCollector.
|
|
435
|
+
var bodyScanner = null;
|
|
436
|
+
// The slow-loris byte-rate floor, measured over bounded windows so an early
|
|
437
|
+
// burst cannot buy credit for a slow tail.
|
|
438
|
+
var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
|
|
427
439
|
var inDataBody = false;
|
|
428
440
|
// RFC 3030 CHUNKING — state for the BDAT command. `bdatCollector`
|
|
429
441
|
// accumulates the message body across multiple BDAT chunks; it lives
|
|
@@ -432,6 +444,15 @@ function create(opts) {
|
|
|
432
444
|
// bytes still owed by the current BDAT chunk; `bdatIsLast` flags
|
|
433
445
|
// whether the current chunk is the terminator.
|
|
434
446
|
var inBdatChunk = false;
|
|
447
|
+
// Whether the rate window has been opened for the BDAT sequence in flight.
|
|
448
|
+
// Explicit rather than inferred from the byte count, because a sequence of
|
|
449
|
+
// zero-length chunks leaves that count at zero.
|
|
450
|
+
var bdatRateStarted = false;
|
|
451
|
+
// Every byte this connection has received, counted once at the wire. The
|
|
452
|
+
// rate window takes its baseline from this rather than from a per-transfer
|
|
453
|
+
// counter, so nothing the parser does downstream — including re-feeding a
|
|
454
|
+
// chunk's tail through itself — can credit a byte twice.
|
|
455
|
+
var wireBytes = 0;
|
|
435
456
|
var bdatRemaining = 0;
|
|
436
457
|
var bdatIsLast = false;
|
|
437
458
|
var bdatCollector = null;
|
|
@@ -447,7 +468,6 @@ function create(opts) {
|
|
|
447
468
|
{ connectionId: state.id, code: (err && err.code) || "unknown" }, "warning");
|
|
448
469
|
_closeConnection(socket);
|
|
449
470
|
});
|
|
450
|
-
socket.on("close", function () { connections.delete(socket); });
|
|
451
471
|
|
|
452
472
|
_emit("mail.server.submission.connect", {
|
|
453
473
|
connectionId: state.id,
|
|
@@ -458,18 +478,67 @@ function create(opts) {
|
|
|
458
478
|
|
|
459
479
|
_writeReply(socket, REPLY_220_READY, greeting + " ready");
|
|
460
480
|
|
|
461
|
-
|
|
462
|
-
|
|
481
|
+
// The one funnel both transports feed. Counted HERE and nowhere else:
|
|
482
|
+
// _ingestBytes re-feeds the tail of a chunk through itself when a BDAT
|
|
483
|
+
// payload and the next command arrive in one packet, so a counter kept
|
|
484
|
+
// inside it credits those bytes twice — a peer pipelining a one-byte
|
|
485
|
+
// payload with its next command would draw roughly double the rate it was
|
|
486
|
+
// really sending.
|
|
487
|
+
//
|
|
488
|
+
// `activeSock` is whichever socket is current, because STARTTLS replaces
|
|
489
|
+
// it. Counting on the plaintext `data` listener alone stopped counting the
|
|
490
|
+
// moment a connection upgraded, which is the moment it becomes the shape
|
|
491
|
+
// operators actually deploy: the reading froze, and after the grace period
|
|
492
|
+
// a client sending well above the floor was judged to have sent nothing.
|
|
493
|
+
function _feedChunk(activeSock, chunk) {
|
|
494
|
+
wireBytes += chunk.length;
|
|
495
|
+
try { _ingestBytes(state, activeSock, chunk); }
|
|
463
496
|
catch (err) {
|
|
464
497
|
_emit("mail.server.submission.handler_threw",
|
|
465
498
|
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
466
|
-
try { _writeReply(
|
|
499
|
+
try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
|
|
467
500
|
catch (_e) { /* socket already gone */ }
|
|
468
|
-
_closeConnection(
|
|
501
|
+
_closeConnection(activeSock);
|
|
469
502
|
}
|
|
470
|
-
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
socket.on("data", function (chunk) { _feedChunk(socket, chunk); });
|
|
471
506
|
|
|
472
507
|
function _ingestBytes(state, socket, chunk) {
|
|
508
|
+
// The body-rate floor is enforced HERE, on every inbound byte, rather
|
|
509
|
+
// than inside the DATA and BDAT handlers.
|
|
510
|
+
//
|
|
511
|
+
// A check reached only from a body handler is one the peer chooses
|
|
512
|
+
// whether to reach. Enforced on the DATA path, a client used BDAT; moved
|
|
513
|
+
// onto BDAT payloads, a client used `BDAT 0`; keyed off the byte count, a
|
|
514
|
+
// client interleaved NOOP, which resets the socket idle timer without
|
|
515
|
+
// ever passing through a body handler. Each fix closed the path it was
|
|
516
|
+
// written for and left the next one open, because the peer picks the
|
|
517
|
+
// command stream.
|
|
518
|
+
//
|
|
519
|
+
// What the peer CANNOT do is hold the connection without sending bytes:
|
|
520
|
+
// stop sending and idleTimeoutMs cuts it. So every byte is measured, from
|
|
521
|
+
// the moment a body transfer opens until it closes, whatever the bytes
|
|
522
|
+
// happen to spell. No timer, and nothing to leak on teardown.
|
|
523
|
+
if (bdatRateStarted || inDataBody) {
|
|
524
|
+
// Read, never incremented: the wire handler owns the counter. The
|
|
525
|
+
// window measures against the baseline it took when the transfer
|
|
526
|
+
// opened, so this is "every byte since then", command bytes included.
|
|
527
|
+
// Counting only BODY bytes made the number go flat — and, with an
|
|
528
|
+
// interleaved command, backwards — across a window roll, which reads as
|
|
529
|
+
// no progress and refuses a client that is in fact sending steadily.
|
|
530
|
+
if (bodyRateWindow.starved(wireBytes, Date.now())) {
|
|
531
|
+
_emit("mail.server.submission.data_refused",
|
|
532
|
+
{ connectionId: state.id, reason: "body-rate-below-floor",
|
|
533
|
+
minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
|
|
534
|
+
_writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
|
|
535
|
+
"4.7.0 Message body arriving below the minimum rate; closing connection");
|
|
536
|
+
_resetTransaction(state);
|
|
537
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
538
|
+
_closeConnection(socket);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
473
542
|
// RFC 3030 — when a BDAT chunk is in progress we consume exactly
|
|
474
543
|
// `bdatRemaining` bytes off the wire, no dot-stuffing, no end-of-
|
|
475
544
|
// data marker. Any excess bytes in the chunk after the BDAT
|
|
@@ -507,6 +576,7 @@ function create(opts) {
|
|
|
507
576
|
var bdatBody = bdatCollector.result();
|
|
508
577
|
bdatCollector = null;
|
|
509
578
|
bdatTotalBytes = 0;
|
|
579
|
+
if (_refuseSmuggledBdatBody(state, socket, bdatBody)) return;
|
|
510
580
|
_finalizeAcceptedBody(state, socket, bdatBody, "BDAT");
|
|
511
581
|
} else {
|
|
512
582
|
// Non-final chunk — per-chunk acknowledgement only.
|
|
@@ -530,27 +600,37 @@ function create(opts) {
|
|
|
530
600
|
_writeReply(socket, REPLY_552_SIZE_EXCEEDED,
|
|
531
601
|
"5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
|
|
532
602
|
_resetTransaction(state);
|
|
533
|
-
inDataBody = false; bodyCollector = null;
|
|
603
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
534
604
|
return;
|
|
535
605
|
}
|
|
536
|
-
|
|
537
|
-
|
|
606
|
+
// Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
|
|
607
|
+
// Re-deriving the whole accumulated body per chunk (`result()` is a
|
|
608
|
+
// fresh concat of everything received) and scanning it twice made
|
|
609
|
+
// acceptance quadratic in the message size: the byte cap still held,
|
|
610
|
+
// but a message inside the cap cost 4949 ms at 8 MiB against 143 ms at
|
|
611
|
+
// 1 MiB. `result()` is now called ONCE, when the terminator is found.
|
|
612
|
+
//
|
|
613
|
+
// The slow-loris floor is NOT applied here. It lives at the top of
|
|
614
|
+
// _ingestBytes, where every inbound byte passes regardless of which
|
|
615
|
+
// command it belongs to — a check reached only from this handler is one
|
|
616
|
+
// a peer skips by sending anything else.
|
|
617
|
+
var seen = bodyScanner.push(chunk);
|
|
618
|
+
if (seen.smuggling) {
|
|
538
619
|
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
539
620
|
{ connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
|
|
540
621
|
"denied");
|
|
541
622
|
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
542
623
|
"5.7.0 Bare-LF in DATA body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
|
|
543
624
|
_resetTransaction(state);
|
|
544
|
-
inDataBody = false; bodyCollector = null;
|
|
625
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
545
626
|
return;
|
|
546
627
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
var body = collected.subarray(0, endIdx);
|
|
628
|
+
if (seen.terminatorAt !== -1) {
|
|
629
|
+
var body = bodyCollector.result().subarray(0, seen.terminatorAt);
|
|
550
630
|
// DATA path dot-unstuffs here; BDAT path skips this step.
|
|
551
631
|
var dedotted = safeSmtp.dotUnstuff(body);
|
|
552
632
|
_finalizeAcceptedBody(state, socket, dedotted, "DATA");
|
|
553
|
-
inDataBody = false; bodyCollector = null;
|
|
633
|
+
inDataBody = false; bodyCollector = null; bodyScanner = null;
|
|
554
634
|
}
|
|
555
635
|
return;
|
|
556
636
|
}
|
|
@@ -705,12 +785,13 @@ function create(opts) {
|
|
|
705
785
|
// body collector AND strip the plain-socket "data" listener
|
|
706
786
|
// before wrapping in TLSSocket so bytes the peer pipelined
|
|
707
787
|
// pre-handshake cannot reach the post-TLS state machine.
|
|
708
|
-
lineBuffer = Buffer.alloc(0); bodyCollector = null; inDataBody = false;
|
|
788
|
+
lineBuffer = Buffer.alloc(0); bodyCollector = null; bodyScanner = null; inDataBody = false;
|
|
709
789
|
// BDAT-side state cleared on STARTTLS upgrade too — same threat
|
|
710
790
|
// model as CVE-2021-38371 (Exim) / CVE-2021-33515 (Dovecot):
|
|
711
791
|
// pre-handshake bytes the peer pipelined MUST NOT reach the
|
|
712
792
|
// post-TLS state machine via the BDAT collector either.
|
|
713
793
|
inBdatChunk = false; bdatRemaining = 0; bdatCollector = null; bdatTotalBytes = 0;
|
|
794
|
+
bdatRateStarted = false;
|
|
714
795
|
mailServerTls.upgradeSocket({
|
|
715
796
|
plainSocket: socket,
|
|
716
797
|
secureContext: opts.tlsContext,
|
|
@@ -723,12 +804,9 @@ function create(opts) {
|
|
|
723
804
|
// tradeoff acknowledged.
|
|
724
805
|
},
|
|
725
806
|
onData: function (tlsSocket, chunk) {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
730
|
-
_closeConnection(tlsSocket);
|
|
731
|
-
}
|
|
807
|
+
// Through the SAME funnel as the plaintext path, so the byte count
|
|
808
|
+
// the rate window measures against does not stop at the upgrade.
|
|
809
|
+
_feedChunk(tlsSocket, chunk);
|
|
732
810
|
},
|
|
733
811
|
onError: function (err) {
|
|
734
812
|
_emit("mail.server.submission.tls_handshake_failed",
|
|
@@ -1133,6 +1211,32 @@ function create(opts) {
|
|
|
1133
1211
|
sizeCode: "mail-server-submission/body-too-large",
|
|
1134
1212
|
sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
|
|
1135
1213
|
});
|
|
1214
|
+
bodyScanner = safeSmtp.createBodyScanner();
|
|
1215
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// The DATA branch's smuggling screen, for the BDAT paths. Both BDAT exits
|
|
1219
|
+
// — the sized LAST chunk and the zero-length LAST that terminates a
|
|
1220
|
+
// previous one — reach the agent, so both need it, and both call HERE
|
|
1221
|
+
// rather than carrying a copy. A screen that exists on one framing and not
|
|
1222
|
+
// its sibling is what this fixes; a second copy is how that happens again.
|
|
1223
|
+
//
|
|
1224
|
+
// BDAT counts its octets, so a dot-line cannot end THIS transfer early. It
|
|
1225
|
+
// matters because the body is relayed onward and the next hop is usually
|
|
1226
|
+
// DATA, where it can. The question is what the body CONTAINS, not how it
|
|
1227
|
+
// arrived: framing changes downstream, content does not.
|
|
1228
|
+
//
|
|
1229
|
+
// Returns true when the transaction was refused and the caller must stop.
|
|
1230
|
+
function _refuseSmuggledBdatBody(state, socket, body) {
|
|
1231
|
+
if (!guardSmtpCommand.detectBodySmuggling(body)) return false;
|
|
1232
|
+
_emit("mail.server.submission.smtp_smuggling_detected",
|
|
1233
|
+
{ connectionId: state.id, mailFrom: state.mailFrom,
|
|
1234
|
+
rcptCount: state.rcpts.length, framing: "BDAT" },
|
|
1235
|
+
"denied");
|
|
1236
|
+
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
1237
|
+
"5.7.0 Bare-LF in BDAT body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
|
|
1238
|
+
_resetTransaction(state);
|
|
1239
|
+
return true;
|
|
1136
1240
|
}
|
|
1137
1241
|
|
|
1138
1242
|
function _finalizeAcceptedBody(state, socket, dedotted, source) {
|
|
@@ -1279,6 +1383,21 @@ function create(opts) {
|
|
|
1279
1383
|
});
|
|
1280
1384
|
}
|
|
1281
1385
|
state.stage = "bdat";
|
|
1386
|
+
// Open the rate window on the FIRST BDAT of a sequence, the way the DATA
|
|
1387
|
+
// prompt opens it on that path, and judge EVERY BDAT command against it —
|
|
1388
|
+
// including a zero-length one.
|
|
1389
|
+
//
|
|
1390
|
+
// Keyed on an explicit flag rather than on the byte count: `BDAT 0`
|
|
1391
|
+
// without LAST leaves the count at zero, so a count-based test restarted
|
|
1392
|
+
// the window on every command while the zero-length path returned before
|
|
1393
|
+
// reaching any measurement. One such command before each idle timeout
|
|
1394
|
+
// then held the connection indefinitely without ever meeting the floor —
|
|
1395
|
+
// the same slow-loris this check exists to stop, wearing a different
|
|
1396
|
+
// command.
|
|
1397
|
+
if (!bdatRateStarted) {
|
|
1398
|
+
bodyRateWindow.start(Date.now(), wireBytes);
|
|
1399
|
+
bdatRateStarted = true;
|
|
1400
|
+
}
|
|
1282
1401
|
bdatRemaining = sizeN;
|
|
1283
1402
|
bdatIsLast = isLast;
|
|
1284
1403
|
// size=0 + LAST is a valid sequence — finalises the message
|
|
@@ -1288,8 +1407,13 @@ function create(opts) {
|
|
|
1288
1407
|
// _finalizeAcceptedBody for size=0 LAST.
|
|
1289
1408
|
if (sizeN === 0) {
|
|
1290
1409
|
if (isLast) {
|
|
1410
|
+
// NOT necessarily empty: this carries everything the prior chunks
|
|
1411
|
+
// accumulated, so it needs the same screen as the sized-LAST path
|
|
1412
|
+
// above. A smuggled body sent as chunk one and terminated with
|
|
1413
|
+
// `BDAT 0 LAST` reaches the agent through here.
|
|
1291
1414
|
var emptyBody = bdatCollector ? bdatCollector.result() : Buffer.alloc(0);
|
|
1292
1415
|
bdatCollector = null; bdatTotalBytes = 0;
|
|
1416
|
+
if (_refuseSmuggledBdatBody(state, socket, emptyBody)) return;
|
|
1293
1417
|
_finalizeAcceptedBody(state, socket, emptyBody, "BDAT");
|
|
1294
1418
|
} else {
|
|
1295
1419
|
_writeReply(socket, REPLY_250_OK, "2.0.0 0 octets received");
|
|
@@ -1312,6 +1436,10 @@ function create(opts) {
|
|
|
1312
1436
|
bdatIsLast = false;
|
|
1313
1437
|
bdatCollector = null;
|
|
1314
1438
|
bdatTotalBytes = 0;
|
|
1439
|
+
// A new transaction opens a new rate window rather than inheriting the
|
|
1440
|
+
// last one's elapsed time, which would refuse the first chunk of a
|
|
1441
|
+
// perfectly fast sender that happened to follow a slow one.
|
|
1442
|
+
bdatRateStarted = false;
|
|
1315
1443
|
}
|
|
1316
1444
|
}
|
|
1317
1445
|
|
|
@@ -1321,6 +1449,7 @@ function create(opts) {
|
|
|
1321
1449
|
// listening event reports implicitTls so an operator can confirm the wire mode.
|
|
1322
1450
|
var _tcpListener = mailServerNet.createTcpListener(net, {
|
|
1323
1451
|
defaultPort: implicitTls ? 465 : 587, // RFC 8314 implicit-TLS / RFC 6409 submission ports
|
|
1452
|
+
maxConnections: opts.maxConnections,
|
|
1324
1453
|
handleConnection: _handleConnection,
|
|
1325
1454
|
errorFactory: function (code, message) { return new MailServerSubmissionError("mail-server-submission/" + code, message); },
|
|
1326
1455
|
emit: _emit,
|
package/lib/mail-server-tls.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* });
|
|
25
25
|
*
|
|
26
26
|
* var mx = b.mail.server.mx.create({
|
|
27
|
-
*
|
|
27
|
+
* // A getter, not `tlsCtx.secureContext` — see "Picking up a rotation".
|
|
28
|
+
* get tlsContext() { return tlsCtx.secureContext; },
|
|
28
29
|
* ...
|
|
29
30
|
* });
|
|
30
31
|
* ```
|
|
@@ -75,10 +76,20 @@
|
|
|
75
76
|
*
|
|
76
77
|
* // Once per process at boot:
|
|
77
78
|
* var tls = b.mail.server.tls.context({ certFile, keyFile, watch: true });
|
|
78
|
-
* var mx = b.mail.server.mx.create({
|
|
79
|
-
*
|
|
79
|
+
* var mx = b.mail.server.mx.create({
|
|
80
|
+
* get tlsContext() { return tls.secureContext; },
|
|
81
|
+
* ...
|
|
82
|
+
* });
|
|
80
83
|
* ```
|
|
81
84
|
*
|
|
85
|
+
* **Picking up a rotation.** Every listener reads `opts.tlsContext` at the
|
|
86
|
+
* point it needs a context rather than capturing it at construction, so a
|
|
87
|
+
* getter is all a rotation needs: the next connection sees the reloaded
|
|
88
|
+
* context and nothing has to be swapped or restarted. Passing
|
|
89
|
+
* `tlsContext: tls.secureContext` instead copies the context that happened
|
|
90
|
+
* to be current at boot, and that one keeps being served after it expires.
|
|
91
|
+
* There is no listener method to swap a context, and none is needed.
|
|
92
|
+
*
|
|
82
93
|
* The cleartext-refused error message from `b.mail.server.mx` /
|
|
83
94
|
* `b.mail.server.submission` points at this primitive so the
|
|
84
95
|
* operator's boot dead-end becomes a one-line fix.
|
|
@@ -100,6 +111,9 @@ var validateOpts = require("./validate-opts");
|
|
|
100
111
|
var { defineClass } = require("./framework-error");
|
|
101
112
|
|
|
102
113
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
114
|
+
// Lazy — network-tls pulls the posture machinery, and only the context build
|
|
115
|
+
// needs it.
|
|
116
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
103
117
|
|
|
104
118
|
var MailServerTlsError = defineClass("MailServerTlsError", { alwaysPermanent: true });
|
|
105
119
|
|
|
@@ -134,16 +148,58 @@ var DEFAULT_POLL_MS = C.TIME.seconds(30);
|
|
|
134
148
|
* keyFile: "/etc/letsencrypt/live/mail.example.com/privkey.pem",
|
|
135
149
|
* watch: true,
|
|
136
150
|
* });
|
|
137
|
-
* //
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
151
|
+
* // Pass a getter so each connection reads the current context. Listeners
|
|
152
|
+
* // read opts.tlsContext at the point of use, so a reload reaches the next
|
|
153
|
+
* // connection with nothing to swap.
|
|
154
|
+
* var mx = b.mail.server.mx.create({
|
|
155
|
+
* get tlsContext() { return tls.secureContext; },
|
|
156
|
+
* localDomains: ["mail.example.com"]
|
|
142
157
|
* });
|
|
158
|
+
* // onReload is for observing a rotation — logging it, re-checking expiry.
|
|
159
|
+
* // Delivering it to the listeners is not something it has to do.
|
|
160
|
+
* tls.onReload(function (newCtx) { void newCtx; });
|
|
143
161
|
*
|
|
144
162
|
* // ... later, on shutdown:
|
|
145
163
|
* tls.stop(); // clears the poll timer
|
|
146
164
|
*/
|
|
165
|
+
|
|
166
|
+
// The createSecureContext options, assembled in ONE place so the live build and
|
|
167
|
+
// the test hook cannot diverge. Module scope is what makes that claim true: a
|
|
168
|
+
// copy nested inside context() is unreachable from the hook, which then has no
|
|
169
|
+
// way to answer the question except by re-deriving the options itself — and a
|
|
170
|
+
// re-derivation agrees with the build right up until someone changes one.
|
|
171
|
+
//
|
|
172
|
+
// The key-agreement preference comes from `b.network.tls.keyAgreementGroups`,
|
|
173
|
+
// which is where the framework's PQC-first policy lives — the ML-KEM hybrids
|
|
174
|
+
// with a classical X25519 fallback — along with the reasoning about which key
|
|
175
|
+
// node reads it under: a `groups` key is accepted and silently ignored while a
|
|
176
|
+
// malformed `ecdhCurve` throws, so the list has to land under `ecdhCurve`. This
|
|
177
|
+
// built `{ cert, key }` and set no group list at all, so the listener that
|
|
178
|
+
// speaks STARTTLS to the public internet negotiated whatever the runtime
|
|
179
|
+
// defaulted to, and an `ecdhCurve` a consumer passed was accepted and dropped —
|
|
180
|
+
// a failed attempt to set a policy looked exactly like a successful one.
|
|
181
|
+
//
|
|
182
|
+
// RFC 8879 certificate compression belongs here too, on the CONTEXT: a
|
|
183
|
+
// TLSSocket wrapping a pre-built context ignores the option, so setting it at
|
|
184
|
+
// the wrap site is inert and the server keeps writing the full uncompressed
|
|
185
|
+
// chain. A mail certificate is the same ML-DSA-87 chain as the HTTP listener's,
|
|
186
|
+
// and the same dominant share of the handshake.
|
|
187
|
+
//
|
|
188
|
+
// Deliberately NOT `applyToContext`: that also merges the framework trust store
|
|
189
|
+
// into the context, and a `ca` list on a SERVER context changes how it treats
|
|
190
|
+
// client certificates. This listener asked for a group policy, not a
|
|
191
|
+
// verification posture.
|
|
192
|
+
function _contextOptions(sourceOpts, certPem, keyPem) {
|
|
193
|
+
var base = { cert: certPem, key: keyPem };
|
|
194
|
+
var groups = networkTls().keyAgreementGroups(
|
|
195
|
+
sourceOpts ? sourceOpts.ecdhCurve : undefined,
|
|
196
|
+
"b.mail.server.tls.context: opts.ecdhCurve");
|
|
197
|
+
if (groups) base.ecdhCurve = groups;
|
|
198
|
+
var certCompression = C.TLS_CERT_COMPRESSION();
|
|
199
|
+
if (certCompression.length > 0) base.certificateCompression = certCompression;
|
|
200
|
+
return base;
|
|
201
|
+
}
|
|
202
|
+
|
|
147
203
|
function context(opts) {
|
|
148
204
|
validateOpts.requireObject(opts, "b.mail.server.tls.context",
|
|
149
205
|
MailServerTlsError, "mail-server-tls/bad-opts");
|
|
@@ -227,9 +283,7 @@ function context(opts) {
|
|
|
227
283
|
// it at the wrap site is inert and the server keeps writing the full
|
|
228
284
|
// uncompressed chain. A mail certificate is the same ML-DSA-87 chain as
|
|
229
285
|
// the HTTP listener's and the same dominant share of the handshake.
|
|
230
|
-
var ctxOpts =
|
|
231
|
-
var certCompression = C.TLS_CERT_COMPRESSION();
|
|
232
|
-
if (certCompression.length > 0) ctxOpts.certificateCompression = certCompression;
|
|
286
|
+
var ctxOpts = _contextOptions(opts, certPem, keyPem);
|
|
233
287
|
ctx = nodeTls.createSecureContext(ctxOpts);
|
|
234
288
|
} catch (e) {
|
|
235
289
|
throw new MailServerTlsError("mail-server-tls/secure-context-failed",
|
|
@@ -569,4 +623,10 @@ module.exports = {
|
|
|
569
623
|
upgradeSocket: upgradeSocket,
|
|
570
624
|
upgradeLineProtocol: upgradeLineProtocol,
|
|
571
625
|
MailServerTlsError: MailServerTlsError,
|
|
626
|
+
// Test hook: the resolved createSecureContext options, so the group-policy
|
|
627
|
+
// assertions read what the context is actually built with rather than
|
|
628
|
+
// re-deriving it.
|
|
629
|
+
_contextOptionsForTest: function (opts) {
|
|
630
|
+
return _contextOptions(opts, "<cert>", "<key>");
|
|
631
|
+
},
|
|
572
632
|
};
|
package/lib/mcp.js
CHANGED
|
@@ -479,7 +479,12 @@ function _toolResultSanitize(result, opts) {
|
|
|
479
479
|
"toolResult.sanitize: posture must be 'refuse' | 'sanitize' | 'audit-only'");
|
|
480
480
|
}
|
|
481
481
|
var maxBytes = opts.maxTextBytes || DEFAULT_TOOL_OUTPUT_MAX_BYTES;
|
|
482
|
-
|
|
482
|
+
// `null` when no allowlist was supplied, the array when one was — including
|
|
483
|
+
// an EMPTY one, which asks that no URL be permitted. Normalising the absent
|
|
484
|
+
// case to `[]` made the two indistinguishable, and the length test below then
|
|
485
|
+
// read both as "no restriction". A tool result is attacker-influenced
|
|
486
|
+
// content, so this is the allowlist that stops one pointing wherever it likes.
|
|
487
|
+
var allowedHosts = Array.isArray(opts.allowedHosts) ? opts.allowedHosts : null;
|
|
483
488
|
if (!result || typeof result !== "object") {
|
|
484
489
|
throw new McpError("mcp/bad-tool-result",
|
|
485
490
|
"toolResult.sanitize: result must be an object");
|
|
@@ -522,7 +527,7 @@ function _toolResultSanitize(result, opts) {
|
|
|
522
527
|
cleaned.push({ type: "text", text: t });
|
|
523
528
|
} else if (block.type === "image" || block.type === "resource_link" || block.type === "audio") {
|
|
524
529
|
var url = block.url || (block.resource && block.resource.uri);
|
|
525
|
-
if (typeof url === "string" && url.length > 0 && allowedHosts
|
|
530
|
+
if (typeof url === "string" && url.length > 0 && allowedHosts) {
|
|
526
531
|
var u; try { u = new URL(url); } catch (_e) { u = null; } // allow:raw-new-url-parse-only — operator-supplied tool URL; allowlist enforced below
|
|
527
532
|
if (!u || allowedHosts.indexOf(u.host) === -1) {
|
|
528
533
|
issues.push({ kind: "off-allowlist-url", index: i, url: url });
|
|
@@ -950,7 +955,10 @@ function _samplingGuard(opts) {
|
|
|
950
955
|
function _elicitationGuard(opts) {
|
|
951
956
|
opts = opts || {};
|
|
952
957
|
var maxBytes = opts.maxMessageBytes || (8 * 1024); // allow:raw-byte-literal — 8 KiB elicitation message cap
|
|
953
|
-
|
|
958
|
+
// The default applies when the option is OMITTED. An explicitly empty list
|
|
959
|
+
// says no schema type is acceptable, and falling back to the default there
|
|
960
|
+
// would answer a caller's "none" with "object".
|
|
961
|
+
var allowedSchemaTypes = Array.isArray(opts.allowedSchemaTypes)
|
|
954
962
|
? opts.allowedSchemaTypes : ["object"];
|
|
955
963
|
var posture = opts.posture || "refuse";
|
|
956
964
|
|
|
@@ -253,7 +253,7 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
|
253
253
|
* protocolResolver: function(req): "http"|"https", // own the HTTPS decision
|
|
254
254
|
* trustProxy: boolean|number, // legacy; refused unless paired with trustedProxies/protocolResolver (spoofable)
|
|
255
255
|
* audit: boolean,
|
|
256
|
-
* skipStateless: boolean, // default false — skip
|
|
256
|
+
* skipStateless: boolean, // default false — skip the token check for cookieless (not-CSRF-able) requests. Turns on the absence of the ambient credential only: an Authorization header is not part of the test, and it never waives `checkOrigin`.
|
|
257
257
|
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
258
258
|
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
259
259
|
* }
|
|
@@ -363,17 +363,21 @@ function create(opts) {
|
|
|
363
363
|
// is opt-in rather than silent.
|
|
364
364
|
var requireOriginOpt = opts.requireOrigin === true;
|
|
365
365
|
|
|
366
|
-
// skipStateless — skip token VALIDATION for requests that carry
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
366
|
+
// skipStateless — skip token VALIDATION for requests that carry no Cookie
|
|
367
|
+
// header at all. Such requests are not CSRF-able: CSRF abuses a victim's
|
|
368
|
+
// ambient cookie credential, and a request that sends none has nothing to
|
|
369
|
+
// abuse. The token is still ISSUED on safe methods so a later
|
|
370
|
+
// cookie-authenticated browser flow on the same app works. Default false
|
|
371
|
+
// (strict — every state-changing request is validated). createApp wires its
|
|
372
|
+
// default csrf with this on so mixed browser-form + token-API surfaces don't
|
|
373
|
+
// reject legitimate API clients. Cross-site form CSRF is unaffected: the
|
|
374
|
+
// browser auto-sends the victim's cookies, so the attack request always
|
|
375
|
+
// carries a Cookie header and is validated.
|
|
376
|
+
//
|
|
377
|
+
// An `Authorization` header is NOT part of the test, and a bearer client that
|
|
378
|
+
// also sends an unrelated cookie is validated like anything else. Deciding
|
|
379
|
+
// otherwise needs the auth layer's verdict about which credential
|
|
380
|
+
// authenticated the request, which header presence cannot supply.
|
|
377
381
|
var skipStateless = opts.skipStateless === true;
|
|
378
382
|
|
|
379
383
|
// Per-path exemption (string-prefix / RegExp / skip predicate), validated at
|
|
@@ -548,14 +552,6 @@ function create(opts) {
|
|
|
548
552
|
|
|
549
553
|
if (methods.indexOf(req.method) === -1) return next();
|
|
550
554
|
|
|
551
|
-
// Stateless / token-authenticated requests are not CSRF-able — the
|
|
552
|
-
// token was still issued above for any later browser flow.
|
|
553
|
-
if (skipStateless) {
|
|
554
|
-
var hasAuthHeader = !!(req.headers && req.headers.authorization);
|
|
555
|
-
var hasCookieHeader = !!(req.headers && req.headers.cookie);
|
|
556
|
-
if (hasAuthHeader || !hasCookieHeader) return next();
|
|
557
|
-
}
|
|
558
|
-
|
|
559
555
|
// requireJsonContentType — refuse before the token check.
|
|
560
556
|
if (requireJsonCt) {
|
|
561
557
|
var ct = req.headers && req.headers["content-type"];
|
|
@@ -578,6 +574,27 @@ function create(opts) {
|
|
|
578
574
|
}
|
|
579
575
|
}
|
|
580
576
|
|
|
577
|
+
// Stateless requests are not CSRF-able — the token was still issued above
|
|
578
|
+
// for any later browser flow.
|
|
579
|
+
//
|
|
580
|
+
// The test is the ABSENCE of the ambient credential, and nothing else. CSRF
|
|
581
|
+
// spends a cookie the browser attaches on its own; a request carrying none
|
|
582
|
+
// has nothing to abuse. This used to fire on an `Authorization` header too,
|
|
583
|
+
// which was two mistakes at once. Presence is not authenticity — an
|
|
584
|
+
// attacker composing a cross-site request writes their own headers, so
|
|
585
|
+
// `Authorization: Bearer nonsense` satisfied it by being typed. And the
|
|
586
|
+
// header says nothing about which credential authenticated the request:
|
|
587
|
+
// attachUser with tokenFrom: "both" reads the cookie FIRST, so a request
|
|
588
|
+
// carrying both was authenticated by exactly the ambient credential this
|
|
589
|
+
// gate protects, and skipped the gate on a header nobody had read.
|
|
590
|
+
//
|
|
591
|
+
// It also sits BELOW the origin cross-check now rather than above it. A
|
|
592
|
+
// consumer that asked for checkOrigin asked for something the token compare
|
|
593
|
+
// does not give them, and there is no reading of "stateless" under which a
|
|
594
|
+
// cross-origin state change becomes acceptable — the branch that waived the
|
|
595
|
+
// first line of defence was waiving the second one with it.
|
|
596
|
+
if (skipStateless && !(req.headers && req.headers.cookie)) return next();
|
|
597
|
+
|
|
581
598
|
if (!cookieCfg) {
|
|
582
599
|
// Session-stored mode — operator's tokenLookup is the source.
|
|
583
600
|
expected = opts.tokenLookup(req);
|
|
@@ -272,7 +272,14 @@ function create(opts) {
|
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
-
|
|
275
|
+
// Gated on "was an allowlist supplied", NOT on "is it non-empty". The
|
|
276
|
+
// derivation above already spells the two apart — `null` when the option is
|
|
277
|
+
// omitted, an array when it is given — and re-testing the length here
|
|
278
|
+
// collapsed them, so a pin built from configuration that returned zero
|
|
279
|
+
// entries admitted every client certificate instead of none. An allowlist
|
|
280
|
+
// that disappears when empty is a firewall rule set that opens when the
|
|
281
|
+
// last rule is deleted, and here that rule set is the front door.
|
|
282
|
+
if (allowList && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
|
|
276
283
|
return _refuse(req, res, "fingerprint-not-allowed", {
|
|
277
284
|
fingerprint: fp.colon,
|
|
278
285
|
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
package/lib/network-tls.js
CHANGED
|
@@ -549,6 +549,23 @@ function _stripUnreachableCertCompression(merged, caller) {
|
|
|
549
549
|
return merged;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
// The key-agreement preference alone, as the string node:tls reads under
|
|
553
|
+
// `ecdhCurve`. `applyToContext` is the whole-context form and also merges the
|
|
554
|
+
// framework trust store, minimum version and certificate compression; a caller
|
|
555
|
+
// that only wants the group policy — a listener building its own server context
|
|
556
|
+
// — takes this instead, so it does not inherit a `ca` list it never asked for.
|
|
557
|
+
//
|
|
558
|
+
// An override is honoured, and a malformed one is REFUSED rather than replaced:
|
|
559
|
+
// quietly substituting the default would start a listener on groups the
|
|
560
|
+
// operator did not choose, with nothing said. Absent takes the framework
|
|
561
|
+
// default (the ML-KEM hybrids with a classical X25519 fallback).
|
|
562
|
+
function keyAgreementGroups(override, where) {
|
|
563
|
+
if (override !== undefined && override !== null) {
|
|
564
|
+
return _groupPreferenceString(override, where || "tls.keyAgreementGroups");
|
|
565
|
+
}
|
|
566
|
+
return STATE.tlsKeyShares.length > 0 ? STATE.tlsKeyShares.join(":") : null;
|
|
567
|
+
}
|
|
568
|
+
|
|
552
569
|
function applyToContext(opts) {
|
|
553
570
|
opts = opts || {};
|
|
554
571
|
validateOpts(opts, ["base"], "tls.applyToContext");
|
|
@@ -4138,6 +4155,7 @@ module.exports = {
|
|
|
4138
4155
|
captureBaselineFingerprints: captureBaselineFingerprints,
|
|
4139
4156
|
detectBaselineDrift: detectBaselineDrift,
|
|
4140
4157
|
applyToContext: applyToContext,
|
|
4158
|
+
keyAgreementGroups: keyAgreementGroups,
|
|
4141
4159
|
buildOptions: buildOptions,
|
|
4142
4160
|
getCaPems: getCaPems,
|
|
4143
4161
|
ocsp: ocsp,
|