@blamejs/core 0.7.4 → 0.7.19

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.
@@ -0,0 +1,744 @@
1
+ "use strict";
2
+ /**
3
+ * guard-email — Email content-safety primitive (b.guardEmail).
4
+ *
5
+ * Threat catalog grounded in current research:
6
+ * - SMTP smuggling (CVE-2023-51764 Postfix; CVE-2023-51765 Sendmail;
7
+ * CVE-2023-51766 Exim; CVE-2026-32178 .NET System.Net.Mail) —
8
+ * embedded SMTP verbs after bare-CR / bare-LF / dot-stuffing
9
+ * manipulation lets an attacker inject a second message in the
10
+ * same SMTP session with a forged envelope.
11
+ * - CRLF header injection — `\r\n` inside any header field value
12
+ * splits the header section and lets the attacker forge `From:`,
13
+ * `Bcc:`, or smuggle a body.
14
+ * - IDN homograph spoofing — mixed-script Unicode in the domain part
15
+ * (Cyrillic а / Greek α / Armenian / Cherokee letters that look
16
+ * like Latin lowercase). Most filters miss confusables.
17
+ * - Display-name spoofing — `"support@apple.com" <attacker@evil>` —
18
+ * the rendered name impersonates a trusted address while the
19
+ * envelope routes elsewhere.
20
+ * - Bare IP literal addresses — `user@[1.2.3.4]` / `user@[IPv6:...]`.
21
+ * - Comment syntax in addresses — `(comment)` per RFC 5322 — most
22
+ * receivers reject; senders that accept it are a smuggling vector.
23
+ * - RFC 5321 / 5322 length caps — local-part 64; domain 255; total
24
+ * address 320; per-line 998.
25
+ * - Multiple @ characters / multiple addresses in a single field.
26
+ * - Bidi / null / control / zero-width chars in addresses + headers.
27
+ * - BOM injection at the start of a header.
28
+ *
29
+ * var rv = b.guardEmail.validateAddress(addr, { profile: "strict" });
30
+ * var rv = b.guardEmail.validateMessage(rfc822, { profile: "strict" });
31
+ * var safe = b.guardEmail.sanitize(input, { profile: "balanced" });
32
+ * var g = b.guardEmail.gate({ profile: "strict" });
33
+ */
34
+
35
+ var codepointClass = require("./codepoint-class");
36
+ var lazyRequire = require("./lazy-require");
37
+ var gateContract = require("./gate-contract");
38
+ var C = require("./constants");
39
+ var numericBounds = require("./numeric-bounds");
40
+ var { GuardEmailError } = require("./framework-error");
41
+
42
+ var observability = lazyRequire(function () { return require("./observability"); });
43
+ void observability;
44
+
45
+ var _err = GuardEmailError.factory;
46
+
47
+ // ---- RFC 5321 / 5322 limits ----
48
+
49
+ var LIMIT_LOCAL_PART = 64; // allow:raw-byte-literal — RFC 5321 §4.5.3.1.1
50
+ var LIMIT_DOMAIN = 255; // allow:raw-byte-literal — RFC 5321 §4.5.3.1.2
51
+ var LIMIT_ADDRESS = 320; // allow:raw-byte-literal — RFC 5321 sum (64 + 1 + 255)
52
+ var LIMIT_LINE = 998; // allow:raw-byte-literal — RFC 5322 §2.1.1 maximum line length
53
+
54
+ // ---- Source-level threat detectors ----
55
+
56
+ // Bare CR or bare LF outside CRLF pair — SMTP smuggling vector class.
57
+ // Detection MUST scan the full message bytes; a bare LF in the body
58
+ // can let an attacker smuggle a follow-on `\r\n.\r\n` sequence past
59
+ // the upstream MTA's end-of-data check.
60
+ function _scanBareLineEndings(input) {
61
+ var bareCr = false;
62
+ var bareLf = false;
63
+ for (var i = 0; i < input.length; i += 1) {
64
+ var c = input.charCodeAt(i);
65
+ if (c === 13) { // allow:raw-byte-literal — CR
66
+ var next = i + 1 < input.length ? input.charCodeAt(i + 1) : -1;
67
+ if (next !== 10) bareCr = true; // allow:raw-byte-literal — LF
68
+ } else if (c === 10) { // allow:raw-byte-literal — LF
69
+ var prev = i > 0 ? input.charCodeAt(i - 1) : -1;
70
+ if (prev !== 13) bareLf = true; // allow:raw-byte-literal — CR
71
+ }
72
+ if (bareCr && bareLf) break;
73
+ }
74
+ return { bareCr: bareCr, bareLf: bareLf };
75
+ }
76
+
77
+ // Smuggled SMTP verbs after a bare line ending — the canonical
78
+ // SEC Consult / smtpsmuggling.com pattern.
79
+ var SMUGGLED_VERB_RE = /(?:\r(?!\n)|(?<!\r)\n)\.?\s*(?:MAIL FROM|RCPT TO|DATA|EHLO|HELO|RSET|QUIT)\b/i;
80
+
81
+ // CRLF in any single-line header value — header injection.
82
+ function _hasCrlfInHeaderValue(value) {
83
+ for (var i = 0; i < value.length; i += 1) {
84
+ var c = value.charCodeAt(i);
85
+ if (c === 13 || c === 10) return true; // allow:raw-byte-literal — CR or LF in header value
86
+ }
87
+ return false;
88
+ }
89
+
90
+ // Strict address regex — RFC 5321 plus a small, conservative subset of
91
+ // 5322 atext. Domains MUST be DNS-shaped (label syntax).
92
+ //
93
+ // allow:dynamic-regex — built once at module load from the static
94
+ // atext class; no runtime input.
95
+ // Local-part regex is permissive on length so the explicit cap check
96
+ // can produce a useful local-part-cap issue (instead of failing the
97
+ // regex first and surfacing address-syntax). RFC 5321 cap is enforced
98
+ // downstream via opts.maxLocalPartBytes.
99
+ var _LOCAL = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+";
100
+ var _LABEL = "[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?";
101
+ var _DOMAIN = "(?:" + _LABEL + "(?:\\." + _LABEL + ")+)";
102
+ // allow:dynamic-regex — built once at module load from the static
103
+ // _LOCAL + _DOMAIN literal strings; no runtime input.
104
+ var ADDRESS_RE = new RegExp("^(" + _LOCAL + ")@(" + _DOMAIN + ")$");
105
+
106
+ // IP-literal (square-bracketed IPv4 / IPv6). Allowed-or-refused via opt.
107
+ var IP_LITERAL_RE = /^[^@]+@\[[^\]]+\]$/;
108
+
109
+ // Comment syntax in address — `(comment)` per RFC 5322. Most receivers
110
+ // reject; we flag.
111
+ var ADDRESS_COMMENT_RE = /[()]/;
112
+
113
+ // Punycode prefix on a domain label.
114
+ var PUNYCODE_LABEL_RE = /(?:^|\.)xn--/i;
115
+
116
+ // Mixed-script detection — flag any domain with characters from more
117
+ // than one of: Latin / Cyrillic / Greek / Armenian / Cherokee. The
118
+ // catalog is conservative; operators with legitimate non-Latin domains
119
+ // register the script via `allowedScripts: ["latin", "cyrillic"]`.
120
+ //
121
+ // Codepoints from Unicode 15.1 official script ranges (via codepoint-
122
+ // class.js conventions — keep numeric, no literal characters).
123
+ var SCRIPT_RANGES = {
124
+ latin: [[0x0041, 0x005a], [0x0061, 0x007a],
125
+ [0x00c0, 0x024f], [0x1e00, 0x1eff]], // allow:raw-byte-literal — Unicode script ranges
126
+ cyrillic: [[0x0400, 0x04ff], [0x0500, 0x052f]], // allow:raw-byte-literal — Unicode Cyrillic + Cyrillic Supplement
127
+ greek: [[0x0370, 0x03ff], [0x1f00, 0x1fff]], // allow:raw-byte-literal — Unicode Greek + Greek Extended
128
+ armenian: [[0x0530, 0x058f]], // allow:raw-byte-literal — Unicode Armenian
129
+ cherokee: [[0x13a0, 0x13ff], [0xab70, 0xabbf]], // allow:raw-byte-literal — Unicode Cherokee + Cherokee Supplement
130
+ };
131
+
132
+ function _scriptFor(cp) {
133
+ var keys = Object.keys(SCRIPT_RANGES);
134
+ for (var i = 0; i < keys.length; i += 1) {
135
+ var ranges = SCRIPT_RANGES[keys[i]];
136
+ for (var j = 0; j < ranges.length; j += 1) {
137
+ if (cp >= ranges[j][0] && cp <= ranges[j][1]) return keys[i];
138
+ }
139
+ }
140
+ return null; // unknown script — not a confusable
141
+ }
142
+
143
+ function _detectMixedScripts(domain, allowedScripts) {
144
+ var seen = {};
145
+ for (var i = 0; i < domain.length; i += 1) {
146
+ var script = _scriptFor(domain.charCodeAt(i));
147
+ if (script === null) continue;
148
+ seen[script] = true;
149
+ }
150
+ var scripts = Object.keys(seen);
151
+ if (scripts.length <= 1) return null;
152
+ var disallowed = [];
153
+ for (var k = 0; k < scripts.length; k += 1) {
154
+ if (!allowedScripts || allowedScripts.indexOf(scripts[k]) === -1) {
155
+ disallowed.push(scripts[k]);
156
+ }
157
+ }
158
+ return scripts.length > 1 && disallowed.length > 0 ? scripts : null;
159
+ }
160
+
161
+ // Header fields the spec treats as single-line (no folded CR/LF).
162
+ var SINGLE_LINE_HEADERS = ["from", "to", "cc", "bcc", "reply-to", "sender",
163
+ "subject", "message-id", "in-reply-to", "references",
164
+ "date", "return-path"];
165
+
166
+ // Display-name + envelope split.
167
+ var DISPLAY_PHRASE_ANGLE_RE = /^\s*(.*?)\s*<\s*([^>]+)\s*>\s*$/;
168
+
169
+ function _parseAddressLine(line) {
170
+ // Returns { display, envelope } — display may be empty.
171
+ var m = line.match(DISPLAY_PHRASE_ANGLE_RE);
172
+ if (m) return { display: m[1].replace(/^"|"$/g, ""), envelope: m[2] };
173
+ return { display: "", envelope: line.trim() };
174
+ }
175
+
176
+ // ---- Profile presets ----
177
+
178
+ var PROFILES = Object.freeze({
179
+ "strict": {
180
+ crlfHeaderInjectionPolicy: "reject",
181
+ smtpSmugglingPolicy: "reject",
182
+ bareCrPolicy: "reject",
183
+ bareLfPolicy: "reject",
184
+ multiAtPolicy: "reject",
185
+ ipLiteralPolicy: "reject",
186
+ addressCommentPolicy: "reject",
187
+ punycodePolicy: "reject", // strict refuses Punycode — operators with legit IDN traffic opt up
188
+ mixedScriptPolicy: "reject",
189
+ displayNameSpoofPolicy: "reject",
190
+ bomPolicy: "reject",
191
+ bidiPolicy: "reject",
192
+ controlPolicy: "reject",
193
+ nullBytePolicy: "reject",
194
+ zeroWidthPolicy: "reject",
195
+ allowedScripts: ["latin"],
196
+ maxLocalPartBytes: LIMIT_LOCAL_PART,
197
+ maxDomainBytes: LIMIT_DOMAIN,
198
+ maxAddressBytes: LIMIT_ADDRESS,
199
+ maxHeaderLineBytes: LIMIT_LINE,
200
+ maxHeaders: 128, // allow:raw-byte-literal — header count cap
201
+ maxBytes: C.BYTES.mib(8),
202
+ },
203
+ "balanced": {
204
+ crlfHeaderInjectionPolicy: "reject",
205
+ smtpSmugglingPolicy: "reject",
206
+ bareCrPolicy: "audit",
207
+ bareLfPolicy: "audit",
208
+ multiAtPolicy: "reject",
209
+ ipLiteralPolicy: "audit",
210
+ addressCommentPolicy: "audit",
211
+ punycodePolicy: "audit",
212
+ mixedScriptPolicy: "reject",
213
+ displayNameSpoofPolicy: "audit",
214
+ bomPolicy: "strip",
215
+ bidiPolicy: "strip",
216
+ controlPolicy: "strip",
217
+ nullBytePolicy: "strip",
218
+ zeroWidthPolicy: "strip",
219
+ allowedScripts: ["latin", "cyrillic", "greek"],
220
+ maxLocalPartBytes: LIMIT_LOCAL_PART,
221
+ maxDomainBytes: LIMIT_DOMAIN,
222
+ maxAddressBytes: LIMIT_ADDRESS,
223
+ maxHeaderLineBytes: LIMIT_LINE,
224
+ maxHeaders: 512, // allow:raw-byte-literal — header count cap
225
+ maxBytes: C.BYTES.mib(32),
226
+ },
227
+ "permissive": {
228
+ crlfHeaderInjectionPolicy: "reject", // header injection refused at every profile — universal smuggling vector
229
+ smtpSmugglingPolicy: "reject", // SMTP smuggling refused at every profile — universal vector
230
+ bareCrPolicy: "audit",
231
+ bareLfPolicy: "audit",
232
+ multiAtPolicy: "reject", // multi-@ refused at every profile — RFC 5322 violates
233
+ ipLiteralPolicy: "allow",
234
+ addressCommentPolicy: "audit",
235
+ punycodePolicy: "audit",
236
+ mixedScriptPolicy: "audit",
237
+ displayNameSpoofPolicy: "audit",
238
+ bomPolicy: "audit",
239
+ bidiPolicy: "audit",
240
+ controlPolicy: "strip",
241
+ nullBytePolicy: "reject", // null bytes refused at every profile
242
+ zeroWidthPolicy: "audit",
243
+ allowedScripts: null, // permissive — allow every Unicode script
244
+ maxLocalPartBytes: LIMIT_LOCAL_PART,
245
+ maxDomainBytes: LIMIT_DOMAIN,
246
+ maxAddressBytes: LIMIT_ADDRESS,
247
+ maxHeaderLineBytes: LIMIT_LINE,
248
+ maxHeaders: 2048, // allow:raw-byte-literal — header count cap
249
+ maxBytes: C.BYTES.mib(128),
250
+ },
251
+ });
252
+
253
+ var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
254
+ mode: "enforce",
255
+ maxRuntimeMs: C.TIME.seconds(10),
256
+ }));
257
+
258
+ var COMPLIANCE_POSTURES = Object.freeze({
259
+ "hipaa": Object.assign({}, PROFILES["strict"], {
260
+ forensicSnippetBytes: C.BYTES.bytes(256),
261
+ }),
262
+ "pci-dss": Object.assign({}, PROFILES["strict"], {
263
+ forensicSnippetBytes: C.BYTES.bytes(256),
264
+ }),
265
+ "gdpr": Object.assign({}, PROFILES["balanced"], {
266
+ forensicSnippetBytes: C.BYTES.bytes(128),
267
+ }),
268
+ "soc2-cc7": Object.assign({}, PROFILES["strict"], {
269
+ forensicSnippetBytes: C.BYTES.bytes(512),
270
+ }),
271
+ });
272
+
273
+ function _resolveOpts(opts) {
274
+ return gateContract.resolveProfileAndPosture(opts, {
275
+ profiles: PROFILES,
276
+ compliancePostures: COMPLIANCE_POSTURES,
277
+ defaults: DEFAULTS,
278
+ errorClass: GuardEmailError,
279
+ errCodePrefix: "email",
280
+ });
281
+ }
282
+
283
+ // ---- Address validation ----
284
+
285
+ function _detectAddressIssues(input, opts) {
286
+ var issues = [];
287
+ if (typeof input !== "string") {
288
+ return [{ kind: "bad-input", severity: "high",
289
+ snippet: "address is not a string" }];
290
+ }
291
+
292
+ // Total-address length cap.
293
+ if (input.length > opts.maxAddressBytes) {
294
+ issues.push({
295
+ kind: "address-cap", severity: "high", ruleId: "email.address-cap",
296
+ snippet: "address " + input.length + " bytes exceeds maxAddressBytes " +
297
+ opts.maxAddressBytes,
298
+ });
299
+ }
300
+
301
+ // Multi-@ check — count any unquoted @ outside square brackets.
302
+ var atCount = 0;
303
+ var inQuote = false;
304
+ var inBrack = false;
305
+ for (var i = 0; i < input.length; i += 1) {
306
+ var c = input.charAt(i);
307
+ if (c === '"') inQuote = !inQuote;
308
+ else if (c === "[" && !inQuote) inBrack = true;
309
+ else if (c === "]" && !inQuote) inBrack = false;
310
+ else if (c === "@" && !inQuote && !inBrack) atCount += 1;
311
+ }
312
+ if (atCount !== 1 && opts.multiAtPolicy !== "allow") {
313
+ issues.push({
314
+ kind: "multi-at", severity: "critical",
315
+ ruleId: "email.multi-at",
316
+ snippet: "address has " + atCount + " '@' characters; expected exactly 1",
317
+ });
318
+ return issues; // can't continue without one envelope split
319
+ }
320
+
321
+ // Comment syntax — `(comment)` per RFC 5322; most receivers reject.
322
+ if (opts.addressCommentPolicy !== "allow" && ADDRESS_COMMENT_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxAddressBytes
323
+ issues.push({
324
+ kind: "address-comment",
325
+ severity: opts.addressCommentPolicy === "reject" ? "high" : "warn",
326
+ ruleId: "email.address-comment",
327
+ snippet: "address contains '(' or ')' — RFC 5322 comment syntax, " +
328
+ "smuggling-prone vs RFC 5321 receivers",
329
+ });
330
+ }
331
+
332
+ // IP-literal check.
333
+ if (IP_LITERAL_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxAddressBytes
334
+ if (opts.ipLiteralPolicy !== "allow") {
335
+ issues.push({
336
+ kind: "ip-literal",
337
+ severity: opts.ipLiteralPolicy === "reject" ? "high" : "warn",
338
+ ruleId: "email.ip-literal",
339
+ snippet: "address uses IP literal `[...]` — bypasses DNS / DMARC alignment",
340
+ });
341
+ }
342
+ } else {
343
+ // Length-aware cap checks BEFORE syntax-regex. The regex enforces
344
+ // RFC 5321 label-length (63) and atext shape; oversized inputs would
345
+ // fail the regex first and bury the more useful cap diagnosis.
346
+ var atIdx = input.lastIndexOf("@");
347
+ var localPart = atIdx === -1 ? input : input.slice(0, atIdx);
348
+ var domain = atIdx === -1 ? "" : input.slice(atIdx + 1);
349
+
350
+ if (localPart.length > opts.maxLocalPartBytes) {
351
+ issues.push({
352
+ kind: "local-part-cap", severity: "high",
353
+ ruleId: "email.local-part-cap",
354
+ snippet: "local-part " + localPart.length + " bytes exceeds " +
355
+ opts.maxLocalPartBytes + " (RFC 5321 §4.5.3.1.1)",
356
+ });
357
+ }
358
+ if (domain.length > opts.maxDomainBytes) {
359
+ issues.push({
360
+ kind: "domain-cap", severity: "high",
361
+ ruleId: "email.domain-cap",
362
+ snippet: "domain " + domain.length + " bytes exceeds " +
363
+ opts.maxDomainBytes + " (RFC 5321 §4.5.3.1.2)",
364
+ });
365
+ }
366
+
367
+ // Punycode / IDN — flag operator's choice. Runs against the lexed
368
+ // domain so non-ASCII codepoints (which fail the strict ASCII regex)
369
+ // still surface this issue.
370
+ if (opts.punycodePolicy !== "allow" && PUNYCODE_LABEL_RE.test(domain)) { // allow:regex-no-length-cap — domain bounded by maxDomainBytes
371
+ issues.push({
372
+ kind: "punycode-domain",
373
+ severity: opts.punycodePolicy === "reject" ? "high" : "warn",
374
+ ruleId: "email.punycode-domain",
375
+ snippet: "domain uses IDN/Punycode (`xn--` label) — may be " +
376
+ "homograph-spoofing",
377
+ });
378
+ }
379
+
380
+ // Mixed-script confusable detection in domain. Runs on the lexed
381
+ // domain (post-@ split) so non-ASCII codepoints surface here even
382
+ // when they fail the strict ASCII syntax regex below.
383
+ var mixed = _detectMixedScripts(domain, opts.allowedScripts);
384
+ if (mixed && opts.mixedScriptPolicy !== "allow") {
385
+ issues.push({
386
+ kind: "mixed-script-domain",
387
+ severity: opts.mixedScriptPolicy === "reject" ? "critical" : "high",
388
+ ruleId: "email.mixed-script-domain",
389
+ snippet: "domain mixes scripts (" + mixed.join(", ") + ") — " +
390
+ "IDN homograph spoofing class",
391
+ });
392
+ }
393
+
394
+ // If we found a cap issue, skip the strict-ASCII regex (over-cap
395
+ // input fails it anyway and the cap diagnosis is more actionable).
396
+ var hasCap = issues.some(function (i) {
397
+ return i.kind === "local-part-cap" || i.kind === "domain-cap";
398
+ });
399
+ if (!hasCap) {
400
+ var match = input.match(ADDRESS_RE); // allow:regex-no-length-cap — input bounded by maxAddressBytes
401
+ if (!match) {
402
+ // The strict regex caught a non-ASCII or shape issue. If we've
403
+ // already surfaced a punycode / mixed-script issue (the actual
404
+ // semantic threat), don't pile on with address-syntax — the
405
+ // operator gets the real diagnosis.
406
+ var hasIdnIssue = issues.some(function (i) {
407
+ return i.kind === "punycode-domain" || i.kind === "mixed-script-domain";
408
+ });
409
+ if (!hasIdnIssue) {
410
+ issues.push({
411
+ kind: "address-syntax", severity: "high",
412
+ ruleId: "email.address-syntax",
413
+ snippet: "address does not match RFC 5321 atext@DNS-domain shape",
414
+ });
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ // Codepoint-class threats inside the address.
421
+ issues.push.apply(issues, codepointClass.detectCharThreats(input, opts, "email"));
422
+
423
+ return issues;
424
+ }
425
+
426
+ function validateAddress(input, opts) {
427
+ opts = _resolveOpts(opts);
428
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
429
+ ["maxLocalPartBytes", "maxDomainBytes", "maxAddressBytes",
430
+ "maxHeaderLineBytes", "maxHeaders", "maxBytes"],
431
+ "guardEmail.validateAddress", GuardEmailError, "email.bad-opt");
432
+ if (typeof input !== "string") {
433
+ return {
434
+ ok: false,
435
+ issues: [{ kind: "bad-input", severity: "high",
436
+ snippet: "address is not a string" }],
437
+ };
438
+ }
439
+ return gateContract.aggregateIssues(_detectAddressIssues(input, opts));
440
+ }
441
+
442
+ // ---- Message validation (full RFC 822 / 5322) ----
443
+
444
+ function _detectMessageIssues(input, opts) {
445
+ var issues = [];
446
+ if (typeof input !== "string") {
447
+ return [{ kind: "bad-input", severity: "high",
448
+ snippet: "input is not a string" }];
449
+ }
450
+ if (input.length > opts.maxBytes) {
451
+ return [{ kind: "too-large", severity: "high",
452
+ ruleId: "email.too-large",
453
+ snippet: "input " + input.length +
454
+ " bytes exceeds maxBytes " + opts.maxBytes }];
455
+ }
456
+
457
+ // BOM at start of message — header-injection prelude.
458
+ if (opts.bomPolicy !== "allow") {
459
+ if (input.charCodeAt(0) === 0xfeff) { // allow:raw-byte-literal — Unicode BOM
460
+ issues.push({
461
+ kind: "bom",
462
+ severity: opts.bomPolicy === "reject" ? "high" : "warn",
463
+ ruleId: "email.bom",
464
+ snippet: "message starts with BOM (U+FEFF) — header-parser confusion",
465
+ });
466
+ }
467
+ }
468
+
469
+ // SMTP smuggling — bare CR / bare LF + smuggled-verb scan.
470
+ var bare = _scanBareLineEndings(input);
471
+ if (bare.bareCr && opts.bareCrPolicy !== "allow") {
472
+ issues.push({
473
+ kind: "bare-cr",
474
+ severity: opts.bareCrPolicy === "reject" ? "critical" : "warn",
475
+ ruleId: "email.bare-cr",
476
+ snippet: "message contains bare CR (not part of CRLF) — SMTP " +
477
+ "smuggling vector class (CVE-2023-51764)",
478
+ });
479
+ }
480
+ if (bare.bareLf && opts.bareLfPolicy !== "allow") {
481
+ issues.push({
482
+ kind: "bare-lf",
483
+ severity: opts.bareLfPolicy === "reject" ? "critical" : "warn",
484
+ ruleId: "email.bare-lf",
485
+ snippet: "message contains bare LF (not part of CRLF) — SMTP " +
486
+ "smuggling vector class (CVE-2023-51765 / CVE-2023-51766)",
487
+ });
488
+ }
489
+ if (opts.smtpSmugglingPolicy !== "allow" && SMUGGLED_VERB_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes
490
+ issues.push({
491
+ kind: "smtp-smuggling", severity: "critical",
492
+ ruleId: "email.smtp-smuggling",
493
+ snippet: "embedded SMTP verb after bare CR/LF — smuggling vector " +
494
+ "(SEC Consult / smtpsmuggling.com class)",
495
+ });
496
+ }
497
+
498
+ // Split header section from body.
499
+ var headerEnd = input.indexOf("\r\n\r\n");
500
+ if (headerEnd === -1) headerEnd = input.indexOf("\n\n");
501
+ var headerSection = headerEnd === -1 ? input : input.slice(0, headerEnd);
502
+
503
+ // Per-line cap + count cap.
504
+ var lines = headerSection.split(/\r?\n/);
505
+ if (lines.length > opts.maxHeaders) {
506
+ issues.push({
507
+ kind: "header-count-cap", severity: "high",
508
+ ruleId: "email.header-count-cap",
509
+ snippet: "header count " + lines.length + " exceeds maxHeaders " +
510
+ opts.maxHeaders,
511
+ });
512
+ }
513
+ for (var li = 0; li < lines.length; li += 1) {
514
+ if (lines[li].length > opts.maxHeaderLineBytes) {
515
+ issues.push({
516
+ kind: "header-line-cap", severity: "high",
517
+ ruleId: "email.header-line-cap",
518
+ snippet: "header line " + (li + 1) + " is " + lines[li].length +
519
+ " bytes (RFC 5322 §2.1.1 limit " + opts.maxHeaderLineBytes + ")",
520
+ });
521
+ break;
522
+ }
523
+ }
524
+
525
+ // Walk single-line headers checking for embedded CRLF + display-name
526
+ // spoofing.
527
+ var unfolded = _unfoldHeaders(lines);
528
+ for (var hi = 0; hi < unfolded.length; hi += 1) {
529
+ var entry = unfolded[hi];
530
+ var name = entry.name.toLowerCase();
531
+ if (SINGLE_LINE_HEADERS.indexOf(name) === -1) continue;
532
+
533
+ // CRLF in single-line header value.
534
+ if (opts.crlfHeaderInjectionPolicy !== "allow" &&
535
+ _hasCrlfInHeaderValue(entry.value)) {
536
+ issues.push({
537
+ kind: "crlf-header-injection", severity: "critical",
538
+ ruleId: "email.crlf-header-injection",
539
+ snippet: "header `" + entry.name + "` contains CR/LF — header " +
540
+ "injection vector (smuggle From/Bcc/body)",
541
+ });
542
+ }
543
+
544
+ // Address-bearing headers — run address checks on each address.
545
+ if (name === "from" || name === "to" || name === "cc" ||
546
+ name === "bcc" || name === "reply-to" || name === "sender" ||
547
+ name === "return-path") {
548
+ var addrIssues = _checkAddressHeaderValue(entry.value, opts, entry.name);
549
+ for (var ai = 0; ai < addrIssues.length; ai += 1) {
550
+ issues.push(addrIssues[ai]);
551
+ }
552
+ }
553
+ }
554
+
555
+ // Codepoint-class threats in the full message.
556
+ issues.push.apply(issues, codepointClass.detectCharThreats(input, opts, "email"));
557
+
558
+ return issues;
559
+ }
560
+
561
+ function _unfoldHeaders(lines) {
562
+ var out = [];
563
+ var current = null;
564
+ for (var i = 0; i < lines.length; i += 1) {
565
+ var line = lines[i];
566
+ if (line === "") { current = null; continue; }
567
+ if (current && (line.charAt(0) === " " || line.charAt(0) === "\t")) {
568
+ current.value += " " + line.replace(/^\s+/, "");
569
+ continue;
570
+ }
571
+ var colonAt = line.indexOf(":");
572
+ if (colonAt === -1) { current = null; continue; }
573
+ current = {
574
+ name: line.slice(0, colonAt).trim(),
575
+ value: line.slice(colonAt + 1).replace(/^\s+/, ""),
576
+ };
577
+ out.push(current);
578
+ }
579
+ return out;
580
+ }
581
+
582
+ function _splitAddressList(value) {
583
+ // Split on commas outside angle brackets and quoted strings.
584
+ var parts = [];
585
+ var depth = 0;
586
+ var inQuote = false;
587
+ var start = 0;
588
+ for (var i = 0; i < value.length; i += 1) {
589
+ var c = value.charAt(i);
590
+ if (c === '"' && (i === 0 || value.charAt(i - 1) !== "\\")) inQuote = !inQuote;
591
+ else if (!inQuote && c === "<") depth += 1;
592
+ else if (!inQuote && c === ">") depth -= 1;
593
+ else if (!inQuote && depth === 0 && c === ",") {
594
+ parts.push(value.slice(start, i).trim());
595
+ start = i + 1;
596
+ }
597
+ }
598
+ if (start < value.length) parts.push(value.slice(start).trim());
599
+ return parts.filter(function (s) { return s.length > 0; });
600
+ }
601
+
602
+ function _checkAddressHeaderValue(value, opts, headerName) {
603
+ var issues = [];
604
+ var parts = _splitAddressList(value);
605
+ for (var p = 0; p < parts.length; p += 1) {
606
+ var parsed = _parseAddressLine(parts[p]);
607
+ var addrIssues = _detectAddressIssues(parsed.envelope, opts);
608
+ for (var k = 0; k < addrIssues.length; k += 1) {
609
+ var iss = Object.assign({}, addrIssues[k], {
610
+ snippet: headerName + ": " + addrIssues[k].snippet,
611
+ });
612
+ issues.push(iss);
613
+ }
614
+ // Display-name spoofing: display contains an `@` that does NOT match
615
+ // the envelope domain.
616
+ if (parsed.display && parsed.display.indexOf("@") !== -1 &&
617
+ opts.displayNameSpoofPolicy !== "allow") {
618
+ var atIdx = parsed.envelope.lastIndexOf("@");
619
+ var envDomain = atIdx === -1 ? "" : parsed.envelope.slice(atIdx + 1);
620
+ var displayHasDomain = parsed.display.toLowerCase().indexOf(envDomain.toLowerCase()) !== -1;
621
+ if (!displayHasDomain) {
622
+ issues.push({
623
+ kind: "display-name-spoof",
624
+ severity: opts.displayNameSpoofPolicy === "reject" ? "critical" : "high",
625
+ ruleId: "email.display-name-spoof",
626
+ snippet: headerName + ": display name `" +
627
+ parsed.display.slice(0, 64) + "` includes an @-address that " + // allow:raw-byte-literal — snippet truncation
628
+ "doesn't match the envelope domain `" + envDomain + "`",
629
+ });
630
+ }
631
+ }
632
+ }
633
+ return issues;
634
+ }
635
+
636
+ function validateMessage(input, opts) {
637
+ opts = _resolveOpts(opts);
638
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
639
+ ["maxLocalPartBytes", "maxDomainBytes", "maxAddressBytes",
640
+ "maxHeaderLineBytes", "maxHeaders", "maxBytes"],
641
+ "guardEmail.validateMessage", GuardEmailError, "email.bad-opt");
642
+ if (typeof input !== "string") {
643
+ return {
644
+ ok: false,
645
+ issues: [{ kind: "bad-input", severity: "high",
646
+ snippet: "input is not a string" }],
647
+ };
648
+ }
649
+ return gateContract.aggregateIssues(_detectMessageIssues(input, opts));
650
+ }
651
+
652
+ // validate(input, opts) — auto-detect single address vs full message.
653
+ function validate(input, opts) {
654
+ if (typeof input === "string" && input.indexOf("\n") === -1 &&
655
+ input.indexOf(":") === -1) {
656
+ return validateAddress(input, opts);
657
+ }
658
+ return validateMessage(input, opts);
659
+ }
660
+
661
+ function sanitize(input, opts) {
662
+ opts = _resolveOpts(opts);
663
+ if (typeof input !== "string") {
664
+ throw _err("email.bad-input", "sanitize requires string input");
665
+ }
666
+ // Critical shapes have no safe sanitization in email — throw on
667
+ // smuggling / CRLF injection / multi-@ / mixed-script.
668
+ var issues = _detectMessageIssues(input, opts);
669
+ for (var i = 0; i < issues.length; i += 1) {
670
+ if (issues[i].severity === "critical") {
671
+ throw _err(issues[i].ruleId || "email.refused",
672
+ "guardEmail.sanitize: " + issues[i].snippet);
673
+ }
674
+ }
675
+ return codepointClass.applyCharStripPolicies(input, opts);
676
+ }
677
+
678
+ function gate(opts) {
679
+ opts = _resolveOpts(opts);
680
+ return gateContract.buildGuardGate(
681
+ opts.name || "guardEmail:" + (opts.profile || "default"),
682
+ opts,
683
+ async function (ctx) {
684
+ var text = gateContract.extractBytesAsText(ctx);
685
+ if (!text) return { ok: true, action: "serve" };
686
+ var rv = validateMessage(text, opts);
687
+ if (rv.issues.length === 0) return { ok: true, action: "serve" };
688
+ var hasCritical = rv.issues.some(function (i) {
689
+ return i.severity === "critical";
690
+ });
691
+ var hasHigh = rv.issues.some(function (i) {
692
+ return i.severity === "high";
693
+ });
694
+ if (!hasCritical && !hasHigh) {
695
+ return { ok: true, action: "audit-only", issues: rv.issues };
696
+ }
697
+ return { ok: false, action: "refuse", issues: rv.issues };
698
+ });
699
+ }
700
+
701
+ var buildProfile = gateContract.makeProfileBuilder(PROFILES);
702
+
703
+ function compliancePosture(name) {
704
+ return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES, _err, "email");
705
+ }
706
+
707
+ var _emailRulePacks = gateContract.makeRulePackLoader(GuardEmailError, "email");
708
+ var loadRulePack = _emailRulePacks.load;
709
+
710
+ module.exports = {
711
+ // ---- guard-* family registry exports ----
712
+ NAME: "email",
713
+ KIND: "content",
714
+ MIME_TYPES: Object.freeze(["message/rfc822", "message/global"]),
715
+ EXTENSIONS: Object.freeze([".eml", ".mbox", ".msg"]),
716
+ INTEGRATION_FIXTURES: Object.freeze({
717
+ kind: "content",
718
+ contentType: "message/rfc822",
719
+ extension: ".eml",
720
+ benignBytes: Buffer.from(
721
+ "From: alice@example.com\r\nTo: bob@example.com\r\n" +
722
+ "Subject: hello\r\nDate: Mon, 5 May 2026 10:00:00 +0000\r\n\r\n" +
723
+ "Hello.\r\n", "utf8"),
724
+ // Hostile: SMTP-smuggling pattern — bare LF followed by SMTP verb
725
+ // (CVE-2023-51764 / 51765 / 51766 class).
726
+ hostileBytes: Buffer.from(
727
+ "From: alice@example.com\r\nTo: bob@example.com\r\n" +
728
+ "Subject: hi\r\n\r\n" +
729
+ "body line 1\n.\nMAIL FROM: <evil@attacker>\r\n", "utf8"),
730
+ }),
731
+ // ---- primitive surface ----
732
+ validate: validate,
733
+ validateAddress: validateAddress,
734
+ validateMessage: validateMessage,
735
+ sanitize: sanitize,
736
+ gate: gate,
737
+ buildProfile: buildProfile,
738
+ compliancePosture: compliancePosture,
739
+ loadRulePack: loadRulePack,
740
+ PROFILES: PROFILES,
741
+ DEFAULTS: DEFAULTS,
742
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
743
+ GuardEmailError: GuardEmailError,
744
+ };