@blamejs/core 0.9.28 → 0.9.39
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 +886 -875
- package/index.js +20 -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-list-unsubscribe.js +337 -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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardSmtpCommand
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard SMTP Command
|
|
6
|
+
* @order 450
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* SMTP command-line validator. Gates every command verb the framework's
|
|
10
|
+
* inbound MX listener (v0.9.34) and outbound submission listener
|
|
11
|
+
* (v0.9.35) accept from peers — `EHLO` / `HELO` / `MAIL FROM` /
|
|
12
|
+
* `RCPT TO` / `DATA` / `BDAT` / `VRFY` / `EXPN` / `NOOP` / `RSET` /
|
|
13
|
+
* `QUIT` / `AUTH` / `STARTTLS` / `HELP`.
|
|
14
|
+
*
|
|
15
|
+
* ## Smuggling defense — bare-CR / bare-LF refusal
|
|
16
|
+
*
|
|
17
|
+
* The SMTP smuggling class (`CVE-2023-51764` Postfix, `CVE-2023-51765`
|
|
18
|
+
* Sendmail, `CVE-2023-51766` Exim, `CVE-2026-32178` .NET
|
|
19
|
+
* `System.Net.Mail`) exploits implementations that accept the
|
|
20
|
+
* non-standard end-of-data sequence `<LF>.<LF>` or `<LF>.<CR><LF>`
|
|
21
|
+
* instead of the standard `<CR><LF>.<CR><LF>`. The introduced break-
|
|
22
|
+
* out lets a malicious peer inject a second message past SPF / DMARC
|
|
23
|
+
* checks performed only on the outer envelope.
|
|
24
|
+
*
|
|
25
|
+
* At the command-line level the defense is the same: every command
|
|
26
|
+
* line MUST be CRLF-terminated; bare `\r` or `\n` anywhere inside a
|
|
27
|
+
* command line is refused. Operators with peers that legitimately
|
|
28
|
+
* speak bare-LF (rare; legacy Sendmail-to-Sendmail) opt into
|
|
29
|
+
* `permissive` profile with audit emit per accepted bare-LF line.
|
|
30
|
+
*
|
|
31
|
+
* ## STARTTLS command-buffer injection
|
|
32
|
+
*
|
|
33
|
+
* `CVE-2021-38371` (Exim STARTTLS response injection) and
|
|
34
|
+
* `CVE-2021-33515` (Dovecot lib-smtp STARTTLS command injection)
|
|
35
|
+
* exploit implementations that don't drain the pre-STARTTLS receive
|
|
36
|
+
* buffer when negotiating TLS — commands queued by an MitM before
|
|
37
|
+
* the handshake get applied to the post-handshake (TLS-protected)
|
|
38
|
+
* stream. The fix is stateful (drain the buffer on STARTTLS), so
|
|
39
|
+
* this guard alone can't fully defend; it surfaces the requirement
|
|
40
|
+
* to the v0.9.34 listener via `validate({ verb: "STARTTLS" })`
|
|
41
|
+
* refusing trailing payload on the STARTTLS line and the listener's
|
|
42
|
+
* pipelining-after-STARTTLS check enforcing buffer drain.
|
|
43
|
+
*
|
|
44
|
+
* ## Per-verb shape
|
|
45
|
+
*
|
|
46
|
+
* Each verb has a fixed argument shape (RFC 5321 §3 / §4.1):
|
|
47
|
+
*
|
|
48
|
+
* - `EHLO` / `HELO` — exactly one arg (domain or address literal).
|
|
49
|
+
* - `MAIL` — `FROM:<reverse-path>` (RFC 5321 §3.3) + optional
|
|
50
|
+
* `SIZE=` / `BODY=` / `RET=` / `ENVID=` / `AUTH=` extension
|
|
51
|
+
* params.
|
|
52
|
+
* - `RCPT` — `TO:<forward-path>` (RFC 5321 §3.3) + optional
|
|
53
|
+
* `NOTIFY=` / `ORCPT=` extension params.
|
|
54
|
+
* - `DATA` — no args.
|
|
55
|
+
* - `BDAT` — single decimal chunk size + optional `LAST` keyword
|
|
56
|
+
* (RFC 3030 CHUNKING).
|
|
57
|
+
* - `VRFY` / `EXPN` — single mailbox arg.
|
|
58
|
+
* - `NOOP` — optional opaque string.
|
|
59
|
+
* - `RSET` / `QUIT` / `STARTTLS` — no args.
|
|
60
|
+
* - `AUTH` — SASL mechanism name + optional initial-response
|
|
61
|
+
* (RFC 4954).
|
|
62
|
+
* - `HELP` — optional argument.
|
|
63
|
+
*
|
|
64
|
+
* Anything not matching the shape under `strict` profile is refused
|
|
65
|
+
* with `guard-smtp-command/bad-shape`.
|
|
66
|
+
*
|
|
67
|
+
* ## Caps
|
|
68
|
+
*
|
|
69
|
+
* - Command line (path + arguments + CRLF) capped at 512 bytes
|
|
70
|
+
* per RFC 5321 §4.5.3.1.1. SMTPUTF8 / EAI peers (RFC 6531) may
|
|
71
|
+
* send longer command lines for non-ASCII addresses; `balanced`
|
|
72
|
+
* profile bumps the cap to 1024.
|
|
73
|
+
* - Forward-path / reverse-path mailbox capped at 256 bytes per
|
|
74
|
+
* RFC 5321 §4.5.3.1.3.
|
|
75
|
+
* - Domain part of a path capped at 255 bytes per RFC 1035
|
|
76
|
+
* §2.3.4.
|
|
77
|
+
* - Local part capped at 64 bytes per RFC 5321 §4.5.3.1.1.
|
|
78
|
+
*
|
|
79
|
+
* Throws `GuardSmtpCommandError` on every refusal. Pure-functional —
|
|
80
|
+
* no I/O, no state. The MX / submission listener composes one
|
|
81
|
+
* instance per accepted connection.
|
|
82
|
+
*
|
|
83
|
+
* @card
|
|
84
|
+
* SMTP command-line validator. Refuses bare-CR / bare-LF (smuggling
|
|
85
|
+
* defense, CVE-2023-51764/51765/51766/2026-32178), caps line + path
|
|
86
|
+
* + domain + local-part byte lengths (RFC 5321 §4.5.3.1), per-verb
|
|
87
|
+
* shape check (EHLO / HELO / MAIL FROM / RCPT TO / DATA / BDAT /
|
|
88
|
+
* VRFY / EXPN / NOOP / RSET / QUIT / AUTH / STARTTLS / HELP).
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
var { defineClass } = require("./framework-error");
|
|
92
|
+
var gateContract = require("./gate-contract");
|
|
93
|
+
|
|
94
|
+
var GuardSmtpCommandError = defineClass("GuardSmtpCommandError", { alwaysPermanent: true });
|
|
95
|
+
|
|
96
|
+
var DEFAULT_PROFILE = "strict";
|
|
97
|
+
|
|
98
|
+
// RFC 5321 §4.5.3.1.1 — 512-octet line cap (excluding the trailing
|
|
99
|
+
// CRLF). SMTPUTF8 / EAI extends this in practice; balanced/permissive
|
|
100
|
+
// raise the cap accordingly.
|
|
101
|
+
var PROFILES = Object.freeze({
|
|
102
|
+
strict: { maxLineBytes: 512, maxMailbox: 256, maxLocalPart: 64, maxDomain: 255, allowBareLf: false, allowSmtpUtf8: false }, // allow:raw-byte-literal — RFC 5321 §4.5.3.1.1 caps
|
|
103
|
+
balanced: { maxLineBytes: 1024, maxMailbox: 320, maxLocalPart: 64, maxDomain: 255, allowBareLf: false, allowSmtpUtf8: true }, // allow:raw-byte-literal — SMTPUTF8 (RFC 6531) line cap
|
|
104
|
+
permissive: { maxLineBytes: 4096, maxMailbox: 512, maxLocalPart: 64, maxDomain: 255, allowBareLf: true, allowSmtpUtf8: true }, // allow:raw-byte-literal — permissive cap for legacy peers
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
108
|
+
hipaa: "strict",
|
|
109
|
+
"pci-dss": "strict",
|
|
110
|
+
gdpr: "strict",
|
|
111
|
+
soc2: "strict",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Verbs we know — anything else is refused under strict, accepted as
|
|
115
|
+
// opaque under permissive (operator's responsibility to handle).
|
|
116
|
+
var KNOWN_VERBS = Object.freeze({
|
|
117
|
+
EHLO: true, HELO: true, MAIL: true, RCPT: true, DATA: true,
|
|
118
|
+
BDAT: true, VRFY: true, EXPN: true, NOOP: true, RSET: true,
|
|
119
|
+
QUIT: true, AUTH: true, STARTTLS: true, HELP: true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Verbs that take exactly zero arguments (anything trailing the verb
|
|
123
|
+
// itself is refused). DATA's trailing CRLF is the end-of-command, not
|
|
124
|
+
// an argument.
|
|
125
|
+
var ZERO_ARG_VERBS = Object.freeze({ DATA: true, RSET: true, QUIT: true, STARTTLS: true });
|
|
126
|
+
|
|
127
|
+
// Address-literal shape per RFC 5321 §4.1.3 (very loose — full
|
|
128
|
+
// validation lives in safeUrl / IP-address parsing; here we just
|
|
129
|
+
// gate the SMTP-side bracket shape).
|
|
130
|
+
var ADDR_LIT_RE = /^\[(?:IPv6:)?[0-9A-Fa-f:.]+\]$/; // allow:regex-no-length-cap — caller's command line is already maxLineBytes-capped
|
|
131
|
+
var DOMAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; // allow:regex-no-length-cap — domain length is checked separately against maxDomain
|
|
132
|
+
var DECIMAL_RE = /^[1-9][0-9]{0,9}$|^0$/; // allow:regex-no-length-cap — bounded by anchor + repeat-cap
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @primitive b.guardSmtpCommand.validate
|
|
136
|
+
* @signature b.guardSmtpCommand.validate(line, opts?)
|
|
137
|
+
* @since 0.9.32
|
|
138
|
+
* @status stable
|
|
139
|
+
* @related b.guardEmail.validateMessage, b.safeMime.parse
|
|
140
|
+
*
|
|
141
|
+
* Validate a single SMTP command line (without its CRLF terminator —
|
|
142
|
+
* the listener strips that before calling this). Returns a structured
|
|
143
|
+
* `{ verb, args, params }` shape on success; throws
|
|
144
|
+
* `GuardSmtpCommandError` on any refusal.
|
|
145
|
+
*
|
|
146
|
+
* @opts
|
|
147
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
148
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* var parsed = b.guardSmtpCommand.validate("MAIL FROM:<alice@example.com> SIZE=12345");
|
|
152
|
+
* // → { verb: "MAIL", args: ["FROM:<alice@example.com>"], params: { SIZE: "12345" } }
|
|
153
|
+
*/
|
|
154
|
+
function validate(line, opts) {
|
|
155
|
+
opts = opts || {};
|
|
156
|
+
var caps = _resolveProfile(opts);
|
|
157
|
+
if (typeof line !== "string") {
|
|
158
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
|
|
159
|
+
"guardSmtpCommand.validate: line must be a string; got " + (typeof line));
|
|
160
|
+
}
|
|
161
|
+
if (line.length === 0) {
|
|
162
|
+
throw new GuardSmtpCommandError("guard-smtp-command/empty",
|
|
163
|
+
"guardSmtpCommand.validate: empty command line");
|
|
164
|
+
}
|
|
165
|
+
if (Buffer.byteLength(line, caps.allowSmtpUtf8 ? "utf8" : "ascii") > caps.maxLineBytes) {
|
|
166
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-line",
|
|
167
|
+
"guardSmtpCommand.validate: line exceeds maxLineBytes=" + caps.maxLineBytes +
|
|
168
|
+
" (RFC 5321 §4.5.3.1.1)");
|
|
169
|
+
}
|
|
170
|
+
// Smuggling defense — refuse bare CR / bare LF anywhere in the line.
|
|
171
|
+
// Listener has already stripped the terminating CRLF before calling.
|
|
172
|
+
if (line.indexOf("\r") !== -1) {
|
|
173
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bare-cr",
|
|
174
|
+
"guardSmtpCommand.validate: bare CR in command line (RFC 5321 §2.3.8; " +
|
|
175
|
+
"smuggling defense CVE-2023-51764/51765/51766)");
|
|
176
|
+
}
|
|
177
|
+
if (line.indexOf("\n") !== -1 && !caps.allowBareLf) {
|
|
178
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bare-lf",
|
|
179
|
+
"guardSmtpCommand.validate: bare LF in command line (RFC 5321 §2.3.8; " +
|
|
180
|
+
"smuggling defense CVE-2023-51764/51765/51766/2026-32178)");
|
|
181
|
+
}
|
|
182
|
+
if (line.indexOf("\u0000") !== -1) {
|
|
183
|
+
throw new GuardSmtpCommandError("guard-smtp-command/nul",
|
|
184
|
+
"guardSmtpCommand.validate: NUL byte refused");
|
|
185
|
+
}
|
|
186
|
+
// C0 controls (except SP=0x20, HTAB=0x09 not legal in commands, and
|
|
187
|
+
// LF=0x0a when allowBareLf is true under permissive profile per
|
|
188
|
+
// legacy Sendmail compat) plus DEL.
|
|
189
|
+
for (var i = 0; i < line.length; i += 1) {
|
|
190
|
+
var c = line.charCodeAt(i);
|
|
191
|
+
// LF under permissive: allowed by profile, already passed the
|
|
192
|
+
// bare-LF refusal earlier in this fn. Skip the control-char throw
|
|
193
|
+
// so the documented allowBareLf path actually accepts LF (Codex
|
|
194
|
+
// caught this: permissive profile was effectively broken).
|
|
195
|
+
if (c === 0x0a && caps.allowBareLf) continue; // allow:raw-byte-literal — RFC 5321 §2.3.8 LF, permissive bypass
|
|
196
|
+
if (c < 0x20 || c === 0x7f) { // allow:raw-byte-literal — RFC 5321 §2.3.8 forbids C0 / DEL
|
|
197
|
+
throw new GuardSmtpCommandError("guard-smtp-command/control-char",
|
|
198
|
+
"guardSmtpCommand.validate: control char 0x" + c.toString(16) + " refused");
|
|
199
|
+
}
|
|
200
|
+
if (!caps.allowSmtpUtf8 && c > 0x7e) { // allow:raw-byte-literal — RFC 5321 §2.3.1 7-bit ASCII; SMTPUTF8 relaxes
|
|
201
|
+
throw new GuardSmtpCommandError("guard-smtp-command/non-ascii",
|
|
202
|
+
"guardSmtpCommand.validate: non-ASCII byte refused (no SMTPUTF8 negotiated)");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
var firstSpace = line.indexOf(" ");
|
|
206
|
+
var verb = (firstSpace === -1 ? line : line.slice(0, firstSpace)).toUpperCase();
|
|
207
|
+
var rest = firstSpace === -1 ? "" : line.slice(firstSpace + 1);
|
|
208
|
+
|
|
209
|
+
if (!KNOWN_VERBS[verb]) {
|
|
210
|
+
throw new GuardSmtpCommandError("guard-smtp-command/unknown-verb",
|
|
211
|
+
"guardSmtpCommand.validate: unknown verb '" + verb + "' (RFC 5321 §3)");
|
|
212
|
+
}
|
|
213
|
+
if (ZERO_ARG_VERBS[verb] && rest.length > 0) {
|
|
214
|
+
throw new GuardSmtpCommandError("guard-smtp-command/unexpected-args",
|
|
215
|
+
"guardSmtpCommand.validate: verb '" + verb + "' takes no arguments");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (verb === "EHLO" || verb === "HELO") return _validateGreeting(verb, rest, caps);
|
|
219
|
+
if (verb === "MAIL") return _validatePath(verb, rest, caps, "FROM:");
|
|
220
|
+
if (verb === "RCPT") return _validatePath(verb, rest, caps, "TO:");
|
|
221
|
+
if (verb === "BDAT") return _validateBdat(rest);
|
|
222
|
+
if (verb === "VRFY" || verb === "EXPN") return _validateMailbox(verb, rest, caps);
|
|
223
|
+
if (verb === "AUTH") return _validateAuth(rest);
|
|
224
|
+
if (verb === "NOOP" || verb === "HELP") return { verb: verb, args: rest ? [rest] : [], params: {} };
|
|
225
|
+
|
|
226
|
+
return { verb: verb, args: [], params: {} };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @primitive b.guardSmtpCommand.compliancePosture
|
|
231
|
+
* @signature b.guardSmtpCommand.compliancePosture(posture)
|
|
232
|
+
* @since 0.9.32
|
|
233
|
+
* @status stable
|
|
234
|
+
*
|
|
235
|
+
* Return the effective profile name for a compliance posture, or
|
|
236
|
+
* `null` for unknown posture names.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* b.guardSmtpCommand.compliancePosture("hipaa"); // → "strict"
|
|
240
|
+
*/
|
|
241
|
+
function compliancePosture(posture) {
|
|
242
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _validateGreeting(verb, rest, caps) {
|
|
246
|
+
if (rest.length === 0) {
|
|
247
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
248
|
+
verb + " requires a domain or address literal argument (RFC 5321 §4.1.1.1)");
|
|
249
|
+
}
|
|
250
|
+
// Trim trailing-space tolerance — most peers send a single space; we
|
|
251
|
+
// accept it but refuse multiple spaces or leading spaces.
|
|
252
|
+
if (rest.charAt(0) === " " || rest.indexOf(" ") !== -1) {
|
|
253
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-whitespace",
|
|
254
|
+
verb + " greeting has anomalous whitespace");
|
|
255
|
+
}
|
|
256
|
+
var arg = rest;
|
|
257
|
+
if (ADDR_LIT_RE.test(arg)) return { verb: verb, args: [arg], params: {} }; // allow:regex-no-length-cap — line is maxLineBytes-capped upstream
|
|
258
|
+
if (Buffer.byteLength(arg, "utf8") > caps.maxDomain) {
|
|
259
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-domain",
|
|
260
|
+
verb + ": domain exceeds maxDomain=" + caps.maxDomain + " (RFC 1035 §2.3.4)");
|
|
261
|
+
}
|
|
262
|
+
if (!DOMAIN_RE.test(arg)) { // allow:regex-no-length-cap — domain just length-checked above
|
|
263
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
264
|
+
verb + ": domain '" + arg + "' does not match LDH shape (RFC 5321 §4.1.2)");
|
|
265
|
+
}
|
|
266
|
+
return { verb: verb, args: [arg], params: {} };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function _validatePath(verb, rest, caps, requiredPrefix) {
|
|
270
|
+
// SMTP allows OPTIONAL whitespace between e.g. MAIL and FROM:<...>
|
|
271
|
+
// per RFC 5321 §4.1.1.2; we accept a single space (consumed above)
|
|
272
|
+
// and refuse multiple spaces around the colon.
|
|
273
|
+
if (rest.indexOf(requiredPrefix) !== 0) {
|
|
274
|
+
// Allow a single leading SP — but our caller already split on the
|
|
275
|
+
// first SP; rest is post-SP. Some implementations send the path
|
|
276
|
+
// prefix verbatim. Refuse anything else.
|
|
277
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
278
|
+
verb + " must begin with '" + requiredPrefix + "' (RFC 5321 §3.3)");
|
|
279
|
+
}
|
|
280
|
+
var after = rest.slice(requiredPrefix.length);
|
|
281
|
+
var pathEnd = after.indexOf(">");
|
|
282
|
+
if (after.charAt(0) !== "<" || pathEnd === -1) {
|
|
283
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
284
|
+
verb + " path missing angle brackets (RFC 5321 §3.3)");
|
|
285
|
+
}
|
|
286
|
+
var pathRaw = after.slice(0, pathEnd + 1);
|
|
287
|
+
if (Buffer.byteLength(pathRaw, "utf8") > caps.maxMailbox) {
|
|
288
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-path",
|
|
289
|
+
verb + ": path '" + pathRaw + "' exceeds maxMailbox=" + caps.maxMailbox +
|
|
290
|
+
" (RFC 5321 §4.5.3.1.3)");
|
|
291
|
+
}
|
|
292
|
+
var pathBody = pathRaw.slice(1, -1);
|
|
293
|
+
// Reverse-path can be empty (`MAIL FROM:<>` per RFC 5321 §3.3 bounce
|
|
294
|
+
// sender). Forward-path can't be empty.
|
|
295
|
+
if (pathBody.length === 0) {
|
|
296
|
+
if (verb === "MAIL") return { verb: verb, args: [pathRaw], params: _parseExtParams(after.slice(pathEnd + 1)) };
|
|
297
|
+
throw new GuardSmtpCommandError("guard-smtp-command/empty-path",
|
|
298
|
+
verb + " path must not be empty");
|
|
299
|
+
}
|
|
300
|
+
// Validate mailbox local-part and domain caps.
|
|
301
|
+
var atIdx = pathBody.lastIndexOf("@");
|
|
302
|
+
if (atIdx === -1) {
|
|
303
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
304
|
+
verb + ": mailbox missing '@' (RFC 5321 §4.1.2)");
|
|
305
|
+
}
|
|
306
|
+
var localPart = pathBody.slice(0, atIdx);
|
|
307
|
+
var domain = pathBody.slice(atIdx + 1);
|
|
308
|
+
if (Buffer.byteLength(localPart, "utf8") > caps.maxLocalPart) {
|
|
309
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-local-part",
|
|
310
|
+
verb + ": local-part exceeds maxLocalPart=" + caps.maxLocalPart);
|
|
311
|
+
}
|
|
312
|
+
if (Buffer.byteLength(domain, "utf8") > caps.maxDomain) {
|
|
313
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-domain",
|
|
314
|
+
verb + ": domain exceeds maxDomain=" + caps.maxDomain);
|
|
315
|
+
}
|
|
316
|
+
if (!ADDR_LIT_RE.test(domain) && !DOMAIN_RE.test(domain)) { // allow:regex-no-length-cap — domain length-checked above
|
|
317
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
318
|
+
verb + ": domain '" + domain + "' does not match LDH or address-literal shape");
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
verb: verb,
|
|
322
|
+
args: [pathRaw],
|
|
323
|
+
params: _parseExtParams(after.slice(pathEnd + 1)),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function _parseExtParams(tail) {
|
|
328
|
+
// RFC 5321 §4.1.1.11 — esmtp-keyword + optional '=' + esmtp-value.
|
|
329
|
+
// Caller passes the post-path slice; leading SP is allowed and
|
|
330
|
+
// separates params from each other.
|
|
331
|
+
var params = {};
|
|
332
|
+
var parts = tail.trim().split(/\s+/).filter(Boolean);
|
|
333
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
334
|
+
var eq = parts[i].indexOf("=");
|
|
335
|
+
var key = (eq === -1 ? parts[i] : parts[i].slice(0, eq)).toUpperCase();
|
|
336
|
+
var val = eq === -1 ? true : parts[i].slice(eq + 1);
|
|
337
|
+
if (!/^[A-Za-z0-9-]+$/.test(key)) { // allow:regex-no-length-cap — bounded by maxLineBytes via line cap
|
|
338
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-ext-param",
|
|
339
|
+
"esmtp-keyword '" + key + "' not in [A-Za-z0-9-] (RFC 5321 §4.1.1.11)");
|
|
340
|
+
}
|
|
341
|
+
params[key] = val;
|
|
342
|
+
}
|
|
343
|
+
return params;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function _validateBdat(rest) {
|
|
347
|
+
// RFC 3030 §2: `BDAT <chunk-size> [LAST]`
|
|
348
|
+
var parts = rest.split(/\s+/).filter(Boolean);
|
|
349
|
+
if (parts.length === 0 || parts.length > 2) {
|
|
350
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
351
|
+
"BDAT requires chunk-size and optional LAST (RFC 3030)");
|
|
352
|
+
}
|
|
353
|
+
if (!DECIMAL_RE.test(parts[0])) { // allow:regex-no-length-cap — DECIMAL_RE has built-in repeat cap
|
|
354
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
355
|
+
"BDAT chunk-size must be a decimal number");
|
|
356
|
+
}
|
|
357
|
+
if (parts.length === 2 && parts[1].toUpperCase() !== "LAST") {
|
|
358
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
359
|
+
"BDAT second arg must be LAST (RFC 3030)");
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
verb: "BDAT",
|
|
363
|
+
args: [parts[0]],
|
|
364
|
+
params: parts.length === 2 ? { LAST: true } : {},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function _validateMailbox(verb, rest, caps) {
|
|
369
|
+
if (rest.length === 0) {
|
|
370
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
371
|
+
verb + " requires a mailbox argument (RFC 5321 §4.1.1.7/§4.1.1.8)");
|
|
372
|
+
}
|
|
373
|
+
if (Buffer.byteLength(rest, "utf8") > caps.maxMailbox) {
|
|
374
|
+
throw new GuardSmtpCommandError("guard-smtp-command/oversize-path",
|
|
375
|
+
verb + ": mailbox exceeds maxMailbox=" + caps.maxMailbox);
|
|
376
|
+
}
|
|
377
|
+
return { verb: verb, args: [rest], params: {} };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _validateAuth(rest) {
|
|
381
|
+
// RFC 4954: `AUTH <SASL-mech> [<initial-response>]`
|
|
382
|
+
var parts = rest.split(/\s+/).filter(Boolean);
|
|
383
|
+
if (parts.length === 0 || parts.length > 2) {
|
|
384
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
385
|
+
"AUTH requires mechanism and optional initial-response (RFC 4954)");
|
|
386
|
+
}
|
|
387
|
+
// SASL mechanism names are RFC 4422 — ALPHA + DIGIT + "-" + "_", up
|
|
388
|
+
// to 20 octets. We accept the 4422 charset and a 32-byte cap so
|
|
389
|
+
// operator-extension mechanisms have room.
|
|
390
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(parts[0])) { // allow:regex-no-length-cap — anchored + repeat-cap
|
|
391
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
|
|
392
|
+
"AUTH: SASL mechanism '" + parts[0] + "' not in RFC 4422 charset or too long");
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
verb: "AUTH",
|
|
396
|
+
args: [parts[0].toUpperCase()],
|
|
397
|
+
params: parts.length === 2 ? { initialResponse: parts[1] } : {},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function _resolveProfile(opts) {
|
|
402
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
403
|
+
return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
|
|
404
|
+
}
|
|
405
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
406
|
+
if (!PROFILES[p]) {
|
|
407
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-profile",
|
|
408
|
+
"guardSmtpCommand: unknown profile '" + p + "'");
|
|
409
|
+
}
|
|
410
|
+
return PROFILES[p];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* @primitive b.guardSmtpCommand.gate
|
|
415
|
+
* @signature b.guardSmtpCommand.gate(opts?)
|
|
416
|
+
* @since 0.9.32
|
|
417
|
+
* @status stable
|
|
418
|
+
*
|
|
419
|
+
* Build a guard gate compatible with `b.guardAll.allGuards()`. The
|
|
420
|
+
* gate's `decide(ctx)` reads `ctx.identifier` (or `ctx.commandLine`)
|
|
421
|
+
* and routes through `validate()`; refuse on any thrown
|
|
422
|
+
* `GuardSmtpCommandError`, serve otherwise.
|
|
423
|
+
*
|
|
424
|
+
* @opts
|
|
425
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
426
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
427
|
+
* name: string, // gate identity label
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* var gate = b.guardSmtpCommand.gate({ profile: "strict" });
|
|
431
|
+
* await gate.decide({ identifier: "EHLO mail.example.com" });
|
|
432
|
+
* // → { ok: true, action: "serve" }
|
|
433
|
+
*/
|
|
434
|
+
function gate(opts) {
|
|
435
|
+
opts = opts || {};
|
|
436
|
+
// Resolve profile eagerly so a bad profile / posture surfaces here
|
|
437
|
+
// rather than inside the first gate.check() call.
|
|
438
|
+
_resolveProfile(opts);
|
|
439
|
+
var name = opts.name || "guardSmtpCommand:" + (opts.profile || opts.posture || "default");
|
|
440
|
+
return gateContract.buildGuardGate(name, opts, async function (ctx) {
|
|
441
|
+
var line = ctx && (ctx.identifier || ctx.commandLine || "");
|
|
442
|
+
if (!line) return { ok: true, action: "serve" };
|
|
443
|
+
try {
|
|
444
|
+
validate(line, opts);
|
|
445
|
+
return { ok: true, action: "serve" };
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (e && typeof e.code === "string" && e.code.indexOf("guard-smtp-command/") === 0) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
action: "refuse",
|
|
451
|
+
issues: [{
|
|
452
|
+
kind: e.code.split("/")[1],
|
|
453
|
+
severity: "critical",
|
|
454
|
+
ruleId: "smtp-command." + e.code.split("/")[1],
|
|
455
|
+
snippet: e.message,
|
|
456
|
+
}],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
throw e;
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
module.exports = {
|
|
465
|
+
validate: validate,
|
|
466
|
+
gate: gate,
|
|
467
|
+
compliancePosture: compliancePosture,
|
|
468
|
+
PROFILES: PROFILES,
|
|
469
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
470
|
+
KNOWN_VERBS: KNOWN_VERBS,
|
|
471
|
+
GuardSmtpCommandError: GuardSmtpCommandError,
|
|
472
|
+
NAME: "smtpCommand",
|
|
473
|
+
KIND: "identifier",
|
|
474
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
475
|
+
kind: "identifier",
|
|
476
|
+
// Benign: standard EHLO greeting.
|
|
477
|
+
benignBytes: Buffer.from("EHLO mail.example.com", "ascii"),
|
|
478
|
+
// Hostile: CRLF smuggling attempt — bare CR inside a command line
|
|
479
|
+
// (CVE-2023-51764 / 51765 / 51766 class).
|
|
480
|
+
hostileBytes: Buffer.from("MAIL FROM:<a@b.com>\r\n.\r\nMAIL FROM:<evil@x.com>", "ascii"),
|
|
481
|
+
benignIdentifier: "EHLO mail.example.com",
|
|
482
|
+
hostileIdentifier: "MAIL FROM:<a@b.com>\r\n.\r\nMAIL FROM:<evil@x.com>",
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardSnapshotEnvelope
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Snapshot Envelope
|
|
6
|
+
* @order 444
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Snapshot envelope shape validator. The agent snapshot primitive
|
|
10
|
+
* (v0.9.30) writes a structured envelope to durable storage on drain
|
|
11
|
+
* and reads it back on restart. The guard refuses malformed
|
|
12
|
+
* envelopes at the boundary so a corrupt or tampered snapshot
|
|
13
|
+
* doesn't get partially restored.
|
|
14
|
+
*
|
|
15
|
+
* Envelope contract: snapshotId, takenAt, frameworkVersion,
|
|
16
|
+
* orchestratorState, inFlight, idempotencyCache (optional), sig,
|
|
17
|
+
* schemaVersion.
|
|
18
|
+
*
|
|
19
|
+
* Hard caps:
|
|
20
|
+
* - total serialized size (default 50 MiB)
|
|
21
|
+
* - in-flight items count (default 65536 — orchestrator can't
|
|
22
|
+
* legitimately hold more in-flight streams + sagas + outbox-
|
|
23
|
+
* jobs at one moment than that)
|
|
24
|
+
* - schemaVersion must be a positive integer
|
|
25
|
+
*
|
|
26
|
+
* @card
|
|
27
|
+
* Validates snapshot envelopes at drain/restore boundary. Bounded
|
|
28
|
+
* size + in-flight count + schema-version monotonic check.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
var { defineClass } = require("./framework-error");
|
|
32
|
+
|
|
33
|
+
var GuardSnapshotEnvelopeError = defineClass("GuardSnapshotEnvelopeError", { alwaysPermanent: true });
|
|
34
|
+
|
|
35
|
+
var DEFAULT_PROFILE = "strict";
|
|
36
|
+
|
|
37
|
+
var PROFILES = Object.freeze({
|
|
38
|
+
strict: { maxBytes: 52428800, maxInFlight: 65536 }, // allow:raw-byte-literal — 50 MiB cap
|
|
39
|
+
balanced: { maxBytes: 209715200, maxInFlight: 262144 }, // allow:raw-byte-literal — 200 MiB
|
|
40
|
+
permissive: { maxBytes: 1073741824, maxInFlight: 1048576 }, // allow:raw-byte-literal — 1 GiB
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
44
|
+
hipaa: "strict",
|
|
45
|
+
"pci-dss": "strict",
|
|
46
|
+
gdpr: "strict",
|
|
47
|
+
soc2: "strict",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @primitive b.guardSnapshotEnvelope.validate
|
|
52
|
+
* @signature b.guardSnapshotEnvelope.validate(envelope, opts?)
|
|
53
|
+
* @since 0.9.30
|
|
54
|
+
* @status stable
|
|
55
|
+
* @related b.agent.snapshot.create
|
|
56
|
+
*
|
|
57
|
+
* Validate a snapshot envelope shape. Returns envelope on success;
|
|
58
|
+
* throws on shape refusal.
|
|
59
|
+
*
|
|
60
|
+
* @opts
|
|
61
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
62
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* b.guardSnapshotEnvelope.validate({
|
|
66
|
+
* snapshotId: "snap-abc",
|
|
67
|
+
* takenAt: 1700000000000,
|
|
68
|
+
* frameworkVersion: "0.9.30",
|
|
69
|
+
* schemaVersion: 1,
|
|
70
|
+
* orchestratorState: {},
|
|
71
|
+
* inFlight: {},
|
|
72
|
+
* });
|
|
73
|
+
*/
|
|
74
|
+
function validate(envelope, opts) {
|
|
75
|
+
opts = opts || {};
|
|
76
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
77
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
78
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-input",
|
|
79
|
+
"guardSnapshotEnvelope.validate: envelope required");
|
|
80
|
+
}
|
|
81
|
+
if (typeof envelope.snapshotId !== "string" || envelope.snapshotId.length === 0) {
|
|
82
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-snapshot-id",
|
|
83
|
+
"guardSnapshotEnvelope.validate: snapshotId required");
|
|
84
|
+
}
|
|
85
|
+
if (typeof envelope.takenAt !== "number" || !isFinite(envelope.takenAt) || envelope.takenAt <= 0) {
|
|
86
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-taken-at",
|
|
87
|
+
"guardSnapshotEnvelope.validate: takenAt must be a positive finite number");
|
|
88
|
+
}
|
|
89
|
+
if (typeof envelope.frameworkVersion !== "string" || envelope.frameworkVersion.length === 0) {
|
|
90
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-framework-version",
|
|
91
|
+
"guardSnapshotEnvelope.validate: frameworkVersion required");
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isInteger(envelope.schemaVersion) || envelope.schemaVersion < 1) {
|
|
94
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-schema-version",
|
|
95
|
+
"guardSnapshotEnvelope.validate: schemaVersion must be a positive integer");
|
|
96
|
+
}
|
|
97
|
+
if (!envelope.orchestratorState || typeof envelope.orchestratorState !== "object") {
|
|
98
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-orchestrator-state",
|
|
99
|
+
"guardSnapshotEnvelope.validate: orchestratorState object required");
|
|
100
|
+
}
|
|
101
|
+
if (!envelope.inFlight || typeof envelope.inFlight !== "object") {
|
|
102
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-in-flight",
|
|
103
|
+
"guardSnapshotEnvelope.validate: inFlight object required");
|
|
104
|
+
}
|
|
105
|
+
// Total in-flight count cap — sum of streams + sagas + subscribers + pendingDeliveries.
|
|
106
|
+
var inFlightCount = 0;
|
|
107
|
+
["streams", "sagas", "outboxJobs", "busSubscribers", "pendingDeliveries"].forEach(function (k) {
|
|
108
|
+
if (Array.isArray(envelope.inFlight[k])) inFlightCount += envelope.inFlight[k].length;
|
|
109
|
+
});
|
|
110
|
+
if (inFlightCount > profile.maxInFlight) {
|
|
111
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/in-flight-cap",
|
|
112
|
+
"guardSnapshotEnvelope.validate: " + inFlightCount +
|
|
113
|
+
" in-flight items exceeds maxInFlight=" + profile.maxInFlight);
|
|
114
|
+
}
|
|
115
|
+
// Size cap — serialize the whole envelope to JSON for size check.
|
|
116
|
+
var serialized;
|
|
117
|
+
try { serialized = JSON.stringify(envelope); }
|
|
118
|
+
catch (e) {
|
|
119
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/unserializable",
|
|
120
|
+
"guardSnapshotEnvelope.validate: envelope not JSON-serializable: " +
|
|
121
|
+
(e && e.message ? e.message : String(e)));
|
|
122
|
+
}
|
|
123
|
+
if (Buffer.byteLength(serialized, "utf8") > profile.maxBytes) {
|
|
124
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/oversize",
|
|
125
|
+
"guardSnapshotEnvelope.validate: " + Buffer.byteLength(serialized, "utf8") +
|
|
126
|
+
" bytes exceeds maxBytes=" + profile.maxBytes);
|
|
127
|
+
}
|
|
128
|
+
return envelope;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @primitive b.guardSnapshotEnvelope.compliancePosture
|
|
133
|
+
* @signature b.guardSnapshotEnvelope.compliancePosture(posture)
|
|
134
|
+
* @since 0.9.30
|
|
135
|
+
* @status stable
|
|
136
|
+
*
|
|
137
|
+
* Return the effective profile for a given compliance posture name.
|
|
138
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
139
|
+
* here instead of silently falling through to the default profile.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* b.guardSnapshotEnvelope.compliancePosture("hipaa"); // returns "strict"
|
|
143
|
+
*/
|
|
144
|
+
function compliancePosture(posture) {
|
|
145
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _resolveProfile(opts) {
|
|
149
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
150
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
151
|
+
}
|
|
152
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
153
|
+
if (!PROFILES[p]) {
|
|
154
|
+
throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-profile",
|
|
155
|
+
"guardSnapshotEnvelope: unknown profile '" + p + "'");
|
|
156
|
+
}
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
validate: validate,
|
|
162
|
+
compliancePosture: compliancePosture,
|
|
163
|
+
PROFILES: PROFILES,
|
|
164
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
165
|
+
GuardSnapshotEnvelopeError: GuardSnapshotEnvelopeError,
|
|
166
|
+
NAME: "snapshotEnvelope",
|
|
167
|
+
KIND: "snapshot-envelope",
|
|
168
|
+
};
|