@blamejs/core 0.9.38 → 0.9.40

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.40 (2026-05-15) — **`b.guardListId` — RFC 2919 List-Id header validator.** Companion to v0.9.39 `b.guardListUnsubscribe`; gates outbound mailing-list mail so the List-Id carries a well-formed identifier downstream filters + bulk-sender pipelines reliably route on. (1) **`b.guardListId.validate(headerValue, opts?)`** — parses bracketed (`<my-list.example.com>`), phrase-prefixed (`My Newsletter <my-list.example.com>`), and bare-identifier forms per RFC 2919 §2. Returns `{ action, listId, label, namespace, phrase, reason }`. Action one of `accept` / `refuse`. (2) **RFC 2919 §3 caps + ABNF** — list-id capped at 255 octets; header value capped at RFC 5322 §2.1.1 line cap (998 bytes); per-label shape per RFC 5322 §3.2.3 dot-atom-text. (3) **Phrase-smuggling defense** — phrase MUST NOT contain `<` / `>` (would smuggle a second bracketed identifier through the parser). Trailing content after `>` refused. Nested or unmatched brackets refused. (4) **CRLF / NUL / C0 / DEL refusal** — header-injection defense per RFC 5322 §3.2.5 + CVE-2026-32178 wire-protocol surface class. (5) **`localhost` namespace handling** (RFC 2919 §3) — strict requires the recommended 32-hex random component in the label (the SHOULD becomes operator-strict for HIPAA / PCI / GDPR / SOC2 postures); balanced / permissive accept without. (6) **FQDN namespace enforcement** under strict / balanced — list-id with single-label namespace (e.g. `mylist.test`) refused unless permissive. (7) Heuristic label / namespace split — last 2 dot-segments → namespace (matches typical DNS delegation); consumers needing PSL-accurate org-domain extraction compose `b.publicSuffix.organizationalDomain`. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-id.fuzz.js`. Registered as standalone guard with `KIND="list-id"`. Threat-model: List-Id forging (RFC 2919 §8 explicitly notes the identifier is NOT an authentication signal; operators wanting authentication compose b.mail.auth.dmarc / arc.verify), bulk-sender bucket-drop (Gmail 2024 keys on List-Id presence for Precedence: list / 5000+ daily-send mail).
12
+ - v0.9.39 (2026-05-15) — **`b.guardListUnsubscribe` — RFC 2369 + RFC 8058 List-Unsubscribe / List-Unsubscribe-Post validator.** Gates the outbound submission path so messages carrying a List-Id (or any mailing-list shape) emit headers Gmail / Yahoo / Outlook one-click unsubscribe machinery actually accepts. (1) **`b.guardListUnsubscribe.validate({ listUnsubscribe, listUnsubscribePost }, opts?)`** — returns `{ action, reason, uris, hasHttpsUri, hasMailtoUri, postHeaderOk, oneClickReady }`. (2) **Gmail / Yahoo bulk-sender 2024 enforcement** — under strict requires at least one `https://` URI in the header (mailto: alone refused) + the paired `List-Unsubscribe-Post: List-Unsubscribe=One-Click` value EXACTLY (case-sensitive — Gmail silently fails one-click on mixed-case variants). (3) **Always-refused schemes** — `javascript:` / `data:` / `file:` / `vbscript:` / `blob:` refused regardless of profile (XSS / file-read class in mail-client rendering). (4) **`http://` refused under strict / balanced** — one-click endpoint MUST be TLS per RFC 8058 §2. Permissive accepts http for audit-only legacy use. (5) **Header-injection defense** — CRLF, NUL, C0 controls, DEL refused at validate time (RFC 5322 §3.2.5). (6) **Bounded surface** — per-URI byte cap (2 KiB strict / 4 KiB permissive), URI-count cap (4 / 8 / 16), header total byte cap (4 / 4 / 8 KiB). RFC 3986 §3.1 scheme shape; RFC 2369 §3.1 angle-bracket URI list. HTTPS URIs validated through `b.safeUrl.parse` with the framework's HTTPS allowlist. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-unsubscribe.fuzz.js`. Registered as a standalone guard with KIND="list-unsubscribe". Threat-model coverage: unsubscribe-link injection via AI-generated newsletter templates, open-redirect via List-Unsubscribe (operator validates target host downstream via own safeRedirect allowlist), mail-client mishandling (Outlook's mailto: auto-fetch history).
11
13
  - v0.9.38 (2026-05-15) — **Re-publish bundle: prefix npm tarball path with `./` so npm doesn't mis-classify it as a git spec.** v0.9.30 and v0.9.37 publish workflow runs both failed at exit 128 — npm 10+ interprets a relative tarball path containing `/` (`dist/blamejs-core-0.9.X.tgz`) as a git spec and attempts `git ls-remote ssh://git@github.com/dist/...tgz`, which the runner's SSH credentials can't auth against. v0.9.29-v0.9.37 never reached npm as a result; v0.9.28 remained the latest published version on the registry. v0.9.38 ships only the workflow path fix (no operator-facing primitive change vs v0.9.37) — operators upgrading from v0.9.28 see the full bundled surface delivered by v0.9.29-v0.9.37: agent.trace + agent.snapshot (v0.9.29 / v0.9.30), safeDns + network.dns.resolver (v0.9.31), guardSmtpCommand (v0.9.32), mail.rbl (v0.9.33), mail.greylist + lib/ip-utils (v0.9.34), mail.helo (v0.9.35), guardEnvelope (v0.9.36), guardDsn (v0.9.37).
