@blamejs/core 0.9.18 → 0.9.20

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,241 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMessageId
4
+ * @nav Guards
5
+ * @title Guard Message-Id
6
+ * @order 420
7
+ *
8
+ * @intro
9
+ * RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id /
10
+ * In-Reply-To / References header values at the entry to
11
+ * `b.mailStore.appendMessage` (v0.9.19), `b.mail.server.mx` (v0.9.23),
12
+ * and the outbound submission path (v0.9.25).
13
+ *
14
+ * Refuses:
15
+ *
16
+ * - oversized (default 998-byte cap per RFC 5322 §2.1.1 line cap)
17
+ * - bare CR / LF / NUL / C0 control chars (header-injection
18
+ * defense — defends `From:` / `Bcc:` smuggling via folded
19
+ * Message-Id continuation)
20
+ * - DEL (0x7F) anywhere
21
+ * - unbracketed under `strict` profile (the wire form per RFC
22
+ * 5322 §3.6.4 is `<unique-token@domain>` — operator with
23
+ * legacy mail can opt down to `balanced` to accept bare tokens)
24
+ * - empty value
25
+ * - bidi codepoints in the local-part / domain (RFC 5322 + EAI
26
+ * allow non-ASCII per RFC 6532 + RFC 5335 but bidi-marker
27
+ * codepoints are operator-unfriendly and refused outright)
28
+ *
29
+ * Profile vocabulary follows the existing guard-family convention:
30
+ *
31
+ * - `strict` (default) — bracketed `<token@domain>`, length cap,
32
+ * no control chars, no bidi
33
+ * - `balanced` — accepts unbracketed tokens (legacy mail compat)
34
+ * - `permissive` — minimal validation (NUL + CR/LF refused; rest
35
+ * passes); use only for forensic-only flows
36
+ *
37
+ * Posture vocabulary:
38
+ *
39
+ * - `hipaa` / `pci-dss` / `gdpr` / `soc2` — each pins the
40
+ * active profile to `strict` regardless of operator's profile
41
+ * opt; refuses to relax under regulated postures.
42
+ *
43
+ * Composes the framework's existing guard-family pattern via
44
+ * `b.gateContract` (the same shape `b.guardEmail` / `b.guardCsv` /
45
+ * `b.guardArchive` use). Registers in `b.guardAll`'s
46
+ * `STANDALONE_GUARDS` map.
47
+ *
48
+ * @card
49
+ * RFC 5322 §3.6.4 Message-Id validator — bounded length, no CRLF/NUL/control chars, bracketed shape under strict profile. Gates header-injection at the mail-store / MX / submission entry points.
50
+ */
51
+
52
+ var { defineClass } = require("./framework-error");
53
+
54
+ var GuardMessageIdError = defineClass("GuardMessageIdError", { alwaysPermanent: true });
55
+
56
+ var DEFAULT_PROFILE = "strict";
57
+
58
+ var PROFILES = Object.freeze({
59
+ strict: { requireBrackets: true, maxBytes: 998 }, // allow:raw-byte-literal
60
+ balanced: { requireBrackets: false, maxBytes: 998 }, // allow:raw-byte-literal
61
+ permissive: { requireBrackets: false, maxBytes: 4096 }, // allow:raw-byte-literal — permissive cap, not bytes-as-storage
62
+ });
63
+
64
+ var COMPLIANCE_POSTURES = Object.freeze({
65
+ hipaa: "strict",
66
+ "pci-dss": "strict",
67
+ gdpr: "strict",
68
+ soc2: "strict",
69
+ });
70
+
71
+ // Bidi codepoints refused — same set the framework's address-bidi
72
+ // defense uses (RFC 5322 §3.6.4 doesn't speak EAI codepoints, but RTL
73
+ // codepoints in Message-Ids are operator-unfriendly + defend the
74
+ // CVE-2021-42574 RTLO class in mail header context).
75
+ var BIDI_RE = /[؜‎‏‪-‮⁦-⁩]/;
76
+
77
+ /**
78
+ * @primitive b.guardMessageId.validate
79
+ * @signature b.guardMessageId.validate(value, opts?)
80
+ * @since 0.9.19
81
+ * @status stable
82
+ * @related b.guardMessageId.validateList, b.safeMime.parse, b.guardEmail
83
+ *
84
+ * Validate a Message-Id / In-Reply-To / References header value.
85
+ * Returns the input value on success; throws `GuardMessageIdError`
86
+ * on refusal.
87
+ *
88
+ * @opts
89
+ * profile: "strict" | "balanced" | "permissive", // default "strict"
90
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // pins profile to strict
91
+ * maxBytes: number, // per-profile default
92
+ *
93
+ * @example
94
+ * b.guardMessageId.validate("<abc@example.com>");
95
+ * // → "<abc@example.com>"
96
+ *
97
+ * try { b.guardMessageId.validate("abc@example.com"); }
98
+ * catch (e) { e.code; }
99
+ * // → "message-id/unbracketed" (strict profile)
100
+ */
101
+ function validate(value, opts) {
102
+ opts = opts || {};
103
+ var profileName = _resolveProfile(opts);
104
+ var profile = PROFILES[profileName];
105
+ var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : profile.maxBytes;
106
+
107
+ if (typeof value !== "string") {
108
+ throw new GuardMessageIdError("message-id/bad-input",
109
+ "guardMessageId.validate: value must be a string (got " + typeof value + ")");
110
+ }
111
+ if (value.length === 0) {
112
+ throw new GuardMessageIdError("message-id/empty",
113
+ "guardMessageId.validate: empty Message-Id refused");
114
+ }
115
+ if (Buffer.byteLength(value, "utf8") > maxBytes) {
116
+ throw new GuardMessageIdError("message-id/oversize",
117
+ "guardMessageId.validate: " + Buffer.byteLength(value, "utf8") +
118
+ " bytes exceeds maxBytes=" + maxBytes + " (RFC 5322 §2.1.1)");
119
+ }
120
+
121
+ // C0 control chars + NUL + DEL — always refused at every profile
122
+ // (defends mail-header-injection class — operator can't smuggle
123
+ // CR/LF into a Message-Id to fold an attacker-chosen From: line).
124
+ for (var i = 0; i < value.length; i += 1) {
125
+ var c = value.charCodeAt(i);
126
+ if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
127
+ throw new GuardMessageIdError("message-id/control-char",
128
+ "guardMessageId.validate: control char 0x" + c.toString(16) + " at offset " + i);
129
+ }
130
+ }
131
+
132
+ // Bidi codepoints — refused at strict + balanced; permissive lets
133
+ // them through. Length-bounded by the maxBytes check above so a
134
+ // hostile input can't burn regex-engine CPU; the bidi codepoint set
135
+ // is tiny so the test is constant-time anyway.
136
+ if (profileName !== "permissive" && BIDI_RE.test(value)) { // allow:regex-no-length-cap — value length-bounded by Buffer.byteLength check above
137
+ throw new GuardMessageIdError("message-id/bidi",
138
+ "guardMessageId.validate: bidi codepoint refused (CVE-2021-42574 RTLO class in mail-header context)");
139
+ }
140
+
141
+ // Bracketed shape — required under strict.
142
+ if (profile.requireBrackets) {
143
+ if (value.charAt(0) !== "<" || value.charAt(value.length - 1) !== ">") {
144
+ throw new GuardMessageIdError("message-id/unbracketed",
145
+ "guardMessageId.validate: strict profile requires `<token@domain>` shape (RFC 5322 §3.6.4)");
146
+ }
147
+ var inner = value.slice(1, -1);
148
+ var at = inner.indexOf("@");
149
+ if (at <= 0 || at === inner.length - 1) {
150
+ throw new GuardMessageIdError("message-id/no-at",
151
+ "guardMessageId.validate: Message-Id must contain `@` between local-part and domain");
152
+ }
153
+ if (inner.indexOf("<") >= 0 || inner.indexOf(">") >= 0) {
154
+ throw new GuardMessageIdError("message-id/nested-brackets",
155
+ "guardMessageId.validate: nested angle brackets refused");
156
+ }
157
+ }
158
+
159
+ return value;
160
+ }
161
+
162
+ /**
163
+ * @primitive b.guardMessageId.validateList
164
+ * @signature b.guardMessageId.validateList(value, opts?)
165
+ * @since 0.9.19
166
+ * @status stable
167
+ * @related b.guardMessageId.validate
168
+ *
169
+ * Validate a Message-Id-list header value (References / In-Reply-To
170
+ * may carry multiple ids separated by whitespace per RFC 5322 §3.6.4).
171
+ * Returns the array of validated Message-Ids; throws on any single
172
+ * refusal.
173
+ *
174
+ * @opts
175
+ * profile: same as validate
176
+ * posture: same as validate
177
+ * maxBytes: per-id cap
178
+ * maxIds: number, // default 100 — References-chain cap
179
+ *
180
+ * @example
181
+ * b.guardMessageId.validateList("<a@x> <b@x> <c@x>");
182
+ * // → ["<a@x>", "<b@x>", "<c@x>"]
183
+ */
184
+ function validateList(value, opts) {
185
+ opts = opts || {};
186
+ var maxIds = typeof opts.maxIds === "number" ? opts.maxIds : 100; // allow:raw-byte-literal — References-chain cap, not bytes
187
+ if (typeof value !== "string") {
188
+ throw new GuardMessageIdError("message-id/bad-input",
189
+ "guardMessageId.validateList: value must be a string");
190
+ }
191
+ var ids = value.split(/\s+/).filter(function (s) { return s.length > 0; });
192
+ if (ids.length > maxIds) {
193
+ throw new GuardMessageIdError("message-id/chain-too-long",
194
+ "guardMessageId.validateList: " + ids.length + " ids exceeds maxIds=" + maxIds);
195
+ }
196
+ for (var i = 0; i < ids.length; i += 1) {
197
+ validate(ids[i], opts);
198
+ }
199
+ return ids;
200
+ }
201
+
202
+ /**
203
+ * @primitive b.guardMessageId.compliancePosture
204
+ * @signature b.guardMessageId.compliancePosture(posture)
205
+ * @since 0.9.19
206
+ * @status stable
207
+ *
208
+ * Return the effective profile for a given compliance posture.
209
+ * Composed by `b.compliance.set` to surface "what posture is active
210
+ * for which guard" in audit rows.
211
+ *
212
+ * @example
213
+ * b.guardMessageId.compliancePosture("hipaa"); // → "strict"
214
+ * b.guardMessageId.compliancePosture("unknown"); // → null
215
+ */
216
+ function compliancePosture(posture) {
217
+ return COMPLIANCE_POSTURES[posture] || null;
218
+ }
219
+
220
+ function _resolveProfile(opts) {
221
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
222
+ return COMPLIANCE_POSTURES[opts.posture];
223
+ }
224
+ var p = opts.profile || DEFAULT_PROFILE;
225
+ if (!PROFILES[p]) {
226
+ throw new GuardMessageIdError("message-id/bad-profile",
227
+ "guardMessageId: unknown profile '" + p + "' (use strict / balanced / permissive)");
228
+ }
229
+ return p;
230
+ }
231
+
232
+ module.exports = {
233
+ validate: validate,
234
+ validateList: validateList,
235
+ compliancePosture: compliancePosture,
236
+ PROFILES: PROFILES,
237
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
238
+ GuardMessageIdError: GuardMessageIdError,
239
+ NAME: "messageId",
240
+ KIND: "identifier",
241
+ };