@blamejs/core 0.8.52 → 0.8.57

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/mail-arf.js +343 -0
  15. package/lib/mail-auth.js +265 -40
  16. package/lib/mail-bimi.js +948 -33
  17. package/lib/mail-bounce.js +386 -4
  18. package/lib/mail-mdn.js +424 -0
  19. package/lib/mail-unsubscribe.js +265 -25
  20. package/lib/mail.js +403 -21
  21. package/lib/middleware/bearer-auth.js +1 -1
  22. package/lib/middleware/clear-site-data.js +122 -0
  23. package/lib/middleware/dpop.js +1 -1
  24. package/lib/middleware/index.js +9 -0
  25. package/lib/middleware/nel.js +214 -0
  26. package/lib/middleware/security-headers.js +56 -4
  27. package/lib/middleware/speculation-rules.js +323 -0
  28. package/lib/mime-parse.js +198 -0
  29. package/lib/network-dns.js +890 -27
  30. package/lib/network-tls.js +745 -0
  31. package/lib/object-store/sigv4.js +54 -0
  32. package/lib/public-suffix.js +414 -0
  33. package/lib/safe-buffer.js +7 -0
  34. package/lib/safe-json.js +1 -1
  35. package/lib/static.js +120 -0
  36. package/lib/storage.js +11 -0
  37. package/lib/vendor/MANIFEST.json +33 -0
  38. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  39. package/lib/vendor/public-suffix-list.dat +16376 -0
  40. package/package.json +1 -1
  41. package/sbom.cyclonedx.json +6 -6
@@ -1,12 +1,18 @@
1
1
  "use strict";
2
2
  /**
3
- * mail-unsubscribe — RFC 8058 / RFC 2369 List-Unsubscribe support.
3
+ * mail-unsubscribe — RFC 8058 / RFC 2369 / RFC 2919 List-* support.
4
4
  *
5
- * Two pieces:
5
+ * Three pieces:
6
6
  * 1. buildHeaders({ url, mailto, oneClick }) — produces the
7
7
  * `List-Unsubscribe` and (when oneClick) `List-Unsubscribe-Post`
8
8
  * header values that get merged into the outbound message.
9
- * 2. handler({ onUnsubscribe }) — request-lifecycle middleware that
9
+ * 2. buildAllListHeaders({ unsubscribeUrl, helpUrl, ownerEmail,
10
+ * archiveUrl, listId, listOwner }) — single-call builder for the
11
+ * full RFC 2369 / RFC 2919 List-* header bundle (List-Unsubscribe,
12
+ * List-Help, List-Archive, List-Owner, List-Post, List-ID).
13
+ * Every URL/email/list-id is shape-validated at config-time so
14
+ * operator typos surface here, not silently downstream.
15
+ * 3. handler({ onUnsubscribe }) — request-lifecycle middleware that
10
16
  * validates the RFC 8058 one-click POST body
11
17
  * (`List-Unsubscribe=One-Click`) and dispatches to the operator's
12
18
  * onUnsubscribe callback. Returns 200 OK with empty body on
@@ -39,41 +45,164 @@
39
45
  * app.post("/email/unsubscribe", unsubMw);
40
46
  */
41
47
 
48
+ var C = require("./constants");
42
49
  var lazyRequire = require("./lazy-require");
43
50
  var safeUrl = require("./safe-url");
51
+ var validateOpts = require("./validate-opts");
52
+ var { MailUnsubscribeError } = require("./framework-error");
44
53
 
45
54
  var observability = lazyRequire(function () { return require("./observability"); });
46
55
  void observability;
47
56
 
