@blamejs/core 0.9.17 → 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 +2 -0
- package/MIGRATING.md +23 -1
- 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,8 @@ 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.
|
|
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.
|
|
11
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`).
|
|
12
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.
|
|
13
15
|
- v0.9.15 (2026-05-13) — **`b.middleware.idempotencyKey.dbStore` hardening + framework-wide `require()` binding-name consistency.** Two operator-surfaced gaps closed: (1) **dbStore now hashes keys + seals body/headers by default.** Operator-supplied idempotency keys sometimes carry PII (order numbers, emails, vendor prefixes); the `k` column previously stored them raw, leaving every DB dump as a PII surface. v0.9.15 sha3-512 namespace-hashes the key via `b.crypto.namespaceHash("idempotency-key", key)` before insert/lookup — round-trips are transparent (operators still pass raw keys), but the DB never sees the original. The schema also splits the previous single-`v` JSON-envelope column into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` (vault-managed AEAD envelope) when vault is initialized, so a DB dump leaks neither cached response bodies nor headers. Non-sealed columns (`status_code`, `fingerprint`, `expires_at`) stay forensic-queryable. Both defaults are operator-opt-out via `opts.hashKeys: false` and `opts.seal: false`; the seal path silently falls back to plaintext + emits an `idempotency.seal_skipped_no_vault` audit warning on first use when vault isn't ready, so test fixtures and boot scripts still work. **Schema migration**: v0.9.15's split columns are incompatible with v0.9.14's single-`v` column — operators with a v0.9.14 idempotency table `DROP TABLE <tableName>;` (or pick a fresh `tableName`) before upgrading. Pre-v1 framework breaks across patch versions for security correctness. (2) **New `require-binding-name` codebase-patterns detector enforces consistent `var X = require("M")` names framework-wide.** Inconsistent names (`fs` vs `nodeFs`, `crypto` vs `nodeCrypto`, `path` vs `nodePath`, `nb` vs `numericBounds`) made `grep` across the lib unreliable and let reviewers miss shadowing bugs (`var crypto = require("crypto")` collides with the framework's own `b.crypto`). The detector carries a `CANONICAL_REQUIRE_BINDINGS` map with safety-first defaults: Node built-ins get a `node<X>` prefix (`nodeFs` / `nodePath` / `nodeCrypto` / `nodeStream` / `nodeTls` / `nodeUrl`) so a local var named `fs` / `path` / `crypto` can never shadow them; the framework's own `lib/crypto.js` binds as `bCrypto` (matches the `b.crypto` public-namespace shape and doesn't shadow node:crypto). Modules without a declared canonical fall back to majority-wins (most-sites name wins, alphabetical tiebreak). Fix is rename, not allowlist — every minority site was updated. **Sweep**: 184 require-binding renames across 108 framework files in this release; primitives + tests unchanged in surface. (3) **`b.graphqlFederation` internal `_timingSafeEqual` now routes through `b.crypto.timingSafeEqual`** (was re-implementing the length-tolerant wrapper inline; the new require-binding-name detector surfaced this; same-patch fix per the audit-existing-code rule).
|
package/MIGRATING.md
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
# Migrating
|
|
2
2
|
|
|
3
|
-
Operator-facing migration recipes per breaking change.
|
|
3
|
+
Operator-facing migration recipes per breaking change. The bulk of this file is auto-generated from `deprecate()`-marked surface in the framework — the running app warns about each (with `BLAMEJS_DEPRECATIONS=warn` set, or by default outside production) before the noted removal version. Re-run `node scripts/gen-migrating.js` before each release; the file is committed so operators can diff it against the prior tag.
|
|
4
|
+
|
|
5
|
+
**Out-of-band breaking changes** (schema breaks, config-shape changes, on-disk format breaks) cannot be expressed as `deprecate()` calls because there's no in-process runtime to warn from. They're hardcoded in the OUT_OF_BAND_BREAKS table inside `scripts/gen-migrating.js` so the operator sees the full upgrade path here without needing to grep CHANGELOG.
|
|
4
6
|
|
|
5
7
|
## No active deprecations
|
|
6
8
|
|
|
7
9
|
The framework has no `deprecate()`-marked surface awaiting removal.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Out-of-band breaking changes
|
|
14
|
+
|
|
15
|
+
Listed newest-first.
|
|
16
|
+
|
|
17
|
+
### v0.9.15 — `b.middleware.idempotencyKey.dbStore — table schema`
|
|
18
|
+
|
|
19
|
+
Single `v` JSON-envelope column split into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` when vault is initialized; `k` column carries the sha3-512 namespace-hash of the operator-supplied key.
|
|
20
|
+
|
|
21
|
+
Operators with a v0.9.14 (or earlier) idempotency table on disk:
|
|
22
|
+
|
|
23
|
+
```sql
|
|
24
|
+
DROP TABLE <tableName>; -- default: blamejs_idempotency_keys
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or pick a fresh `tableName` in v0.9.15+ `dbStore({ tableName: "..." })`. The init step (`init: true`, default) creates the new split-column schema. `CREATE TABLE IF NOT EXISTS` does NOT migrate column layout on an existing table, so the drop-and-recreate is required.
|
|
28
|
+
|
|
29
|
+
Cached records in the existing table are not recoverable across the schema break — operators who care about replay continuity warm the new table by retrying the in-flight requests under the new dbStore.
|
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
|
+
};
|