@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.
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardListUnsubscribe
4
+ * @nav Guards
5
+ * @title Guard List-Unsubscribe
6
+ * @order 465
7
+ *
8
+ * @intro
9
+ * RFC 2369 `List-Unsubscribe` + RFC 8058 one-click
10
+ * `List-Unsubscribe-Post` header validator. Gates the outbound
11
+ * submission path's marketing / transactional mail so messages
12
+ * carrying a `List-Id` (or any mailing-list shape) emit headers
13
+ * that Gmail / Yahoo / Outlook one-click unsubscribe machinery
14
+ * actually accepts.
15
+ *
16
+ * ## Why this primitive vs. inline header construction
17
+ *
18
+ * Gmail's bulk-sender requirements (effective 2024-02) and Yahoo's
19
+ * matching policy refuse mail that doesn't carry the RFC 8058 pair
20
+ * correctly. Operators get senders rate-limited or buckets-dropped
21
+ * when the headers are malformed. Common pitfalls this primitive
22
+ * refuses:
23
+ *
24
+ * - **No HTTPS URI** — Gmail+Yahoo require at least one
25
+ * `https://` URI in the `List-Unsubscribe` header. `mailto:`
26
+ * alone is no longer sufficient post-2024.
27
+ * - **`http://` instead of `https://`** — refused; one-click
28
+ * endpoint MUST be TLS.
29
+ * - **`javascript:` / `data:` / `file:` schemes** — always
30
+ * refused regardless of context.
31
+ * - **`List-Unsubscribe-Post: List-Unsubscribe=One-Click`** —
32
+ * MUST be EXACTLY this token. Operator-supplied variants
33
+ * (`OneClick`, `one-click`, lowercased `=` value) refused.
34
+ * - **HTTPS URI without paired `List-Unsubscribe-Post`** — the
35
+ * Post header opts the endpoint into one-click. Without it,
36
+ * Gmail's UI treats the HTTPS URI as a regular link (operator
37
+ * loses the inbox-list "Unsubscribe" button).
38
+ *
39
+ * ## Verdict shape
40
+ *
41
+ * ```js
42
+ * {
43
+ * action: "accept" | "refuse",
44
+ * reason: string,
45
+ * uris: [{ scheme, raw, oneClickEligible }, ...],
46
+ * hasHttpsUri: bool,
47
+ * hasMailtoUri: bool,
48
+ * postHeaderOk: bool,
49
+ * oneClickReady: bool,
50
+ * }
51
+ * ```
52
+ *
53
+ * Under `strict` (default for HIPAA / PCI / GDPR / SOC2 mailings
54
+ * that need bulk-sender compliance), `oneClickReady: false` →
55
+ * `action: "refuse"`. Under `balanced`, the primitive returns the
56
+ * verdict but always accepts — operator's outbound pipeline makes
57
+ * the policy decision downstream.
58
+ *
59
+ * ## CVE / threat model
60
+ *
61
+ * - **Unsubscribe-link injection** — operator's template-rendered
62
+ * `List-Unsubscribe` could be tampered through prompt-injection
63
+ * into an AI-generated newsletter. CRLF refused (header
64
+ * injection); `javascript:` / `data:` / `file:` refused (XSS via
65
+ * mail-client rendering); URL length cap (default 2048).
66
+ * - **Open-redirect via List-Unsubscribe** — operator validates the
67
+ * HTTPS URI's target host with their own `safeRedirect` /
68
+ * `safeUrl` allowlist downstream; this guard checks the SHAPE,
69
+ * not the operator's target-host policy.
70
+ * - **Email client mishandling** (Outlook's history of fetching
71
+ * `mailto:` automatically) — the primitive doesn't render the
72
+ * header; consumers using it inside `b.guardEmail.validateMessage`
73
+ * get layered defense.
74
+ *
75
+ * @card
76
+ * RFC 2369 + RFC 8058 List-Unsubscribe / List-Unsubscribe-Post validator. Refuses non-HTTPS one-click URIs, javascript:/data:/file: schemes, missing Post header, malformed Post token. Gmail+Yahoo bulk-sender compliance defense.
77
+ */
78
+
79
+ var C = require("./constants");
80
+ var { defineClass } = require("./framework-error");
81
+ var safeUrl = require("./safe-url");
82
+
83
+ var GuardListUnsubscribeError = defineClass("GuardListUnsubscribeError", { alwaysPermanent: true });
84
+
85
+ var DEFAULT_PROFILE = "strict";
86
+
87
+ var PROFILES = Object.freeze({
88
+ strict: {
89
+ maxBytes: C.BYTES.kib(4),
90
+ maxUris: 4, // allow:raw-byte-literal — URI-count cap
91
+ maxUriBytes: 2048, // allow:raw-byte-literal — per-URI byte cap
92
+ requireHttpsUri: true,
93
+ requirePostHeader: true,
94
+ refuseHttp: true,
95
+ },
96
+ balanced: {
97
+ maxBytes: C.BYTES.kib(4),
98
+ maxUris: 8, // allow:raw-byte-literal — URI-count cap
99
+ maxUriBytes: 2048, // allow:raw-byte-literal — per-URI byte cap
100
+ requireHttpsUri: false,
101
+ requirePostHeader: false,
102
+ refuseHttp: true,
103
+ },
104
+ permissive: {
105
+ maxBytes: C.BYTES.kib(8),
106
+ maxUris: 16, // allow:raw-byte-literal — URI-count cap
107
+ maxUriBytes: 4096, // allow:raw-byte-literal — per-URI byte cap
108
+ requireHttpsUri: false,
109
+ requirePostHeader: false,
110
+ refuseHttp: false,
111
+ },
112
+ });
113
+
114
+ var COMPLIANCE_POSTURES = Object.freeze({
115
+ hipaa: "strict",
116
+ "pci-dss": "strict",
117
+ gdpr: "strict",
118
+ soc2: "strict",
119
+ });
120
+
121
+ // RFC 8058 §2: Post header value MUST be exactly
122
+ // `List-Unsubscribe=One-Click`. Token is case-sensitive per Gmail /
123
+ // Yahoo bulk-sender enforcement (mixed-case variants silently fail
124
+ // one-click on Gmail).
125
+ var ONE_CLICK_POST_VALUE = "List-Unsubscribe=One-Click";
126
+
127
+ // Always-refused schemes regardless of profile (XSS / mail-client
128
+ // rendering / local-file-read class).
129
+ var DANGEROUS_SCHEMES = Object.freeze({
130
+ "javascript:": true,
131
+ "data:": true,
132
+ "file:": true,
133
+ "vbscript:": true,
134
+ "blob:": true,
135
+ });
136
+
137
+ /**
138
+ * @primitive b.guardListUnsubscribe.validate
139
+ * @signature b.guardListUnsubscribe.validate(headers, opts?)
140
+ * @since 0.9.39
141
+ * @status stable
142
+ * @related b.guardEmail.validateMessage, b.safeMime.parse
143
+ *
144
+ * Validate the RFC 2369 / RFC 8058 header pair on an outbound
145
+ * marketing or transactional message. Returns the verdict shape;
146
+ * operator's submission listener consults `verdict.action` to
147
+ * accept / refuse the send.
148
+ *
149
+ * @opts
150
+ * profile: "strict" | "balanced" | "permissive",
151
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
152
+ *
153
+ * @example
154
+ * var v = b.guardListUnsubscribe.validate({
155
+ * listUnsubscribe: "<mailto:u@x.com?subject=unsub>, <https://x.com/unsub?id=42>",
156
+ * listUnsubscribePost: "List-Unsubscribe=One-Click",
157
+ * });
158
+ * if (v.action === "refuse") throw new Error(v.reason);
159
+ */
160
+ function validate(headers, opts) {
161
+ opts = opts || {};
162
+ var caps = _resolveProfile(opts);
163
+ if (!headers || typeof headers !== "object") {
164
+ throw new GuardListUnsubscribeError("guard-list-unsubscribe/bad-input",
165
+ "validate: headers must be a plain object");
166
+ }
167
+ if (typeof headers.listUnsubscribe !== "string" || headers.listUnsubscribe.length === 0) {
168
+ throw new GuardListUnsubscribeError("guard-list-unsubscribe/bad-input",
169
+ "validate: headers.listUnsubscribe must be a non-empty string");
170
+ }
171
+ var raw = headers.listUnsubscribe;
172
+ if (Buffer.byteLength(raw, "utf8") > caps.maxBytes) {
173
+ return _verdict("refuse", "List-Unsubscribe header exceeds maxBytes=" + caps.maxBytes,
174
+ { uris: [], hasHttpsUri: false, hasMailtoUri: false, postHeaderOk: false });
175
+ }
176
+ if (raw.indexOf("\r") !== -1 || raw.indexOf("\n") !== -1) {
177
+ return _verdict("refuse", "header contains CR/LF (RFC 5322 §3.2.5 header-injection refusal)",
178
+ { uris: [], hasHttpsUri: false, hasMailtoUri: false, postHeaderOk: false });
179
+ }
180
+ if (_hasControlChar(raw)) {
181
+ return _verdict("refuse", "header contains NUL / C0 / DEL control char",
182
+ { uris: [], hasHttpsUri: false, hasMailtoUri: false, postHeaderOk: false });
183
+ }
184
+
185
+ var uriParts = _extractUris(raw, caps.maxUris);
186
+ if (uriParts === null) {
187
+ return _verdict("refuse", "more than maxUris=" + caps.maxUris + " URIs in List-Unsubscribe",
188
+ { uris: [], hasHttpsUri: false, hasMailtoUri: false, postHeaderOk: false });
189
+ }
190
+ if (uriParts.length === 0) {
191
+ return _verdict("refuse", "List-Unsubscribe has no <URI> elements (RFC 2369 §3.1)",
192
+ { uris: [], hasHttpsUri: false, hasMailtoUri: false, postHeaderOk: false });
193
+ }
194
+
195
+ var classified = [];
196
+ var hasHttpsUri = false;
197
+ var hasMailtoUri = false;
198
+ for (var i = 0; i < uriParts.length; i += 1) {
199
+ var u = uriParts[i];
200
+ if (Buffer.byteLength(u, "utf8") > caps.maxUriBytes) {
201
+ return _verdict("refuse", "URI '" + _trunc(u) + "' exceeds maxUriBytes=" + caps.maxUriBytes,
202
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
203
+ }
204
+ var schemeMatch = u.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/); // allow:regex-no-length-cap — scheme has fixed-shape repeat cap
205
+ var scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null;
206
+ if (!scheme) {
207
+ return _verdict("refuse", "URI '" + _trunc(u) + "' has no scheme (RFC 3986 §3.1)",
208
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
209
+ }
210
+ if (DANGEROUS_SCHEMES[scheme]) {
211
+ return _verdict("refuse", "URI scheme '" + scheme + "' is on the always-refused list (XSS / file-read class)",
212
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
213
+ }
214
+ if (scheme === "http:" && caps.refuseHttp) {
215
+ return _verdict("refuse", "plain http:// refused in List-Unsubscribe (one-click requires HTTPS per RFC 8058 §2 + Gmail/Yahoo bulk-sender policy)",
216
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
217
+ }
218
+ if (scheme === "https:") {
219
+ try {
220
+ safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
221
+ } catch (e) {
222
+ return _verdict("refuse", "HTTPS URI '" + _trunc(u) + "' failed safeUrl parse: " + (e && e.message || String(e)),
223
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
224
+ }
225
+ hasHttpsUri = true;
226
+ } else if (scheme === "mailto:") {
227
+ hasMailtoUri = true;
228
+ }
229
+ classified.push({
230
+ scheme: scheme,
231
+ raw: u,
232
+ oneClickEligible: scheme === "https:",
233
+ });
234
+ }
235
+
236
+ // RFC 8058 §2 — Post header value MUST be the canonical token.
237
+ var postHeader = headers.listUnsubscribePost;
238
+ var postHeaderOk = typeof postHeader === "string" && postHeader.trim() === ONE_CLICK_POST_VALUE;
239
+
240
+ if (caps.requireHttpsUri && !hasHttpsUri) {
241
+ return _verdict("refuse", "List-Unsubscribe has no https:// URI (RFC 8058 + Gmail/Yahoo bulk-sender 2024 requirement)",
242
+ { uris: classified, hasHttpsUri: false, hasMailtoUri: hasMailtoUri, postHeaderOk: postHeaderOk });
243
+ }
244
+ if (caps.requirePostHeader && hasHttpsUri && !postHeaderOk) {
245
+ var got = postHeader === undefined ? "(absent)" :
246
+ typeof postHeader !== "string" ? "(non-string)" : postHeader;
247
+ return _verdict("refuse",
248
+ "List-Unsubscribe-Post header must be exactly '" + ONE_CLICK_POST_VALUE + "' (RFC 8058 §2); got " + got,
249
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
250
+ }
251
+
252
+ return _verdict("accept", "headers compliant with RFC 2369 + RFC 8058",
253
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: postHeaderOk });
254
+ }
255
+
256
+ /**
257
+ * @primitive b.guardListUnsubscribe.compliancePosture
258
+ * @signature b.guardListUnsubscribe.compliancePosture(posture)
259
+ * @since 0.9.39
260
+ * @status stable
261
+ *
262
+ * Return the effective profile name for a compliance posture, or
263
+ * `null` for unknown posture names.
264
+ *
265
+ * @example
266
+ * b.guardListUnsubscribe.compliancePosture("hipaa"); // → "strict"
267
+ */
268
+ function compliancePosture(posture) {
269
+ return COMPLIANCE_POSTURES[posture] || null;
270
+ }
271
+
272
+ function _extractUris(raw, maxUris) {
273
+ // RFC 2369 §3.1 — comma-separated `<URI>` items. Walk angle-
274
+ // bracket pairs directly via String.matchAll so URIs containing
275
+ // commas (legitimate, e.g. `<https://x/u?tags=a,b>`) parse
276
+ // correctly. Earlier split(",")-based scan misclassified such
277
+ // URIs as "no <URI> elements" and refused legitimate mail
278
+ // (Codex P1 on PR #63).
279
+ var matches = raw.matchAll(/<([^<>]*)>/g); // allow:regex-no-length-cap — input length-bounded by maxBytes check upstream
280
+ var uris = [];
281
+ for (var m of matches) {
282
+ uris.push(m[1].trim());
283
+ if (uris.length > maxUris) return null;
284
+ }
285
+ return uris;
286
+ }
287
+
288
+ function _hasControlChar(s) {
289
+ for (var i = 0; i < s.length; i += 1) {
290
+ var c = s.charCodeAt(i);
291
+ if (c === 0x00 || c === 0x7f || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — RFC 5322 control + TAB allow
292
+ return true;
293
+ }
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function _trunc(s) {
299
+ if (s.length <= 64) return s; // allow:raw-byte-literal — error-message truncation
300
+ return s.slice(0, 60) + "…"; // allow:raw-time-literal — char count for error-message truncation, not seconds
301
+ }
302
+
303
+ function _verdict(action, reason, extra) {
304
+ return {
305
+ action: action,
306
+ reason: reason,
307
+ uris: extra.uris,
308
+ hasHttpsUri: extra.hasHttpsUri,
309
+ hasMailtoUri: extra.hasMailtoUri,
310
+ postHeaderOk: extra.postHeaderOk,
311
+ oneClickReady: extra.hasHttpsUri && extra.postHeaderOk,
312
+ };
313
+ }
314
+
315
+ function _resolveProfile(opts) {
316
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
317
+ return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
318
+ }
319
+ var p = opts.profile || DEFAULT_PROFILE;
320
+ if (!PROFILES[p]) {
321
+ throw new GuardListUnsubscribeError("guard-list-unsubscribe/bad-profile",
322
+ "guardListUnsubscribe: unknown profile '" + p + "'");
323
+ }
324
+ return PROFILES[p];
325
+ }
326
+
327
+ module.exports = {
328
+ validate: validate,
329
+ compliancePosture: compliancePosture,
330
+ PROFILES: PROFILES,
331
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
332
+ ONE_CLICK_POST_VALUE: ONE_CLICK_POST_VALUE,
333
+ DANGEROUS_SCHEMES: DANGEROUS_SCHEMES,
334
+ GuardListUnsubscribeError: GuardListUnsubscribeError,
335
+ NAME: "listUnsubscribe",
336
+ KIND: "list-unsubscribe",
337
+ };