57
+ // RFC 5322 / 5321 header-value upper bound. Used to refuse hostile
58
+ // over-length operator inputs at config-time (throw at config-time).
59
+ var HEADER_VALUE_MAX_BYTES = C.BYTES.kib(2);
60
+
61
+ // RFC 2919 §3 List-ID shape: a phrase (optional) followed by an
62
+ // angle-addr containing a label-list (one or more dot-separated
63
+ // labels). We accept either the raw label-list form
64
+ // `lst.example.com` (most common shape — bare-form opt-in) OR the
65
+ // full `Phrase <lst.example.com>` form. Refuse shapes that smuggle
66
+ // arbitrary header bytes.
67
+ //
68
+ // Label LDH check is inlined as a charCode loop instead of the
69
+ // canonical hostname regex shape — list-id labels are syntactically
70
+ // a subset of RFC 1123 §2.1 hostname labels but the *audience* is
71
+ // mail-list naming, not DNS resolution; the inline form lets the
72
+ // failure point name the list-id concern rather than a shared
73
+ // LDH primitive. Char ranges: 0x30-0x39 digits / 0x41-0x5A upper /
74
+ // 0x61-0x7A lower / 0x2D hyphen.
75
+ function _isLdhListLabel(label) {
76
+ if (typeof label !== "string" || label.length === 0) return false;
77
+ // RFC 1035 §2.3.4 — labels bounded at 63 octets.
78
+ if (label.length > 63) return false; // allow:raw-byte-literal — RFC 1035 §2.3.4 hostname-label limit
79
+ var n = label.length;
80
+ for (var i = 0; i < n; i += 1) {
81
+ var c = label.charCodeAt(i);
82
+ var isDigit = c >= 0x30 && c <= 0x39;
83
+ var isUpper = c >= 0x41 && c <= 0x5A;
84
+ var isLower = c >= 0x61 && c <= 0x7A;
85
+ var isHyphen = c === 0x2D;
86
+ var ok = isDigit || isUpper || isLower || (isHyphen && i > 0 && i < n - 1);
87
+ if (!ok) return false;
88
+ }
89
+ return true;
90
+ }
91
+ function _validateListId(value, label) {
92
+ if (typeof value !== "string" || value.length === 0) {
93
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
94
+ label + " must be a non-empty string");
95
+ }
96
+ if (value.length > HEADER_VALUE_MAX_BYTES) {
97
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
98
+ label + " exceeds " + HEADER_VALUE_MAX_BYTES + " byte cap");
99
+ }
100
+ if (/[\r\n\0]/.test(value)) {
101
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
102
+ label + " contains forbidden CR/LF/NUL byte");
103
+ }
104
+ // Accept either the label-list bare form OR `Phrase <label-list>`.
105
+ // Strip an optional `Phrase <...>` wrap to test the inner label list.
106
+ var inner = value;
107
+ var bracket = value.match(/<([^>]+)>\s*$/);
108
+ if (bracket) inner = bracket[1];
109
+ // Inner is dot-separated labels; each label LDH per RFC 2919 §3.
110
+ var labels = inner.split(".");
111
+ if (labels.length < 2) { // allow:raw-byte-literal — RFC 2919 §3 requires >= 2 labels
112
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
113
+ label + " '" + value + "' must contain at least two dot-separated labels (RFC 2919 §3)");
114
+ }
115
+ for (var i = 0; i < labels.length; i += 1) {
116
+ if (!_isLdhListLabel(labels[i])) {
117
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
118
+ label + " '" + value + "' has invalid label '" + labels[i] + "' (RFC 2919 §3 LDH)");
119
+ }
120
+ }
121
+ // If operator passed `Phrase <label-list>` form, return it as-is;
122
+ // RFC 2919 says Phrase is optional but allowed. Otherwise wrap the
123
+ // bare label-list in angle brackets per the canonical on-the-wire
124
+ // shape (`<lst.example.com>`).
125
+ return bracket ? value : "<" + value + ">";
126
+ }
127
+
128
+ function _validateHttpsUrl(value, label) {
129
+ if (typeof value !== "string" || value.length === 0) {
130
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
131
+ label + " must be a non-empty string");
132
+ }
133
+ if (value.length > HEADER_VALUE_MAX_BYTES) {
134
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
135
+ label + " exceeds " + HEADER_VALUE_MAX_BYTES + " byte cap");
136
+ }
137
+ if (/[\r\n\0]/.test(value)) {
138
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
139
+ label + " contains forbidden CR/LF/NUL byte");
140
+ }
141
+ var parsed;
142
+ try {
143
+ parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
144
+ } catch (e) {
145
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
146
+ label + " must be a valid https URL (got " +
147
+ JSON.stringify(value).slice(0, 200) + "): " + // allow:raw-byte-literal — diagnostic clamp characters
148
+ ((e && e.message) || String(e)));
149
+ }
150
+ if (!parsed) {
151
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
152
+ label + " must be a valid https URL (got " +
153
+ JSON.stringify(value).slice(0, 200) + ")"); // allow:raw-byte-literal — diagnostic clamp characters
154
+ }
155
+ return parsed.href;
156
+ }
157
+
158
+ // mailto: shape validation. Accepts `mailto:addr` form OR a bare
159
+ // `addr@domain` form (we'll prefix `mailto:` ourselves). Refuses CR/LF/
160
+ // NUL injection. Doesn't validate the address with EMAIL_RE here — the
161
+ // addr@domain shape and length cap is what bounds smuggling risk.
162
+ function _validateMailto(value, label) {
163
+ if (typeof value !== "string" || value.length === 0) {
164
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
165
+ label + " must be a non-empty string");
166
+ }
167
+ if (value.length > HEADER_VALUE_MAX_BYTES) {
168
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
169
+ label + " exceeds " + HEADER_VALUE_MAX_BYTES + " byte cap");
170
+ }
171
+ if (/[\r\n\0]/.test(value)) {
172
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
173
+ label + " contains forbidden CR/LF/NUL byte");
174
+ }
175
+ var hasScheme = value.indexOf("mailto:") === 0;
176
+ var inner = hasScheme ? value.slice("mailto:".length) : value;
177
+ // Strip query parameters before testing the addr shape.
178
+ var addrPart = inner.split("?")[0];
179
+ if (addrPart.indexOf("@") < 1 || addrPart.lastIndexOf("@") === addrPart.length - 1) {
180
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
181
+ label + " must be a valid `addr@domain` (with optional `mailto:` prefix)");
182
+ }
183
+ return hasScheme ? value : "mailto:" + value;
184
+ }
185
+
48
186
  // Build the List-Unsubscribe + List-Unsubscribe-Post headers per
