@blamejs/core 0.9.38 → 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.
- package/CHANGELOG.md +1 -0
- package/index.js +2 -0
- package/lib/guard-list-unsubscribe.js +337 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- 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
12
|
- 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
13
|
- 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
14
|
- 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,7 @@ 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");
|
|
167
168
|
var guardMailQuery = require("./lib/guard-mail-query");
|
|
168
169
|
var guardMailCompose = require("./lib/guard-mail-compose");
|
|
169
170
|
var guardMailReply = require("./lib/guard-mail-reply");
|
|
@@ -432,6 +433,7 @@ module.exports = {
|
|
|
432
433
|
guardSmtpCommand: guardSmtpCommand,
|
|
433
434
|
guardEnvelope: guardEnvelope,
|
|
434
435
|
guardDsn: guardDsn,
|
|
436
|
+
guardListUnsubscribe: guardListUnsubscribe,
|
|
435
437
|
guardMailQuery: guardMailQuery,
|
|
436
438
|
guardMailCompose: guardMailCompose,
|
|
437
439
|
guardMailReply: guardMailReply,
|
|
@@ -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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:d55cbdf2-6290-49a7-8fa5-692b14276458",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-15T10:14:41.717Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.39",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.39",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.39",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.9.39",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|