@blamejs/core 0.18.53 → 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.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
package/lib/mail-store.js CHANGED
@@ -484,12 +484,7 @@ function create(opts) {
484
484
  maxMessageBytes: maxMessageBytes,
485
485
  maxBodyBytes: maxBodyBytes,
486
486
  };
487
- if (typeof db.transaction === "function") {
488
- var result;
489
- db.transaction(function () { result = _appendMessage(args); })();
490
- return result;
491
- }
492
- return _appendMessage(args);
487
+ return _runInTransaction(db, function () { return _appendMessage(args); });
493
488
  },
494
489
  fetchByObjectId: function (folderName, objectid) {
495
490
  return _fetchByObjectId({
@@ -782,11 +777,7 @@ function create(opts) {
782
777
  stmtDecrementQuota.run(totalBytes, toDelete.length, folder.id);
783
778
  }
784
779
  }
785
- if (typeof db.transaction === "function") {
786
- db.transaction(_runTxn)();
787
- } else {
788
- _runTxn();
789
- }
780
+ _runInTransaction(db, _runTxn);
790
781
  return {
791
782
  rows: rows,
792
783
  deleted: toDelete.map(function (r) { return r.objectid; }),
@@ -800,6 +791,37 @@ function create(opts) {
800
791
 
801
792
  // ---- Append --------------------------------------------------------------
802
793
 
794
+ // Run `fn` inside the backend's transaction, whichever calling convention the
795
+ // backend uses, and return what `fn` returned.
796
+ //
797
+ // There are two, and they look identical from outside. `b.db.transaction(fn)`
798
+ // RUNS fn between BEGIN and COMMIT and returns its result; better-sqlite3's
799
+ // `transaction(fn)` returns a WRAPPER you then call. The code here was written
800
+ // for the second and used against the first, which is the composition
801
+ // `b.mailStore` documents: `db.transaction(fn)()` therefore ran the work,
802
+ // committed it, and then threw a TypeError trying to call the result. A caller
803
+ // retrying on that throw appended a second copy of a message already durably
804
+ // stored, and `hardExpunge` deleted, committed, and threw the same way.
805
+ //
806
+ // `typeof db.transaction === "function"` is true of both and distinguishes
807
+ // neither, which is why the guard that was there could not have caught it. What
808
+ // distinguishes them is whether `fn` actually RAN, so that is what is checked —
809
+ // not the shape of the return value, which would misread a callback that
810
+ // legitimately returns a function.
811
+ function _runInTransaction(db, fn) {
812
+ if (!db || typeof db.transaction !== "function") return fn();
813
+ var ran = false;
814
+ var out = db.transaction(function () { ran = true; return fn(); });
815
+ if (ran) return out; // executed already: b.db's shape
816
+ if (typeof out === "function") return out(); // a wrapper: better-sqlite3's
817
+ // Neither ran the callback nor handed back something to run. Doing the work
818
+ // outside a transaction would be worse than saying so: a partial write is
819
+ // exactly what the transaction is here to prevent.
820
+ throw new MailStoreError("mail-store/unusable-transaction",
821
+ "backend.transaction(fn) neither ran the callback nor returned a function " +
822
+ "to run it — the write cannot be made atomic, so it is not attempted");
823
+ }
824
+
803
825
  function _appendMessage(args) {
804
826
  var rawBytes = args.rawBytes;
805
827
  if (!Buffer.isBuffer(rawBytes) && typeof rawBytes !== "string") {
package/lib/mail.js CHANGED
@@ -25,6 +25,15 @@
25
25
  * the worked example). Operators can also pass any function or
26
26
  * `{ send }` object as a custom transport.
27
27
  *
28
+ * The `smtp` transport takes `dane: [tlsaRecord, ...]` — the peer's
29
+ * DNSSEC-validated TLSA records, as `b.network.smtp.policy.dane.tlsa`
30
+ * returns them. When present, the certificate chain the peer presents is
31
+ * matched against them once the handshake completes and the send is refused
32
+ * on a mismatch (RFC 7672 §2.2), on both the implicit-TLS and STARTTLS
33
+ * paths. `daneAllowPkixModes: true` additionally accepts the PKIX-TA /
34
+ * PKIX-EE usages, which need a PKIX path validation the transport does not
35
+ * perform. `b.mail.send.deliver` fetches the records and passes them here.
36
+ *
28
37
  * DKIM-Signature header generation lives at `b.mail.dkim` (rsa-sha256
29
38
  * default, ed25519-sha256 opt-in, dual-signer per RFC 8463 §3 for
30
39
  * transition windows). Inbound authentication-results parsing —
@@ -82,6 +91,8 @@ var numericBounds = require("./numeric-bounds");
82
91
  var nodeTls = lazyRequire(function () { return require("node:tls"); });
83
92
  // Lazy — audit a cert-validation-disabled SMTP/TLS session at honor time.
84
93
  var networkTls = lazyRequire(function () { return require("./network-tls"); });
94
+ // Lazy — dane.verifyChain, reached only when a send carries TLSA records.
95
+ var smtpPolicy = lazyRequire(function () { return require("./network-smtp-policy"); });
85
96
  var safeJson = require("./safe-json");
86
97
  var safeSchema = require("./safe-schema");
87
98
  var validateOpts = require("./validate-opts");
@@ -310,6 +321,12 @@ function _isValidEmail(addr) {
310
321
  // contains non-ASCII octets.
311
322
  function _messageRequiresSmtpUtf8(message) {
312
323
  if (!message) return false;
324
+ // For raw bytes the addresses live in the header block, and SMTPUTF8 is about
325
+ // ADDRESSES — a non-ASCII body is 8BITMIME's business, below. The envelope
326
+ // fields are still checked after this, because `deliver` passes them
327
+ // alongside the raw message and either can carry the non-ASCII address.
328
+ var rawUtf8 = _rawText(message);
329
+ if (rawUtf8 !== null && _hasNonAscii(_rawHeaderBlock(rawUtf8))) return true;
313
330
  if (_hasNonAscii(message.from || "")) return true;
314
331
  if (_hasNonAscii(message.subject || "")) return true;
315
332
  var lists = [message.to, message.cc, message.bcc];
@@ -746,7 +763,63 @@ function _buildBodyPart(message) {
746
763
  };
747
764
  }
748
765
 
766
+ // A message may arrive as FIELDS or as raw RFC 822 BYTES, and everything on the
767
+ // send path that reads the message has to know which one it is looking at.
768
+ //
769
+ // `b.mail.send.deliver` requires its caller to supply raw bytes, validates them,
770
+ // and hands them over as `raw` — and every reader here ignored that and read the
771
+ // fields instead. A message carrying only `from`, `to` and `raw` therefore had
772
+ // no body parts at all, so the wire body was an empty `multipart/alternative`
773
+ // and the peer accepted it with a 250. Fixing only the body builder is not
774
+ // enough either: the three capability detectors below decide SMTPUTF8, 8BITMIME
775
+ // and BINARYMIME from the same fields, so a raw message would have gone out with
776
+ // all three un-negotiated — a body that is no longer empty but is silently
777
+ // mangled by a peer that needed the hint, which is the worse failure because it
778
+ // looks like it worked.
779
+ // latin1, not utf8. The rest of this file inspects the message as a string
780
+ // (guardEmail's smuggling screen, the DKIM signer, the dot-stuffer), and a
781
+ // utf8 decode of arbitrary 8-bit bytes replaces every invalid sequence with
782
+ // U+FFFD — measured at 21 bytes in, 29 out, four of them destroyed. That
783
+ // corrupts exactly the BINARYMIME payloads this transport now detects and
784
+ // negotiates for, which is the worst case: the send succeeds and the body is
785
+ // wrong. latin1 maps each byte to one codepoint in U+0000..U+00FF and back,
786
+ // so the string form is a faithful stand-in for the bytes and `_wireEncoding`
787
+ // below recovers them exactly.
788
+ function _rawText(message) {
789
+ if (!message) return null;
790
+ var raw = message.raw;
791
+ if (Buffer.isBuffer(raw)) return raw.toString("latin1");
792
+ if (typeof raw === "string") return raw;
793
+ return null;
794
+ }
795
+
796
+ // Which encoding turns this message's string form back into its wire bytes.
797
+ // A raw Buffer went through latin1 above and must come back the same way; a
798
+ // message this transport composed itself is real text and is utf8.
799
+ function _wireEncoding(message) {
800
+ return (message && Buffer.isBuffer(message.raw)) ? "latin1" : "utf8";
801
+ }
802
+
803
+ // The header block of raw bytes: everything before the first blank line. Used
804
+ // where a decision is about the envelope and headers rather than the content —
805
+ // SMTPUTF8 is about addresses, not about the body.
806
+ function _rawHeaderBlock(raw) {
807
+ var end = raw.indexOf("\r\n\r\n");
808
+ if (end === -1) end = raw.indexOf("\n\n");
809
+ return end === -1 ? raw : raw.slice(0, end);
810
+ }
811
+
812
+ // Built from its code point, never typed: a literal NUL in this source would be
813
+ // an invisible byte in the very check that exists to find one.
814
+ var _NUL_CHAR = String.fromCharCode(0);
815
+
749
816
  function _buildRfc822(message) {
817
+ // Raw bytes ARE the message. Nothing is composed and nothing is re-ordered: a
818
+ // caller that went to the trouble of building RFC 822 itself is entitled to
819
+ // have those exact bytes reach the peer.
820
+ var rawMessage = _rawText(message);
821
+ if (rawMessage !== null) return rawMessage;
822
+
750
823
  var headers = [];
751
824
  headers.push("From: " + message.from);
752
825
  headers.push("To: " + (Array.isArray(message.to) ? message.to.join(", ") : message.to));
@@ -826,6 +899,52 @@ function _smtpTlsOpts(cfg, extra) {
826
899
  return networkTls()._stripUnreachableCertCompression(out, cfg.tlsOpts);
827
900
  }
828
901
 
902
+ // Node hands the peer chain back as a linked structure, each cert pointing at
903
+ // its issuer; dane.verifyChain wants the DER buffers in order, leaf first. A
904
+ // self-signed root points at itself, so the walk stops on a cert it has already
905
+ // taken rather than following that link forever.
906
+ function _peerChainDer(sock) {
907
+ var chain = [];
908
+ var seen = Object.create(null);
909
+ var cert;
910
+ try { cert = sock.getPeerCertificate(true); } catch (_e) { return chain; } // not a TLS socket, or torn down mid-handshake
911
+ while (cert && Buffer.isBuffer(cert.raw)) {
912
+ var id = cert.fingerprint256 || cert.raw.toString("hex");
913
+ if (seen[id]) break;
914
+ seen[id] = true;
915
+ chain.push(cert.raw);
916
+ cert = cert.issuerCertificate;
917
+ }
918
+ return chain;
919
+ }
920
+
921
+ // Match the peer's certificate chain against the TLSA records published for it
922
+ // (RFC 7672 §2.2). Returns a failure reason, or null when the peer authenticated.
923
+ // Every path that cannot reach a verdict returns a reason: an unverifiable peer
924
+ // is not an authenticated one, and this runs only when a caller asked for DANE.
925
+ function _daneChainFailure(sock, cfg) {
926
+ var chain = _peerChainDer(sock);
927
+ if (chain.length === 0) {
928
+ return "dane: peer presented no certificate chain to match against its " +
929
+ cfg.daneTlsa.length + " TLSA record(s)";
930
+ }
931
+ var rv;
932
+ try {
933
+ rv = smtpPolicy().dane.verifyChain(chain, cfg.daneTlsa,
934
+ { allowPkixModes: cfg.daneAllowPkixModes });
935
+ } catch (e) {
936
+ return "dane: chain verification could not run: " + ((e && e.message) || String(e));
937
+ }
938
+ if (!rv || rv.ok !== true) {
939
+ var why = (rv && rv.errors && rv.errors.length)
940
+ ? "; " + rv.errors.map(function (x) { return x.reason; }).join(", ")
941
+ : "";
942
+ return "dane: peer certificate chain matches none of the " +
943
+ cfg.daneTlsa.length + " TLSA record(s) published for it (RFC 7672 §2.2)" + why;
944
+ }
945
+ return null;
946
+ }
947
+
829
948
  function smtpTransport(opts) {
830
949
  opts = opts || {};
831
950
  if (!opts.host) {
@@ -894,8 +1013,75 @@ function smtpTransport(opts) {
894
1013
  // the transport's whole life. minTlsVersion stays operator-settable here,
895
1014
  // unlike the framework's other clients: an MX that only speaks TLS 1.2 is
896
1015
  // common, and refusing it would mean not delivering mail.
1016
+ // RFC 7672 DANE. `dane` carries the peer's TLSA records, already
1017
+ // DNSSEC-validated by whoever fetched them; the transport matches the
1018
+ // certificate chain the peer presents against them once the handshake
1019
+ // completes, and refuses to send on a mismatch. Supplying records and then
1020
+ // not checking them would be discovery, not authentication, so an empty
1021
+ // array is refused rather than quietly meaning "no DANE": a caller that
1022
+ // fetched nothing must omit the option and say so.
1023
+ //
1024
+ // Resolved BEFORE tlsOpts because it decides what the handshake requires.
1025
+ var daneTlsa = null;
1026
+ if (opts.dane !== undefined && opts.dane !== null) {
1027
+ if (!Array.isArray(opts.dane)) {
1028
+ throw new MailError("mail/smtp-misconfigured",
1029
+ "smtp transport: opts.dane must be an array of TLSA records " +
1030
+ "(b.network.smtp.policy.dane.tlsa output), or omitted", true);
1031
+ }
1032
+ if (opts.dane.length === 0) {
1033
+ throw new MailError("mail/smtp-misconfigured",
1034
+ "smtp transport: opts.dane is an empty array — a peer that publishes " +
1035
+ "no TLSA records cannot be DANE-authenticated, so omit the option " +
1036
+ "rather than passing nothing to match against", true);
1037
+ }
1038
+ daneTlsa = opts.dane;
1039
+ }
1040
+ // PKIX-TA (0) and PKIX-EE (1) mean "this certificate, AND it must also pass
1041
+ // PKIX path validation" (RFC 7672 §3.1.1). verifyChain refuses those usages
1042
+ // unless the caller confirms a PKIX validator ran — and one did, precisely
1043
+ // when the record set contains no DANE-TA/DANE-EE to replace it, because then
1044
+ // the WebPKI check below stays on and the handshake would have failed without
1045
+ // it. Tying the permission to that condition rather than to an opt-in is what
1046
+ // makes a PKIX-only peer deliverable at all: it published a valid record, the
1047
+ // chain validated, and the match was then thrown away as not-allowed.
1048
+ //
1049
+ // The converse matters as much. On a mixed set the WebPKI check is off for
1050
+ // the DANE usages, so the PKIX usages' precondition no longer holds and they
1051
+ // are not honoured — unless the operator asserts otherwise, having arranged
1052
+ // path validation themselves.
1053
+ var daneAllowPkixModes = opts.daneAllowPkixModes === true;
1054
+
1055
+ // RFC 7672 §3.1.1 — under DANE-EE (usage 3) and DANE-TA (usage 2) the TLSA
1056
+ // record IS the trust anchor, and PKIX path validation is not performed. That
1057
+ // is not a relaxation: it is what DANE is for, and it is the deployment DANE
1058
+ // exists to enable — a self-signed or privately-issued MX certificate bound
1059
+ // to its name through DNSSEC.
1060
+ //
1061
+ // Without this, Node's WebPKI check rejects such a peer during the handshake,
1062
+ // before the certificate can be compared against the very records that
1063
+ // authenticate it. Every standards-compliant DANE-only MX was deferred while
1064
+ // matching its published records exactly.
1065
+ //
1066
+ // Authentication is moved, not dropped: `dane` being set makes the chain
1067
+ // check mandatory after the handshake, and a mismatch refuses the send.
1068
+ // PKIX-TA (0) and PKIX-EE (1) still require PKIX, so a record set containing
1069
+ // only those leaves the WebPKI check exactly as configured.
1070
+ var daneIsTrustAnchor = false;
1071
+ if (daneTlsa) {
1072
+ for (var dt = 0; dt < daneTlsa.length; dt += 1) {
1073
+ var daneUsage = daneTlsa[dt] && daneTlsa[dt].usage;
1074
+ if (daneUsage === 2 || daneUsage === 3) { daneIsTrustAnchor = true; break; }
1075
+ }
1076
+ }
1077
+
1078
+ // Records present, none of them a DANE trust anchor, and the WebPKI check
1079
+ // therefore still enforced: that IS the PKIX validation the usage-0/1 records
1080
+ // require, so their matches count.
1081
+ if (daneTlsa && !daneIsTrustAnchor && rejectUnauthorized) daneAllowPkixModes = true;
1082
+
897
1083
  var tlsOpts = {
898
- rejectUnauthorized: rejectUnauthorized,
1084
+ rejectUnauthorized: daneIsTrustAnchor ? false : rejectUnauthorized,
899
1085
  minVersion: opts.minTlsVersion || "TLSv1.3",
900
1086
  };
901
1087
  if (opts.ecdhCurve) tlsOpts.ecdhCurve = opts.ecdhCurve;
@@ -952,6 +1138,8 @@ function smtpTransport(opts) {
952
1138
  chunkSize: chunkSize,
953
1139
  respectPeerSize: respectPeerSize,
954
1140
  preferFamily: preferFamily,
1141
+ daneTlsa: daneTlsa,
1142
+ daneAllowPkixModes: daneAllowPkixModes,
955
1143
  };
956
1144
 
957
1145
  return {
@@ -995,6 +1183,19 @@ function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
995
1183
  // the "application/octet-stream" claimed content-type also count.
996
1184
  function _messageRequiresBinaryMime(message) {
997
1185
  if (!message) return false;
1186
+ // Raw bytes carry their attachments already encoded, so the question is
1187
+ // whether those bytes are 8-bit binary — a NUL is the giveaway, exactly as it
1188
+ // is for a Buffer attachment below. Scanned over the whole message rather than
1189
+ // a prefix, because a raw message puts its parts wherever its author did.
1190
+ if (Buffer.isBuffer(message.raw)) {
1191
+ for (var r = 0; r < message.raw.length; r += 1) {
1192
+ if (message.raw[r] === 0) return true;
1193
+ }
1194
+ return false;
1195
+ }
1196
+ if (typeof message.raw === "string") {
1197
+ return message.raw.indexOf(_NUL_CHAR) !== -1;
1198
+ }
998
1199
  if (!Array.isArray(message.attachments)) return false;
999
1200
  for (var i = 0; i < message.attachments.length; i += 1) {
1000
1201
  var att = message.attachments[i];
@@ -1024,6 +1225,12 @@ function _messageRequiresBinaryMime(message) {
1024
1225
  // (NUL-bearing).
1025
1226
  function _messageRequires8BitMime(message) {
1026
1227
  if (!message) return false;
1228
+ // Anywhere in raw bytes, headers included: 8BITMIME is about the octets on
1229
+ // the wire, so unlike SMTPUTF8 above this is not restricted to the header
1230
+ // block. Without it a raw message with a non-ASCII body went out with the
1231
+ // hint un-negotiated and was mangled by any peer that needed it.
1232
+ var raw8 = _rawText(message);
1233
+ if (raw8 !== null) return _hasNonAscii(raw8);
1027
1234
  var fields = ["text", "html", "subject"];
1028
1235
  for (var i = 0; i < fields.length; i += 1) {
1029
1236
  var v = message[fields[i]];
@@ -1068,9 +1275,9 @@ function _autoDetectFamily() {
1068
1275
  // 1870 SIZE pre-check before MAIL FROM. Caller passes the already-
1069
1276
  // CRLF-normalized + dot-stuffed wire string (the same one that goes
1070
1277
  // into DATA / BDAT). Returns the byte count Node will write.
1071
- function _messageWireSize(wire) {
1278
+ function _messageWireSize(wire, encoding) {
1072
1279
  if (typeof wire !== "string") return 0;
1073
- return Buffer.byteLength(wire, "utf8");
1280
+ return Buffer.byteLength(wire, encoding || "utf8");
1074
1281
  }
1075
1282
 
1076
1283
  // Parse the SIZE keyword's argument from a `SIZE 12345` EHLO line.
@@ -1124,22 +1331,51 @@ function _smtpSend(message, cfg) {
1124
1331
  var requiresBinaryMime = _messageRequiresBinaryMime(message);
1125
1332
  var requires8BitMime = _messageRequires8BitMime(message);
1126
1333
  var dataMessage = _buildRfc822(message);
1334
+ // Every conversion of dataMessage back to bytes uses this, so the size the
1335
+ // peer is told, the bytes BDAT frames, and the bytes DATA writes are all
1336
+ // the same bytes. Resolved before signing because the signer needs it too.
1337
+ var wireEncoding = _wireEncoding(message);
1127
1338
  if (cfg.dkimSigner) {
1128
- try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
1129
- catch (e) {
1339
+ try {
1340
+ // A message carrying raw octets is handed to the signer AS octets. A
1341
+ // signature covers the bytes on the wire, and passing a latin1 wire
1342
+ // string would have the signer read it as text, sign its UTF-8
1343
+ // re-encoding, and hand back a decoded copy — a corrupted message
1344
+ // carrying a signature that verifies, which is worse than an
1345
+ // unsigned one.
1346
+ if (wireEncoding === "latin1") {
1347
+ var signedBuf = cfg.dkimSigner.sign(Buffer.from(dataMessage, "latin1"));
1348
+ dataMessage = Buffer.isBuffer(signedBuf)
1349
+ ? signedBuf.toString("latin1")
1350
+ : String(signedBuf);
1351
+ } else {
1352
+ dataMessage = cfg.dkimSigner.sign(dataMessage);
1353
+ }
1354
+ } catch (e) {
1130
1355
  reject(new MailError("mail/dkim-sign-failed",
1131
1356
  "dkim signing failed: " + ((e && e.message) || String(e)), true));
1132
1357
  return;
1133
1358
  }
1134
1359
  }
1135
- var messageWireSize = _messageWireSize(dataMessage);
1360
+ var messageWireSize = _messageWireSize(dataMessage, wireEncoding);
1136
1361
 
1137
1362
  // Outbound SMTP-smuggling defense — refuse before opening the
1138
1363
  // socket if the produced RFC 822 wire contains the bare-CR / bare-
1139
1364
  // LF + smuggled-verb shape (CVE-2023-51764 / 51765 / 51766 class).
1140
1365
  // Operator-supplied subject / body / headers can sneak the pattern
1141
1366
  // through _buildRfc822 if the input wasn't already gated.
1142
- var rv = guardEmail().validateMessage(dataMessage, { profile: "strict" });
1367
+ //
1368
+ // For a BINARYMIME message the screen covers the header block rather than
1369
+ // the whole wire. A NUL is what identifies a body as binary in the first
1370
+ // place, so screening the body for one refused every message the transport
1371
+ // detects, negotiates and advertises support for: BINARYMIME was
1372
+ // unreachable. Narrowing the scope is safe because the smuggling class
1373
+ // lives in line structure, and RFC 3030 §3 sends a BINARYMIME body under
1374
+ // BDAT, which is length-framed — there is no dot terminator to forge and no
1375
+ // line scanning to confuse. The framing requirement is enforced below
1376
+ // rather than assumed.
1377
+ var screened = requiresBinaryMime ? _rawHeaderBlock(dataMessage) : dataMessage;
1378
+ var rv = guardEmail().validateMessage(screened, { profile: "strict" });
1143
1379
  if (!rv.ok) {
1144
1380
  var critical = rv.issues.filter(function (i) {
1145
1381
  return i.severity === "critical";
@@ -1203,7 +1439,7 @@ function _smtpSend(message, cfg) {
1203
1439
  // immediately by exactly N bytes of body (no CRLF terminator on
1204
1440
  // the chunk; SMTP framing is purely length-based per RFC 3030 §2).
1205
1441
  function sendBdatChunk() {
1206
- if (!dataWireBytes) dataWireBytes = Buffer.from(dataMessage, "utf8");
1442
+ if (!dataWireBytes) dataWireBytes = Buffer.from(dataMessage, wireEncoding);
1207
1443
  var remaining = dataWireBytes.length - bdatOffset;
1208
1444
  if (remaining <= 0) {
1209
1445
  // Empty body — send `BDAT 0 LAST` to terminate gracefully.
@@ -1285,7 +1521,14 @@ function _smtpSend(message, cfg) {
1285
1521
  tlsConnectOpts.port = cfg.port;
1286
1522
  if (family === 4 || family === 6) tlsConnectOpts.family = family;
1287
1523
  // allow:outbound-tls-posture — _smtpTlsOpts merges the live posture
1288
- attachSocket(nodeTls().connect(tlsConnectOpts));
1524
+ var implicitSock = nodeTls().connect(tlsConnectOpts);
1525
+ if (cfg.daneTlsa) {
1526
+ implicitSock.on("secureConnect", function () {
1527
+ var bad = _daneChainFailure(implicitSock, cfg);
1528
+ if (bad) fail(bad);
1529
+ });
1530
+ }
1531
+ attachSocket(implicitSock);
1289
1532
  } else {
1290
1533
  var netOpts = { host: cfg.host, port: cfg.port };
1291
1534
  if (family === 4 || family === 6) netOpts.family = family;
@@ -1306,6 +1549,25 @@ function _smtpSend(message, cfg) {
1306
1549
  peerSupportsBinaryMime = ehloLines.indexOf("BINARYMIME") !== -1;
1307
1550
  peerSupportsChunking = ehloLines.indexOf("CHUNKING") !== -1;
1308
1551
  peerSizeCap = _parsePeerSize(ehloFullLines);
1552
+
1553
+ // RFC 3207 §4.2 — the client MUST discard every service extension
1554
+ // learned before the upgrade, so nothing below may be decided from
1555
+ // this EHLO when a STARTTLS is still to come. The cleartext leg is
1556
+ // precisely what a network attacker can rewrite, and each of these
1557
+ // decisions is one an injected extension line would steer: a SIZE cap
1558
+ // that refuses every message, a CHUNKING that picks a framing the real
1559
+ // peer never offered, a BINARYMIME whose absence refuses a message the
1560
+ // peer would have taken.
1561
+ //
1562
+ // It reads as an over-refusal too. Servers commonly advertise CHUNKING
1563
+ // and BINARYMIME only once TLS is up, so a binary message was refused
1564
+ // before STARTTLS was even sent.
1565
+ if (!cfg.useImplicitTLS && !upgradedToTLS) {
1566
+ send("STARTTLS");
1567
+ step = SMTP_STEP_STARTTLS;
1568
+ return;
1569
+ }
1570
+
1309
1571
  // RFC 6531 §3.2 — if the message requires SMTPUTF8 and the
1310
1572
  // peer does not advertise it, refuse hard rather than emit a
1311
1573
  // mangled wire (server might still accept but headers/local
@@ -1344,12 +1606,48 @@ function _smtpSend(message, cfg) {
1344
1606
  // transaction. CHUNKING + BDAT is preferred when both peer
1345
1607
  // advertises it AND operator didn't disable it.
1346
1608
  useBdat = peerSupportsChunking && cfg.chunkingEnabled;
1609
+ // RFC 6152 §3 — a body carrying octets with the high bit set needs
1610
+ // 8BITMIME. Falling through to 7BIT and writing those octets anyway
1611
+ // declares one thing and sends another: the peer may reject the
1612
+ // transaction, or strip the eighth bit and deliver a corrupted message
1613
+ // under a signature that no longer matches it. The BINARYMIME sibling
1614
+ // below already refused; marking a body as needing an extension and
1615
+ // then shipping it without one is the same defect either way.
1616
+ if (requires8BitMime && !peerSupports8BitMime && !requiresBinaryMime) {
1617
+ settled = true;
1618
+ clearTxTimer();
1619
+ try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1620
+ reject(new MailError("mail/8bitmime-not-advertised",
1621
+ "message body has 8-bit content but peer does not advertise 8BITMIME " +
1622
+ "(RFC 6152); encode it as quoted-printable or base64, or deliver " +
1623
+ "through a peer that supports the extension", true));
1624
+ return;
1625
+ }
1347
1626
  if (requiresBinaryMime) bodyMode = "BINARYMIME";
1348
1627
  else if (requires8BitMime && peerSupports8BitMime) bodyMode = "8BITMIME";
1349
1628
  else bodyMode = "7BIT";
1629
+ // RFC 3030 §3 — a BINARYMIME body travels under BDAT, never DATA. The
1630
+ // body may contain a bare CR, a bare LF, or a lone dot on a line, so
1631
+ // the dot-terminated DATA framing cannot carry it: the peer would end
1632
+ // the message early and read the remainder as commands. That is also
1633
+ // the property the header-scoped smuggling screen above relies on, so
1634
+ // this refusal keeps the two consistent rather than leaving the
1635
+ // narrower screen resting on an assumption.
1636
+ if (requiresBinaryMime && !useBdat) {
1637
+ settled = true;
1638
+ clearTxTimer();
1639
+ try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1640
+ reject(new MailError("mail/binarymime-requires-chunking",
1641
+ "message body is binary (RFC 3030 BINARYMIME) but this transaction " +
1642
+ "would use DATA framing: " +
1643
+ (peerSupportsChunking ? "opts.chunking is disabled on this transport"
1644
+ : "the peer does not advertise CHUNKING") +
1645
+ ". A binary body cannot be dot-terminated; encode it as base64 or " +
1646
+ "enable CHUNKING", true));
1647
+ return;
1648
+ }
1350
1649
 
1351
- if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
1352
- else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
1650
+ if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
1353
1651
  else { send("MAIL FROM:<" + fromAddr + ">" + _mailFromSuffix()); step = SMTP_STEP_MAIL_FROM; }
1354
1652
  }
1355
1653
  else if (step === SMTP_STEP_STARTTLS) {
@@ -1361,6 +1659,19 @@ function _smtpSend(message, cfg) {
1361
1659
  upgradedToTLS = true;
1362
1660
  try { socket.removeAllListeners("data"); } catch (_e) { /* listeners migrate to upgraded socket */ }
1363
1661
  attachSocket(tlsSocket);
1662
+ // Before the second EHLO, so a peer that fails DANE never sees our
1663
+ // credentials or the message: the handshake is done, and this is the
1664
+ // first moment its certificate can be judged.
1665
+ if (cfg.daneTlsa) {
1666
+ var bad = _daneChainFailure(tlsSocket, cfg);
1667
+ if (bad) { fail(bad); return; }
1668
+ }
1669
+ // RFC 3207 §4.2 — discarded, not appended to. The extension lines
1670
+ // accumulate as they arrive, so leaving the cleartext ones in place
1671
+ // makes the post-upgrade set the UNION of both legs, and a capability
1672
+ // the real peer never advertised inside TLS is still believed.
1673
+ ehloLines.length = 0;
1674
+ ehloFullLines.length = 0;
1364
1675
  send("EHLO " + cfg.ehloName);
1365
1676
  step = SMTP_STEP_EHLO_RESP;
1366
1677
  });
@@ -1421,7 +1732,23 @@ function _smtpSend(message, cfg) {
1421
1732
  }
1422
1733
  else if (step === SMTP_STEP_DATA) {
1423
1734
  if (code !== 354) { fail("data-rejected (code " + code + ")"); return; }
1424
- send(_dotStuffForData(dataMessage) + "\r\n.");
1735
+ // Written as bytes, not as a string: socket.write(string) encodes utf8,
1736
+ // which would re-introduce the U+FFFD substitution the latin1 round
1737
+ // trip exists to avoid. Dot-stuffing runs on the string first because
1738
+ // under latin1 one character is one byte, so the line scan is
1739
+ // byte-accurate either way.
1740
+ //
1741
+ // RFC 5321 §4.1.1.4 — the terminator is CRLF "." CRLF, and the first
1742
+ // CRLF belongs to the body's last line. A serialized RFC 822 message
1743
+ // normally already ends in one, so appending another inserted a blank
1744
+ // line: the bytes were not verbatim after all, and a DKIM signature
1745
+ // over them no longer verified. Only the missing CRLF is added.
1746
+ var stuffed = _dotStuffForData(dataMessage);
1747
+ var terminator = (stuffed.length >= 2 && stuffed.slice(-2) === "\r\n")
1748
+ ? ".\r\n" : "\r\n.\r\n";
1749
+ try {
1750
+ socket.write(Buffer.from(stuffed + terminator, wireEncoding));
1751
+ } catch (e) { fail(e.message || String(e)); return; }
1425
1752
  step = SMTP_STEP_BODY;
1426
1753
  }
1427
1754
  else if (step === SMTP_STEP_BDAT) {
@@ -2075,9 +2402,10 @@ module.exports = {
2075
2402
  // verify (RFC 6376, on .dkim above alongside outbound signing),
2076
2403
  // DMARC (RFC 9989), ARC (RFC 8617). `.inbound.verify` is the
2077
2404
  // one-call receiver pipeline — SPF + DKIM + From-header extraction +
2078
- // DMARC policy + the RFC 8601 Authentication-Results header —
2079
- // composed by b.mail.server.mx at DATA time via its guardEnvelope
2080
- // opt and callable directly by operator-built listeners.
2405
+ // DMARC policy + ARC chain status + the RFC 8601
2406
+ // Authentication-Results header — composed by b.mail.server.mx at
2407
+ // DATA time via its guardEnvelope opt and callable directly by
2408
+ // operator-built listeners.
2081
2409
  spf: mailAuth.spf,
2082
2410
  dmarc: mailAuth.dmarc,
2083
2411
  arc: mailAuth.arc,
@@ -2085,9 +2413,19 @@ module.exports = {
2085
2413
  authResults: mailAuth.authResults,
2086
2414
  inbound: mailAuth.inbound,
2087
2415
  bimi: mailBimi,
2088
- // Test-only export: lets unit tests inspect the wire format without
2089
- // standing up a TLS-capable SMTP fixture. Operators don't call this.
2416
+ // Test-only exports: let unit tests inspect the wire format and the
2417
+ // capability decisions without standing up a TLS-capable SMTP fixture that
2418
+ // advertises SMTPUTF8 / 8BITMIME / BINARYMIME. Operators don't call these.
2419
+ //
2420
+ // The three detectors are exported for the same reason the builder is: they
2421
+ // decide what goes on the wire alongside the body, they read the message the
2422
+ // same way it does, and when the builder learned about raw bytes and they did
2423
+ // not, a raw message went out with every hint un-negotiated. Testing the
2424
+ // builder alone is what left that gap open.
2090
2425
  _buildRfc822ForTest: _buildRfc822,
2426
+ _requiresSmtpUtf8ForTest: _messageRequiresSmtpUtf8,
2427
+ _requiresBinaryMimeForTest: _messageRequiresBinaryMime,
2428
+ _requires8BitMimeForTest: _messageRequires8BitMime,
2091
2429
  transports: {
2092
2430
  console: consoleTransport,
2093
2431
  memory: memoryTransport,
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
 
@@ -198,7 +198,12 @@ function create(opts) {
198
198
  }
199
199
 
200
200
  function _emitObs(metric, n, tags) {
201
- try { observability().count(metric, n, tags || {}); }
201
+ // `safeEvent`, which is the name the module exports. It was `count`, which
202
+ // it does not, so this threw a TypeError on every call and the drop-silent
203
+ // catch below swallowed it — the counter never emitted once. `safeEvent` is
204
+ // itself drop-silent, so the catch stays as a belt on a working emitter
205
+ // rather than as the thing hiding a broken one.
206
+ try { observability().safeEvent(metric, n, tags || {}); }
202
207
  catch (_e) { /* best-effort */ }
203
208
  }
204
209
 
@@ -316,7 +316,11 @@ function create(opts) {
316
316
  }
317
317
  _emitDenied(req, "cross-site-refused (mode=" + (mode || "?") +
318
318
  ", dest=" + (dest || "?") + ")");
319
- try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
319
+ // `safeEvent`, not `count` the module exports no `count`, so this threw on
320
+ // every cross-site refusal and the drop-silent catch swallowed it. The
321
+ // refusals happened; the metric that was meant to make them visible never
322
+ // did.
323
+ try { observability().safeEvent("auth.fetch_metadata.cross_site_refused", 1, {}); }
320
324
  catch (_e) { /* best-effort */ }
321
325
  return _writeReject(req, res, "Cross-site request refused.", "cross-site-refused", onDeny, problemMode);
322
326
  };