@blamejs/core 0.8.52 → 0.8.57
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 +5 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/mail.js
CHANGED
|
@@ -69,7 +69,9 @@ var mailAuth = require("./mail-auth");
|
|
|
69
69
|
var mailBimi = require("./mail-bimi");
|
|
70
70
|
var mailUnsubscribe = require("./mail-unsubscribe");
|
|
71
71
|
var net = lazyRequire(function () { return require("net"); });
|
|
72
|
+
var networkDns = lazyRequire(function () { return require("./network-dns"); });
|
|
72
73
|
var nodeUrl = require("url");
|
|
74
|
+
var numericBounds = require("./numeric-bounds");
|
|
73
75
|
var tls = lazyRequire(function () { return require("tls"); });
|
|
74
76
|
var safeJson = require("./safe-json");
|
|
75
77
|
var safeSchema = require("./safe-schema");
|
|
@@ -165,6 +167,85 @@ function toUnicode(domain) {
|
|
|
165
167
|
catch (_e) { return null; }
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* @primitive b.mail.reverseDns
|
|
172
|
+
* @signature b.mail.reverseDns(ip)
|
|
173
|
+
* @since 0.8.53
|
|
174
|
+
* @status stable
|
|
175
|
+
* @related b.mail.create
|
|
176
|
+
*
|
|
177
|
+
* Forward-confirmed reverse DNS lookup (FCrDNS, RFC 8601 §3 lite) for
|
|
178
|
+
* an IPv4 or IPv6 address. Returns
|
|
179
|
+
* `{ ok, ptr, forward, fcrdns }`:
|
|
180
|
+
*
|
|
181
|
+
* - `ok` — whether the PTR resolved at all.
|
|
182
|
+
* - `ptr` — the first PTR record name (or `null`).
|
|
183
|
+
* - `forward` — array of A / AAAA addresses for that name (or `[]`).
|
|
184
|
+
* - `fcrdns` — `true` when the original `ip` appears in `forward`.
|
|
185
|
+
*
|
|
186
|
+
* Used as the building block for the iprev mail-authentication check
|
|
187
|
+
* (RFC 8601 §2.7.3): a sender's connect-IP must reverse-resolve to a
|
|
188
|
+
* PTR name whose forward A/AAAA includes that IP. Operators wiring
|
|
189
|
+
* inbound mail-receive paths call this on the connect address before
|
|
190
|
+
* accepting the SMTP transaction; bulk-sender reputation systems use
|
|
191
|
+
* the same check for outbound submission.
|
|
192
|
+
*
|
|
193
|
+
* Errors thrown by the underlying DNS path (bad-IP shape / lookup
|
|
194
|
+
* timeout) are caught and surfaced as `{ ok: false, error: code }`
|
|
195
|
+
* so the call doesn't reject the inbound path on a transient DNS
|
|
196
|
+
* blip; `fcrdns` remains `false`.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* var b = require("@blamejs/core");
|
|
200
|
+
* var r = await b.mail.reverseDns("8.8.8.8");
|
|
201
|
+
* // → { ok: true, ptr: "dns.google", forward: ["8.8.8.8"], fcrdns: true }
|
|
202
|
+
*/
|
|
203
|
+
async function reverseDns(ip) {
|
|
204
|
+
var dns = networkDns();
|
|
205
|
+
var result = { ok: false, ptr: null, forward: [], fcrdns: false };
|
|
206
|
+
var ptrs;
|
|
207
|
+
try {
|
|
208
|
+
ptrs = await dns.reverse(ip);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
result.error = (e && e.code) || "dns/reverse-failed";
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(ptrs) || ptrs.length === 0) {
|
|
214
|
+
result.error = "dns/no-ptr";
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
var ptrName = String(ptrs[0]);
|
|
218
|
+
result.ok = true;
|
|
219
|
+
result.ptr = ptrName;
|
|
220
|
+
// Forward-confirm — query A or AAAA depending on the IP family of
|
|
221
|
+
// the original input. RFC 8601 §3 says the forward query must use
|
|
222
|
+
// the same family as the source; mismatched families don't count
|
|
223
|
+
// as confirmation.
|
|
224
|
+
var net = require("net");
|
|
225
|
+
var forwardAddrs = [];
|
|
226
|
+
try {
|
|
227
|
+
if (net.isIPv6(ip)) {
|
|
228
|
+
forwardAddrs = await dns.resolveAaaa(ptrName);
|
|
229
|
+
} else {
|
|
230
|
+
forwardAddrs = await dns.resolve4(ptrName);
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {
|
|
233
|
+
result.error = (e && e.code) || "dns/forward-failed";
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
result.forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
|
|
237
|
+
// Case-insensitive equality on IPv6 (canonical form differs for
|
|
238
|
+
// ::ffff:8.8.8.8 vs 8.8.8.8); compare lower-cased strings.
|
|
239
|
+
var ipLc = String(ip).toLowerCase();
|
|
240
|
+
for (var i = 0; i < result.forward.length; i += 1) {
|
|
241
|
+
if (String(result.forward[i]).toLowerCase() === ipLc) {
|
|
242
|
+
result.fcrdns = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
168
249
|
function _isValidEmail(addr) {
|
|
169
250
|
if (typeof addr !== "string" || addr.length === 0 || addr.length > EMAIL_MAX_LEN) {
|
|
170
251
|
return false;
|
|
@@ -707,17 +788,44 @@ function smtpTransport(opts) {
|
|
|
707
788
|
? undefined : host;
|
|
708
789
|
}
|
|
709
790
|
|
|
791
|
+
// RFC 3030 BDAT chunking — default chunk size 256 KiB. Operator opt
|
|
792
|
+
// `chunking: false` disables BDAT even when offered (some legacy
|
|
793
|
+
// receivers advertise CHUNKING but mishandle bare BDAT framing).
|
|
794
|
+
var chunkingEnabled = opts.chunking !== false;
|
|
795
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.chunkSize,
|
|
796
|
+
"smtp transport: opts.chunkSize", MailError, "mail/smtp-misconfigured");
|
|
797
|
+
var chunkSize = (opts.chunkSize !== undefined) ? opts.chunkSize : C.BYTES.kib(256);
|
|
798
|
+
|
|
799
|
+
// RFC 1870 SIZE — by default we honor the peer's advertised cap and
|
|
800
|
+
// refuse before opening DATA / BDAT. `respectPeerSize: false`
|
|
801
|
+
// disables the precheck (operator-asserted "I trust the peer to
|
|
802
|
+
// accept whatever I send").
|
|
803
|
+
var respectPeerSize = opts.respectPeerSize !== false;
|
|
804
|
+
|
|
805
|
+
// IPv4 / IPv6 family preference for the underlying connect. "any"
|
|
806
|
+
// lets Node pick (default — Happy Eyeballs / system policy); "4" or
|
|
807
|
+
// "6" forces a single family. The framework auto-detects when the
|
|
808
|
+
// local network has no IPv6 interfaces and prefers v4 in that case
|
|
809
|
+
// so a v6-only peer doesn't hang the connect timeout.
|
|
810
|
+
var preferFamily = (opts.preferFamily === 4 || opts.preferFamily === 6 ||
|
|
811
|
+
opts.preferFamily === "4" || opts.preferFamily === "6")
|
|
812
|
+
? Number(opts.preferFamily) : "any";
|
|
813
|
+
|
|
710
814
|
var cfg = {
|
|
711
|
-
host:
|
|
712
|
-
port:
|
|
713
|
-
user:
|
|
714
|
-
pass:
|
|
715
|
-
useImplicitTLS:
|
|
716
|
-
ehloName:
|
|
717
|
-
timeoutMs:
|
|
718
|
-
tlsOpts:
|
|
719
|
-
servername:
|
|
720
|
-
dkimSigner:
|
|
815
|
+
host: host,
|
|
816
|
+
port: port,
|
|
817
|
+
user: opts.user,
|
|
818
|
+
pass: opts.pass,
|
|
819
|
+
useImplicitTLS: useImplicitTLS,
|
|
820
|
+
ehloName: ehloName,
|
|
821
|
+
timeoutMs: timeoutMs,
|
|
822
|
+
tlsOpts: tlsOpts,
|
|
823
|
+
servername: servername,
|
|
824
|
+
dkimSigner: opts.dkimSigner || null,
|
|
825
|
+
chunkingEnabled: chunkingEnabled,
|
|
826
|
+
chunkSize: chunkSize,
|
|
827
|
+
respectPeerSize: respectPeerSize,
|
|
828
|
+
preferFamily: preferFamily,
|
|
721
829
|
};
|
|
722
830
|
|
|
723
831
|
return {
|
|
@@ -739,6 +847,10 @@ var SMTP_STEP_RCPT_TO = 0x6;
|
|
|
739
847
|
var SMTP_STEP_DATA = 0x7;
|
|
740
848
|
var SMTP_STEP_BODY = 0x8;
|
|
741
849
|
var SMTP_STEP_STARTTLS = 0xA;
|
|
850
|
+
// RFC 3030 BDAT chunked-body framing. The transport sends `BDAT N`
|
|
851
|
+
// (or `BDAT N LAST`) followed by exactly N bytes of body; each chunk
|
|
852
|
+
// expects a 250 response before the next chunk is written.
|
|
853
|
+
var SMTP_STEP_BDAT = 0xB;
|
|
742
854
|
|
|
743
855
|
function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
|
|
744
856
|
// RFC 6531 §3.4 — when SMTPUTF8 is advertised by the peer AND the
|
|
@@ -748,16 +860,133 @@ function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
|
|
|
748
860
|
return (requiresSmtpUtf8 && peerSupportsSmtpUtf8) ? " SMTPUTF8" : "";
|
|
749
861
|
}
|
|
750
862
|
|
|
863
|
+
// Detect whether the message has an 8-bit / binary attachment that
|
|
864
|
+
// requires BINARYMIME (RFC 3030 §3) on the wire. Pure-text messages —
|
|
865
|
+
// even when text/html bodies contain UTF-8 — go through 8BITMIME
|
|
866
|
+
// (RFC 6152) which is universally supported. The "binary" trigger is
|
|
867
|
+
// a Buffer attachment whose octet stream includes NUL bytes (or any
|
|
868
|
+
// byte > 0x7F when the operator marks `binary: true`). Buffers with
|
|
869
|
+
// the "application/octet-stream" claimed content-type also count.
|
|
870
|
+
function _messageRequiresBinaryMime(message) {
|
|
871
|
+
if (!message) return false;
|
|
872
|
+
if (!Array.isArray(message.attachments)) return false;
|
|
873
|
+
for (var i = 0; i < message.attachments.length; i += 1) {
|
|
874
|
+
var att = message.attachments[i];
|
|
875
|
+
if (!att) continue;
|
|
876
|
+
// Operator can mark explicit binary intent.
|
|
877
|
+
if (att.binary === true) return true;
|
|
878
|
+
// Buffer attachments whose content includes NUL are binary —
|
|
879
|
+
// base64 wraps them safely but the source octets are 8-bit-binary
|
|
880
|
+
// so the BODY=BINARYMIME hint is what tells the peer to expect
|
|
881
|
+
// RFC 3030 framing instead of bare 8BITMIME.
|
|
882
|
+
if (Buffer.isBuffer(att.content)) {
|
|
883
|
+
// Quick scan — first 4 KiB is enough to detect the common case
|
|
884
|
+
// (any executable / image / archive / pdf).
|
|
885
|
+
var max = Math.min(att.content.length, C.BYTES.kib(4));
|
|
886
|
+
for (var j = 0; j < max; j += 1) {
|
|
887
|
+
if (att.content[j] === 0) return true;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Detect whether the message body has any non-ASCII (8-bit) octets
|
|
895
|
+
// requiring at minimum 8BITMIME (RFC 6152) on the wire. Triggers on
|
|
896
|
+
// non-ASCII text bytes in subject / text / html / calendar parts.
|
|
897
|
+
// Distinct from BINARYMIME — the latter is for true binary streams
|
|
898
|
+
// (NUL-bearing).
|
|
899
|
+
function _messageRequires8BitMime(message) {
|
|
900
|
+
if (!message) return false;
|
|
901
|
+
var fields = ["text", "html", "subject"];
|
|
902
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
903
|
+
var v = message[fields[i]];
|
|
904
|
+
if (typeof v === "string" && NON_ASCII_RE.test(v)) return true; // allow:regex-no-length-cap — header-value detector; bounded by SMTP line cap upstream
|
|
905
|
+
}
|
|
906
|
+
if (message.calendar && typeof message.calendar.icalText === "string" &&
|
|
907
|
+
NON_ASCII_RE.test(message.calendar.icalText)) return true; // allow:regex-no-length-cap — caller bounds calendar.icalText size
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Auto-detect IPv4 / IPv6 family for outbound connect when the
|
|
912
|
+
// operator didn't pin one. Walks `os.networkInterfaces()`; if the
|
|
913
|
+
// host has no non-internal IPv6 interfaces, prefer family=4 so a
|
|
914
|
+
// v6-only AAAA record doesn't hang the connect timeout. When the
|
|
915
|
+
// host has both, return 0 (let Node pick — Happy Eyeballs / system
|
|
916
|
+
// resultOrder applies).
|
|
917
|
+
function _autoDetectFamily() {
|
|
918
|
+
try {
|
|
919
|
+
var os = require("os");
|
|
920
|
+
var ifaces = os.networkInterfaces();
|
|
921
|
+
var hasV6 = false;
|
|
922
|
+
var hasV4 = false;
|
|
923
|
+
var keys = Object.keys(ifaces);
|
|
924
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
925
|
+
var arr = ifaces[keys[k]] || [];
|
|
926
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
927
|
+
var entry = arr[i];
|
|
928
|
+
if (entry.internal) continue;
|
|
929
|
+
if (entry.family === "IPv6" || entry.family === 6) hasV6 = true;
|
|
930
|
+
if (entry.family === "IPv4" || entry.family === 4) hasV4 = true;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (hasV4 && !hasV6) return 4;
|
|
934
|
+
if (!hasV4 && hasV6) return 6;
|
|
935
|
+
return 0;
|
|
936
|
+
} catch (_e) {
|
|
937
|
+
return 0;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Compute the wire size of the produced RFC 822 message. Used by RFC
|
|
942
|
+
// 1870 SIZE pre-check before MAIL FROM. Caller passes the already-
|
|
943
|
+
// CRLF-normalized + dot-stuffed wire string (the same one that goes
|
|
944
|
+
// into DATA / BDAT). Returns the byte count Node will write.
|
|
945
|
+
function _messageWireSize(wire) {
|
|
946
|
+
if (typeof wire !== "string") return 0;
|
|
947
|
+
return Buffer.byteLength(wire, "utf8");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Parse the SIZE keyword's argument from a `SIZE 12345` EHLO line.
|
|
951
|
+
// Returns 0 when SIZE is advertised without a value (RFC 1870 §3 —
|
|
952
|
+
// some peers omit the limit, indicating "no enforced cap"); returns
|
|
953
|
+
// -1 when SIZE isn't advertised; otherwise returns the operator-side
|
|
954
|
+
// peer cap.
|
|
955
|
+
function _parsePeerSize(ehloLines) {
|
|
956
|
+
if (!Array.isArray(ehloLines)) return -1;
|
|
957
|
+
for (var i = 0; i < ehloLines.length; i += 1) {
|
|
958
|
+
var line = ehloLines[i];
|
|
959
|
+
// Lines come in already uppercased keyword form; the SIZE entry
|
|
960
|
+
// may be `SIZE` alone OR `SIZE 12345` — split on whitespace.
|
|
961
|
+
var parts = String(line).split(/\s+/);
|
|
962
|
+
if (parts[0] === "SIZE") {
|
|
963
|
+
if (parts.length < 2) return 0;
|
|
964
|
+
var n = parseInt(parts[1], 10);
|
|
965
|
+
return isFinite(n) && n >= 0 ? n : -1;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return -1;
|
|
969
|
+
}
|
|
970
|
+
|
|
751
971
|
function _smtpSend(message, cfg) {
|
|
752
972
|
return new Promise(function (resolve, reject) {
|
|
753
973
|
var socket;
|
|
754
974
|
var step = SMTP_STEP_GREETING;
|
|
755
975
|
var buffer = "";
|
|
756
|
-
var ehloLines = [];
|
|
757
|
-
var
|
|
976
|
+
var ehloLines = []; // RFC 5321 §4.1.1.1 — EHLO extension lines (uppercase keyword)
|
|
977
|
+
var ehloFullLines = []; // Full extension text (incl. args, e.g. "SIZE 12345")
|
|
978
|
+
var peerSupportsSmtpUtf8 = false; // RFC 6531 — set from EHLO response
|
|
979
|
+
var peerSupportsChunking = false; // RFC 3030 §2 CHUNKING
|
|
980
|
+
var peerSupportsBinaryMime = false; // RFC 3030 §3 BINARYMIME
|
|
981
|
+
var peerSupports8BitMime = false; // RFC 6152 (eight-bit MIME) // allow:raw-byte-literal — RFC number, not a byte literal
|
|
982
|
+
var peerSizeCap = -1; // RFC 1870 SIZE — -1 unset, 0 = no cap, >0 = byte limit
|
|
758
983
|
var upgradedToTLS = false;
|
|
759
984
|
var settled = false;
|
|
760
985
|
var rcptIndex = 0;
|
|
986
|
+
var bdatOffset = 0; // Bytes of dataMessage written so far via BDAT
|
|
987
|
+
var dataWireBytes = null; // Buffer view of dataMessage for BDAT slicing
|
|
988
|
+
var useBdat = false; // Decided post-EHLO based on peerSupportsChunking + cfg.chunkingEnabled
|
|
989
|
+
var bodyMode = "7BIT"; // "7BIT" / "8BITMIME" / "BINARYMIME"
|
|
761
990
|
|
|
762
991
|
var fromAddr = _extractAddr(message.from);
|
|
763
992
|
var toList = _toArray(message.to).map(_extractAddr);
|
|
@@ -765,6 +994,8 @@ function _smtpSend(message, cfg) {
|
|
|
765
994
|
var bccList = _toArray(message.bcc).map(_extractAddr);
|
|
766
995
|
var rcpts = toList.concat(ccList, bccList);
|
|
767
996
|
var requiresSmtpUtf8 = _messageRequiresSmtpUtf8(message);
|
|
997
|
+
var requiresBinaryMime = _messageRequiresBinaryMime(message);
|
|
998
|
+
var requires8BitMime = _messageRequires8BitMime(message);
|
|
768
999
|
var dataMessage = _buildRfc822(message);
|
|
769
1000
|
if (cfg.dkimSigner) {
|
|
770
1001
|
try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
|
|
@@ -774,6 +1005,7 @@ function _smtpSend(message, cfg) {
|
|
|
774
1005
|
return;
|
|
775
1006
|
}
|
|
776
1007
|
}
|
|
1008
|
+
var messageWireSize = _messageWireSize(dataMessage);
|
|
777
1009
|
|
|
778
1010
|
// Outbound SMTP-smuggling defense — refuse before opening the
|
|
779
1011
|
// socket if the produced RFC 822 wire contains the bare-CR / bare-
|
|
@@ -814,6 +1046,51 @@ function _smtpSend(message, cfg) {
|
|
|
814
1046
|
catch (e) { fail(e.message || String(e)); }
|
|
815
1047
|
}
|
|
816
1048
|
|
|
1049
|
+
// RFC 5321 + 6531 + 6152 + 3030 + 1870 — MAIL FROM keyword bundle.
|
|
1050
|
+
// Order: SMTPUTF8 (6531) → BODY=<7BIT|8BITMIME|BINARYMIME> →
|
|
1051
|
+
// SIZE=<bytes>. Peers tolerate any order but consistent ordering
|
|
1052
|
+
// simplifies the wire-trace gold files.
|
|
1053
|
+
function _mailFromSuffix() {
|
|
1054
|
+
var s = "";
|
|
1055
|
+
s += _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8);
|
|
1056
|
+
if (bodyMode === "BINARYMIME" && peerSupportsBinaryMime) {
|
|
1057
|
+
s += " BODY=BINARYMIME";
|
|
1058
|
+
} else if (bodyMode === "8BITMIME" && peerSupports8BitMime) {
|
|
1059
|
+
s += " BODY=8BITMIME";
|
|
1060
|
+
}
|
|
1061
|
+
// Append SIZE= when peer advertised SIZE (cap or no-cap form).
|
|
1062
|
+
// Peers without SIZE support get no SIZE= keyword (some legacy
|
|
1063
|
+
// peers reject unknown MAIL FROM keywords).
|
|
1064
|
+
if (peerSizeCap !== -1) {
|
|
1065
|
+
s += " SIZE=" + messageWireSize;
|
|
1066
|
+
}
|
|
1067
|
+
return s;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Send the next BDAT chunk. Each `BDAT N [LAST]` line is followed
|
|
1071
|
+
// immediately by exactly N bytes of body (no CRLF terminator on
|
|
1072
|
+
// the chunk; SMTP framing is purely length-based per RFC 3030 §2).
|
|
1073
|
+
function sendBdatChunk() {
|
|
1074
|
+
if (!dataWireBytes) dataWireBytes = Buffer.from(dataMessage, "utf8");
|
|
1075
|
+
var remaining = dataWireBytes.length - bdatOffset;
|
|
1076
|
+
if (remaining <= 0) {
|
|
1077
|
+
// Empty body — send `BDAT 0 LAST` to terminate gracefully.
|
|
1078
|
+
socket.write("BDAT 0 LAST\r\n");
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
var thisChunk = Math.min(remaining, cfg.chunkSize);
|
|
1082
|
+
var isLast = (bdatOffset + thisChunk) >= dataWireBytes.length;
|
|
1083
|
+
var header = "BDAT " + thisChunk + (isLast ? " LAST" : "") + "\r\n";
|
|
1084
|
+
try {
|
|
1085
|
+
socket.write(header);
|
|
1086
|
+
socket.write(dataWireBytes.slice(bdatOffset, bdatOffset + thisChunk));
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
fail(e.message || String(e));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
bdatOffset += thisChunk;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
817
1094
|
function onData(data) {
|
|
818
1095
|
buffer += data;
|
|
819
1096
|
var lines = buffer.split("\r\n");
|
|
@@ -823,11 +1100,17 @@ function _smtpSend(message, cfg) {
|
|
|
823
1100
|
if (!line) continue;
|
|
824
1101
|
var code = parseInt(line.slice(0, 3), 10);
|
|
825
1102
|
// EHLO continuation lines (250-X) carry extension names. We
|
|
826
|
-
// capture them so the dispatcher can branch on SMTPUTF8 /
|
|
827
|
-
// /
|
|
1103
|
+
// capture them so the dispatcher can branch on SMTPUTF8 /
|
|
1104
|
+
// 8BITMIME / BINARYMIME / CHUNKING / SIZE / STARTTLS before
|
|
1105
|
+
// MAIL FROM is sent. Both forms recorded: keyword-only (used
|
|
1106
|
+
// for set-membership tests) and full-line (used for SIZE arg).
|
|
828
1107
|
if (step === SMTP_STEP_EHLO_RESP) {
|
|
829
|
-
var
|
|
830
|
-
|
|
1108
|
+
var rest = line.slice(4);
|
|
1109
|
+
var keyword = rest.split(" ")[0].toUpperCase();
|
|
1110
|
+
if (keyword) {
|
|
1111
|
+
ehloLines.push(keyword);
|
|
1112
|
+
ehloFullLines.push(rest.toUpperCase());
|
|
1113
|
+
}
|
|
831
1114
|
}
|
|
832
1115
|
if (line[3] === "-") continue; // continuation line
|
|
833
1116
|
try { handleResponse(code); }
|
|
@@ -846,12 +1129,24 @@ function _smtpSend(message, cfg) {
|
|
|
846
1129
|
}
|
|
847
1130
|
|
|
848
1131
|
function connect() {
|
|
1132
|
+
// Family preference for the underlying lookup. Node's net /
|
|
1133
|
+
// tls.connect both honor `family: 4|6` to bias the dns lookup;
|
|
1134
|
+
// the framework auto-detects an IPv4-only host when the operator
|
|
1135
|
+
// didn't pin one explicitly so a v6-only AAAA result doesn't
|
|
1136
|
+
// hang the connect.
|
|
1137
|
+
var family = cfg.preferFamily;
|
|
1138
|
+
if (family === "any") family = _autoDetectFamily();
|
|
849
1139
|
if (cfg.useImplicitTLS) {
|
|
850
1140
|
var tlsConnectOpts = Object.assign({}, cfg.tlsOpts);
|
|
851
1141
|
if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
|
|
852
|
-
|
|
1142
|
+
tlsConnectOpts.host = cfg.host;
|
|
1143
|
+
tlsConnectOpts.port = cfg.port;
|
|
1144
|
+
if (family === 4 || family === 6) tlsConnectOpts.family = family;
|
|
1145
|
+
attachSocket(tls().connect(tlsConnectOpts));
|
|
853
1146
|
} else {
|
|
854
|
-
|
|
1147
|
+
var netOpts = { host: cfg.host, port: cfg.port };
|
|
1148
|
+
if (family === 4 || family === 6) netOpts.family = family;
|
|
1149
|
+
attachSocket(net().createConnection(netOpts));
|
|
855
1150
|
}
|
|
856
1151
|
}
|
|
857
1152
|
|
|
@@ -863,7 +1158,11 @@ function _smtpSend(message, cfg) {
|
|
|
863
1158
|
else if (step === SMTP_STEP_EHLO_RESP) {
|
|
864
1159
|
if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
|
|
865
1160
|
// Snapshot extensions advertised on the wire for downstream use.
|
|
866
|
-
peerSupportsSmtpUtf8
|
|
1161
|
+
peerSupportsSmtpUtf8 = ehloLines.indexOf("SMTPUTF8") !== -1;
|
|
1162
|
+
peerSupports8BitMime = ehloLines.indexOf("8BITMIME") !== -1;
|
|
1163
|
+
peerSupportsBinaryMime = ehloLines.indexOf("BINARYMIME") !== -1;
|
|
1164
|
+
peerSupportsChunking = ehloLines.indexOf("CHUNKING") !== -1;
|
|
1165
|
+
peerSizeCap = _parsePeerSize(ehloFullLines);
|
|
867
1166
|
// RFC 6531 §3.2 — if the message requires SMTPUTF8 and the
|
|
868
1167
|
// peer does not advertise it, refuse hard rather than emit a
|
|
869
1168
|
// mangled wire (server might still accept but headers/local
|
|
@@ -872,9 +1171,41 @@ function _smtpSend(message, cfg) {
|
|
|
872
1171
|
fail("eai-required-not-supported: message has non-ASCII content but peer does not advertise SMTPUTF8");
|
|
873
1172
|
return;
|
|
874
1173
|
}
|
|
1174
|
+
// RFC 3030 §3 — BINARYMIME is only legal when the peer
|
|
1175
|
+
// advertises it. If the message requires it but the peer
|
|
1176
|
+
// doesn't offer it, refuse: silently downgrading to 8BITMIME
|
|
1177
|
+
// would corrupt NUL-bearing octets in transit.
|
|
1178
|
+
if (requiresBinaryMime && !peerSupportsBinaryMime) {
|
|
1179
|
+
settled = true;
|
|
1180
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1181
|
+
reject(new MailError("mail/binarymime-not-advertised",
|
|
1182
|
+
"message has 8-bit binary content but peer does not advertise BINARYMIME (RFC 3030 §3)",
|
|
1183
|
+
true));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
// RFC 1870 §3 — SIZE pre-check. Refuse before opening DATA /
|
|
1187
|
+
// BDAT so the caller gets a clean error instead of a 552
|
|
1188
|
+
// mid-stream rejection. peerSizeCap = 0 means "no enforced
|
|
1189
|
+
// cap"; -1 means "SIZE not advertised" (no precheck).
|
|
1190
|
+
if (cfg.respectPeerSize && peerSizeCap > 0 && messageWireSize > peerSizeCap) {
|
|
1191
|
+
settled = true;
|
|
1192
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1193
|
+
reject(new MailError("mail/peer-size-exceeded",
|
|
1194
|
+
"message wire size " + messageWireSize + " bytes exceeds peer SIZE cap " +
|
|
1195
|
+
peerSizeCap + " bytes (RFC 1870)", true));
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
// Decide BDAT vs DATA + BODY=8BITMIME / BINARYMIME for this
|
|
1199
|
+
// transaction. CHUNKING + BDAT is preferred when both peer
|
|
1200
|
+
// advertises it AND operator didn't disable it.
|
|
1201
|
+
useBdat = peerSupportsChunking && cfg.chunkingEnabled;
|
|
1202
|
+
if (requiresBinaryMime) bodyMode = "BINARYMIME";
|
|
1203
|
+
else if (requires8BitMime && peerSupports8BitMime) bodyMode = "8BITMIME";
|
|
1204
|
+
else bodyMode = "7BIT";
|
|
1205
|
+
|
|
875
1206
|
if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
|
|
876
1207
|
else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
|
|
877
|
-
else { send("MAIL FROM:<" + fromAddr + ">" +
|
|
1208
|
+
else { send("MAIL FROM:<" + fromAddr + ">" + _mailFromSuffix()); step = SMTP_STEP_MAIL_FROM; }
|
|
878
1209
|
}
|
|
879
1210
|
else if (step === SMTP_STEP_STARTTLS) {
|
|
880
1211
|
if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
|
|
@@ -901,7 +1232,7 @@ function _smtpSend(message, cfg) {
|
|
|
901
1232
|
}
|
|
902
1233
|
else if (step === SMTP_STEP_AUTH_FINAL) {
|
|
903
1234
|
if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
|
|
904
|
-
send("MAIL FROM:<" + fromAddr + ">" +
|
|
1235
|
+
send("MAIL FROM:<" + fromAddr + ">" + _mailFromSuffix()); step = SMTP_STEP_MAIL_FROM;
|
|
905
1236
|
}
|
|
906
1237
|
else if (step === SMTP_STEP_MAIL_FROM) {
|
|
907
1238
|
if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
|
|
@@ -911,6 +1242,33 @@ function _smtpSend(message, cfg) {
|
|
|
911
1242
|
if (code < 200 || code >= 300) { fail("rcpt-rejected (code " + code + ")"); return; }
|
|
912
1243
|
if (rcptIndex < rcpts.length) {
|
|
913
1244
|
send("RCPT TO:<" + rcpts[rcptIndex++] + ">");
|
|
1245
|
+
} else if (useBdat) {
|
|
1246
|
+
// RFC 3030 §2 — BDAT framing replaces DATA + CRLF.CRLF.
|
|
1247
|
+
// Each chunk is `BDAT <octet-count> [LAST]\r\n` followed by
|
|
1248
|
+
// exactly <octet-count> bytes of body. We emit the audit
|
|
1249
|
+
// signal once per send (not per chunk) so the audit chain
|
|
1250
|
+
// doesn't get flooded for large messages.
|
|
1251
|
+
try {
|
|
1252
|
+
audit().safeEmit({
|
|
1253
|
+
action: "mail.transport.bdat",
|
|
1254
|
+
outcome: "success",
|
|
1255
|
+
metadata: {
|
|
1256
|
+
wireBytes: messageWireSize,
|
|
1257
|
+
chunkSize: cfg.chunkSize,
|
|
1258
|
+
expectedChunks: Math.max(1, Math.ceil(messageWireSize / cfg.chunkSize)),
|
|
1259
|
+
bodyMode: bodyMode,
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
if (bodyMode === "BINARYMIME") {
|
|
1263
|
+
audit().safeEmit({
|
|
1264
|
+
action: "mail.transport.binarymime",
|
|
1265
|
+
outcome: "success",
|
|
1266
|
+
metadata: { wireBytes: messageWireSize },
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
} catch (_e) { /* audit best-effort */ }
|
|
1270
|
+
step = SMTP_STEP_BDAT;
|
|
1271
|
+
sendBdatChunk();
|
|
914
1272
|
} else {
|
|
915
1273
|
send("DATA"); step = SMTP_STEP_DATA;
|
|
916
1274
|
}
|
|
@@ -920,6 +1278,26 @@ function _smtpSend(message, cfg) {
|
|
|
920
1278
|
send(dataMessage + "\r\n.");
|
|
921
1279
|
step = SMTP_STEP_BODY;
|
|
922
1280
|
}
|
|
1281
|
+
else if (step === SMTP_STEP_BDAT) {
|
|
1282
|
+
// Each BDAT chunk gets a 250 response per RFC 3030 §2. Anything
|
|
1283
|
+
// else (4xx / 5xx) is a hard rejection of the chunk; surface
|
|
1284
|
+
// a stable error code so downstream retry policy can identify
|
|
1285
|
+
// the chunked-body failure mode.
|
|
1286
|
+
if (code !== 250) {
|
|
1287
|
+
settled = true;
|
|
1288
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1289
|
+
reject(new MailError("mail/bdat-chunk-rejected",
|
|
1290
|
+
"BDAT chunk rejected (code " + code + ", offset " + bdatOffset + "/" +
|
|
1291
|
+
messageWireSize + ")", false));
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (bdatOffset >= messageWireSize) {
|
|
1295
|
+
// Final chunk acknowledged — message accepted.
|
|
1296
|
+
done(true, code);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
sendBdatChunk();
|
|
1300
|
+
}
|
|
923
1301
|
else if (step === SMTP_STEP_BODY) {
|
|
924
1302
|
var ok = code === 250;
|
|
925
1303
|
done(ok, code);
|
|
@@ -1368,6 +1746,9 @@ module.exports = {
|
|
|
1368
1746
|
// pre-SMTPUTF8 ASCII regex check.
|
|
1369
1747
|
toAscii: toAscii,
|
|
1370
1748
|
toUnicode: toUnicode,
|
|
1749
|
+
// Forward-confirmed reverse DNS lookup (RFC 8601 §3 lite). Building
|
|
1750
|
+
// block for inbound iprev / outbound submission reputation checks.
|
|
1751
|
+
reverseDns: reverseDns,
|
|
1371
1752
|
// DKIM-Signature header generation for outbound mail (rsa-sha256
|
|
1372
1753
|
// default, ed25519-sha256 opt-in). Wire it into the smtp transport
|
|
1373
1754
|
// via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
|
|
@@ -1379,6 +1760,7 @@ module.exports = {
|
|
|
1379
1760
|
spf: mailAuth.spf,
|
|
1380
1761
|
dmarc: mailAuth.dmarc,
|
|
1381
1762
|
arc: mailAuth.arc,
|
|
1763
|
+
iprev: mailAuth.iprev,
|
|
1382
1764
|
authResults: mailAuth.authResults,
|
|
1383
1765
|
bimi: mailBimi,
|
|
1384
1766
|
// Test-only export: lets unit tests inspect the wire format without
|
|
@@ -82,7 +82,7 @@ function _extractToken(req, scheme) {
|
|
|
82
82
|
* @primitive b.middleware.bearerAuth
|
|
83
83
|
* @signature b.middleware.bearerAuth(req, res, next)
|
|
84
84
|
* @since 0.1.0
|
|
85
|
-
* @related b.middleware.attachUser, b.middleware.requireAuth
|
|
85
|
+
* @related b.middleware.attachUser, b.middleware.requireAuth
|
|
86
86
|
*
|
|
87
87
|
* Extracts `Authorization: Bearer <token>`, calls an operator-supplied
|
|
88
88
|
* verifier, attaches the result to `req.user`. Constructed via
|