@blamejs/core 0.9.39 → 0.9.41
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 +2 -0
- package/index.js +2 -0
- package/lib/guard-list-id.js +309 -0
- package/lib/problem-details.js +43 -0
- package/lib/storage.js +12 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
|
|
12
|
+
- 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).
|
|
11
13
|
- 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).
|
|
12
14
|
- 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).
|
|
13
15
|
- 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`.
|
package/index.js
CHANGED
|
@@ -165,6 +165,7 @@ var guardSmtpCommand = require("./lib/guard-smtp-command");
|
|
|
165
165
|
var guardEnvelope = require("./lib/guard-envelope");
|
|
166
166
|
var guardDsn = require("./lib/guard-dsn");
|
|
167
167
|
var guardListUnsubscribe = require("./lib/guard-list-unsubscribe");
|
|
168
|
+
var guardListId = require("./lib/guard-list-id");
|
|
168
169
|
var guardMailQuery = require("./lib/guard-mail-query");
|
|
169
170
|
var guardMailCompose = require("./lib/guard-mail-compose");
|
|
170
171
|
var guardMailReply = require("./lib/guard-mail-reply");
|
|
@@ -434,6 +435,7 @@ module.exports = {
|
|
|
434
435
|
guardEnvelope: guardEnvelope,
|
|
435
436
|
guardDsn: guardDsn,
|
|
436
437
|
guardListUnsubscribe: guardListUnsubscribe,
|
|
438
|
+
guardListId: guardListId,
|
|
437
439
|
guardMailQuery: guardMailQuery,
|
|
438
440
|
guardMailCompose: guardMailCompose,
|
|
439
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
|
+
};
|
package/lib/problem-details.js
CHANGED
|
@@ -358,6 +358,48 @@ function respond(res, problem) {
|
|
|
358
358
|
res.end(body);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @primitive b.problemDetails.send
|
|
363
|
+
* @signature b.problemDetails.send(res, fields)
|
|
364
|
+
* @since 0.9.41
|
|
365
|
+
* @status stable
|
|
366
|
+
* @related b.problemDetails.create, b.problemDetails.respond
|
|
367
|
+
*
|
|
368
|
+
* Build + emit a problem-details response in one call. Equivalent
|
|
369
|
+
* to `respond(res, create(fields))` but lets routes migrate
|
|
370
|
+
* incrementally from inline `res.status(400).json({ error: "..." })`
|
|
371
|
+
* shapes without restructuring the handler around an error throw.
|
|
372
|
+
*
|
|
373
|
+
* The same RFC 9457 §3 `application/problem+json` content type +
|
|
374
|
+
* `Cache-Control: no-store` are written; status code defaults to
|
|
375
|
+
* 500 when omitted.
|
|
376
|
+
*
|
|
377
|
+
* @opts
|
|
378
|
+
* status: number, // HTTP status code (100..599); default 500
|
|
379
|
+
* title: string, // operator-supplied short title
|
|
380
|
+
* detail: string, // operator-supplied human-readable explanation
|
|
381
|
+
* type: string, // problem-type URI (defaults to "about:blank")
|
|
382
|
+
* instance: string, // optional per-occurrence URI
|
|
383
|
+
* extensions: object, // operator-specific extension fields
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* // Migrating from inline JSON-error shape:
|
|
387
|
+
* // res.status(400).json({ error: "Missing 'name' field" });
|
|
388
|
+
* // to RFC 9457 problem-details:
|
|
389
|
+
* b.problemDetails.send(res, {
|
|
390
|
+
* status: 400,
|
|
391
|
+
* title: "Missing required field",
|
|
392
|
+
* detail: "Body field 'name' is required",
|
|
393
|
+
* });
|
|
394
|
+
*/
|
|
395
|
+
function send(res, fields) {
|
|
396
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
|
|
397
|
+
throw new ProblemDetailsError("problem-details/bad-fields",
|
|
398
|
+
"send: fields must be a non-null object", true);
|
|
399
|
+
}
|
|
400
|
+
return respond(res, create(fields));
|
|
401
|
+
}
|
|
402
|
+
|
|
361
403
|
/**
|
|
362
404
|
* @primitive b.problemDetails.validate
|
|
363
405
|
* @signature b.problemDetails.validate(doc)
|
|
@@ -431,6 +473,7 @@ module.exports = {
|
|
|
431
473
|
create: create,
|
|
432
474
|
fromError: fromError,
|
|
433
475
|
respond: respond,
|
|
476
|
+
send: send,
|
|
434
477
|
validate: validate,
|
|
435
478
|
RESERVED_FIELDS: RESERVED_FIELDS,
|
|
436
479
|
ProblemDetailsError: ProblemDetailsError,
|
package/lib/storage.js
CHANGED
|
@@ -577,13 +577,23 @@ function listBackends() {
|
|
|
577
577
|
_requireInit();
|
|
578
578
|
var out = [];
|
|
579
579
|
for (var name in backends) {
|
|
580
|
-
|
|
580
|
+
var entry = {
|
|
581
581
|
name: name,
|
|
582
582
|
protocol: backends[name].protocol,
|
|
583
583
|
classifications: backends[name].classifications.slice(),
|
|
584
584
|
residencyTag: backends[name].residencyTag,
|
|
585
585
|
breakerState: backends[name].breaker.getState(),
|
|
586
|
-
}
|
|
586
|
+
};
|
|
587
|
+
// Surface the resolved local rootDir so downstream operators
|
|
588
|
+
// building path-traversal guards or scratch-dir layouts read the
|
|
589
|
+
// live path (with config-reload propagation) directly from the
|
|
590
|
+
// backend rather than re-deriving from operator-supplied opts.
|
|
591
|
+
// Remote protocols (sigv4 / gcs / azure-blob / http-put) don't
|
|
592
|
+
// have a rootDir; the field stays absent for those.
|
|
593
|
+
if (backends[name].protocol === "local" && backends[name].raw && typeof backends[name].raw.rootDir === "string") {
|
|
594
|
+
entry.rootDir = backends[name].raw.rootDir;
|
|
595
|
+
}
|
|
596
|
+
out.push(entry);
|
|
587
597
|
}
|
|
588
598
|
return out;
|
|
589
599
|
}
|
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:2ba814f6-24ab-4017-914e-f9ddba6c50ef",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-15T14:40:55.638Z",
|
|
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.41",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.41",
|
|
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.41",
|
|
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.41",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|