@blamejs/core 0.9.18 → 0.9.19
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 +6 -0
- package/lib/guard-message-id.js +241 -0
- package/lib/mail-store.js +585 -0
- package/lib/safe-mime.js +714 -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.19 (2026-05-14) — **First slice of the blamepost mail-stack sequence — `b.mailStore` + `b.safeMime` + `b.guardMessageId` substrates.** Byte-level mail-store foundation that every above-the-wire mail primitive composes (agent at v0.9.20, MX listener at v0.9.23, submission listener at v0.9.24, JMAP/IMAP/POP3 at v0.9.26-29, ManageSieve at v0.9.30, DAV at v0.9.32). (1) **`b.safeMime`** — RFC 5322 + 2045/2046/2047/EAI MIME parser. Bounded: total parts cap (default 64), nesting-depth cap (default 16), boundary length cap (default 70 per RFC 2046 §5.1.1), header-bytes cap (default 64 KiB), header-line cap (default 998 per RFC 5322 §2.1.1), body-bytes cap (default 25 MiB), message cap (default 50 MiB), charset allowlist (UTF-8 / US-ASCII / common legacy 8-bit), transfer-encoding allowlist (7bit/8bit/binary/qp/base64). Surface: `parse(bytes, opts) → tree`, `walk(tree, visitor)`, `findFirst(tree, predicate)`, `extractText(tree, opts)` (RFC 2046 §5.1.4 last-wins for `multipart/alternative`), `extractAttachments(tree, opts)`. Includes RFC 2047 Q + B encoded-word decoding for `Subject:` / `From:` etc. + RFC 2231 charset'lang'value filename decoding. Throws `safe-mime/<code>` on every cap exceeded / malformed boundary / unknown charset / unknown CTE / control chars in headers / NUL bytes. **Defends CVE-2024-39929** (Exim MIME multipart parser) and **CVE-2025-30258** (gnumail truncated-MIME-tree class). Fuzz harness ships in `fuzz/safe-mime.fuzz.js`. (2) **`b.guardMessageId`** — RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id / In-Reply-To / References at the mail-store append boundary, the MX inbound boundary (v0.9.23), and the submission outbound path (v0.9.24). Refuses oversized (>998 bytes), bare CR/LF/NUL/C0-control/DEL (header-injection defense — defends `From:` / `Bcc:` smuggling via folded Message-Id continuation), unbracketed under strict profile, empty value, missing `@`, nested angle brackets, bidi codepoints (CVE-2021-42574 RTLO class in mail-header context). Profile family: strict (default) / balanced / permissive. Posture family: hipaa / pci-dss / gdpr / soc2 → all pin profile to strict. Surface: `validate(value, opts)`, `validateList(value, opts)` (References-chain cap = 100), `compliancePosture(posture)`. Fuzz harness ships in `fuzz/guard-message-id.fuzz.js`. (3) **`b.mailStore`** — byte-level mail-store substrate with pluggable backend (sqlite default; operator's `b.externalDb` Postgres or any `{ prepare(sql) → { run, get, all } }`-shaped object). Surface: `create(opts)` returning `{ appendMessage, fetchByObjectId, queryByModseq, setFlags, createFolder, listFolders, threadFor, quota, setLegalHold }`. **Sealed by default** via `b.cryptoField.sealRow` — `subject` / `from_addr` / `to_addrs` / `body_text` / `body_html` route through vault-managed AEAD envelope on insert + unseal on fetch. Plaintext (forensic-queryable without unsealing): `objectid` / `modseq` / `internal_date` / `received_at` / `size_bytes` / `flags` / `legal_hold` / `from_hash` / `message_id_hash`. Per-folder monotonic `modseq` counter (RFC 7162 CONDSTORE substrate). Per-message `objectid` (RFC 8474 JMAP cross-protocol identity). Threading at append time via In-Reply-To + References chain walk (cryptoField.lookupHash for hash-aware threading on sealed columns). Quota substrate (per-folder `used_bytes` + `used_count` maintained atomically). Legal-hold flag composes existing `b.legalHold`. Schema bootstraps at construction with six IMAP4rev2 default folders (INBOX / Sent / Drafts / Trash / Junk / Archive) and JMAP role mapping. Append composes `b.safeMime.parse` (bounded inbound) + `b.guardMessageId.validate` (header-injection gate). **Per the operator-confirmed blamepost roadmap** (`memory/specs/blamepost-roadmap.md`); next slice v0.9.20 wires `b.mail.agent` on top of this substrate.
|
|
11
12
|
- v0.9.18 (2026-05-14) — **18 CodeQL alerts closed across 4 rule classes + SECURITY.md hardening checklist additions for v0.9.13+ primitives + MIGRATING.md out-of-band breaking-changes section.** Post-v0.9.17 audit identified 18 pre-existing CodeQL security findings on `main` — accumulated over many releases, surfaced explicitly when v0.9.15's rename sweep changed line content. v0.9.18 closes them all. (1) **`js/file-system-race` (6 sites)** — TOCTOU between `fs.existsSync()` / `fs.statSync()` and a subsequent file op. Fixed via the framework's canonical TOCTOU-safe-read scaffold (open fd first → `fstatSync` → `readSync` loop → `closeSync` in `finally`) at `lib/atomic-file.js` (`_readSyncCore`), `lib/restore-rollback.js` (marker write switched to exclusive-create `wx` + EEXIST-tolerant), `lib/network-tls.js` (`_readPathFile` extraction with per-file ENOENT tolerance), `lib/backup/bundle.js` (open-fd-first plus required-vs-skip branch routing), `lib/static.js` (request-serve hot path narrowed to single fd). `lib/vault/seal-pem-file.js` retained as-is with a CodeQL suppression — the site has an in-line `lstat.ino === fstat.ino` inode-equality defense (line 290) that refuses with `seal-pem-file/toctou-detected` if an attacker swaps the file between `lstat` and `open`. (2) **`js/insecure-temporary-file` (6 sites)** — predictable temp paths. `lib/vault/rotate.js` now uses `mkdtempSync` for a per-rotation random scratch dir + plain filenames inside (replaces the predictable `_blamejs_rotate.tmp.db` / `_blamejs_verify.tmp.db` paths in `stagingDir`). `lib/mtls-ca.js` switched to exclusive-create `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` so an attacker pre-creating the path is refused at `EEXIST`. `lib/atomic-file.js` (`fsyncDir`), `lib/vault/rotate.js` (`_fsyncFileByPath`), `lib/http-client.js` (atomic tmp path) retained as-is with suppressions — `dirPath` / `p` are operator-supplied framework data paths (not `os.tmpdir`-reachable), and `tmpPath` carries 16 hex chars of crypto-random suffix (line 1802 `dest + ".tmp-" + bCrypto.generateToken(8)`). (3) **`js/path-injection` (2 sites in `lib/static.js`)** — `nodeFs.createReadStream(absPath)` in `_readMeta` (line 161) and the request-serve hot path (line 1115). Suppression comments added referencing the upstream `_resolveSafe` lexical-resolve + `startsWith(rootResolved + nodePath.sep)` + realpath escape check at lines 181-207 — `absPath` is sandbox-validated against `root` before reaching these lines. (4) **`js/remote-property-injection` (4 sites)** — `lib/websocket.js` (`ext.params: {}` → `Object.create(null)`), `lib/middleware/csrf-protect.js` (`var out = {}` → `Object.create(null)` for cookie-parse output). `lib/middleware/body-parser.js` (multipart `fields[currentField] = ...`) retained as-is with suppression — `currentField` is gated upstream at line 867 by `POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"])` refusing the field BEFORE assignment with a 400 BodyParserError. **Plus: SECURITY.md hardening checklist** gains 5 lines covering `b.middleware.idempotencyKey.dbStore` (hash + seal defaults), `b.metrics.snapshot` (out-of-process metrics export), `b.selfUpdate.standaloneVerifier` (zero-dep install-pipeline verifier), `b.pqcAgent.reload` (TLS-posture refresh without restart), `b.crypto.hashFilesParallel` (parallel SBOM/integrity-sweep hashing). **Plus: MIGRATING.md** now carries an "Out-of-band breaking changes" section (the v0.9.15 dbStore schema break is the first entry); `scripts/gen-migrating.js` extended with an `OUT_OF_BAND_BREAKS` table so future schema/on-disk format breaks land in MIGRATING.md without operators needing to grep CHANGELOG.
|
|
12
13
|
- v0.9.17 (2026-05-14) — **Two new `codebase-patterns` detectors + 192-site cleanup sweep — `node:` prefix consistency + internal-binding leak prevention.** Post-v0.9.16 audit surfaced two enforceable invariants the existing detectors didn't cover. (1) **`node-builtin-prefix` detector** — every `require("<X>")` of a Node built-in (`fs`, `path`, `crypto`, `stream`, `tls`, `url`, `os`, `net`, `http`, `http2`, `https`, `zlib`, `dgram`, `events`, `child_process`, `readline`, …) must use the modern `require("node:<X>")` form. Three reasons: (a) userland packages on npm CAN be named after built-ins, so without the `node:` prefix a typo or `npm install` accident could shadow the built-in; (b) the prefix is a clearer at-a-glance signal that the dependency is on Node, not on a userland module; (c) bundler / SEA static-trace passes treat `node:` prefix as an unambiguous Node-builtin marker. Sweep: 153 `require()` rewrites across 79 framework files (2 parallel agents). The detector skips JSDoc `@example` block continuation lines (`*`-prefixed), so operator-facing examples that show `var fs = require("fs")` aren't rewritten — operators write their own bindings however they prefer. (2) **`internal-binding-in-prose` detector** — internal binding names (`nodeFs` / `nodePath` / `nodeCrypto` / `nodeStream` / `nodeTls` / `nodeUrl` / `bCrypto` / `retryHelper`) must NOT appear in operator-facing surface: JSDoc/comment continuation lines or string literals (error messages, audit metadata). Operators see the public API name (`path` / `fs` / `crypto` / `retry` / …), never the framework's internal alias. Sweep: 39 prose-leak fixes across 16 files — comments rewritten to use the operator-facing word (`nodePath` → `path`, `nodeFs.watch failed` → `fs.watch failed`, debug-log `"op": "nodeFs.unlinkSync"` → `"op": "fs.unlinkSync"`). (3) **2 follow-on require-binding canonicalizations** surfaced by the node-prefix sweep — `lib/ws-client.js` now destructures `var { EventEmitter } = require("node:events")` (was binding the entire `events` module to a class-shaped name) and `lib/process-spawn.js` renames inline `nodeChild` → `childProcess` (matches the module-level `childProcess` lazyRequire in `lib/dev.js`).
|
|
13
14
|
- v0.9.16 (2026-05-14) — **Operator-facing prose cleanup + `require-binding-name` detector now covers `lazyRequire` wrappers + dbStore seal round-trip test added.** Post-v0.9.15 audit surfaced three classes of follow-up. (1) **Operator-facing prose leaks (7 sites)** — the v0.9.15 mechanical rename pattern `<OLD>.` → `<NEW>.` also caught occurrences inside JSDoc `@opts` comments and error-message string literals, so operators reading `b.keychain.create(opts)` saw `// absolute nodePath; required if file fallback may engage` instead of `// absolute path`. Fixed: `lib/db.js` (stream-limit error), `lib/keychain.js` (fallback-file error + 3 JSDoc lines), `lib/restore-bundle.js` (staging-dir error), `lib/watcher.js` (fs.watch failure error). Operators see plain English; internal binding names stay internal. (2) **`require-binding-name` detector extended to cover `lazyRequire`** — the v0.9.15 detector only matched plain `var X = require("M")` and missed the framework's `var X = lazyRequire(function () { return require("M"); })` pattern (used to break load cycles). 34 additional inconsistencies surfaced (`auditFwk` / `auditMod` / `auditModule` / `lazyAudit` → `audit`, `crypto` / `fwCrypto` → `bCrypto`, `dbMod` / `dbModule` → `db`, etc.) — every minority site renamed per the same canonical-name map. (3) **dbStore seal round-trip test** — the v0.9.15 test suite covered seal-falls-back-when-vault-not-ready and cross-process-sealed-row-preserved, but did NOT exercise the actual default-ON seal/unseal path because the test environment didn't `b.vault.init(...)`. New `testDbStoreSealRoundTripWithVault` bootstraps a plaintext vault, builds a dbStore with `seal: true`, writes a record + reads it back, and asserts (a) `headers` + `body` columns carry the `vault:` envelope on disk, (b) the round-trip restores the original values, and (c) `status_code` stays plaintext so forensic SELECTs still work without unsealing.
|
package/index.js
CHANGED
|
@@ -86,6 +86,8 @@ var session = require("./lib/session");
|
|
|
86
86
|
var storage = require("./lib/storage");
|
|
87
87
|
var safeJson = require("./lib/safe-json");
|
|
88
88
|
var safeJsonPath = require("./lib/safe-jsonpath");
|
|
89
|
+
var safeMime = require("./lib/safe-mime");
|
|
90
|
+
var mailStore = require("./lib/mail-store");
|
|
89
91
|
var ntpCheck = require("./lib/ntp-check");
|
|
90
92
|
var auditSign = require("./lib/audit-sign");
|
|
91
93
|
var objectStore = require("./lib/object-store");
|
|
@@ -157,6 +159,7 @@ var guardCsv = require("./lib/guard-csv");
|
|
|
157
159
|
var guardHtml = require("./lib/guard-html");
|
|
158
160
|
var guardSvg = require("./lib/guard-svg");
|
|
159
161
|
var guardFilename = require("./lib/guard-filename");
|
|
162
|
+
var guardMessageId = require("./lib/guard-message-id");
|
|
160
163
|
var guardArchive = require("./lib/guard-archive");
|
|
161
164
|
var guardJson = require("./lib/guard-json");
|
|
162
165
|
var guardYaml = require("./lib/guard-yaml");
|
|
@@ -394,6 +397,7 @@ module.exports = {
|
|
|
394
397
|
guardHtml: guardHtml,
|
|
395
398
|
guardSvg: guardSvg,
|
|
396
399
|
guardFilename: guardFilename,
|
|
400
|
+
guardMessageId: guardMessageId,
|
|
397
401
|
guardArchive: guardArchive,
|
|
398
402
|
guardJson: guardJson,
|
|
399
403
|
guardYaml: guardYaml,
|
|
@@ -473,6 +477,8 @@ module.exports = {
|
|
|
473
477
|
flag: flag,
|
|
474
478
|
safeJson: safeJson,
|
|
475
479
|
safeJsonPath: safeJsonPath,
|
|
480
|
+
safeMime: safeMime,
|
|
481
|
+
mailStore: mailStore,
|
|
476
482
|
safeSchema: safeSchema,
|
|
477
483
|
pagination: pagination,
|
|
478
484
|
metrics: metrics,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMessageId
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Message-Id
|
|
6
|
+
* @order 420
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id /
|
|
10
|
+
* In-Reply-To / References header values at the entry to
|
|
11
|
+
* `b.mailStore.appendMessage` (v0.9.19), `b.mail.server.mx` (v0.9.23),
|
|
12
|
+
* and the outbound submission path (v0.9.25).
|
|
13
|
+
*
|
|
14
|
+
* Refuses:
|
|
15
|
+
*
|
|
16
|
+
* - oversized (default 998-byte cap per RFC 5322 §2.1.1 line cap)
|
|
17
|
+
* - bare CR / LF / NUL / C0 control chars (header-injection
|
|
18
|
+
* defense — defends `From:` / `Bcc:` smuggling via folded
|
|
19
|
+
* Message-Id continuation)
|
|
20
|
+
* - DEL (0x7F) anywhere
|
|
21
|
+
* - unbracketed under `strict` profile (the wire form per RFC
|
|
22
|
+
* 5322 §3.6.4 is `<unique-token@domain>` — operator with
|
|
23
|
+
* legacy mail can opt down to `balanced` to accept bare tokens)
|
|
24
|
+
* - empty value
|
|
25
|
+
* - bidi codepoints in the local-part / domain (RFC 5322 + EAI
|
|
26
|
+
* allow non-ASCII per RFC 6532 + RFC 5335 but bidi-marker
|
|
27
|
+
* codepoints are operator-unfriendly and refused outright)
|
|
28
|
+
*
|
|
29
|
+
* Profile vocabulary follows the existing guard-family convention:
|
|
30
|
+
*
|
|
31
|
+
* - `strict` (default) — bracketed `<token@domain>`, length cap,
|
|
32
|
+
* no control chars, no bidi
|
|
33
|
+
* - `balanced` — accepts unbracketed tokens (legacy mail compat)
|
|
34
|
+
* - `permissive` — minimal validation (NUL + CR/LF refused; rest
|
|
35
|
+
* passes); use only for forensic-only flows
|
|
36
|
+
*
|
|
37
|
+
* Posture vocabulary:
|
|
38
|
+
*
|
|
39
|
+
* - `hipaa` / `pci-dss` / `gdpr` / `soc2` — each pins the
|
|
40
|
+
* active profile to `strict` regardless of operator's profile
|
|
41
|
+
* opt; refuses to relax under regulated postures.
|
|
42
|
+
*
|
|
43
|
+
* Composes the framework's existing guard-family pattern via
|
|
44
|
+
* `b.gateContract` (the same shape `b.guardEmail` / `b.guardCsv` /
|
|
45
|
+
* `b.guardArchive` use). Registers in `b.guardAll`'s
|
|
46
|
+
* `STANDALONE_GUARDS` map.
|
|
47
|
+
*
|
|
48
|
+
* @card
|
|
49
|
+
* RFC 5322 §3.6.4 Message-Id validator — bounded length, no CRLF/NUL/control chars, bracketed shape under strict profile. Gates header-injection at the mail-store / MX / submission entry points.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
var { defineClass } = require("./framework-error");
|
|
53
|
+
|
|
54
|
+
var GuardMessageIdError = defineClass("GuardMessageIdError", { alwaysPermanent: true });
|
|
55
|
+
|
|
56
|
+
var DEFAULT_PROFILE = "strict";
|
|
57
|
+
|
|
58
|
+
var PROFILES = Object.freeze({
|
|
59
|
+
strict: { requireBrackets: true, maxBytes: 998 }, // allow:raw-byte-literal
|
|
60
|
+
balanced: { requireBrackets: false, maxBytes: 998 }, // allow:raw-byte-literal
|
|
61
|
+
permissive: { requireBrackets: false, maxBytes: 4096 }, // allow:raw-byte-literal — permissive cap, not bytes-as-storage
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
65
|
+
hipaa: "strict",
|
|
66
|
+
"pci-dss": "strict",
|
|
67
|
+
gdpr: "strict",
|
|
68
|
+
soc2: "strict",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Bidi codepoints refused — same set the framework's address-bidi
|
|
72
|
+
// defense uses (RFC 5322 §3.6.4 doesn't speak EAI codepoints, but RTL
|
|
73
|
+
// codepoints in Message-Ids are operator-unfriendly + defend the
|
|
74
|
+
// CVE-2021-42574 RTLO class in mail header context).
|
|
75
|
+
var BIDI_RE = /[--]/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @primitive b.guardMessageId.validate
|
|
79
|
+
* @signature b.guardMessageId.validate(value, opts?)
|
|
80
|
+
* @since 0.9.19
|
|
81
|
+
* @status stable
|
|
82
|
+
* @related b.guardMessageId.validateList, b.safeMime.parse, b.guardEmail
|
|
83
|
+
*
|
|
84
|
+
* Validate a Message-Id / In-Reply-To / References header value.
|
|
85
|
+
* Returns the input value on success; throws `GuardMessageIdError`
|
|
86
|
+
* on refusal.
|
|
87
|
+
*
|
|
88
|
+
* @opts
|
|
89
|
+
* profile: "strict" | "balanced" | "permissive", // default "strict"
|
|
90
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // pins profile to strict
|
|
91
|
+
* maxBytes: number, // per-profile default
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* b.guardMessageId.validate("<abc@example.com>");
|
|
95
|
+
* // → "<abc@example.com>"
|
|
96
|
+
*
|
|
97
|
+
* try { b.guardMessageId.validate("abc@example.com"); }
|
|
98
|
+
* catch (e) { e.code; }
|
|
99
|
+
* // → "message-id/unbracketed" (strict profile)
|
|
100
|
+
*/
|
|
101
|
+
function validate(value, opts) {
|
|
102
|
+
opts = opts || {};
|
|
103
|
+
var profileName = _resolveProfile(opts);
|
|
104
|
+
var profile = PROFILES[profileName];
|
|
105
|
+
var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : profile.maxBytes;
|
|
106
|
+
|
|
107
|
+
if (typeof value !== "string") {
|
|
108
|
+
throw new GuardMessageIdError("message-id/bad-input",
|
|
109
|
+
"guardMessageId.validate: value must be a string (got " + typeof value + ")");
|
|
110
|
+
}
|
|
111
|
+
if (value.length === 0) {
|
|
112
|
+
throw new GuardMessageIdError("message-id/empty",
|
|
113
|
+
"guardMessageId.validate: empty Message-Id refused");
|
|
114
|
+
}
|
|
115
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes) {
|
|
116
|
+
throw new GuardMessageIdError("message-id/oversize",
|
|
117
|
+
"guardMessageId.validate: " + Buffer.byteLength(value, "utf8") +
|
|
118
|
+
" bytes exceeds maxBytes=" + maxBytes + " (RFC 5322 §2.1.1)");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// C0 control chars + NUL + DEL — always refused at every profile
|
|
122
|
+
// (defends mail-header-injection class — operator can't smuggle
|
|
123
|
+
// CR/LF into a Message-Id to fold an attacker-chosen From: line).
|
|
124
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
125
|
+
var c = value.charCodeAt(i);
|
|
126
|
+
if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
|
|
127
|
+
throw new GuardMessageIdError("message-id/control-char",
|
|
128
|
+
"guardMessageId.validate: control char 0x" + c.toString(16) + " at offset " + i);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Bidi codepoints — refused at strict + balanced; permissive lets
|
|
133
|
+
// them through. Length-bounded by the maxBytes check above so a
|
|
134
|
+
// hostile input can't burn regex-engine CPU; the bidi codepoint set
|
|
135
|
+
// is tiny so the test is constant-time anyway.
|
|
136
|
+
if (profileName !== "permissive" && BIDI_RE.test(value)) { // allow:regex-no-length-cap — value length-bounded by Buffer.byteLength check above
|
|
137
|
+
throw new GuardMessageIdError("message-id/bidi",
|
|
138
|
+
"guardMessageId.validate: bidi codepoint refused (CVE-2021-42574 RTLO class in mail-header context)");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Bracketed shape — required under strict.
|
|
142
|
+
if (profile.requireBrackets) {
|
|
143
|
+
if (value.charAt(0) !== "<" || value.charAt(value.length - 1) !== ">") {
|
|
144
|
+
throw new GuardMessageIdError("message-id/unbracketed",
|
|
145
|
+
"guardMessageId.validate: strict profile requires `<token@domain>` shape (RFC 5322 §3.6.4)");
|
|
146
|
+
}
|
|
147
|
+
var inner = value.slice(1, -1);
|
|
148
|
+
var at = inner.indexOf("@");
|
|
149
|
+
if (at <= 0 || at === inner.length - 1) {
|
|
150
|
+
throw new GuardMessageIdError("message-id/no-at",
|
|
151
|
+
"guardMessageId.validate: Message-Id must contain `@` between local-part and domain");
|
|
152
|
+
}
|
|
153
|
+
if (inner.indexOf("<") >= 0 || inner.indexOf(">") >= 0) {
|
|
154
|
+
throw new GuardMessageIdError("message-id/nested-brackets",
|
|
155
|
+
"guardMessageId.validate: nested angle brackets refused");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @primitive b.guardMessageId.validateList
|
|
164
|
+
* @signature b.guardMessageId.validateList(value, opts?)
|
|
165
|
+
* @since 0.9.19
|
|
166
|
+
* @status stable
|
|
167
|
+
* @related b.guardMessageId.validate
|
|
168
|
+
*
|
|
169
|
+
* Validate a Message-Id-list header value (References / In-Reply-To
|
|
170
|
+
* may carry multiple ids separated by whitespace per RFC 5322 §3.6.4).
|
|
171
|
+
* Returns the array of validated Message-Ids; throws on any single
|
|
172
|
+
* refusal.
|
|
173
|
+
*
|
|
174
|
+
* @opts
|
|
175
|
+
* profile: same as validate
|
|
176
|
+
* posture: same as validate
|
|
177
|
+
* maxBytes: per-id cap
|
|
178
|
+
* maxIds: number, // default 100 — References-chain cap
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* b.guardMessageId.validateList("<a@x> <b@x> <c@x>");
|
|
182
|
+
* // → ["<a@x>", "<b@x>", "<c@x>"]
|
|
183
|
+
*/
|
|
184
|
+
function validateList(value, opts) {
|
|
185
|
+
opts = opts || {};
|
|
186
|
+
var maxIds = typeof opts.maxIds === "number" ? opts.maxIds : 100; // allow:raw-byte-literal — References-chain cap, not bytes
|
|
187
|
+
if (typeof value !== "string") {
|
|
188
|
+
throw new GuardMessageIdError("message-id/bad-input",
|
|
189
|
+
"guardMessageId.validateList: value must be a string");
|
|
190
|
+
}
|
|
191
|
+
var ids = value.split(/\s+/).filter(function (s) { return s.length > 0; });
|
|
192
|
+
if (ids.length > maxIds) {
|
|
193
|
+
throw new GuardMessageIdError("message-id/chain-too-long",
|
|
194
|
+
"guardMessageId.validateList: " + ids.length + " ids exceeds maxIds=" + maxIds);
|
|
195
|
+
}
|
|
196
|
+
for (var i = 0; i < ids.length; i += 1) {
|
|
197
|
+
validate(ids[i], opts);
|
|
198
|
+
}
|
|
199
|
+
return ids;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @primitive b.guardMessageId.compliancePosture
|
|
204
|
+
* @signature b.guardMessageId.compliancePosture(posture)
|
|
205
|
+
* @since 0.9.19
|
|
206
|
+
* @status stable
|
|
207
|
+
*
|
|
208
|
+
* Return the effective profile for a given compliance posture.
|
|
209
|
+
* Composed by `b.compliance.set` to surface "what posture is active
|
|
210
|
+
* for which guard" in audit rows.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* b.guardMessageId.compliancePosture("hipaa"); // → "strict"
|
|
214
|
+
* b.guardMessageId.compliancePosture("unknown"); // → null
|
|
215
|
+
*/
|
|
216
|
+
function compliancePosture(posture) {
|
|
217
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _resolveProfile(opts) {
|
|
221
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
222
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
223
|
+
}
|
|
224
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
225
|
+
if (!PROFILES[p]) {
|
|
226
|
+
throw new GuardMessageIdError("message-id/bad-profile",
|
|
227
|
+
"guardMessageId: unknown profile '" + p + "' (use strict / balanced / permissive)");
|
|
228
|
+
}
|
|
229
|
+
return p;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
validate: validate,
|
|
234
|
+
validateList: validateList,
|
|
235
|
+
compliancePosture: compliancePosture,
|
|
236
|
+
PROFILES: PROFILES,
|
|
237
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
238
|
+
GuardMessageIdError: GuardMessageIdError,
|
|
239
|
+
NAME: "messageId",
|
|
240
|
+
KIND: "identifier",
|
|
241
|
+
};
|