49
187
  // RFC 8058 + RFC 2369. Returns a headers object suitable for merging
50
188
  // into `b.mail.send({ headers })`.
51
189
  function buildHeaders(opts) {
52
190
  if (!opts || typeof opts !== "object") {
53
- throw new Error("buildHeaders: opts object required " +
54
- "({ url?, mailto?, oneClick? })");
191
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
192
+ "buildHeaders: opts object required ({ url?, mailto?, oneClick? })");
55
193
  }
56
194
  var parts = [];
57
195
  if (typeof opts.url === "string" && opts.url.length > 0) {
58
- // Validate URL refuse non-https / non-http schemes.
59
- var parsed = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
60
- if (!parsed) {
61
- throw new Error("buildHeaders: opts.url must be a valid http(s) URL");
62
- }
63
- parts.push("<" + parsed.href + ">");
196
+ var href = _validateHttpsUrl(opts.url, "buildHeaders: opts.url");
197
+ parts.push("<" + href + ">");
64
198
  }
65
199
  if (typeof opts.mailto === "string" && opts.mailto.length > 0) {
66
- // mailto: is `mailto:addr` or `mailto:addr?subject=...&body=...`.
67
- // Don't run safeUrl on it (mailto isn't in ALLOW_HTTP_TLS); just
68
- // do a minimal shape check.
69
- if (opts.mailto.indexOf("mailto:") === 0) {
70
- parts.push("<" + opts.mailto + ">");
71
- } else {
72
- parts.push("<mailto:" + opts.mailto + ">");
73
- }
200
+ var mt = _validateMailto(opts.mailto, "buildHeaders: opts.mailto");
201
+ parts.push("<" + mt + ">");
74
202
  }
