@blamejs/core 0.9.28 → 0.9.38
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 +885 -875
- package/index.js +18 -1
- package/lib/agent-snapshot.js +346 -0
- package/lib/agent-trace.js +218 -0
- package/lib/guard-all.js +1 -0
- package/lib/guard-dsn.js +379 -0
- package/lib/guard-envelope.js +294 -0
- package/lib/guard-smtp-command.js +484 -0
- package/lib/guard-snapshot-envelope.js +168 -0
- package/lib/guard-trace-context.js +172 -0
- package/lib/ip-utils.js +102 -0
- package/lib/mail-auth.js +4 -35
- package/lib/mail-greylist.js +448 -0
- package/lib/mail-helo.js +473 -0
- package/lib/mail-rbl.js +392 -0
- package/lib/mail.js +2 -1
- package/lib/network-dns-resolver.js +500 -0
- package/lib/network.js +1 -0
- package/lib/redis-client.js +2 -1
- package/lib/safe-dns.js +665 -0
- package/lib/tracing.js +36 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-dsn.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardDsn
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard DSN
|
|
6
|
+
* @order 460
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 3464 Delivery Status Notification parser. Reads the
|
|
10
|
+
* `multipart/report; report-type=delivery-status` structure that
|
|
11
|
+
* bounces, delayed-delivery notices, and successful-delivery
|
|
12
|
+
* confirmations carry and surfaces the per-recipient action +
|
|
13
|
+
* enhanced status code so operator-side delivery-failure routing
|
|
14
|
+
* (`b.mail.bounce` retry curve, address-book invalidation, mailing-
|
|
15
|
+
* list cleanup, transactional-mail dead-letter handling) reads a
|
|
16
|
+
* stable shape regardless of MTA wording.
|
|
17
|
+
*
|
|
18
|
+
* ## RFC 3464 structure
|
|
19
|
+
*
|
|
20
|
+
* `multipart/report` per RFC 6522 §3:
|
|
21
|
+
*
|
|
22
|
+
* 1. `text/plain` (or text/html) — human-readable wording
|
|
23
|
+
* ("Your message could not be delivered to alice@example.com");
|
|
24
|
+
* the framework does NOT route on this prose.
|
|
25
|
+
* 2. **`message/delivery-status`** (RFC 3464 §2) — the
|
|
26
|
+
* machine-readable DSN body the framework parses.
|
|
27
|
+
* 3. Optional `message/rfc822` (or `text/rfc822-headers`) —
|
|
28
|
+
* the original message (or its headers) that bounced.
|
|
29
|
+
*
|
|
30
|
+
* ## Required fields the parser extracts
|
|
31
|
+
*
|
|
32
|
+
* **Per-message fields (RFC 3464 §2.2)**:
|
|
33
|
+
* - `Reporting-MTA` — MTA that issued the DSN. Mandatory.
|
|
34
|
+
* - `Original-Envelope-Id` (optional) — DSN-tied envelope id.
|
|
35
|
+
* - `Arrival-Date` (optional) — when the original message
|
|
36
|
+
* arrived at the reporting MTA.
|
|
37
|
+
*
|
|
38
|
+
* **Per-recipient fields (RFC 3464 §2.3)** — repeated, one block
|
|
39
|
+
* per recipient:
|
|
40
|
+
* - `Final-Recipient` — recipient address as the reporting MTA
|
|
41
|
+
* knows it. Mandatory.
|
|
42
|
+
* - `Action` — `failed` / `delayed` / `delivered` / `relayed` /
|
|
43
|
+
* `expanded`. Mandatory.
|
|
44
|
+
* - `Status` — RFC 3463 enhanced status code, format
|
|
45
|
+
* `D.D[D[D]].D[D[D]]` (e.g. `5.1.1` = bad address).
|
|
46
|
+
* - `Original-Recipient` (optional).
|
|
47
|
+
* - `Diagnostic-Code` (optional) — raw MTA error line.
|
|
48
|
+
*
|
|
49
|
+
* ## RFC 3463 status-class semantics
|
|
50
|
+
*
|
|
51
|
+
* The first digit classifies the verdict and drives the framework's
|
|
52
|
+
* downstream routing:
|
|
53
|
+
*
|
|
54
|
+
* - **`2.x.y`** — success (delivered / relayed / expanded). Used
|
|
55
|
+
* by mailing-list `verp` tracking + delivery-receipt auditing.
|
|
56
|
+
* - **`4.x.y`** — persistent transient failure. Operator's
|
|
57
|
+
* `b.outbox` retry curve applies; address stays valid.
|
|
58
|
+
* - **`5.x.y`** — permanent failure. Address-book invalidation
|
|
59
|
+
* trigger; mailing-list cleanup; no further retries.
|
|
60
|
+
*
|
|
61
|
+
* The framework surfaces `statusClass` (`success` / `temporary` /
|
|
62
|
+
* `permanent`) so operator routing reads one shape regardless of
|
|
63
|
+
* the exact subcode.
|
|
64
|
+
*
|
|
65
|
+
* ## Defenses
|
|
66
|
+
*
|
|
67
|
+
* - **Oversize DSN** — bounded body cap (default 256 KiB strict)
|
|
68
|
+
* per the profile; legitimate DSNs are KB-scale, multi-MB DSNs
|
|
69
|
+
* are pathological / DoS-shaped.
|
|
70
|
+
* - **Recipient-count cap** — per-DSN recipient cap (default 256
|
|
71
|
+
* strict). A DSN with thousands of recipients is forged or
|
|
72
|
+
* misconfigured; operator opts permissive for mailing-list
|
|
73
|
+
* blast-bounces.
|
|
74
|
+
* - **Header-line cap** — each field-line capped at 998 bytes
|
|
75
|
+
* per RFC 5322 §2.1.1.
|
|
76
|
+
* - **CRLF + control-char refusal** — header injection defense
|
|
77
|
+
* for fields that propagate to operator's audit log /
|
|
78
|
+
* monitoring dashboard.
|
|
79
|
+
*
|
|
80
|
+
* ## CVE / threat model
|
|
81
|
+
*
|
|
82
|
+
* - **Bounce-flood / backscatter** — operator's MX should refuse
|
|
83
|
+
* mail with envelope-from that doesn't pass SPF before
|
|
84
|
+
* generating a DSN (the existing `b.mail.bounce` primitive does
|
|
85
|
+
* this); this guard parses INBOUND DSNs and gates the parse
|
|
86
|
+
* surface bounds, not the bounce-generation policy.
|
|
87
|
+
* - **DSN header-injection class** (CVE-2026-32178 .NET
|
|
88
|
+
* System.Net.Mail at outbound; the inbound parse path here)
|
|
89
|
+
* — refuses CR/LF/NUL/C0 in header lines.
|
|
90
|
+
* - **CSAF / iSchedule prose tampering** — operator inspecting
|
|
91
|
+
* the prose part for the original recipient runs into the
|
|
92
|
+
* ambiguous wording that DSNs vary across MTAs (Postfix vs
|
|
93
|
+
* Exchange vs SES vs Gmail). The parser surfaces the
|
|
94
|
+
* STRUCTURED fields so operator routing doesn't have to
|
|
95
|
+
* regex MTA-specific prose.
|
|
96
|
+
*
|
|
97
|
+
* @card
|
|
98
|
+
* RFC 3464 DSN parser. Walks message/delivery-status per-message + per-recipient blocks, surfaces Action / Status / Final-Recipient + the RFC 3463 status-class verdict (success / temporary / permanent). Bounded recipient count + body size + header-line length; CRLF / NUL / C0 refusal. Operator delivery-failure routing reads one shape regardless of MTA wording.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
var C = require("./constants");
|
|
102
|
+
var { defineClass } = require("./framework-error");
|
|
103
|
+
|
|
104
|
+
var GuardDsnError = defineClass("GuardDsnError", { alwaysPermanent: true });
|
|
105
|
+
|
|
106
|
+
var DEFAULT_PROFILE = "strict";
|
|
107
|
+
|
|
108
|
+
var PROFILES = Object.freeze({
|
|
109
|
+
strict: { maxBytes: C.BYTES.kib(256), maxRecipients: 256, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 header line cap; RFC 3464 recipient count
|
|
110
|
+
balanced: { maxBytes: C.BYTES.mib(1), maxRecipients: 1024, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap; mailing-list blast bounces
|
|
111
|
+
permissive: { maxBytes: C.BYTES.mib(4), maxRecipients: 4096, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap; large-blast bounce class
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
115
|
+
hipaa: "strict",
|
|
116
|
+
"pci-dss": "strict",
|
|
117
|
+
gdpr: "strict",
|
|
118
|
+
soc2: "strict",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
var KNOWN_ACTIONS = Object.freeze({
|
|
122
|
+
failed: true,
|
|
123
|
+
delayed: true,
|
|
124
|
+
delivered: true,
|
|
125
|
+
relayed: true,
|
|
126
|
+
expanded: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// RFC 3463 §3.1: status code is digit . digit{1,3} . digit{1,3}.
|
|
130
|
+
var STATUS_RE = /^([245])\.(\d{1,3})\.(\d{1,3})$/; // allow:regex-no-length-cap — anchored + per-component repeat cap
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @primitive b.guardDsn.parse
|
|
134
|
+
* @signature b.guardDsn.parse(deliveryStatusBody, opts?)
|
|
135
|
+
* @since 0.9.37
|
|
136
|
+
* @status stable
|
|
137
|
+
* @related b.safeMime.parse, b.guardEnvelope.check
|
|
138
|
+
*
|
|
139
|
+
* Parse a `message/delivery-status` body (the MIME part body, not
|
|
140
|
+
* the entire RFC 3464 multipart/report — extract that via
|
|
141
|
+
* `b.safeMime.parse` first). Returns `{ perMessage, perRecipients,
|
|
142
|
+
* worstStatusClass, action }`.
|
|
143
|
+
*
|
|
144
|
+
* Throws `GuardDsnError` on oversize body / recipient count /
|
|
145
|
+
* header-line length / malformed status code / required-field
|
|
146
|
+
* missing / control-char in field value.
|
|
147
|
+
*
|
|
148
|
+
* @opts
|
|
149
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
150
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* var mime = b.safeMime.parse(rawBouncedMessage);
|
|
154
|
+
* var deliveryStatusPart = b.safeMime.findFirst(mime, function (p) {
|
|
155
|
+
* return p.leaf && p.leaf.contentType === "message/delivery-status";
|
|
156
|
+
* });
|
|
157
|
+
* var dsn = b.guardDsn.parse(deliveryStatusPart.leaf.body);
|
|
158
|
+
* if (dsn.worstStatusClass === "permanent") {
|
|
159
|
+
* dsn.perRecipients.forEach(function (r) { invalidateAddress(r.finalRecipient); });
|
|
160
|
+
* }
|
|
161
|
+
*/
|
|
162
|
+
function parse(deliveryStatusBody, opts) {
|
|
163
|
+
opts = opts || {};
|
|
164
|
+
var caps = _resolveProfile(opts);
|
|
165
|
+
var bytes;
|
|
166
|
+
if (Buffer.isBuffer(deliveryStatusBody)) {
|
|
167
|
+
bytes = deliveryStatusBody;
|
|
168
|
+
} else if (typeof deliveryStatusBody === "string") {
|
|
169
|
+
bytes = Buffer.from(deliveryStatusBody, "utf8");
|
|
170
|
+
} else {
|
|
171
|
+
throw new GuardDsnError("guard-dsn/bad-input",
|
|
172
|
+
"parse: deliveryStatusBody must be a Buffer or string");
|
|
173
|
+
}
|
|
174
|
+
if (bytes.length > caps.maxBytes) {
|
|
175
|
+
throw new GuardDsnError("guard-dsn/oversize-body",
|
|
176
|
+
"parse: body " + bytes.length + " bytes exceeds maxBytes=" + caps.maxBytes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// RFC 3464 §2.1: the delivery-status body is per-message fields,
|
|
180
|
+
// a blank line, then per-recipient field groups separated by
|
|
181
|
+
// blank lines.
|
|
182
|
+
var text = bytes.toString("utf8");
|
|
183
|
+
var blocks = _splitBlocks(text);
|
|
184
|
+
if (blocks.length === 0) {
|
|
185
|
+
throw new GuardDsnError("guard-dsn/empty",
|
|
186
|
+
"parse: delivery-status body has no field blocks");
|
|
187
|
+
}
|
|
188
|
+
var perMessageFields = _parseFieldBlock(blocks[0], caps.maxHeaderLine);
|
|
189
|
+
|
|
190
|
+
// Reporting-MTA is mandatory per RFC 3464 §2.2.2.
|
|
191
|
+
if (!perMessageFields["reporting-mta"]) {
|
|
192
|
+
throw new GuardDsnError("guard-dsn/missing-reporting-mta",
|
|
193
|
+
"parse: required per-message field Reporting-MTA missing (RFC 3464 §2.2.2)");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var perRecipients = [];
|
|
197
|
+
for (var i = 1; i < blocks.length; i += 1) {
|
|
198
|
+
if (perRecipients.length >= caps.maxRecipients) {
|
|
199
|
+
throw new GuardDsnError("guard-dsn/too-many-recipients",
|
|
200
|
+
"parse: per-recipient count exceeds maxRecipients=" + caps.maxRecipients);
|
|
201
|
+
}
|
|
202
|
+
var fields = _parseFieldBlock(blocks[i], caps.maxHeaderLine);
|
|
203
|
+
if (Object.keys(fields).length === 0) continue; // empty trailing block
|
|
204
|
+
if (!fields["final-recipient"]) {
|
|
205
|
+
throw new GuardDsnError("guard-dsn/missing-final-recipient",
|
|
206
|
+
"parse: per-recipient block missing Final-Recipient (RFC 3464 §2.3.2)");
|
|
207
|
+
}
|
|
208
|
+
if (!fields["action"]) {
|
|
209
|
+
throw new GuardDsnError("guard-dsn/missing-action",
|
|
210
|
+
"parse: per-recipient block missing Action (RFC 3464 §2.3.3)");
|
|
211
|
+
}
|
|
212
|
+
var action = fields["action"].toLowerCase();
|
|
213
|
+
if (!KNOWN_ACTIONS[action]) {
|
|
214
|
+
throw new GuardDsnError("guard-dsn/bad-action",
|
|
215
|
+
"parse: Action '" + action + "' not in RFC 3464 §2.3.3 vocabulary");
|
|
216
|
+
}
|
|
217
|
+
if (!fields["status"]) {
|
|
218
|
+
throw new GuardDsnError("guard-dsn/missing-status",
|
|
219
|
+
"parse: per-recipient block missing Status (RFC 3464 §2.3.4)");
|
|
220
|
+
}
|
|
221
|
+
var statusMatch = fields["status"].match(STATUS_RE);
|
|
222
|
+
if (!statusMatch) {
|
|
223
|
+
throw new GuardDsnError("guard-dsn/bad-status",
|
|
224
|
+
"parse: Status '" + fields["status"] + "' not RFC 3463 D.D.D form");
|
|
225
|
+
}
|
|
226
|
+
perRecipients.push({
|
|
227
|
+
finalRecipient: _stripRecipientType(fields["final-recipient"]),
|
|
228
|
+
originalRecipient: fields["original-recipient"] ? _stripRecipientType(fields["original-recipient"]) : null,
|
|
229
|
+
action: action,
|
|
230
|
+
status: fields["status"],
|
|
231
|
+
statusClass: _statusClass(statusMatch[1]),
|
|
232
|
+
diagnosticCode: fields["diagnostic-code"] || null,
|
|
233
|
+
remoteMta: fields["remote-mta"] || null,
|
|
234
|
+
lastAttemptDate: fields["last-attempt-date"] || null,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (perRecipients.length === 0) {
|
|
239
|
+
throw new GuardDsnError("guard-dsn/no-recipients",
|
|
240
|
+
"parse: delivery-status has no per-recipient blocks (RFC 3464 §2.1 requires at least one)");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Worst status class across recipients: permanent > temporary > success.
|
|
244
|
+
var worst = "success";
|
|
245
|
+
for (var r = 0; r < perRecipients.length; r += 1) {
|
|
246
|
+
if (perRecipients[r].statusClass === "permanent") { worst = "permanent"; break; }
|
|
247
|
+
if (perRecipients[r].statusClass === "temporary") worst = "temporary";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
perMessage: {
|
|
252
|
+
reportingMta: perMessageFields["reporting-mta"],
|
|
253
|
+
originalEnvelopeId: perMessageFields["original-envelope-id"] || null,
|
|
254
|
+
arrivalDate: perMessageFields["arrival-date"] || null,
|
|
255
|
+
receivedFromMta: perMessageFields["received-from-mta"] || null,
|
|
256
|
+
},
|
|
257
|
+
perRecipients: perRecipients,
|
|
258
|
+
worstStatusClass: worst,
|
|
259
|
+
action: worst === "permanent" ? "invalidate" :
|
|
260
|
+
worst === "temporary" ? "retry" :
|
|
261
|
+
"deliver",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @primitive b.guardDsn.compliancePosture
|
|
267
|
+
* @signature b.guardDsn.compliancePosture(posture)
|
|
268
|
+
* @since 0.9.37
|
|
269
|
+
* @status stable
|
|
270
|
+
*
|
|
271
|
+
* Return the effective profile name for a compliance posture, or
|
|
272
|
+
* `null` for unknown posture names.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* b.guardDsn.compliancePosture("hipaa"); // → "strict"
|
|
276
|
+
*/
|
|
277
|
+
function compliancePosture(posture) {
|
|
278
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function _splitBlocks(text) {
|
|
282
|
+
// RFC 3464 §2.1: blank line separates per-message from
|
|
283
|
+
// per-recipient blocks; blank lines also separate consecutive
|
|
284
|
+
// per-recipient blocks.
|
|
285
|
+
// Normalize CRLF + bare-CR to LF for split.
|
|
286
|
+
var normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // allow:regex-no-length-cap — input length already capped
|
|
287
|
+
return normalized.split(/\n\s*\n/); // allow:regex-no-length-cap — input length already capped
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _parseFieldBlock(block, maxHeaderLine) {
|
|
291
|
+
// RFC 5322 §2.2: header field = name ":" value; continuation
|
|
292
|
+
// lines start with whitespace.
|
|
293
|
+
var lines = block.split("\n");
|
|
294
|
+
var fields = Object.create(null);
|
|
295
|
+
var current = null;
|
|
296
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
297
|
+
var raw = lines[i];
|
|
298
|
+
if (raw.length > maxHeaderLine) {
|
|
299
|
+
throw new GuardDsnError("guard-dsn/oversize-header-line",
|
|
300
|
+
"parse: header line " + raw.length + " bytes exceeds maxHeaderLine=" + maxHeaderLine + " (RFC 5322 §2.1.1)");
|
|
301
|
+
}
|
|
302
|
+
if (raw.length === 0) continue;
|
|
303
|
+
_checkControlChars(raw);
|
|
304
|
+
if (/^[ \t]/.test(raw) && current) { // allow:regex-no-length-cap — single-char check on capped line
|
|
305
|
+
// Continuation.
|
|
306
|
+
fields[current] += " " + raw.replace(/^[ \t]+/, ""); // allow:regex-no-length-cap — trim on capped line // allow:duplicate-regex — leading-WS-trim shape common to RFC 5322 header continuation parsers
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
var colon = raw.indexOf(":");
|
|
310
|
+
if (colon === -1) {
|
|
311
|
+
throw new GuardDsnError("guard-dsn/malformed-field",
|
|
312
|
+
"parse: line '" + raw + "' missing ':' field-name terminator");
|
|
313
|
+
}
|
|
314
|
+
var name = raw.slice(0, colon).trim().toLowerCase();
|
|
315
|
+
var value = raw.slice(colon + 1).trim();
|
|
316
|
+
if (name.length === 0) {
|
|
317
|
+
throw new GuardDsnError("guard-dsn/malformed-field",
|
|
318
|
+
"parse: empty field name on line '" + raw + "'");
|
|
319
|
+
}
|
|
320
|
+
fields[name] = value;
|
|
321
|
+
current = name;
|
|
322
|
+
}
|
|
323
|
+
return fields;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function _checkControlChars(line) {
|
|
327
|
+
// Refuse NUL, C0 controls (except TAB which is valid in
|
|
328
|
+
// continuation), DEL. Bare CR and LF can't appear because we
|
|
329
|
+
// already split on \n; this catches forms that survive the
|
|
330
|
+
// split (e.g. backslash + literal sequence).
|
|
331
|
+
for (var i = 0; i < line.length; i += 1) {
|
|
332
|
+
var c = line.charCodeAt(i);
|
|
333
|
+
if (c === 0x00 || c === 0x7f || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — RFC 5322 control char + TAB allow
|
|
334
|
+
throw new GuardDsnError("guard-dsn/control-char",
|
|
335
|
+
"parse: control char 0x" + c.toString(16) + " in field line refused (header-injection defense)");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function _stripRecipientType(value) {
|
|
341
|
+
// RFC 3464 §2.3.2: "rfc822;alice@example.com" — type prefix
|
|
342
|
+
// before semicolon classifies the address. Strip for the common
|
|
343
|
+
// case of rfc822, surface the raw value otherwise.
|
|
344
|
+
var semi = value.indexOf(";");
|
|
345
|
+
if (semi === -1) return value;
|
|
346
|
+
var type = value.slice(0, semi).trim().toLowerCase();
|
|
347
|
+
if (type === "rfc822") return value.slice(semi + 1).trim();
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _statusClass(firstDigit) {
|
|
352
|
+
if (firstDigit === "2") return "success";
|
|
353
|
+
if (firstDigit === "4") return "temporary";
|
|
354
|
+
if (firstDigit === "5") return "permanent";
|
|
355
|
+
return "unknown";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function _resolveProfile(opts) {
|
|
359
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
360
|
+
return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
|
|
361
|
+
}
|
|
362
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
363
|
+
if (!PROFILES[p]) {
|
|
364
|
+
throw new GuardDsnError("guard-dsn/bad-profile",
|
|
365
|
+
"guardDsn: unknown profile '" + p + "'");
|
|
366
|
+
}
|
|
367
|
+
return PROFILES[p];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
parse: parse,
|
|
372
|
+
compliancePosture: compliancePosture,
|
|
373
|
+
PROFILES: PROFILES,
|
|
374
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
375
|
+
KNOWN_ACTIONS: KNOWN_ACTIONS,
|
|
376
|
+
GuardDsnError: GuardDsnError,
|
|
377
|
+
NAME: "dsn",
|
|
378
|
+
KIND: "delivery-status",
|
|
379
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEnvelope
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Envelope
|
|
6
|
+
* @order 455
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 7489 §3.1 DMARC Identifier Alignment validator. Gates the
|
|
10
|
+
* envelope-vs-header domain relationship at the MX listener's
|
|
11
|
+
* end-of-DATA boundary so a sender that passes SPF / DKIM under
|
|
12
|
+
* one domain but spoofs the user-visible `From:` header under
|
|
13
|
+
* another is refused before the message reaches the mail-store.
|
|
14
|
+
*
|
|
15
|
+
* ## What aligns with what
|
|
16
|
+
*
|
|
17
|
+
* DMARC's central identifier is **RFC 5322 `From:` domain** — the
|
|
18
|
+
* user-visible header field. Alignment requires at least one of:
|
|
19
|
+
*
|
|
20
|
+
* - **SPF alignment** — `RFC5321.MailFrom` domain (envelope-from)
|
|
21
|
+
* passed SPF (RFC 7208) AND matches the From-header domain.
|
|
22
|
+
* - **DKIM alignment** — at least one DKIM signature with `d=<X>`
|
|
23
|
+
* verified (RFC 6376) AND `<X>` matches the From-header domain.
|
|
24
|
+
*
|
|
25
|
+
* Match semantics (RFC 7489 §3.1.1 / §3.1.2):
|
|
26
|
+
*
|
|
27
|
+
* - **Strict (`s`)** — exact FQDN match. `From: alice@example.com`
|
|
28
|
+
* requires the authenticated identifier to be exactly
|
|
29
|
+
* `example.com`.
|
|
30
|
+
* - **Relaxed (`r`)** — organizational-domain match (via Public
|
|
31
|
+
* Suffix List). `From: alice@mail.example.com` aligns with
|
|
32
|
+
* SPF `bounces.example.com` because both share organizational
|
|
33
|
+
* domain `example.com`. Relaxed is the spec default per
|
|
34
|
+
* RFC 7489 §6.2.
|
|
35
|
+
*
|
|
36
|
+
* ## Why this primitive vs. b.mail.auth.dmarc.evaluate
|
|
37
|
+
*
|
|
38
|
+
* `b.mail.auth.dmarc.evaluate` (existing) is the FULL DMARC policy
|
|
39
|
+
* evaluation: parse DMARC TXT record, evaluate pct sampling,
|
|
40
|
+
* compute final disposition (none / quarantine / reject), produce
|
|
41
|
+
* the aggregate-report tuple. It composes the alignment check
|
|
42
|
+
* internally.
|
|
43
|
+
*
|
|
44
|
+
* `b.guardEnvelope.check` exposes JUST the alignment primitive so:
|
|
45
|
+
*
|
|
46
|
+
* - The v0.9.36 MX listener can short-circuit on alignment fail
|
|
47
|
+
* before even running the upstream DMARC TXT lookup.
|
|
48
|
+
* - Operator middleware composing a custom anti-spoofing policy
|
|
49
|
+
* can reuse the alignment primitive without dragging in the
|
|
50
|
+
* full DMARC machinery (TXT parse, aggregate reporting, …).
|
|
51
|
+
* - Tests against alignment edge cases don't have to mock the
|
|
52
|
+
* full DMARC pipeline.
|
|
53
|
+
*
|
|
54
|
+
* Both primitives produce the same alignment verdict for the same
|
|
55
|
+
* input — `b.guardEnvelope` is the focused gate; `b.mail.auth.dmarc`
|
|
56
|
+
* is the orchestrator.
|
|
57
|
+
*
|
|
58
|
+
* ## Verdict shape
|
|
59
|
+
*
|
|
60
|
+
* ```js
|
|
61
|
+
* {
|
|
62
|
+
* spf: { aligned: bool, mode: "strict"|"relaxed", domain: string, fromDomain: string },
|
|
63
|
+
* dkim: [{ aligned: bool, mode, signingDomain, fromDomain }, …],
|
|
64
|
+
* aligned: bool, // at least one of SPF/DKIM aligned
|
|
65
|
+
* action: "accept" | "refuse"
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* When operator's profile is `strict` and neither SPF nor DKIM
|
|
70
|
+
* aligns, action = `"refuse"`. Under `permissive`, action is
|
|
71
|
+
* always `"accept"` (the primitive computes alignment but doesn't
|
|
72
|
+
* gate on it — operator decides downstream from the verdict).
|
|
73
|
+
*
|
|
74
|
+
* ## CVE / threat model
|
|
75
|
+
*
|
|
76
|
+
* - **Display-name spoofing class** — `From: "Bank Of Foo" <a@evil.com>`
|
|
77
|
+
* where SPF passes for `evil.com` and DKIM signs `evil.com`: this
|
|
78
|
+
* primitive ALIGNS (both `evil.com`), so the spoof passes DMARC.
|
|
79
|
+
* Defense lives upstream in `b.guardEmail` (display-name vs
|
|
80
|
+
* domain mismatch detection).
|
|
81
|
+
* - **Envelope-vs-header spoofing** (the class this PRIMITIVE
|
|
82
|
+
* defends): `MAIL FROM:<service@aws-bounces.com>` SPF passes for
|
|
83
|
+
* aws-bounces.com, but `From: payments@your-bank.example` —
|
|
84
|
+
* misalignment refused under strict.
|
|
85
|
+
* - **Same-org-different-subdomain attack** under strict: legitimate
|
|
86
|
+
* mail from `bounces.example.com` to alignment-strict `example.com`
|
|
87
|
+
* is REFUSED — operator opts to relaxed for cross-subdomain mail.
|
|
88
|
+
* - **Public-suffix confusion** — relaxed mode uses
|
|
89
|
+
* `b.publicSuffix.organizationalDomain` which composes the
|
|
90
|
+
* vendored PSL; an attacker can't claim `co.uk` as their org
|
|
91
|
+
* domain because PSL classifies it as a public suffix.
|
|
92
|
+
*
|
|
93
|
+
* @card
|
|
94
|
+
* RFC 7489 §3.1 DMARC Identifier Alignment validator. Strict / relaxed match between RFC 5322 From-header domain and SPF MailFrom + DKIM d= identifiers. Composes b.publicSuffix.organizationalDomain for relaxed mode. Refuses envelope-vs-header spoofs at the MX boundary before mail-store touch.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
var { defineClass } = require("./framework-error");
|
|
98
|
+
var lazyRequire = require("./lazy-require");
|
|
99
|
+
var publicSuffix = require("./public-suffix");
|
|
100
|
+
|
|
101
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
102
|
+
|
|
103
|
+
var GuardEnvelopeError = defineClass("GuardEnvelopeError", { alwaysPermanent: true });
|
|
104
|
+
|
|
105
|
+
var DEFAULT_PROFILE = "strict";
|
|
106
|
+
|
|
107
|
+
var PROFILES = Object.freeze({
|
|
108
|
+
// Strict: gate refuses on alignment fail. Default for HIPAA / PCI /
|
|
109
|
+
// GDPR / SOC2 / banking / regulated mail.
|
|
110
|
+
strict: { gateOnFailure: true, defaultMode: "relaxed" },
|
|
111
|
+
// Balanced: gate refuses on alignment fail but defaults to relaxed
|
|
112
|
+
// mode (RFC 7489 §6.2 default). For most operator deployments.
|
|
113
|
+
balanced: { gateOnFailure: true, defaultMode: "relaxed" },
|
|
114
|
+
// Permissive: compute alignment but always accept; operator
|
|
115
|
+
// pipelines downstream consume the verdict for score-tagging.
|
|
116
|
+
permissive: { gateOnFailure: false, defaultMode: "relaxed" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
120
|
+
hipaa: "strict",
|
|
121
|
+
"pci-dss": "strict",
|
|
122
|
+
gdpr: "strict",
|
|
123
|
+
soc2: "strict",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @primitive b.guardEnvelope.check
|
|
128
|
+
* @signature b.guardEnvelope.check(ctx, opts?)
|
|
129
|
+
* @since 0.9.36
|
|
130
|
+
* @status stable
|
|
131
|
+
* @related b.publicSuffix.organizationalDomain, b.guardEmail.validateMessage
|
|
132
|
+
*
|
|
133
|
+
* Evaluate DMARC Identifier Alignment between the user-visible
|
|
134
|
+
* `From:` header domain and the authenticated identifiers (SPF
|
|
135
|
+
* MailFrom + DKIM d=). Returns the alignment verdict.
|
|
136
|
+
*
|
|
137
|
+
* @opts
|
|
138
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
139
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
140
|
+
* spfMode: "strict" | "relaxed", // per-call override (RFC 7489 §6.2)
|
|
141
|
+
* dkimMode: "strict" | "relaxed", // per-call override
|
|
142
|
+
* audit: b.audit namespace,
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* var v = b.guardEnvelope.check({
|
|
146
|
+
* fromHeaderDomain: "example.com",
|
|
147
|
+
* spfResult: { result: "pass", domain: "bounces.example.com" },
|
|
148
|
+
* dkimResults: [{ result: "pass", signingDomain: "example.com" }],
|
|
149
|
+
* });
|
|
150
|
+
* if (v.action === "refuse") return reply(550, "5.7.1 DMARC alignment fail");
|
|
151
|
+
*/
|
|
152
|
+
function check(ctx, opts) {
|
|
153
|
+
opts = opts || {};
|
|
154
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
155
|
+
if (!PROFILES[profile]) {
|
|
156
|
+
throw new GuardEnvelopeError("guard-envelope/bad-profile",
|
|
157
|
+
"check: unknown profile '" + profile + "'");
|
|
158
|
+
}
|
|
159
|
+
var caps = PROFILES[profile];
|
|
160
|
+
var spfMode = opts.spfMode || caps.defaultMode;
|
|
161
|
+
var dkimMode = opts.dkimMode || caps.defaultMode;
|
|
162
|
+
if (spfMode !== "strict" && spfMode !== "relaxed") {
|
|
163
|
+
throw new GuardEnvelopeError("guard-envelope/bad-mode",
|
|
164
|
+
"check: spfMode must be 'strict' or 'relaxed'");
|
|
165
|
+
}
|
|
166
|
+
if (dkimMode !== "strict" && dkimMode !== "relaxed") {
|
|
167
|
+
throw new GuardEnvelopeError("guard-envelope/bad-mode",
|
|
168
|
+
"check: dkimMode must be 'strict' or 'relaxed'");
|
|
169
|
+
}
|
|
170
|
+
var auditImpl = opts.audit || audit();
|
|
171
|
+
|
|
172
|
+
if (!ctx || typeof ctx !== "object") {
|
|
173
|
+
throw new GuardEnvelopeError("guard-envelope/bad-input",
|
|
174
|
+
"check: ctx must be a plain object");
|
|
175
|
+
}
|
|
176
|
+
if (typeof ctx.fromHeaderDomain !== "string" || ctx.fromHeaderDomain.length === 0) {
|
|
177
|
+
throw new GuardEnvelopeError("guard-envelope/bad-input",
|
|
178
|
+
"check: ctx.fromHeaderDomain must be a non-empty string");
|
|
179
|
+
}
|
|
180
|
+
var fromDomain = ctx.fromHeaderDomain.toLowerCase();
|
|
181
|
+
|
|
182
|
+
// SPF alignment.
|
|
183
|
+
var spfVerdict = _spfVerdict(ctx.spfResult, fromDomain, spfMode);
|
|
184
|
+
|
|
185
|
+
// DKIM alignment — one entry per signature.
|
|
186
|
+
var dkimResults = Array.isArray(ctx.dkimResults) ? ctx.dkimResults : [];
|
|
187
|
+
var dkimVerdicts = dkimResults.map(function (r) {
|
|
188
|
+
return _dkimVerdict(r, fromDomain, dkimMode);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
var anyAligned = spfVerdict.aligned || dkimVerdicts.some(function (d) { return d.aligned; });
|
|
192
|
+
var action = anyAligned || !caps.gateOnFailure ? "accept" : "refuse";
|
|
193
|
+
|
|
194
|
+
_emitAudit(auditImpl, anyAligned ? "guard.envelope.aligned" : "guard.envelope.misaligned", {
|
|
195
|
+
fromDomain: fromDomain,
|
|
196
|
+
spfAligned: spfVerdict.aligned,
|
|
197
|
+
dkimAligned: dkimVerdicts.some(function (d) { return d.aligned; }),
|
|
198
|
+
profile: profile,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
spf: spfVerdict,
|
|
203
|
+
dkim: dkimVerdicts,
|
|
204
|
+
aligned: anyAligned,
|
|
205
|
+
action: action,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @primitive b.guardEnvelope.compliancePosture
|
|
211
|
+
* @signature b.guardEnvelope.compliancePosture(posture)
|
|
212
|
+
* @since 0.9.36
|
|
213
|
+
* @status stable
|
|
214
|
+
*
|
|
215
|
+
* Return the effective profile name for a compliance posture, or
|
|
216
|
+
* `null` for unknown posture names.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* b.guardEnvelope.compliancePosture("hipaa"); // → "strict"
|
|
220
|
+
*/
|
|
221
|
+
function compliancePosture(posture) {
|
|
222
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _spfVerdict(spfResult, fromDomain, mode) {
|
|
226
|
+
var verdict = {
|
|
227
|
+
aligned: false,
|
|
228
|
+
mode: mode,
|
|
229
|
+
domain: null,
|
|
230
|
+
fromDomain: fromDomain,
|
|
231
|
+
spfPass: false,
|
|
232
|
+
};
|
|
233
|
+
if (!spfResult || typeof spfResult !== "object") return verdict;
|
|
234
|
+
verdict.spfPass = spfResult.result === "pass";
|
|
235
|
+
if (typeof spfResult.domain !== "string" || spfResult.domain.length === 0) return verdict;
|
|
236
|
+
verdict.domain = spfResult.domain.toLowerCase();
|
|
237
|
+
if (!verdict.spfPass) return verdict;
|
|
238
|
+
verdict.aligned = _domainAligned(verdict.domain, fromDomain, mode);
|
|
239
|
+
return verdict;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _dkimVerdict(dkimResult, fromDomain, mode) {
|
|
243
|
+
var verdict = {
|
|
244
|
+
aligned: false,
|
|
245
|
+
mode: mode,
|
|
246
|
+
signingDomain: null,
|
|
247
|
+
fromDomain: fromDomain,
|
|
248
|
+
dkimPass: false,
|
|
249
|
+
};
|
|
250
|
+
if (!dkimResult || typeof dkimResult !== "object") return verdict;
|
|
251
|
+
verdict.dkimPass = dkimResult.result === "pass";
|
|
252
|
+
if (typeof dkimResult.signingDomain !== "string" || dkimResult.signingDomain.length === 0) return verdict;
|
|
253
|
+
verdict.signingDomain = dkimResult.signingDomain.toLowerCase();
|
|
254
|
+
if (!verdict.dkimPass) return verdict;
|
|
255
|
+
verdict.aligned = _domainAligned(verdict.signingDomain, fromDomain, mode);
|
|
256
|
+
return verdict;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _domainAligned(authDomain, fromDomain, mode) {
|
|
260
|
+
if (mode === "strict") {
|
|
261
|
+
return authDomain === fromDomain;
|
|
262
|
+
}
|
|
263
|
+
// Relaxed — organizational-domain match via PSL.
|
|
264
|
+
var orgAuth, orgFrom;
|
|
265
|
+
try {
|
|
266
|
+
orgAuth = publicSuffix.organizationalDomain(authDomain);
|
|
267
|
+
orgFrom = publicSuffix.organizationalDomain(fromDomain);
|
|
268
|
+
} catch (_e) { return false; }
|
|
269
|
+
if (!orgAuth || !orgFrom) return false;
|
|
270
|
+
return orgAuth === orgFrom;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _emitAudit(auditImpl, action, metadata) {
|
|
274
|
+
try {
|
|
275
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
276
|
+
auditImpl.safeEmit({
|
|
277
|
+
action: action,
|
|
278
|
+
outcome: "success",
|
|
279
|
+
metadata: metadata,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
} catch (_e) { /* drop-silent — audit failure must not block accept loop */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
check: check,
|
|
287
|
+
compliancePosture: compliancePosture,
|
|
288
|
+
PROFILES: PROFILES,
|
|
289
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
290
|
+
GuardEnvelopeError: GuardEnvelopeError,
|
|
291
|
+
NAME: "envelope",
|
|
292
|
+
KIND: "envelope-alignment",
|
|
293
|
+
_domainAligned: _domainAligned,
|
|
294
|
+
};
|