@blamejs/core 0.14.22 → 0.14.24
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 +4 -0
- package/README.md +2 -2
- package/lib/compliance.js +37 -0
- package/lib/crypto-field.js +111 -5
- package/lib/db-query.js +123 -0
- package/lib/external-db-migrate.js +19 -7
- package/lib/external-db.js +508 -20
- package/lib/framework-error.js +6 -0
- package/lib/mail-auth.js +236 -0
- package/lib/mail-dkim.js +1 -0
- package/lib/mail-server-mx.js +276 -7
- package/lib/mail.js +8 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-auth.js
CHANGED
|
@@ -2090,6 +2090,239 @@ function authResultsEmit(opts) {
|
|
|
2090
2090
|
return "Authentication-Results: " + head + ";\r\n " + clauses.join(sep);
|
|
2091
2091
|
}
|
|
2092
2092
|
|
|
2093
|
+
// ---- Inbound message-authentication pipeline (RFC 7489 §6.6) ----
|
|
2094
|
+
//
|
|
2095
|
+
// One call runs the receiver-side authentication set on a message as it
|
|
2096
|
+
// arrives: SPF (RFC 7208) on the envelope identity, DKIM (RFC 6376) on
|
|
2097
|
+
// the message bytes, DMARC (RFC 7489 / DMARCbis) policy + alignment on
|
|
2098
|
+
// the From-header domain, and — when an authserv-id is supplied — the
|
|
2099
|
+
// RFC 8601 Authentication-Results header the receiver prepends before
|
|
2100
|
+
// delivery. b.mail.server.mx composes this at DATA time via its
|
|
2101
|
+
// guardEnvelope opt; operators running their own listeners (or doing
|
|
2102
|
+
// post-delivery verification in an agent) call it directly:
|
|
2103
|
+
//
|
|
2104
|
+
// var v = await b.mail.inbound.verify({
|
|
2105
|
+
// ip: "203.0.113.5",
|
|
2106
|
+
// helo: "mail.sender.example",
|
|
2107
|
+
// mailFrom: "bounce@sender.example",
|
|
2108
|
+
// message: rfc5322Bytes, // string or Buffer
|
|
2109
|
+
// authservId: "mx.example.com",
|
|
2110
|
+
// });
|
|
2111
|
+
// // → { spf, dkim, from, dmarc, authResults }
|
|
2112
|
+
// if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
|
|
2113
|
+
//
|
|
2114
|
+
// From-header discipline (RFC 7489 §6.6.1): DMARC evaluates exactly one
|
|
2115
|
+
// author domain. A message with zero From fields, several From fields,
|
|
2116
|
+
// or several author addresses in one field is the header-duplication
|
|
2117
|
+
// spoofing shape — an attacker pairs an aligned-but-hidden From with the
|
|
2118
|
+
// one the mail client displays (the CVE-2024-7208 / CVE-2024-7209
|
|
2119
|
+
// hosted-relay spoofing class rides on exactly this ambiguity). Those
|
|
2120
|
+
// messages return `dmarc.result: "permerror"` with
|
|
2121
|
+
// `recommendedAction: "reject"` instead of picking one of the Froms.
|
|
2122
|
+
|
|
2123
|
+
// RFC 5322 §2.1 — the header block ends at the first empty line. SMTP
|
|
2124
|
+
// wire format is CRLF; bare-LF input is accepted defensively for
|
|
2125
|
+
// operator-fed strings that lost CRs in their own tooling.
|
|
2126
|
+
function _splitHeaderBlock(message) {
|
|
2127
|
+
var idx = message.indexOf("\r\n\r\n");
|
|
2128
|
+
if (idx !== -1) return { headers: message.slice(0, idx), body: message.slice(idx + 4) };
|
|
2129
|
+
idx = message.indexOf("\n\n");
|
|
2130
|
+
if (idx !== -1) return { headers: message.slice(0, idx), body: message.slice(idx + 2) };
|
|
2131
|
+
return { headers: message, body: "" };
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Quote-aware single pass over a From field value (RFC 5322 phrase
|
|
2135
|
+
// quoting): counts angle-addr pairs that contain an `@` (a `<` inside
|
|
2136
|
+
// a quoted-string is display-name text — `"John <Jr.> Smith" <u@d>`
|
|
2137
|
+
// is one author, not two) and top-level commas (address-list
|
|
2138
|
+
// separators; a comma inside a quoted display-name like
|
|
2139
|
+
// `"Doe, John" <j@d>` does not count). Records the content of the
|
|
2140
|
+
// last @-bearing angle-addr for extraction.
|
|
2141
|
+
function _countFromAuthors(value) {
|
|
2142
|
+
var inQuote = false, inAngle = false, escaped = false;
|
|
2143
|
+
var angleAddrs = 0, topCommas = 0, angleStart = -1;
|
|
2144
|
+
var lastAddr = null;
|
|
2145
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
2146
|
+
var ch = value.charAt(i);
|
|
2147
|
+
if (escaped) { escaped = false; continue; }
|
|
2148
|
+
if (ch === "\\") { escaped = true; continue; }
|
|
2149
|
+
if (ch === "\"" && !inAngle) { inQuote = !inQuote; continue; }
|
|
2150
|
+
if (inQuote) continue;
|
|
2151
|
+
if (ch === "<" && !inAngle) { inAngle = true; angleStart = i; continue; }
|
|
2152
|
+
if (ch === ">" && inAngle) {
|
|
2153
|
+
inAngle = false;
|
|
2154
|
+
var inner = value.slice(angleStart + 1, i).trim();
|
|
2155
|
+
if (inner.indexOf("@") !== -1) { angleAddrs += 1; lastAddr = inner; }
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
if (ch === "," && !inAngle) topCommas += 1;
|
|
2159
|
+
}
|
|
2160
|
+
return { angleAddrs: angleAddrs, topCommas: topCommas, lastAddr: lastAddr };
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// Unfold (RFC 5322 §2.2.3), collect every From: field, and extract the
|
|
2164
|
+
// author address. `count` is the number of From fields, widened by
|
|
2165
|
+
// multiple-author detection inside a single field: several @-bearing
|
|
2166
|
+
// angle-addrs, or a bare address-list separated by top-level commas
|
|
2167
|
+
// (RFC 7489 §6.6.1 — a multi-author From is the header-duplication
|
|
2168
|
+
// spoofing shape and must not have "one" author picked from it).
|
|
2169
|
+
function _extractFromHeaders(headerBlock) {
|
|
2170
|
+
var unfolded = headerBlock.replace(/\r?\n[ \t]+/g, " ");
|
|
2171
|
+
var lines = unfolded.split(/\r?\n/);
|
|
2172
|
+
var fromValues = [];
|
|
2173
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
2174
|
+
var m = /^From[ \t]*:(.*)$/i.exec(lines[i]);
|
|
2175
|
+
if (m) fromValues.push(m[1].trim());
|
|
2176
|
+
}
|
|
2177
|
+
if (fromValues.length === 0) return { count: 0, address: null, domain: null };
|
|
2178
|
+
var count = fromValues.length;
|
|
2179
|
+
var value = fromValues[0];
|
|
2180
|
+
var authors = _countFromAuthors(value);
|
|
2181
|
+
if (count === 1) {
|
|
2182
|
+
if (authors.angleAddrs > 1) count = authors.angleAddrs;
|
|
2183
|
+
else if (authors.topCommas > 0) count = authors.topCommas + 1;
|
|
2184
|
+
}
|
|
2185
|
+
var address;
|
|
2186
|
+
if (authors.angleAddrs >= 1) {
|
|
2187
|
+
// count > 1 is refused by the caller before the address is used;
|
|
2188
|
+
// for the single-author case this is that author's angle-addr.
|
|
2189
|
+
address = authors.lastAddr;
|
|
2190
|
+
} else {
|
|
2191
|
+
// Bare addr-spec form. An RFC 5322 addr-spec cannot contain
|
|
2192
|
+
// whitespace or commas — their presence means an address list or
|
|
2193
|
+
// display-name soup; extracting "the" domain from it would pick
|
|
2194
|
+
// one of several authors (the §6.6.1 forbidden move), so the
|
|
2195
|
+
// field is treated as unparsable instead.
|
|
2196
|
+
address = value.trim();
|
|
2197
|
+
if (/[\s,]/.test(address)) address = null;
|
|
2198
|
+
}
|
|
2199
|
+
var at = address ? address.lastIndexOf("@") : -1;
|
|
2200
|
+
var domain = (at > 0 && address && at < address.length - 1)
|
|
2201
|
+
? address.slice(at + 1).toLowerCase()
|
|
2202
|
+
: null;
|
|
2203
|
+
return { count: count, address: address || null, domain: domain };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
async function inboundVerify(opts) {
|
|
2207
|
+
validateOpts.requireObject(opts, "inbound.verify", MailAuthError, "mail-auth/inbound-bad-input");
|
|
2208
|
+
validateOpts(opts, ["ip", "helo", "mailFrom", "message", "dnsLookup", "domainExists",
|
|
2209
|
+
"maxSignatures", "clockSkewMs", "minRsaBits", "authservId"],
|
|
2210
|
+
"mail.inbound.verify");
|
|
2211
|
+
validateOpts.requireNonEmptyString(opts.ip, "inbound.verify: ip",
|
|
2212
|
+
MailAuthError, "mail-auth/inbound-bad-ip");
|
|
2213
|
+
if (opts.authservId !== undefined && opts.authservId !== null) {
|
|
2214
|
+
validateOpts.requireNonEmptyString(opts.authservId, "inbound.verify: authservId",
|
|
2215
|
+
MailAuthError, "mail-auth/inbound-bad-authserv-id");
|
|
2216
|
+
}
|
|
2217
|
+
var message = opts.message;
|
|
2218
|
+
if (Buffer.isBuffer(message)) {
|
|
2219
|
+
// DKIM canonicalization re-encodes the string form as UTF-8
|
|
2220
|
+
// (lib/mail-dkim.js hashes Buffer.from(canonicalized, "utf8")), so
|
|
2221
|
+
// the byte→string decode must be utf8 for valid-UTF-8 content to
|
|
2222
|
+
// round-trip exactly. Non-UTF-8 8-bit content cannot survive any
|
|
2223
|
+
// decode + utf8 re-encode; such messages verify as DKIM fail and
|
|
2224
|
+
// DMARC falls back to the SPF identity (RFC 7489 §4.2 — one
|
|
2225
|
+
// aligned authenticator is sufficient to pass).
|
|
2226
|
+
message = message.toString("utf8");
|
|
2227
|
+
}
|
|
2228
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
2229
|
+
throw new MailAuthError("mail-auth/inbound-bad-message",
|
|
2230
|
+
"inbound.verify: message must be a non-empty string or Buffer (the full RFC 5322 message)");
|
|
2231
|
+
}
|
|
2232
|
+
var mailFrom = (typeof opts.mailFrom === "string" && opts.mailFrom.length > 0) ? opts.mailFrom : null;
|
|
2233
|
+
var helo = (typeof opts.helo === "string" && opts.helo.length > 0) ? opts.helo : null;
|
|
2234
|
+
|
|
2235
|
+
// SPF — envelope identity: MAIL FROM, falling back to HELO for the
|
|
2236
|
+
// null reverse-path (RFC 7208 §2.4). DNS failures surface as the
|
|
2237
|
+
// RFC's temperror result, not as throws.
|
|
2238
|
+
var spf;
|
|
2239
|
+
if (mailFrom || helo) {
|
|
2240
|
+
spf = await spfVerify({
|
|
2241
|
+
ip: opts.ip,
|
|
2242
|
+
mailFrom: mailFrom || undefined,
|
|
2243
|
+
helo: helo || undefined,
|
|
2244
|
+
dnsLookup: opts.dnsLookup,
|
|
2245
|
+
});
|
|
2246
|
+
} else {
|
|
2247
|
+
spf = { result: "none", domain: null,
|
|
2248
|
+
explanation: "no MAIL FROM or HELO identity supplied", lookupCount: 0 };
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// DKIM — every signature on the message (bounded by maxSignatures;
|
|
2252
|
+
// a signature-less message verifies as a single `none` entry).
|
|
2253
|
+
var dkimVerifyOpts = { dnsLookup: opts.dnsLookup };
|
|
2254
|
+
if (opts.clockSkewMs !== undefined) dkimVerifyOpts.clockSkewMs = opts.clockSkewMs;
|
|
2255
|
+
if (opts.maxSignatures !== undefined) dkimVerifyOpts.maxSignatures = opts.maxSignatures;
|
|
2256
|
+
if (opts.minRsaBits !== undefined) dkimVerifyOpts.minRsaBits = opts.minRsaBits;
|
|
2257
|
+
var dkimResults = await dkim.verify(message, dkimVerifyOpts);
|
|
2258
|
+
|
|
2259
|
+
// From header + DMARC policy/alignment.
|
|
2260
|
+
var from = _extractFromHeaders(_splitHeaderBlock(message).headers);
|
|
2261
|
+
var dmarc;
|
|
2262
|
+
if (from.count === 1 && from.address && from.domain) {
|
|
2263
|
+
dmarc = await dmarcEvaluate({
|
|
2264
|
+
from: from.address,
|
|
2265
|
+
spf: spf,
|
|
2266
|
+
dkim: dkimResults,
|
|
2267
|
+
dnsLookup: opts.dnsLookup,
|
|
2268
|
+
domainExists: opts.domainExists,
|
|
2269
|
+
});
|
|
2270
|
+
// RFC 7489 §6.6.2 — a fail verdict computed while an authenticator
|
|
2271
|
+
// returned temperror is not final: the very lookup that failed
|
|
2272
|
+
// transiently could have produced the aligned pass. Surface
|
|
2273
|
+
// temperror so the caller defers (the sender retries) instead of
|
|
2274
|
+
// permanently refusing a legitimate sender during a DNS blip. A
|
|
2275
|
+
// pass verdict stands — one aligned authenticator is sufficient
|
|
2276
|
+
// regardless of the other's transient failure.
|
|
2277
|
+
if (dmarc.result === "fail" &&
|
|
2278
|
+
(spf.result === "temperror" ||
|
|
2279
|
+
dkimResults.some(function (d) { return d.result === "temperror"; }))) {
|
|
2280
|
+
dmarc.result = "temperror";
|
|
2281
|
+
dmarc.recommendedAction = null;
|
|
2282
|
+
dmarc.explanation = (dmarc.explanation ? dmarc.explanation + "; " : "") +
|
|
2283
|
+
"fail computed while an authenticator returned temperror — transient, retry";
|
|
2284
|
+
}
|
|
2285
|
+
} else {
|
|
2286
|
+
dmarc = {
|
|
2287
|
+
result: "permerror",
|
|
2288
|
+
recommendedAction: "reject",
|
|
2289
|
+
policy: null,
|
|
2290
|
+
alignment: { spf: false, dkim: false },
|
|
2291
|
+
orgDomain: null,
|
|
2292
|
+
explanation: from.count === 0
|
|
2293
|
+
? "message has no From header (RFC 7489 §6.6.1)"
|
|
2294
|
+
: (from.count > 1
|
|
2295
|
+
? "message carries " + from.count + " From authors (RFC 7489 §6.6.1 — multi-From spoofing shape)"
|
|
2296
|
+
: "From header has no parsable author domain"),
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// RFC 8601 Authentication-Results — only when the caller identifies
|
|
2301
|
+
// itself (the authserv-id is the receiver's own name; there is no
|
|
2302
|
+
// sensible default the framework could invent).
|
|
2303
|
+
var authResults = null;
|
|
2304
|
+
if (opts.authservId) {
|
|
2305
|
+
var arResults = [];
|
|
2306
|
+
var spfEntry = { method: "spf", result: spf.result };
|
|
2307
|
+
if (mailFrom) spfEntry.smtpMailfrom = mailFrom;
|
|
2308
|
+
else if (helo) spfEntry.smtpHelo = helo;
|
|
2309
|
+
arResults.push(spfEntry);
|
|
2310
|
+
for (var di = 0; di < dkimResults.length; di += 1) {
|
|
2311
|
+
var d = dkimResults[di];
|
|
2312
|
+
var dkimEntry = { method: "dkim", result: d.result };
|
|
2313
|
+
if (typeof d.d === "string" && d.d.length > 0) dkimEntry.domain = d.d;
|
|
2314
|
+
if (typeof d.s === "string" && d.s.length > 0) dkimEntry.selector = d.s;
|
|
2315
|
+
arResults.push(dkimEntry);
|
|
2316
|
+
}
|
|
2317
|
+
var dmarcEntry = { method: "dmarc", result: dmarc.result };
|
|
2318
|
+
if (from.address) dmarcEntry.from = from.address;
|
|
2319
|
+
arResults.push(dmarcEntry);
|
|
2320
|
+
authResults = authResultsEmit({ authservId: opts.authservId, results: arResults });
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc, authResults: authResults };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2093
2326
|
// ---- DMARC aggregate (RUA) report parser (RFC 7489 §7.2 / draft-ietf-dmarc-aggregate-reporting) ----
|
|
2094
2327
|
//
|
|
2095
2328
|
// MTAs that publish a DMARC `rua=` policy receive aggregate reports
|
|
@@ -2971,6 +3204,9 @@ module.exports = {
|
|
|
2971
3204
|
authResults: Object.freeze({
|
|
2972
3205
|
emit: authResultsEmit,
|
|
2973
3206
|
}),
|
|
3207
|
+
inbound: Object.freeze({
|
|
3208
|
+
verify: inboundVerify,
|
|
3209
|
+
}),
|
|
2974
3210
|
MailAuthError: MailAuthError,
|
|
2975
3211
|
SPF_DNS_LOOKUP_LIMIT: SPF_DNS_LOOKUP_LIMIT,
|
|
2976
3212
|
};
|
package/lib/mail-dkim.js
CHANGED
|
@@ -1242,6 +1242,7 @@ module.exports = {
|
|
|
1242
1242
|
RSA_MIN_BITS: RSA_MIN_BITS,
|
|
1243
1243
|
RSA_LEGACY_MIN_BITS: RSA_LEGACY_MIN_BITS,
|
|
1244
1244
|
DKIM_MAX_SIGNATURES_PER_MESSAGE: DKIM_MAX_SIGNATURES_PER_MESSAGE,
|
|
1245
|
+
DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING: DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING,
|
|
1245
1246
|
DKIM_CLOCK_SKEW_MS_MAX: DKIM_CLOCK_SKEW_MS_MAX,
|
|
1246
1247
|
_canonHeaderRelaxedForTest: _canonHeaderRelaxed,
|
|
1247
1248
|
_canonBodyRelaxedForTest: _canonBodyRelaxed,
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
* - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
|
|
74
74
|
* - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
|
|
75
75
|
* - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
|
|
76
|
+
* - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC results + action (accept / quarantine / reject / defer) + gate mode
|
|
77
|
+
* - `mail.server.mx.envelope_error` — DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)
|
|
76
78
|
* - `mail.server.mx.delivered` — agent.handoff ack
|
|
77
79
|
* - `mail.server.mx.tls_handshake_failed` — handshake error
|
|
78
80
|
* - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
|
|
@@ -111,13 +113,20 @@
|
|
|
111
113
|
* (connecting-IP DNS blocklist, evaluated once per connection) and
|
|
112
114
|
* `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
|
|
113
115
|
* RCPT TO and surface their verdicts on the `rcpt_to` event. The
|
|
114
|
-
* message-authentication
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
116
|
+
* message-authentication gate (`opts.guardEnvelope`) runs at DATA
|
|
117
|
+
* completion through `b.mail.inbound.verify` — SPF (RFC 7208) on the
|
|
118
|
+
* envelope identity, DKIM (RFC 6376) on the message bytes, DMARC
|
|
119
|
+
* (RFC 7489) policy + alignment on the From-header domain — and in
|
|
120
|
+
* enforce mode refuses before the agent handoff: 550 5.7.26
|
|
121
|
+
* (RFC 7372) when the sender's published policy says reject, 550
|
|
122
|
+
* 5.7.1 on the RFC 7489 §6.6.1 multi-From spoofing shape, 451 4.7.0
|
|
123
|
+
* on DNS temperror or pipeline timeout (operator-tunable via
|
|
124
|
+
* `onTemperror` / `timeoutMs`). Accepted messages carry the verdict
|
|
125
|
+
* to the agent handoff as `auth` and gain the receiver's RFC 8601
|
|
126
|
+
* Authentication-Results header — any sender-attached header forging
|
|
127
|
+
* this receiver's authserv-id is stripped first (§5) — so downstream
|
|
128
|
+
* consumers act on authenticated results instead of re-verifying;
|
|
129
|
+
* monitor mode annotates without refusing.
|
|
121
130
|
*
|
|
122
131
|
* @card
|
|
123
132
|
* Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
|
|
@@ -144,6 +153,12 @@ var mailServerTls = require("./mail-server-tls");
|
|
|
144
153
|
var { defineClass } = require("./framework-error");
|
|
145
154
|
|
|
146
155
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
156
|
+
// Lazy like the sibling host primitives' guard loads — the inbound
|
|
157
|
+
// authentication pipeline (and the DKIM verifier whose range
|
|
158
|
+
// constants the boot validation mirrors) only loads when an operator
|
|
159
|
+
// wires opts.guardEnvelope.
|
|
160
|
+
var mailAuth = lazyRequire(function () { return require("./mail-auth"); });
|
|
161
|
+
var dkim = lazyRequire(function () { return require("./mail-dkim"); });
|
|
147
162
|
|
|
148
163
|
var MailServerMxError = defineClass("MailServerMxError", { alwaysPermanent: true });
|
|
149
164
|
|
|
@@ -178,6 +193,59 @@ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
|
|
|
178
193
|
var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
|
|
179
194
|
var RE_SIZE = /SIZE=(\d+)/i;
|
|
180
195
|
|
|
196
|
+
// Map the b.mail.inbound.verify verdict to the DATA-phase gate action.
|
|
197
|
+
// The sender's published DMARC policy drives it (RFC 7489 §6.3 p= /
|
|
198
|
+
// §6.6.2 disposition): reject → refuse at the wire; quarantine →
|
|
199
|
+
// deliver annotated (an MX cannot spam-folder — the downstream agent
|
|
200
|
+
// owns disposition); none / pass → accept. DNS temperror defers or
|
|
201
|
+
// accepts per the operator's onTemperror choice. permerror carries a
|
|
202
|
+
// reject recommendation only for the multi-From spoofing shape
|
|
203
|
+
// (RFC 7489 §6.6.1), set by the pipeline itself.
|
|
204
|
+
function _envelopeActionFor(inbound, gate) {
|
|
205
|
+
var dmarc = inbound.dmarc || {};
|
|
206
|
+
if (dmarc.result === "temperror") {
|
|
207
|
+
return gate.onTemperror === "accept" ? "accept" : "defer";
|
|
208
|
+
}
|
|
209
|
+
if (dmarc.recommendedAction === "reject") return "reject";
|
|
210
|
+
if (dmarc.recommendedAction === "quarantine") return "quarantine";
|
|
211
|
+
return "accept";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// RFC 8601 §5 — an MTA adding its own Authentication-Results header
|
|
215
|
+
// MUST first remove any existing instance claiming its authserv-id: a
|
|
216
|
+
// sender can pre-attach a forged header carrying the receiver's name
|
|
217
|
+
// ("Authentication-Results: mx.example.com; dmarc=pass") and downstream
|
|
218
|
+
// consumers that trust the receiver's A-R header would read the forged
|
|
219
|
+
// verdict instead of the computed one. Headers naming OTHER
|
|
220
|
+
// authserv-ids are prior-hop information and stay. Operates on the
|
|
221
|
+
// header block only — the block is decoded as latin1 (byte-preserving
|
|
222
|
+
// round-trip) and the body bytes are never decoded at all, so 8-bit
|
|
223
|
+
// content is untouched.
|
|
224
|
+
function _stripForgedAuthResults(messageBuf, authservId) {
|
|
225
|
+
if (!authservId) return messageBuf;
|
|
226
|
+
var sepIdx = messageBuf.indexOf("\r\n\r\n");
|
|
227
|
+
var headerEnd = sepIdx === -1 ? messageBuf.length : sepIdx + 2;
|
|
228
|
+
var head = messageBuf.slice(0, headerEnd).toString("latin1");
|
|
229
|
+
var rest = messageBuf.slice(headerEnd);
|
|
230
|
+
if (head.toLowerCase().indexOf("authentication-results:") === -1) return messageBuf;
|
|
231
|
+
var lines = head.split("\r\n");
|
|
232
|
+
var out = [];
|
|
233
|
+
var skipping = false;
|
|
234
|
+
var prefix = "authentication-results:";
|
|
235
|
+
var wantId = authservId.toLowerCase();
|
|
236
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
237
|
+
var line = lines[i];
|
|
238
|
+
if (skipping && (line.charAt(0) === " " || line.charAt(0) === "\t")) continue; // folded continuation
|
|
239
|
+
skipping = false;
|
|
240
|
+
if (line.slice(0, prefix.length).toLowerCase() === prefix) {
|
|
241
|
+
var idTok = line.slice(prefix.length).trim().split(/[;\s]/)[0].toLowerCase();
|
|
242
|
+
if (idTok === wantId) { skipping = true; continue; }
|
|
243
|
+
}
|
|
244
|
+
out.push(line);
|
|
245
|
+
}
|
|
246
|
+
return Buffer.concat([Buffer.from(out.join("\r\n"), "latin1"), rest]);
|
|
247
|
+
}
|
|
248
|
+
|
|
181
249
|
/**
|
|
182
250
|
* @primitive b.mail.server.mx.create
|
|
183
251
|
* @signature b.mail.server.mx.create(opts)
|
|
@@ -202,6 +270,16 @@ var RE_SIZE = /SIZE=(\d+)/i;
|
|
|
202
270
|
* maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
|
|
203
271
|
* idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
|
|
204
272
|
* profile: "strict" | "balanced" | "permissive", // gate posture cascade
|
|
273
|
+
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC via b.mail.inbound.verify
|
|
274
|
+
* mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
|
|
275
|
+
* onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
|
|
276
|
+
* authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
|
|
277
|
+
* dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
|
|
278
|
+
* maxSignatures?: number, // DKIM verify cap (1-16)
|
|
279
|
+
* clockSkewMs?: number, // DKIM timestamp skew tolerance
|
|
280
|
+
* minRsaBits?: number, // DKIM minimum RSA key size
|
|
281
|
+
* timeoutMs?: number, // pipeline wall-clock ceiling; default 20s (timeout → temperror disposition)
|
|
282
|
+
* },
|
|
205
283
|
*
|
|
206
284
|
* @example
|
|
207
285
|
* var tls = b.network.tls.context({ cert: certPem, key: keyPem });
|
|
@@ -269,6 +347,92 @@ function create(opts) {
|
|
|
269
347
|
rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
|
|
270
348
|
}
|
|
271
349
|
|
|
350
|
+
// DATA-phase message-authentication gate. `guardEnvelope: true`
|
|
351
|
+
// gates with defaults; an object tunes it. Like the sibling gates
|
|
352
|
+
// (helo / rbl / greylist) the phase is skipped when the operator
|
|
353
|
+
// doesn't wire it — the gate needs live DNS to evaluate the
|
|
354
|
+
// sender's published policy, which closed-network deployments may
|
|
355
|
+
// not have.
|
|
356
|
+
var envelopeGate = null;
|
|
357
|
+
if (opts.guardEnvelope !== undefined && opts.guardEnvelope !== false) {
|
|
358
|
+
if (opts.guardEnvelope !== true &&
|
|
359
|
+
(typeof opts.guardEnvelope !== "object" || opts.guardEnvelope === null ||
|
|
360
|
+
Array.isArray(opts.guardEnvelope))) {
|
|
361
|
+
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
362
|
+
"mail.server.mx.create: guardEnvelope must be true, false, or a config object");
|
|
363
|
+
}
|
|
364
|
+
var ge = opts.guardEnvelope === true ? {} : opts.guardEnvelope;
|
|
365
|
+
validateOpts(ge, ["mode", "onTemperror", "authservId", "dnsLookup",
|
|
366
|
+
"maxSignatures", "clockSkewMs", "minRsaBits", "timeoutMs"],
|
|
367
|
+
"mail.server.mx.guardEnvelope");
|
|
368
|
+
var geMode = (ge.mode === undefined || ge.mode === null)
|
|
369
|
+
? (profile === "permissive" ? "monitor" : "enforce")
|
|
370
|
+
: ge.mode;
|
|
371
|
+
if (geMode !== "enforce" && geMode !== "monitor") {
|
|
372
|
+
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
373
|
+
"mail.server.mx.create: guardEnvelope.mode must be 'enforce' or 'monitor'");
|
|
374
|
+
}
|
|
375
|
+
var geOnTemperror = (ge.onTemperror === undefined || ge.onTemperror === null)
|
|
376
|
+
? "defer" : ge.onTemperror;
|
|
377
|
+
if (geOnTemperror !== "defer" && geOnTemperror !== "accept") {
|
|
378
|
+
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
379
|
+
"mail.server.mx.create: guardEnvelope.onTemperror must be 'defer' or 'accept'");
|
|
380
|
+
}
|
|
381
|
+
if (ge.authservId !== undefined && ge.authservId !== null) {
|
|
382
|
+
validateOpts.requireNonEmptyString(ge.authservId,
|
|
383
|
+
"mail.server.mx.create: guardEnvelope.authservId",
|
|
384
|
+
MailServerMxError, "mail-server-mx/bad-opts");
|
|
385
|
+
}
|
|
386
|
+
if (ge.dnsLookup !== undefined && ge.dnsLookup !== null &&
|
|
387
|
+
typeof ge.dnsLookup !== "function") {
|
|
388
|
+
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
389
|
+
"mail.server.mx.create: guardEnvelope.dnsLookup must be a function");
|
|
390
|
+
}
|
|
391
|
+
// DKIM bounds caught at boot, not at the first DATA — mirroring
|
|
392
|
+
// the exact ranges b.mail.dkim.verify enforces per call, so an
|
|
393
|
+
// operator typo fails startup instead of turning every live
|
|
394
|
+
// message into an envelope_error + temperror disposition.
|
|
395
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(ge,
|
|
396
|
+
["maxSignatures", "clockSkewMs", "minRsaBits", "timeoutMs"],
|
|
397
|
+
"mail.server.mx.guardEnvelope.", MailServerMxError, "mail-server-mx/bad-bound");
|
|
398
|
+
if (ge.maxSignatures !== undefined && ge.maxSignatures !== null &&
|
|
399
|
+
ge.maxSignatures > dkim().DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING) {
|
|
400
|
+
throw new MailServerMxError("mail-server-mx/bad-bound",
|
|
401
|
+
"mail.server.mx.create: guardEnvelope.maxSignatures " + ge.maxSignatures +
|
|
402
|
+
" exceeds the DKIM verifier ceiling " +
|
|
403
|
+
dkim().DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING +
|
|
404
|
+
" (RFC 6376 §6.1 fan-out DoS bound)");
|
|
405
|
+
}
|
|
406
|
+
if (ge.clockSkewMs !== undefined && ge.clockSkewMs !== null &&
|
|
407
|
+
ge.clockSkewMs > dkim().DKIM_CLOCK_SKEW_MS_MAX) {
|
|
408
|
+
throw new MailServerMxError("mail-server-mx/bad-bound",
|
|
409
|
+
"mail.server.mx.create: guardEnvelope.clockSkewMs " + ge.clockSkewMs +
|
|
410
|
+
" exceeds the DKIM verifier ceiling " + dkim().DKIM_CLOCK_SKEW_MS_MAX +
|
|
411
|
+
" (RFC 6376 §3.5 back-dating replay defense)");
|
|
412
|
+
}
|
|
413
|
+
envelopeGate = Object.freeze({
|
|
414
|
+
mode: geMode,
|
|
415
|
+
onTemperror: geOnTemperror,
|
|
416
|
+
// RFC 8601 authserv-id — the receiver's own name on the
|
|
417
|
+
// Authentication-Results header. Defaults to the first local
|
|
418
|
+
// domain; with neither, the header is skipped (the verdict
|
|
419
|
+
// still reaches the agent handoff).
|
|
420
|
+
authservId: ge.authservId || localDomains[0] || null,
|
|
421
|
+
dnsLookup: ge.dnsLookup || undefined,
|
|
422
|
+
maxSignatures: ge.maxSignatures,
|
|
423
|
+
clockSkewMs: ge.clockSkewMs,
|
|
424
|
+
minRsaBits: ge.minRsaBits,
|
|
425
|
+
// Wall-clock ceiling for the whole pipeline (SPF include chains
|
|
426
|
+
// + per-signature DKIM key fetches + DMARC policy walk). A
|
|
427
|
+
// message stuffed with signatures pointing at slow resolvers
|
|
428
|
+
// must not pin the connection slot — on timeout the message
|
|
429
|
+
// takes the temperror disposition (defer / accept per
|
|
430
|
+
// onTemperror).
|
|
431
|
+
timeoutMs: (ge.timeoutMs === undefined || ge.timeoutMs === null)
|
|
432
|
+
? C.TIME.seconds(20) : ge.timeoutMs,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
272
436
|
// Default-on operator-supplied-domain hardening. opts.localDomains
|
|
273
437
|
// and the HELO / MAIL FROM / RCPT TO domain validations all route
|
|
274
438
|
// through `b.guardDomain` for IDN homograph / Punycode-spoof defense
|
|
@@ -885,6 +1049,110 @@ function create(opts) {
|
|
|
885
1049
|
return;
|
|
886
1050
|
}
|
|
887
1051
|
}
|
|
1052
|
+
// DATA-phase message authentication (opts.guardEnvelope) — SPF /
|
|
1053
|
+
// DKIM / DMARC through b.mail.inbound.verify, refusing before the
|
|
1054
|
+
// agent handoff so a policy-failing message never reaches storage.
|
|
1055
|
+
var inboundAuth = null;
|
|
1056
|
+
if (envelopeGate) {
|
|
1057
|
+
var inboundVerdict = null;
|
|
1058
|
+
try {
|
|
1059
|
+
// Wall-clock ceiling around the whole pipeline — a message
|
|
1060
|
+
// stuffed with signatures pointing at slow resolvers must
|
|
1061
|
+
// not pin the connection slot. Timeout surfaces as
|
|
1062
|
+
// SafeAsyncError(async/timeout) into the catch below.
|
|
1063
|
+
inboundVerdict = await safeAsync.withTimeout(
|
|
1064
|
+
mailAuth().inbound.verify({
|
|
1065
|
+
ip: state.remoteAddress,
|
|
1066
|
+
helo: state.helo || undefined,
|
|
1067
|
+
mailFrom: state.mailFrom || undefined,
|
|
1068
|
+
message: dedotted,
|
|
1069
|
+
dnsLookup: envelopeGate.dnsLookup,
|
|
1070
|
+
maxSignatures: envelopeGate.maxSignatures,
|
|
1071
|
+
clockSkewMs: envelopeGate.clockSkewMs,
|
|
1072
|
+
minRsaBits: envelopeGate.minRsaBits,
|
|
1073
|
+
authservId: envelopeGate.authservId || undefined,
|
|
1074
|
+
}),
|
|
1075
|
+
envelopeGate.timeoutMs,
|
|
1076
|
+
{ name: "mail.server.mx.guardEnvelope" });
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
// Pipeline infrastructure failure or wall-clock timeout (not
|
|
1079
|
+
// an authentication verdict). Same disposition as a DNS
|
|
1080
|
+
// temperror: defer so the sender retries, or accept
|
|
1081
|
+
// unauthenticated when the operator chose availability via
|
|
1082
|
+
// onTemperror.
|
|
1083
|
+
_emit("mail.server.mx.envelope_error", {
|
|
1084
|
+
connectionId: state.id,
|
|
1085
|
+
mailFrom: state.mailFrom,
|
|
1086
|
+
error: (err && err.message) || String(err),
|
|
1087
|
+
}, "failure");
|
|
1088
|
+
if (envelopeGate.mode === "enforce" && envelopeGate.onTemperror === "defer") {
|
|
1089
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR,
|
|
1090
|
+
"4.7.0 Message authentication could not be completed; try again later");
|
|
1091
|
+
_resetTransaction(state);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (inboundVerdict) {
|
|
1096
|
+
var envAction = _envelopeActionFor(inboundVerdict, envelopeGate);
|
|
1097
|
+
var dkimSummary = inboundVerdict.dkim.some(function (d) { return d.result === "pass"; })
|
|
1098
|
+
? "pass"
|
|
1099
|
+
: (inboundVerdict.dkim[0] ? inboundVerdict.dkim[0].result : "none");
|
|
1100
|
+
_emit("mail.server.mx.envelope_verdict", {
|
|
1101
|
+
connectionId: state.id,
|
|
1102
|
+
mailFrom: state.mailFrom,
|
|
1103
|
+
fromDomain: inboundVerdict.from.domain,
|
|
1104
|
+
spf: inboundVerdict.spf.result,
|
|
1105
|
+
dkim: dkimSummary,
|
|
1106
|
+
dmarc: inboundVerdict.dmarc.result,
|
|
1107
|
+
action: envAction,
|
|
1108
|
+
mode: envelopeGate.mode,
|
|
1109
|
+
}, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
|
|
1110
|
+
if (envelopeGate.mode === "enforce" && envAction === "reject") {
|
|
1111
|
+
// RFC 7372 §3.2 — 5.7.26 ("multiple authentication checks
|
|
1112
|
+
// failed") for a DMARC evaluation that failed; the
|
|
1113
|
+
// multi-From / unparsable-author permerror shape is a
|
|
1114
|
+
// message-acceptability refusal and keeps the generic
|
|
1115
|
+
// 5.7.1.
|
|
1116
|
+
var enhanced = inboundVerdict.dmarc.result === "fail" ? "5.7.26" : "5.7.1";
|
|
1117
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
1118
|
+
enhanced + " Message refused by sender authentication policy (DMARC " +
|
|
1119
|
+
inboundVerdict.dmarc.result + "; SPF " + inboundVerdict.spf.result +
|
|
1120
|
+
", DKIM " + dkimSummary + ")");
|
|
1121
|
+
_resetTransaction(state);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (envelopeGate.mode === "enforce" && envAction === "defer") {
|
|
1125
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR,
|
|
1126
|
+
"4.7.0 Sender authentication temporarily unavailable (DNS); try again later");
|
|
1127
|
+
_resetTransaction(state);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
// Accept / quarantine / monitor mode: the verdict rides to
|
|
1131
|
+
// the agent handoff as `auth`, and the receiver's RFC 8601
|
|
1132
|
+
// Authentication-Results header is prepended so downstream
|
|
1133
|
+
// consumers (spam-foldering quarantined mail included) act
|
|
1134
|
+
// on authenticated results instead of re-verifying.
|
|
1135
|
+
if (inboundVerdict.authResults) {
|
|
1136
|
+
// RFC 8601 §5 — strip any sender-attached A-R header
|
|
1137
|
+
// claiming this receiver's authserv-id before prepending
|
|
1138
|
+
// the computed one (forged-verdict shadowing defense).
|
|
1139
|
+
dedotted = _stripForgedAuthResults(dedotted, envelopeGate.authservId);
|
|
1140
|
+
dedotted = Buffer.concat([
|
|
1141
|
+
Buffer.from(inboundVerdict.authResults + "\r\n", "utf8"),
|
|
1142
|
+
dedotted,
|
|
1143
|
+
]);
|
|
1144
|
+
}
|
|
1145
|
+
inboundAuth = {
|
|
1146
|
+
spf: inboundVerdict.spf,
|
|
1147
|
+
dkim: inboundVerdict.dkim,
|
|
1148
|
+
dmarc: inboundVerdict.dmarc,
|
|
1149
|
+
from: inboundVerdict.from,
|
|
1150
|
+
action: envAction,
|
|
1151
|
+
mode: envelopeGate.mode,
|
|
1152
|
+
quarantine: envAction === "quarantine",
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
888
1156
|
// operator-supplied agent handoff — when wired, persist via
|
|
889
1157
|
// agent + write the 250 reply. When not wired, accept-and-drop
|
|
890
1158
|
// (audit-only mode useful for staging deployments).
|
|
@@ -897,6 +1165,7 @@ function create(opts) {
|
|
|
897
1165
|
remote: { address: state.remoteAddress, port: state.remotePort },
|
|
898
1166
|
tls: state.tls,
|
|
899
1167
|
helo: state.helo,
|
|
1168
|
+
auth: inboundAuth,
|
|
900
1169
|
connectionId: state.id,
|
|
901
1170
|
}).then(function (ack) {
|
|
902
1171
|
_emit("mail.server.mx.delivered",
|
package/lib/mail.js
CHANGED
|
@@ -1944,15 +1944,19 @@ module.exports = {
|
|
|
1944
1944
|
// default, ed25519-sha256 opt-in). Wire it into the smtp transport
|
|
1945
1945
|
// via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
|
|
1946
1946
|
dkim: dkim,
|
|
1947
|
-
// Inbound mail authentication
|
|
1948
|
-
//
|
|
1949
|
-
//
|
|
1950
|
-
//
|
|
1947
|
+
// Inbound mail authentication verification: SPF (RFC 7208), DKIM
|
|
1948
|
+
// verify (RFC 6376, on .dkim above alongside outbound signing),
|
|
1949
|
+
// DMARC (RFC 7489), ARC (RFC 8617). `.inbound.verify` is the
|
|
1950
|
+
// one-call receiver pipeline — SPF + DKIM + From-header extraction +
|
|
1951
|
+
// DMARC policy + the RFC 8601 Authentication-Results header —
|
|
1952
|
+
// composed by b.mail.server.mx at DATA time via its guardEnvelope
|
|
1953
|
+
// opt and callable directly by operator-built listeners.
|
|
1951
1954
|
spf: mailAuth.spf,
|
|
1952
1955
|
dmarc: mailAuth.dmarc,
|
|
1953
1956
|
arc: mailAuth.arc,
|
|
1954
1957
|
iprev: mailAuth.iprev,
|
|
1955
1958
|
authResults: mailAuth.authResults,
|
|
1959
|
+
inbound: mailAuth.inbound,
|
|
1956
1960
|
bimi: mailBimi,
|
|
1957
1961
|
// Test-only export: lets unit tests inspect the wire format without
|
|
1958
1962
|
// standing up a TLS-capable SMTP fixture. Operators don't call this.
|