12
14
  - v0.9.37 (2026-05-15) — **`b.guardDsn` — RFC 3464 Delivery Status Notification parser.** Reads the `message/delivery-status` MIME-part body bounces / delayed-delivery notices / successful-delivery confirmations carry and surfaces the per-recipient action + RFC 3463 enhanced status code so operator-side delivery-failure routing (`b.mail.bounce` retry curve, address-book invalidation, mailing-list cleanup, transactional-mail dead-letter) reads one shape regardless of MTA wording. (1) **`b.guardDsn.parse(deliveryStatusBody, opts?)`** — returns `{ perMessage: { reportingMta, originalEnvelopeId?, arrivalDate?, receivedFromMta? }, perRecipients: [{ finalRecipient, action, status, statusClass, diagnosticCode? }, ...], worstStatusClass, action }`. (2) **RFC 3464 mandatory-field enforcement** — Reporting-MTA required per §2.2.2; per-recipient Final-Recipient (§2.3.2), Action (§2.3.3) from `{ failed | delayed | delivered | relayed | expanded }` vocabulary, and Status (§2.3.4) in RFC 3463 `D.D.D` form all required. Missing-field → typed error (`guard-dsn/missing-{reporting-mta|final-recipient|action|status}`). (3) **RFC 3463 status-class verdict** — first digit drives routing: `2.x.y` → success / deliver; `4.x.y` → temporary / retry; `5.x.y` → permanent / invalidate. Worst class across recipients wins so a single permanent failure in a multi-recipient bounce flips `action: invalidate`. (4) **Defenses** — bounded body cap (256 KiB strict / 1 MiB balanced / 4 MiB permissive), per-DSN recipient cap (256 / 1024 / 4096), RFC 5322 §2.1.1 header-line cap (998 bytes), CRLF / NUL / C0 / DEL refusal for header-injection defense (CVE-2026-32178 .NET System.Net.Mail class on the inbound parse path). (5) **RFC 5322 §2.2 continuation lines** — values can wrap onto subsequent lines starting with whitespace; parser folds correctly. (6) **`rfc822;` address-type prefix** stripped per RFC 3464 §2.3.2 so consumers see canonical mailbox form. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-dsn.fuzz.js`.
13
15
  - v0.9.36 (2026-05-15) — **`b.guardEnvelope` — RFC 7489 §3.1 DMARC Identifier Alignment validator.** Focused gate exposing JUST the From-header-vs-SPF/DKIM alignment primitive so the v0.9.36 MX listener can short-circuit on alignment fail before the full DMARC TXT lookup, and operator middleware composing custom anti-spoofing policy can reuse alignment without dragging in the full `b.mail.auth.dmarc` orchestrator. (1) **`b.guardEnvelope.check(ctx, opts?)`** — ctx carries `fromHeaderDomain` + `spfResult: { result, domain }` + `dkimResults: [{ result, signingDomain }]`. Returns `{ spf, dkim, aligned, action }` — `aligned: true` when at least one of SPF/DKIM is identifier-aligned (RFC 7489 §3.1: From-domain matches SPF MailFrom OR DKIM d= under chosen mode). (2) **Strict vs relaxed match** (RFC 7489 §3.1.1 / §3.1.2) — strict requires exact FQDN match, relaxed (RFC 7489 §6.2 default) requires same organizational domain via `b.publicSuffix.organizationalDomain` (Public Suffix List). Per-call override via `spfMode: strict | relaxed` + `dkimMode`. (3) **Verdict shape** — `spf: { aligned, mode, domain, fromDomain, spfPass }`, `dkim: [<verdict>...]` (one per signature so multi-signer messages with mixed pass/fail are visible), `aligned: bool` (any-of), `action: accept | refuse`. Strict + balanced profiles refuse on alignment fail; permissive computes alignment but always accepts (operator score-tags downstream). (4) **CVE / threat model** — envelope-vs-header spoofing: `MAIL FROM:<service@aws-bounces.com>` passes SPF for aws-bounces.com but `From: payments@your-bank.example` — refused under strict. Public-suffix confusion: attacker can't claim `co.uk` as an org domain because PSL classifies it as a public suffix; `victim.co.uk` vs `attacker.co.uk` have different effective org domains. Same-org-different-subdomain attack under strict mode (operator opts to relaxed for legitimate cross-subdomain mail). (5) Posture cascades (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-envelope.fuzz.js`. Registered as a standalone guard via `KIND: "envelope-alignment"` and NAME: "envelope".
