@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/NOTICE +5 -5
  3. package/README.md +9 -9
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/audit-sign.js +24 -5
  6. package/lib/audit.js +26 -24
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/chain-writer.js +17 -0
  9. package/lib/db-file-lifecycle.js +14 -3
  10. package/lib/db.js +505 -49
  11. package/lib/guard-filename.js +8 -1
  12. package/lib/guard-html.js +10 -2
  13. package/lib/guard-list-unsubscribe.js +6 -1
  14. package/lib/guard-managesieve-command.js +24 -3
  15. package/lib/guard-smtp-command.js +20 -4
  16. package/lib/guard-svg.js +6 -1
  17. package/lib/http-client.js +17 -3
  18. package/lib/mail-agent.js +6 -4
  19. package/lib/mail-auth.js +59 -2
  20. package/lib/mail-server-imap.js +65 -34
  21. package/lib/mail-server-managesieve.js +65 -42
  22. package/lib/mail-server-mx.js +313 -40
  23. package/lib/mail-server-net.js +155 -1
  24. package/lib/mail-server-pop3.js +16 -19
  25. package/lib/mail-server-rate-limit.js +104 -6
  26. package/lib/mail-server-submission.js +162 -33
  27. package/lib/mail-server-tls.js +71 -11
  28. package/lib/mcp.js +11 -3
  29. package/lib/middleware/csrf-protect.js +37 -20
  30. package/lib/middleware/require-mtls.js +8 -1
  31. package/lib/network-tls.js +18 -0
  32. package/lib/safe-mount-info.js +39 -6
  33. package/lib/safe-smtp.js +96 -1
  34. package/lib/safe-url.js +8 -2
  35. package/lib/self-update.js +4 -1
  36. package/lib/session-stores.js +6 -3
  37. package/lib/vendor/MANIFEST.json +34 -34
  38. package/lib/vendor/blamejs-pki.cjs +396 -37
  39. package/lib/vendor/browser/noble-ciphers.mjs +15 -1
  40. package/lib/vendor/browser/noble-hashes.mjs +12 -4
  41. package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
  42. package/lib/vendor/noble-ciphers.cjs +15 -1
  43. package/lib/vendor/noble-curves.cjs +46 -21
  44. package/lib/vendor/noble-post-quantum.cjs +184 -75
  45. package/lib/watcher.js +31 -6
  46. package/lib/ws-client.js +17 -2
  47. package/package.json +1 -1
  48. 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 remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
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
- ? new nodeTls.TLSSocket(rawSocket, { isServer: true, secureContext: opts.tlsContext })
400
- : rawSocket;
401
- connections.add(socket);
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
- socket.on("data", function (chunk) {
462
- try { _ingestBytes(state, socket, chunk); }
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(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
499
+ try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
467
500
  catch (_e) { /* socket already gone */ }
468
- _closeConnection(socket);
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
- var collected = bodyCollector.result();
537
- if (guardSmtpCommand.detectBodySmuggling(collected)) {
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
- var endIdx = safeSmtp.findDotTerminator(collected);
548
- if (endIdx !== -1) {
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
- try { _ingestBytes(state, tlsSocket, chunk); }
727
- catch (err) {
728
- _emit("mail.server.submission.handler_threw",
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,
@@ -24,7 +24,8 @@
24
24
  * });
25
25
  *
26
26
  * var mx = b.mail.server.mx.create({
27
- * tlsContext: tlsCtx.secureContext,
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({ tlsContext: tls.secureContext, ... });
79
- * tls.onReload(function (newCtx) { mx.replaceTlsContext(newCtx); });
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
- * // Wire `tls.secureContext` into b.mail.server.mx.create / submission.create
138
- * tls.onReload(function (newCtx) {
139
- * // operator swaps the running listener's SecureContext via the
140
- * // listener's reload hook (when the listener exposes one) or via
141
- * // restart-on-rotation flow
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 = { cert: certPem, key: keyPem };
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
- var allowedHosts = Array.isArray(opts.allowedHosts) ? opts.allowedHosts : [];
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.length > 0) {
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
- var allowedSchemaTypes = Array.isArray(opts.allowedSchemaTypes) && opts.allowedSchemaTypes.length > 0
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 validation for Authorization-header / cookieless (not-CSRF-able) requests
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 an
367
- // Authorization header (bearer / token auth) or no Cookie header at
368
- // all. Such requests are not CSRF-able: CSRF abuses a victim's ambient
369
- // cookie credential, and a token-authenticated or cookieless request
370
- // has none to abuse. The token is still ISSUED on safe methods so a
371
- // later cookie-authenticated browser flow on the same app works. Default
372
- // false (strict every state-changing request is validated). createApp
373
- // wires its default csrf with this on so mixed browser-form + token-API
374
- // surfaces don't reject legitimate API clients. Cross-site form CSRF is
375
- // unaffected: the browser auto-sends the victim's cookies, so the attack
376
- // request always carries a Cookie header and is validated.
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
- if (allowList && allowList.length > 0 && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
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,
@@ -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,