75
203
  if (parts.length === 0) {
76
- throw new Error("buildHeaders: at least one of opts.url / opts.mailto required");
204
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
205
+ "buildHeaders: at least one of opts.url / opts.mailto required");
77
206
  }
78
207
  var headers = { "List-Unsubscribe": parts.join(", ") };
79
208
  if (opts.oneClick === true) {
@@ -83,6 +212,115 @@ function buildHeaders(opts) {
83
212
  return headers;
84
213
  }
85
214
 
215
+ // Build the full RFC 2369 / RFC 2919 List-* header bundle in one call.
216
+ //
217
+ // Single-call shape so operators set the whole list-management bundle
218
+ // in one place rather than juggling individual builders. Every input
219
+ // is shape-validated at config-time (throw at config-time) so a missing
220
+ // scheme / control byte / malformed list-id surfaces here, not as a
221
+ // downstream parser refusing the message after the network hop.
222
+ //
223
+ // var headers = b.mail.unsubscribe.buildAllListHeaders({
224
+ // unsubscribeUrl: "https://example.com/u?t=...",
225
+ // unsubscribeMailto: "unsub@example.com",
226
+ // oneClick: true,
227
+ // helpUrl: "https://example.com/list-help",
228
+ // archiveUrl: "https://example.com/archive",
229
+ // ownerEmail: "owner@example.com",
230
+ // postEmail: "list@example.com",
231
+ // listId: "lst.example.com",
232
+ // listOwner: "Acme List <owner@example.com>",
233
+ // });
234
+ //
235
+ // Returns a flat headers object with the canonical RFC casing
236
+ // (`List-Unsubscribe`, `List-Help`, `List-Archive`, `List-Owner`,
237
+ // `List-Post`, `List-ID`).
238
+ function buildAllListHeaders(opts) {
239
+ if (!opts || typeof opts !== "object") {
240
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
241
+ "buildAllListHeaders: opts object required");
242
+ }
243
+ var headers = {};
244
+
245
+ // List-Unsubscribe + List-Unsubscribe-Post (RFC 8058 / RFC 2369).
246
+ if (opts.unsubscribeUrl != null || opts.unsubscribeMailto != null ||
247
+ opts.oneClick !== undefined) {
248
+ var unsubHeaders = buildHeaders({
249
+ url: opts.unsubscribeUrl,
250
+ mailto: opts.unsubscribeMailto,
251
+ oneClick: opts.oneClick === true,
252
+ });
253
+ Object.assign(headers, unsubHeaders);
254
+ }
255
+
256
+ // List-Help (RFC 2369 §3.2). URL or mailto.
257
+ if (opts.helpUrl != null) {
258
+ headers["List-Help"] = "<" + _validateHttpsUrl(opts.helpUrl,
259
+ "buildAllListHeaders: opts.helpUrl") + ">";
260
+ }
261
+
262
+ // List-Archive (RFC 2369 §3.6). URL.
263
+ if (opts.archiveUrl != null) {
264
+ headers["List-Archive"] = "<" + _validateHttpsUrl(opts.archiveUrl,
265
+ "buildAllListHeaders: opts.archiveUrl") + ">";
266
+ }
267
+
268
+ // List-Owner (RFC 2369 §3.3). mailto:.
269
+ if (opts.ownerEmail != null) {
270
+ headers["List-Owner"] = "<" + _validateMailto(opts.ownerEmail,
271
+ "buildAllListHeaders: opts.ownerEmail") + ">";
272
+ }
273
+
274
+ // List-Post (RFC 2369 §3.4). mailto:, or "NO" sentinel for read-only
275
+ // lists. RFC explicitly carves out the literal NO token.
276
+ if (opts.postEmail != null) {
277
+ if (opts.postEmail === "NO") {
278
+ headers["List-Post"] = "NO";
279
+ } else {
280
+ headers["List-Post"] = "<" + _validateMailto(opts.postEmail,
281
+ "buildAllListHeaders: opts.postEmail") + ">";
282
+ }
283
+ }
284
+
285
+ // List-ID (RFC 2919 §3). Bare label-list OR `Phrase <label-list>`.
286
+ if (opts.listId != null) {
287
+ headers["List-ID"] = _validateListId(opts.listId,
288
+ "buildAllListHeaders: opts.listId");
289
+ }
290
+
291
+ // List-Owner phrase form: an operator may pass a pre-rendered
292
+ // `Owner Name <addr@domain>` value directly via opts.listOwner.
293
+ // Overrides ownerEmail when both are provided.
294
+ if (opts.listOwner != null) {
295
+ validateOpts.requireNonEmptyString(opts.listOwner,
296
+ "buildAllListHeaders: opts.listOwner",
297
+ MailUnsubscribeError, "mailunsubscribe/invalid-list-header-shape");
298
+ if (opts.listOwner.length > HEADER_VALUE_MAX_BYTES) {
299
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
300
+ "buildAllListHeaders: opts.listOwner exceeds " + HEADER_VALUE_MAX_BYTES + " byte cap");
301
+ }
302
+ if (/[\r\n\0]/.test(opts.listOwner)) {
303
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
304
+ "buildAllListHeaders: opts.listOwner contains forbidden CR/LF/NUL byte");
305
+ }
306
+ // Phrase form must contain an angle-bracket address to satisfy
307
+ // RFC 2369 §3.3 — we don't run the full RFC 5322 phrase parser
308
+ // here; presence of `<...@...>` is enough to surface typos.
309
+ var ownerBracket = opts.listOwner.match(/<([^>]+)>/);
310
+ if (!ownerBracket || ownerBracket[1].indexOf("@") < 1) {
311
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
312
+ "buildAllListHeaders: opts.listOwner must contain `<addr@domain>` (RFC 2369 §3.3)");
313
+ }
314
+ headers["List-Owner"] = opts.listOwner;
315
+ }
316
+
317
+ if (Object.keys(headers).length === 0) {
318
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
319
+ "buildAllListHeaders: at least one List-* field must be supplied");
320
+ }
321
+ return headers;
322
+ }
323
+
86
324
  // RFC 8058 §3.1 one-click handler middleware.