package/index.js CHANGED
@@ -164,6 +164,8 @@ var guardMessageId = require("./lib/guard-message-id");
164
164
  var guardSmtpCommand = require("./lib/guard-smtp-command");
165
165
  var guardEnvelope = require("./lib/guard-envelope");
166
166
  var guardDsn = require("./lib/guard-dsn");
167
+ var guardListUnsubscribe = require("./lib/guard-list-unsubscribe");
168
+ var guardListId = require("./lib/guard-list-id");
167
169
  var guardMailQuery = require("./lib/guard-mail-query");
168
170
  var guardMailCompose = require("./lib/guard-mail-compose");
169
171
  var guardMailReply = require("./lib/guard-mail-reply");
@@ -432,6 +434,8 @@ module.exports = {
432
434
  guardSmtpCommand: guardSmtpCommand,
433
435
  guardEnvelope: guardEnvelope,
434
436
  guardDsn: guardDsn,
437
+ guardListUnsubscribe: guardListUnsubscribe,
438
+ guardListId: guardListId,
435
439
  guardMailQuery: guardMailQuery,
436
440
  guardMailCompose: guardMailCompose,
437
441
  guardMailReply: guardMailReply,
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardListId
4
+ * @nav Guards
5
+ * @title Guard List-Id
6
+ * @order 466
7
+ *
8
+ * @intro
9
+ * RFC 2919 `List-Id` header validator. Companion to
10
+ * `b.guardListUnsubscribe`; gates the outbound submission path so
11
+ * mailing-list mail carries a well-formed list identifier that
12
+ * downstream mail-client filters + bulk-sender pipelines can
13
+ * reliably route on.
14
+ *
15
+ * ## RFC 2919 §2 ABNF
16
+ *
17
+ * ```
18
+ * list-id = list-label "." list-id-namespace
19
+ * list-label = dot-atom-text (RFC 5322)
20
+ * list-id-namespace = domain-name / "localhost"
21
+ * ```
22
+ *
23
+ * Headers MAY surround the identifier in angle brackets and
24
+ * prepend a phrase + comment:
25
+ *
26
+ * ```
27
+ * List-Id: My Newsletter <my-newsletter.example.com>
28
+ * List-Id: (Comment text) <list-12345.example.com>
29
+ * ```
30
+ *
31
+ * This validator parses both bare-identifier and bracketed forms,
32
+ * refusing the address-list-injection class.
33
+ *
34
+ * ## Defenses
35
+ *
36
+ * - **Length cap** — RFC 2919 §3 caps the list identifier at 255
37
+ * octets. Total header value capped at 998 bytes per RFC 5322
38
+ * §2.1.1 line cap.
39
+ * - **CRLF + control-char refusal** — header-injection defense
40
+ * (CVE-2026-32178 .NET System.Net.Mail class on the wire-protocol
41
+ * surface; this primitive's job is the SEMANTIC shape).
42
+ * - **Phrase-injection refusal** — Operator-supplied display
43
+ * phrase mustn't carry CRLF / `<` / `>` outside the angle
44
+ * brackets (a separate Bcc/Cc header smuggled into the phrase
45
+ * fails the parse).
46
+ * - **Domain shape** — dot-atom-text per RFC 5322 §3.2.3; LDH
47
+ * labels per RFC 5321 §2.3.5; at least one `.` separator
48
+ * (rejects bare `mylist` claims).
49
+ * - **`localhost` namespace** — RFC 2919 §3 permits, but operator
50
+ * MUST also carry the recommended 32-hex random component when
51
+ * using `localhost`. Strict refuses unmanaged identifiers
52
+ * missing the randomness suffix (`SHOULD` semantics).
53
+ *
54
+ * ## CVE / threat model
55
+ *
56
+ * - **List-Id forging** — RFC 2919 §8 explicitly notes the
57
+ * identifier is NOT an authentication signal; this primitive
58
+ * refuses the SHAPE-injection class (mailing-list pipelines
59
+ * that crash or mis-route on malformed List-Id). Operators
60
+ * wanting authentication compose b.mail.auth.dmarc.evaluate /
61
+ * b.mail.auth.arc.verify on top.
62
+ * - **Bulk-sender bucket-drop** — Gmail's 2024 bulk-sender
63
+ * requirements key on List-Id presence for messages with
64
+ * `Precedence: list` or 5000+ daily sends; malformed List-Id
65
+ * drops the message into spam. This primitive surfaces the
66
+ * refuse-at-submit verdict so operators see the issue at
67
+ * send-time, not at delivery.
68
+ *
69
+ * @card
70
+ * RFC 2919 List-Id validator. Parses bare + bracketed + phrase-prefixed forms; refuses CRLF / control-char / phrase-injection / non-LDH domain / >255-octet identifier / bare-host claim. Companion to b.guardListUnsubscribe for outbound mailing-list compliance.
71
+ */
72
+
73
+ var C = require("./constants");
74
+ var { defineClass } = require("./framework-error");
75
+
76
+ var GuardListIdError = defineClass("GuardListIdError", { alwaysPermanent: true });
77
+
78
+ var DEFAULT_PROFILE = "strict";
79
+
80
+ var PROFILES = Object.freeze({
81
+ strict: {
82
+ maxBytes: 998, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap
83
+ maxListIdBytes: 255, // allow:raw-byte-literal — RFC 2919 §3 cap
84
+ requireFqdn: true,
85
+ requireRandomForLocalhost: true,
86
+ allowPhrase: true,
87
+ },
88
+ balanced: {
89
+ maxBytes: 998, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap
90
+ maxListIdBytes: 255, // allow:raw-byte-literal — RFC 2919 §3 cap
91
+ requireFqdn: true,
92
+ requireRandomForLocalhost: false,
93
+ allowPhrase: true,
94
+ },
95
+ permissive: {
96
+ maxBytes: C.BYTES.kib(4),
97
+ maxListIdBytes: 512, // allow:raw-byte-literal — permissive max
98
+ requireFqdn: false,
99
+ requireRandomForLocalhost: false,
100
+ allowPhrase: true,
101
+ },
102
+ });
103
+
104
+ var COMPLIANCE_POSTURES = Object.freeze({
105
+ hipaa: "strict",
106
+ "pci-dss": "strict",
107
+ gdpr: "strict",
108
+ soc2: "strict",
109
+ });
110
+
111
+ // RFC 5322 §3.2.3 dot-atom-text shape — alphanumeric + select
112
+ // printable specials. We don't allow the full atext set because
113
+ // the relaxed forms (`!`, `#`, `$`, etc.) almost never appear in
114
+ // real-world list IDs and the strictness defends parser drift in
115
+ // downstream consumers.
116
+ var DOT_ATOM_LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$/; // allow:regex-no-length-cap — per-label repeat-cap matches RFC 5321 §2.3.5
117
+ // 32-hex-char random component RFC 2919 §3 recommends for
118
+ // `localhost` namespace identifiers. We test for AT LEAST 32 hex
119
+ // chars somewhere in the list-label part.
120
+ var RANDOM_HEX_RE = /[0-9a-fA-F]{32}/; // allow:regex-no-length-cap — anchored repeat-cap
121
+
122
+ /**
123
+ * @primitive b.guardListId.validate
124
+ * @signature b.guardListId.validate(headerValue, opts?)
125
+ * @since 0.9.40
126
+ * @status stable
127
+ * @related b.guardListUnsubscribe.validate, b.guardEmail.validateMessage
128
+ *
129
+ * Validate an RFC 2919 `List-Id` header value. Accepts:
130
+ *
131
+ * - `<my-list.example.com>` (bracketed bare-identifier form)
132
+ * - `My Newsletter <my-list.example.com>` (phrase + bracketed)
133
+ * - `my-list.example.com` (bare, no brackets — RFC 2919 allows)
134
+ *
135
+ * Returns `{ action, listId, namespace, phrase?, reason }`.
136
+ * Action one of `"accept"` / `"refuse"`.
137
+ *
138
+ * @opts
139
+ * profile: "strict" | "balanced" | "permissive",
140
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
141
+ *
142
+ * @example
143
+ * var v = b.guardListId.validate("My Newsletter <newsletter.example.com>");
144
+ * if (v.action === "accept") emit("List-Id: " + v.raw);
145
+ */
146
+ function validate(headerValue, opts) {
147
+ opts = opts || {};
148
+ var caps = _resolveProfile(opts);
149
+ if (typeof headerValue !== "string") {
150
+ throw new GuardListIdError("guard-list-id/bad-input",
151
+ "validate: headerValue must be a string");
152
+ }
153
+ if (headerValue.length === 0) {
154
+ return _refuse("empty List-Id header value");
155
+ }
156
+ if (Buffer.byteLength(headerValue, "utf8") > caps.maxBytes) {
157
+ return _refuse("List-Id header exceeds maxBytes=" + caps.maxBytes + " (RFC 5322 §2.1.1)");
158
+ }
159
+ if (_hasControlChar(headerValue) || headerValue.indexOf("\r") !== -1 || headerValue.indexOf("\n") !== -1) {
160
+ return _refuse("header contains CRLF / NUL / C0 / DEL (header-injection defense)");
161
+ }
162
+
163
+ // Extract optional phrase + bracketed identifier OR bare identifier.
164
+ var trimmed = headerValue.trim();
165
+ var phrase = null;
166
+ var listId = null;
167
+ var lt = trimmed.indexOf("<");
168
+ if (lt !== -1) {
169
+ var gt = trimmed.indexOf(">", lt + 1);
170
+ if (gt === -1 || trimmed.indexOf("<", lt + 1) !== -1) {
171
+ return _refuse("malformed angle brackets in List-Id");
172
+ }
173
+ if (gt !== trimmed.length - 1) {
174
+ return _refuse("trailing content after '>' in List-Id");
175
+ }
176
+ phrase = trimmed.slice(0, lt).trim();
177
+ listId = trimmed.slice(lt + 1, gt).trim();
178
+ if (phrase.length > 0) {
179
+ if (!caps.allowPhrase) {
180
+ return _refuse("phrase before <list-id> refused by profile");
181
+ }
182
+ // Phrase-injection defense — phrase MUST NOT carry `<` / `>`
183
+ // (would smuggle a second bracketed identifier).
184
+ if (phrase.indexOf("<") !== -1 || phrase.indexOf(">") !== -1) {
185
+ return _refuse("phrase contains '<' or '>' (List-Id smuggling defense)");
186
+ }
187
+ }
188
+ } else {
189
+ // Bare identifier — RFC 2919 §3 allows.
190
+ listId = trimmed;
191
+ }
192
+
193
+ if (listId.length === 0) {
194
+ return _refuse("empty list-id (RFC 2919 §3)");
195
+ }
196
+ if (Buffer.byteLength(listId, "utf8") > caps.maxListIdBytes) {
197
+ return _refuse("list-id exceeds RFC 2919 §3 cap=" + caps.maxListIdBytes);
198
+ }
199
+
200
+ // RFC 2919 §2: `list-id = list-label "." list-id-namespace`.
201
+ // Both sides are dot-atom-text, so string parsing alone can't
202
+ // recover the boundary without Public Suffix List awareness
203
+ // (`team.example.com` could be label=team / ns=example.com OR
204
+ // label=team.example / ns=com). The earlier last-2-segment
205
+ // heuristic produced empty `label` for 2-label IDs (Codex P1 on
206
+ // PR #64), which violates RFC 2919 §2's required label "."
207
+ // namespace decomposition.
208
+ //
209
+ // Drop the heuristic split — surface only the raw `listId` (and
210
+ // the parsed `phrase`). Consumers that need an org-domain split
211
+ // compose `b.publicSuffix.organizationalDomain(listId)` directly,
212
+ // which is PSL-accurate (handles `.co.uk`, `.com.au`, etc.).
213
+ if (listId.indexOf(".") === -1) {
214
+ return _refuse("list-id missing '.' separator (RFC 2919 §2; bare-host '" + listId + "')");
215
+ }
216
+ var parts = listId.split(".");
217
+ for (var i = 0; i < parts.length; i += 1) {
218
+ if (parts[i].length === 0) {
219
+ return _refuse("empty label in list-id '" + listId + "' (RFC 5322 dot-atom-text)");
220
+ }
221
+ if (!DOT_ATOM_LABEL_RE.test(parts[i])) { // allow:regex-no-length-cap — label length-bounded by maxListIdBytes
222
+ return _refuse("label '" + parts[i] + "' not dot-atom-text shape (RFC 5322 §3.2.3)");
223
+ }
224
+ }
225
+ // RFC 2919 §2 requires AT LEAST one `.` (label + namespace);
226
+ // strict/balanced ALSO require the namespace to be a FQDN, which
227
+ // means a minimum of 3 labels total (label + ns-label + ns-tld)
228
+ // OR a 2-label list-id where the namespace is `localhost`.
229
+ if (caps.requireFqdn) {
230
+ var lastLabel = parts[parts.length - 1].toLowerCase();
231
+ if (parts.length < 3 && lastLabel !== "localhost") { // allow:raw-byte-literal — FQDN requires ≥ 3 labels for non-localhost
232
+ return _refuse("list-id has < 3 labels for non-localhost namespace (FQDN required under '" +
233
+ (opts.profile || DEFAULT_PROFILE) + "')");
234
+ }
235
+ }
236
+
237
+ // RFC 2919 §3: `localhost` namespace SHOULD carry 32-hex
238
+ // randomness in the label.
239
+ var isLocalhost = parts[parts.length - 1].toLowerCase() === "localhost";
240
+ if (isLocalhost) {
241
+ if (caps.requireRandomForLocalhost && !RANDOM_HEX_RE.test(listId)) { // allow:regex-no-length-cap — listId length-bounded above
242
+ return _refuse("localhost namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
243
+ }
244
+ }
245
+
246
+ return {
247
+ action: "accept",
248
+ listId: listId,
249
+ phrase: phrase,
250
+ reason: "List-Id compliant with RFC 2919 §2",
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @primitive b.guardListId.compliancePosture
256
+ * @signature b.guardListId.compliancePosture(posture)
257
+ * @since 0.9.40
258
+ * @status stable
259
+ *
260
+ * Return the effective profile name for a compliance posture, or
261
+ * `null` for unknown posture names.
262
+ *
263
+ * @example
264
+ * b.guardListId.compliancePosture("hipaa"); // → "strict"
265
+ */
266
+ function compliancePosture(posture) {
267
+ return COMPLIANCE_POSTURES[posture] || null;
268
+ }
269
+
270
+ function _hasControlChar(s) {
271
+ for (var i = 0; i < s.length; i += 1) {
272
+ var c = s.charCodeAt(i);
273
+ if (c === 0x00 || c === 0x7f || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — RFC 5322 control + TAB allow
274
+ return true;
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
280
+ function _refuse(reason) {
281
+ return {
282
+ action: "refuse",
283
+ listId: null,
284
+ phrase: null,
285
+ reason: reason,
286
+ };
287
+ }
288
+
289
+ function _resolveProfile(opts) {
290
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
291
+ return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
292
+ }
293
+ var p = opts.profile || DEFAULT_PROFILE;
294
+ if (!PROFILES[p]) {
295
+ throw new GuardListIdError("guard-list-id/bad-profile",
296
+ "guardListId: unknown profile '" + p + "'");
297
+ }
298
+ return PROFILES[p];
299
+ }
300
+
301
+ module.exports = {
302
+ validate: validate,
303
+ compliancePosture: compliancePosture,
304
+ PROFILES: PROFILES,
305
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
306
+ GuardListIdError: GuardListIdError,
307
+ NAME: "listId",
308
+ KIND: "list-id",
309
+ };
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:6b9a6fd7-b1f8-4d51-9ad8-b6edd98b31e5",
5
+ "serialNumber": "urn:uuid:414de146-e066-4e4d-aa72-85cc18e385d6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-15T09:16:43.370Z",
8
+ "timestamp": "2026-05-15T11:24:02.385Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.9.38",
22
+ "bom-ref": "@blamejs/core@0.9.40",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.38",
25
+ "version": "0.9.40",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.9.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.9.40",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.9.38",
57
+ "ref": "@blamejs/core@0.9.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]