87
325
  //
88
326
  // On POST, the body MUST contain `List-Unsubscribe=One-Click` (case-
@@ -95,8 +333,8 @@ function buildHeaders(opts) {
95
333
  function handler(opts) {
96
334
  opts = opts || {};
97
335
  if (typeof opts.onUnsubscribe !== "function") {
98
- throw new Error("mail.unsubscribe.handler: opts.onUnsubscribe " +
99
- "must be a function (req, res) → Promise");
336
+ throw new MailUnsubscribeError("mailunsubscribe/invalid-list-header-shape",
337
+ "mail.unsubscribe.handler: opts.onUnsubscribe must be a function (req, res) → Promise");
100
338
  }
101
339
  return async function unsubscribeMiddleware(req, res) {
102
340
  if ((req.method || "").toUpperCase() !== "POST") {
@@ -108,7 +346,7 @@ function handler(opts) {
108
346
  }
109
347
  var bodyChunks = [];
110
348
  var totalLen = 0;
111
- var maxBodyBytes = opts.maxBodyBytes || 4096; // allow:raw-byte-literal — RFC 8058 §3.1 body is short — `List-Unsubscribe=One-Click` plus operator additions
349
+ var maxBodyBytes = opts.maxBodyBytes || C.BYTES.kib(4);
112
350
  var bodyComplete = await new Promise(function (resolve) {
113
351
  req.on("data", function (chunk) {
114
352
  totalLen += chunk.length;
@@ -155,6 +393,8 @@ function handler(opts) {
155
393
  }
156
394
 
157
395
  module.exports = {
158
- buildHeaders: buildHeaders,
159
- handler: handler,
396
+ buildHeaders: buildHeaders,
397
+ buildAllListHeaders: buildAllListHeaders,
398
+ handler: handler,
399
+ MailUnsubscribeError: MailUnsubscribeError,
160
400
  };