@blamejs/core 0.9.49 → 0.10.2

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.spamScore
4
+ * @nav Mail
5
+ * @title Mail Spam Score
6
+ * @order 557
7
+ *
8
+ * @intro
9
+ * Operator-supplied spam scorer facade. The framework deliberately
10
+ * does NOT vendor a spam-classifier engine — the bayes corpora,
11
+ * URIBL caches, neural models, and per-recipient training are all
12
+ * operator state. Instead, `b.mail.spamScore.create` wraps the
13
+ * operator's chosen scorer (SpamAssassin via spamc, Rspamd HTTP
14
+ * API, Cloudmark, Vade, in-house) in a uniform threshold-driven
15
+ * verdict pipeline that the MX listener (v0.9.45) and submission
16
+ * listener (v0.9.47) consume.
17
+ *
18
+ * ## Operator-supplied scorer contract
19
+ *
20
+ * ```
21
+ * async function scorer({ rawBytes, headers, envelope }) {
22
+ * // call out to SpamAssassin / Rspamd / commercial scorer
23
+ * return { score: 7.3, reasons: ["BAYES_99", "URIBL_RED"] };
24
+ * }
25
+ * ```
26
+ *
27
+ * - `score` MUST be a finite number (any range; the threshold is
28
+ * operator-tuned). Negative scores mean "ham-shaped"; positive
29
+ * scores mean "spam-shaped". Convention matches SpamAssassin.
30
+ * - `reasons` MUST be an Array of short ASCII tags. The facade caps
31
+ * each tag at 256 bytes and refuses control bytes; the cap protects
32
+ * audit storage + outbound headers (`X-Spam-Status: ...`) from
33
+ * hostile expansion via a compromised scorer.
34
+ *
35
+ * ## Thresholds
36
+ *
37
+ * - **strict** — 5.0 (matches SpamAssassin's default `required_score`).
38
+ * - **balanced** — 7.5.
39
+ * - **permissive** — 10.0.
40
+ *
41
+ * Operators tune via `opts.threshold` per-instance. The verdict is
42
+ * `"accept"` (score < threshold), `"score-tag"` (score === threshold —
43
+ * add `X-Spam-Status` header but deliver), or `"refuse"`
44
+ * (score > threshold — return SMTP 550).
45
+ *
46
+ * ## Composition
47
+ *
48
+ * - **`b.audit`** receives every `score` / `accept` / `score_tag` /
49
+ * `refuse` decision. Audit failure is drop-silent (hot path).
50
+ *
51
+ * ## Threat model
52
+ *
53
+ * - **Hostile reason-tag** (compromised scorer injects CRLF into a
54
+ * tag, smuggling extra `X-Spam-*` headers into the outbound
55
+ * wrapper): defended by per-tag length cap + control-byte refusal.
56
+ * - **NaN / Infinity score** (scorer bug): refused as
57
+ * `mail-spam-score/bad-score`; the listener treats the message as
58
+ * unscanned (operator's tempfail policy applies).
59
+ * - **Slow scorer DoS**: the scorer function is operator code, so
60
+ * timing belongs to the operator. The listener wraps the
61
+ * `.score()` promise in its own per-connection deadline.
62
+ *
63
+ * @card
64
+ * Threshold-driven spam-scorer facade. Operator wires SpamAssassin /
65
+ * Rspamd / commercial scorer; the framework owns the verdict
66
+ * pipeline + reason-tag hardening + audit emission. Three default
67
+ * thresholds (strict 5.0 / balanced 7.5 / permissive 10.0).
68
+ */
69
+
70
+ var { defineClass } = require("./framework-error");
71
+ var lazyRequire = require("./lazy-require");
72
+ var validateOpts = require("./validate-opts");
73
+
74
+ var audit = lazyRequire(function () { return require("./audit"); });
75
+
76
+ var MailSpamScoreError = defineClass("MailSpamScoreError", { alwaysPermanent: true });
77
+
78
+ var DEFAULT_PROFILE = "strict";
79
+
80
+ // allow:raw-byte-literal — reason-tag length cap defends outbound
81
+ // header / audit-store from hostile expansion via compromised scorer.
82
+ var MAX_REASON_BYTES = 256;
83
+
84
+ // allow:raw-byte-literal — reason-list count cap, defends audit volume.
85
+ var MAX_REASONS = 32;
86
+
87
+ var PROFILES = Object.freeze({
88
+ strict: { threshold: 5.0, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES }, // allow:raw-byte-literal — matches SpamAssassin default required_score
89
+ balanced: { threshold: 7.5, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES },
90
+ permissive: { threshold: 10.0, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES },
91
+ });
92
+
93
+ var COMPLIANCE_POSTURES = Object.freeze({
94
+ hipaa: "strict",
95
+ "pci-dss": "strict",
96
+ gdpr: "strict",
97
+ soc2: "strict",
98
+ });
99
+
100
+ /**
101
+ * @primitive b.mail.spamScore.create
102
+ * @signature b.mail.spamScore.create(opts)
103
+ * @since 0.9.81
104
+ * @status stable
105
+ * @related b.mail.scan.create
106
+ *
107
+ * Build a spam-score handle. Returns `{ score(message, opts),
108
+ * threshold, profile, MailSpamScoreError }` where `.score` resolves to
109
+ * `{ score, reasons, verdict }`. `verdict` is `"accept"` /
110
+ * `"score-tag"` / `"refuse"` based on threshold comparison.
111
+ *
112
+ * @opts
113
+ * scorer: async fn({ rawBytes, headers, envelope }) → { score, reasons } — required
114
+ * threshold: number — overrides profile default
115
+ * profile: "strict" | "balanced" | "permissive"
116
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2"
117
+ * audit: b.audit instance
118
+ *
119
+ * @example
120
+ * var spam = b.mail.spamScore.create({
121
+ * scorer: async function (ctx) {
122
+ * return await callSpamAssassin(ctx.rawBytes);
123
+ * },
124
+ * });
125
+ * var v = await spam.score({ rawBytes: msg });
126
+ * if (v.verdict === "refuse") refuseConnection(v.reasons.join(","));
127
+ */
128
+ function create(opts) {
129
+ opts = validateOpts.requireObject(opts || {}, "mail.spamScore.create",
130
+ MailSpamScoreError, "mail-spam-score/bad-opts");
131
+ validateOpts(opts, [
132
+ "scorer", "threshold", "profile", "posture", "audit",
133
+ ], "mail.spamScore.create");
134
+ if (typeof opts.scorer !== "function") {
135
+ throw new MailSpamScoreError("mail-spam-score/bad-scorer",
136
+ "mail.spamScore.create.scorer must be a function; got " + (typeof opts.scorer));
137
+ }
138
+ var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
139
+ if (!PROFILES[profile]) {
140
+ throw new MailSpamScoreError("mail-spam-score/bad-profile",
141
+ "mail.spamScore.create.profile: unknown '" + profile +
142
+ "' (valid: strict / balanced / permissive)");
143
+ }
144
+ var caps = PROFILES[profile];
145
+ var threshold;
146
+ if (opts.threshold !== undefined) {
147
+ if (typeof opts.threshold !== "number" || !isFinite(opts.threshold)) {
148
+ throw new MailSpamScoreError("mail-spam-score/bad-threshold",
149
+ "mail.spamScore.create.threshold must be a finite number; got " +
150
+ (typeof opts.threshold) + " " + String(opts.threshold));
151
+ }
152
+ threshold = opts.threshold;
153
+ } else {
154
+ threshold = caps.threshold;
155
+ }
156
+ var auditImpl = opts.audit || audit();
157
+
158
+ async function score(message) {
159
+ if (!message || typeof message !== "object") {
160
+ throw new MailSpamScoreError("mail-spam-score/bad-input",
161
+ "mail.spamScore.score: message must be an object with rawBytes/headers/envelope");
162
+ }
163
+ var rv;
164
+ try {
165
+ rv = await opts.scorer({
166
+ rawBytes: message.rawBytes,
167
+ headers: message.headers || {},
168
+ envelope: message.envelope || {},
169
+ });
170
+ } catch (e) {
171
+ _emitAudit(auditImpl, "mail.spam_score.error", "failure", {
172
+ message: (e && e.message) || String(e),
173
+ });
174
+ throw new MailSpamScoreError("mail-spam-score/scorer-threw",
175
+ "mail.spamScore.score: scorer threw: " + ((e && e.message) || e));
176
+ }
177
+ if (!rv || typeof rv !== "object") {
178
+ throw new MailSpamScoreError("mail-spam-score/bad-result",
179
+ "mail.spamScore.score: scorer must return { score, reasons }; got " +
180
+ (typeof rv));
181
+ }
182
+ if (typeof rv.score !== "number" || !isFinite(rv.score)) {
183
+ throw new MailSpamScoreError("mail-spam-score/bad-score",
184
+ "mail.spamScore.score: scorer returned non-finite score=" + String(rv.score));
185
+ }
186
+ var reasons = _sanitizeReasons(rv.reasons, caps);
187
+
188
+ var verdict;
189
+ if (rv.score < threshold) verdict = "accept";
190
+ else if (rv.score === threshold) verdict = "score-tag";
191
+ else verdict = "refuse";
192
+
193
+ _emitAudit(auditImpl, "mail.spam_score.score", "success", {
194
+ score: rv.score, threshold: threshold, verdict: verdict, reasons: reasons,
195
+ });
196
+ if (verdict === "accept") {
197
+ _emitAudit(auditImpl, "mail.spam_score.accept", "success", { score: rv.score });
198
+ } else if (verdict === "score-tag") {
199
+ _emitAudit(auditImpl, "mail.spam_score.score_tag", "success",
200
+ { score: rv.score, reasons: reasons });
201
+ } else {
202
+ _emitAudit(auditImpl, "mail.spam_score.refuse", "success",
203
+ { score: rv.score, reasons: reasons });
204
+ }
205
+ return { score: rv.score, reasons: reasons, verdict: verdict };
206
+ }
207
+
208
+ return {
209
+ score: score,
210
+ threshold: threshold,
211
+ profile: profile,
212
+ MailSpamScoreError: MailSpamScoreError,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * @primitive b.mail.spamScore.compliancePosture
218
+ * @signature b.mail.spamScore.compliancePosture(posture)
219
+ * @since 0.9.81
220
+ * @status stable
221
+ *
222
+ * Return the effective profile name for a compliance posture, or
223
+ * `null` for unknown posture names.
224
+ *
225
+ * @example
226
+ * b.mail.spamScore.compliancePosture("hipaa"); // → "strict"
227
+ */
228
+ function compliancePosture(posture) {
229
+ return COMPLIANCE_POSTURES[posture] || null;
230
+ }
231
+
232
+ function _sanitizeReasons(reasons, caps) {
233
+ if (reasons === undefined || reasons === null) return [];
234
+ if (!Array.isArray(reasons)) {
235
+ throw new MailSpamScoreError("mail-spam-score/bad-reasons",
236
+ "mail.spamScore.score: scorer returned non-array reasons (" + (typeof reasons) + ")");
237
+ }
238
+ if (reasons.length > caps.maxReasons) {
239
+ throw new MailSpamScoreError("mail-spam-score/too-many-reasons",
240
+ "mail.spamScore.score: scorer returned " + reasons.length +
241
+ " reasons; cap is " + caps.maxReasons);
242
+ }
243
+ var out = [];
244
+ for (var i = 0; i < reasons.length; i += 1) {
245
+ var r = reasons[i];
246
+ if (typeof r !== "string" || r.length === 0) {
247
+ throw new MailSpamScoreError("mail-spam-score/bad-reason",
248
+ "mail.spamScore.score: reasons[" + i + "] must be a non-empty string");
249
+ }
250
+ if (Buffer.byteLength(r, "utf8") > caps.maxReasonBytes) {
251
+ throw new MailSpamScoreError("mail-spam-score/oversize-reason",
252
+ "mail.spamScore.score: reasons[" + i + "] exceeds " + caps.maxReasonBytes + " bytes");
253
+ }
254
+ // Refuse control bytes (CR / LF / NUL / etc.) — a compromised
255
+ // scorer could try to smuggle CRLF into an outbound X-Spam-Status
256
+ // header.
257
+ for (var c = 0; c < r.length; c += 1) {
258
+ var cc = r.charCodeAt(c);
259
+ if (cc < 0x20 || cc === 0x7f) { // allow:raw-byte-literal — RFC 5234 CTL refusal range
260
+ throw new MailSpamScoreError("mail-spam-score/control-byte",
261
+ "mail.spamScore.score: reasons[" + i + "] contains control byte 0x" +
262
+ cc.toString(16)); // allow:raw-byte-literal — hex radix
263
+ }
264
+ }
265
+ out.push(r);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function _emitAudit(auditImpl, action, outcome, metadata) {
271
+ try {
272
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
273
+ auditImpl.safeEmit({ action: action, outcome: outcome, metadata: metadata });
274
+ }
275
+ } catch (_e) { /* drop-silent — audit failures don't break score path */ }
276
+ }
277
+
278
+ module.exports = {
279
+ create: create,
280
+ compliancePosture: compliancePosture,
281
+ PROFILES: PROFILES,
282
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
283
+ MailSpamScoreError: MailSpamScoreError,
284
+ };
package/lib/mail.js CHANGED
@@ -59,6 +59,7 @@ var C = require("./constants");
59
59
  var bCrypto = require("./crypto");
60
60
  var lazyRequire = require("./lazy-require");
61
61
  var safeBuffer = require("./safe-buffer");
62
+ var guardDomain = require("./guard-domain");
62
63
  var audit = lazyRequire(function () { return require("./audit"); });
63
64
  var httpClient = lazyRequire(function () { return require("./http-client"); });
64
65
  var guardEmail = lazyRequire(function () { return require("./guard-email"); });
@@ -770,6 +771,28 @@ function smtpTransport(opts) {
770
771
  var useImplicitTLS = port === 465 || opts.implicitTls === true;
771
772
  var rejectUnauthorized = opts.rejectUnauthorized !== false;
772
773
  var ehloName = opts.ehloName || "blamejs";
774
+ // GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g (nodemailer CRLF-injection
775
+ // class) — any string concatenated into an outbound SMTP wire command
776
+ // MUST be CRLF/NUL-free, otherwise an attacker who can shape ehloName /
777
+ // user / pass / host (via config injection or template indirection)
778
+ // gets to inject a fresh EHLO / MAIL FROM / RCPT TO line. Refuse at
779
+ // config-time so the operator's boot dies at the misconfiguration line
780
+ // rather than silently emitting a smuggled command at first send.
781
+ function _refuseCtlBytes(label, val) {
782
+ if (val === undefined || val === null) return;
783
+ if (typeof val !== "string") return;
784
+ if (/[\r\n\0]/.test(val)) { // allow:regex-no-length-cap — CRLF/NUL is a 3-codepoint class
785
+ throw new MailError("mail/smtp-misconfigured",
786
+ "smtp transport: opts." + label + " contains CR/LF/NUL bytes " +
787
+ "(SMTP command-injection class — GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g)",
788
+ true);
789
+ }
790
+ }
791
+ _refuseCtlBytes("ehloName", ehloName);
792
+ _refuseCtlBytes("user", opts.user);
793
+ _refuseCtlBytes("pass", opts.pass);
794
+ _refuseCtlBytes("host", opts.host);
795
+ _refuseCtlBytes("servername", opts.servername);
773
796
  var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
774
797
  var tlsOpts = {
775
798
  rejectUnauthorized: rejectUnauthorized,
@@ -1557,6 +1580,7 @@ function create(opts) {
1557
1580
  validateOpts(opts, [
1558
1581
  "transport", "defaults", "audit",
1559
1582
  "commercial", "postalAddress", "footerSeparator", "footerHtml", "regulated",
1583
+ "guardDomain", "profile",
1560
1584
  ], "mail");
1561
1585
  var transport = opts.transport || consoleTransport();
1562
1586
  if (typeof transport === "function") {
@@ -1569,6 +1593,68 @@ function create(opts) {
1569
1593
  var defaults = opts.defaults || {};
1570
1594
  var auditOn = opts.audit !== false;
1571
1595
 
1596
+ // Default-on guardDomain hardening for every outbound recipient + the
1597
+ // sender address. Refuses CVE-2017-5469-class IDN homograph spoofs in
1598
+ // recipient or from domains, RFC 6761 special-use domain names
1599
+ // (`.localhost`, `.test`, `.invalid`, `.example`) in production sends,
1600
+ // RFC 1035 §2.3.4 label-length violations, and CVE-2021-22931-class
1601
+ // bare-IP-as-domain (DNS-rebinding allowlist-bypass class). Operators
1602
+ // sending to address literals (`<x@[1.2.3.4]>`) — rare; mostly mailing-
1603
+ // list internals — pass `guardDomain: false` to opt out, or pass
1604
+ // `guardDomain: { profile: "permissive" }` to relax the rules.
1605
+ var guardDomainProfileName;
1606
+ if (opts.guardDomain === false) {
1607
+ guardDomainProfileName = null;
1608
+ } else {
1609
+ guardDomainProfileName = opts.guardDomain && typeof opts.guardDomain === "object"
1610
+ ? (opts.guardDomain.profile || opts.profile || "strict")
1611
+ : (opts.profile || "strict");
1612
+ }
1613
+ function _validateAddrDomain(addr, label) {
1614
+ if (!guardDomainProfileName) return;
1615
+ if (typeof addr !== "string") return;
1616
+ // RFC 5322 §3.4 angle-bracket address (`name <local@dom>`) — extract
1617
+ // the inner address via indexOf/lastIndexOf rather than a regex so
1618
+ // we stay linear on input shape (CodeQL js/polynomial-redos class).
1619
+ var ltIdx = addr.indexOf("<");
1620
+ var gtIdx = addr.lastIndexOf(">");
1621
+ var rawAddr = (ltIdx !== -1 && gtIdx > ltIdx)
1622
+ ? addr.slice(ltIdx + 1, gtIdx)
1623
+ : addr;
1624
+ var atIdx = rawAddr.lastIndexOf("@");
1625
+ if (atIdx === -1) return;
1626
+ var domain = rawAddr.slice(atIdx + 1).trim();
1627
+ // RFC 5321 §4.1.3 address-literal form `[1.2.3.4]` / `[IPv6:...]`
1628
+ // — already a syntactic constraint via the brackets; b.guardDomain
1629
+ // refuses bare IPs without brackets which is the security-relevant
1630
+ // shape (CVE-2021-22931 DNS rebinding allowlist-bypass).
1631
+ if (domain.length === 0 || domain[0] === "[") return;
1632
+ // RFC 5891 ToASCII — convert any IDN labels to Punycode BEFORE
1633
+ // guardDomain validation so EAI (RFC 6531) addresses like
1634
+ // `<x@münchen.example>` pass under strict (which refuses raw
1635
+ // Unicode labels per RFC 5891 §4.2 transport-safety rule). The
1636
+ // SMTPUTF8 wire encoding is the transport's concern; the gate
1637
+ // here runs on a transport-safe form.
1638
+ var asciiDomain = toAscii(domain) || domain;
1639
+ // Override punycodePolicy — `xn--…` labels are RFC 5891-encoded
1640
+ // IDNs and the whole point of EAI (RFC 6531) is to deliver to
1641
+ // them. The strict profile defaults to refusing Punycode (the
1642
+ // generic "operator typed a homograph" defense); for mail.send
1643
+ // we've already gone through RFC 5891 ToASCII, so the Punycode
1644
+ // is structural, not a homograph attempt. All other strict
1645
+ // defenses (mixed-script, BIDI, control, IP-literal, special-
1646
+ // use, wildcard, DGA, raw-unicode pre-conversion) remain.
1647
+ var verdict = guardDomain.validate(asciiDomain, {
1648
+ profile: guardDomainProfileName,
1649
+ punycodePolicy: "allow",
1650
+ });
1651
+ if (!verdict.ok) {
1652
+ throw new MailError("mail/recipient-domain-refused",
1653
+ "mail.send: " + label + " domain '" + domain + "' refused by b.guardDomain (" +
1654
+ (verdict.issues && verdict.issues[0] && verdict.issues[0].kind) + ")", true);
1655
+ }
1656
+ }
1657
+
1572
1658
  // CAN-SPAM Act §7704(a)(5) — every commercial-content message MUST
1573
1659
  // include the sender's valid physical postal address. Validate the
1574
1660
  // address shape at create() so a typo / blank field surfaces at boot,
@@ -1698,6 +1784,19 @@ function create(opts) {
1698
1784
 
1699
1785
  _validateMessage(merged);
1700
1786
 
1787
+ // Default-on guardDomain hardening on every recipient + the sender
1788
+ // address (see closure setup above). Skipped when operator opts out
1789
+ // via guardDomain:false.
1790
+ if (guardDomainProfileName) {
1791
+ _validateAddrDomain(merged.from, "from");
1792
+ var _toArr = _normalizeRecipientList(merged.to, "to");
1793
+ var _ccArr = _normalizeRecipientList(merged.cc, "cc");
1794
+ var _bccArr = _normalizeRecipientList(merged.bcc, "bcc");
1795
+ for (var _ti = 0; _ti < _toArr.length; _ti += 1) _validateAddrDomain(_toArr[_ti], "to");
1796
+ for (var _ci = 0; _ci < _ccArr.length; _ci += 1) _validateAddrDomain(_ccArr[_ci], "cc");
1797
+ for (var _bi = 0; _bi < _bccArr.length; _bi += 1) _validateAddrDomain(_bccArr[_bi], "bcc");
1798
+ }
1799
+
1701
1800
  var t0 = Date.now();
1702
1801
  try {
1703
1802
  var result = await transport.send(merged);
package/lib/metrics.js CHANGED
@@ -142,12 +142,70 @@ function _normalizeLabelArg(callLabels, value, defaultValue) {
142
142
  };
143
143
  }
144
144
 
145
+ // CRYPTO-18 — credential-shape detector. Operators routinely tap their
146
+ // own observability with `{ token: req.headers.authorization }` or
147
+ // `{ apiKey: req.headers["x-api-key"] }`, which then leak through the
148
+ // /metrics scrape surface to any reader of the metrics endpoint. The
149
+ // detector refuses (replaces with `[REDACTED-CREDENTIAL]`) any value
150
+ // matching well-known credential shapes:
151
+ //
152
+ // - "Bearer <token>" / "Basic <base64>" / "Negotiate <token>" — RFC
153
+ // 6750 / 7617 / 4559 wire forms
154
+ // - "Token <opaque>" — common GitLab / Trello convention
155
+ // - "sk-" / "pk-" / "rk-" prefixes — Stripe, OpenAI, modern issuers
156
+ // - "ghp_" / "ghs_" / "github_pat_" — GitHub
157
+ // - JWT shape: header.payload.signature (each segment base64url with
158
+ // length >= 8)
159
+ // - High-entropy long strings (>= 40 chars, hex / base64-shape) are
160
+ // a heuristic fallback so unknown-issuer tokens still get caught
161
+ var _CRED_PREFIX_RE = /^(?:Bearer|Basic|Negotiate|Token|Digest)\s+\S/i;
162
+ var _CRED_ISSUER_RE = /^(?:sk-|pk-|rk-|ghp_|ghs_|gho_|github_pat_|xoxb-|xoxa-|xoxp-|xoxr-|xapp-)/;
163
+ var _CRED_JWT_RE = /^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/; // allow:raw-byte-literal — JWT segment min length
164
+ var _CRED_ENTROPY_RE = /^[A-Za-z0-9_+/=-]{40,}$/; // allow:raw-byte-literal — high-entropy length floor
165
+
166
+ // CRED_MAX_SCAN — upper bound on the byte slice the credential
167
+ // detector inspects. Operator-supplied label values longer than this
168
+ // are still REDACTED (a 4 KiB token that opens with a Bearer prefix is
169
+ // still a credential), but the regex tests run on the prefix slice so
170
+ // a 1 GB string can't ReDoS the scanner. Counter cardinality stays
171
+ // stable: the same long string always maps to the same prefix slice.
172
+ var CRED_MAX_SCAN = 256; // allow:raw-byte-literal — prefix-scan length cap
173
+
174
+ function _looksLikeCredential(str) {
175
+ if (typeof str !== "string") return false;
176
+ if (str.length < 8) return false; // allow:raw-byte-literal — minimum credential length floor
177
+ // Clamp to the prefix slice so a hostile label value can't push the
178
+ // regex into superlinear time. All four credential shapes have
179
+ // signature in the first ~256 bytes; Stripe / GitHub / OpenAI tokens
180
+ // are <64 bytes, JWTs are typically <2 KiB but the header + first
181
+ // payload segment fit in the prefix.
182
+ var clamped = str.length > CRED_MAX_SCAN ? str.slice(0, CRED_MAX_SCAN) : str;
183
+ // CRED_MIN_LEN — credential shapes shorter than 8 chars don't carry
184
+ // enough entropy to be real tokens; hoisted to a named constant so
185
+ // every test() has its length floor visible at the call site
186
+ // (testFormatValidatorLengthCap convention).
187
+ var CRED_MIN_LEN = 8; // allow:raw-byte-literal — minimum credential length floor
188
+ if (clamped.length >= CRED_MIN_LEN && _CRED_PREFIX_RE.test(clamped)) return true;
189
+ if (clamped.length >= CRED_MIN_LEN && _CRED_ISSUER_RE.test(clamped)) return true;
190
+ if (clamped.length >= CRED_MIN_LEN && _CRED_JWT_RE.test(clamped)) return true;
191
+ if (clamped.length >= CRED_MIN_LEN && _CRED_ENTROPY_RE.test(clamped)) return true;
192
+ return false;
193
+ }
194
+
145
195
  function _validateLabelValue(value) {
146
196
  // Prometheus exposition: label values are quoted strings; backslash,
147
197
  // newline, double-quote get escaped at serialize time. Coerce here so
148
198
  // counters indexed by various input types still work.
149
199
  if (value === null || value === undefined) return "";
150
- return String(value);
200
+ var coerced = String(value);
201
+ // CRYPTO-18 — credential-shape detector. Operators who tap their
202
+ // observability with raw header values leak bearer tokens / API
203
+ // keys through /metrics to every scrape reader. Refuse the value
204
+ // and surface a redaction marker so the metric still labels (so
205
+ // counter cardinality doesn't collapse to a single empty-string
206
+ // bucket) but the bytes themselves never reach the scrape stream.
207
+ if (_looksLikeCredential(coerced)) return "[REDACTED-CREDENTIAL]";
208
+ return coerced;
151
209
  }
152
210
 
153
211
  // Serialize a labels object to a canonical Map key. Routed through
@@ -725,6 +783,7 @@ function _resetForTest() {
725
783
  * path: string, // absolute path to write the snapshot
726
784
  * intervalMs: number, // milliseconds between flushes (>=100)
727
785
  * fields: Function, // returns an object — written as JSON
786
+ * fileMode: number, // POSIX mode (default 0o640 — owner rw, group r)
728
787
  *
729
788
  * @example
730
789
  * var stop = b.metrics.snapshot.startWriter({
@@ -757,6 +816,17 @@ function snapshotStartWriter(opts) {
757
816
  var p = opts.path;
758
817
  var fieldsFn = opts.fields;
759
818
  var intervalMs = opts.intervalMs;
819
+ // CRYPTO-6 — file mode for the atomic write. Default 0o640
820
+ // (owner rw, group r, world none). Operators with a sidecar
821
+ // reader in a different group override to 0o644; multi-tenant
822
+ // hosts may even tighten to 0o600.
823
+ var fileMode = opts.fileMode !== undefined ? opts.fileMode : 0o640; // allow:raw-byte-literal — POSIX file mode octal
824
+ if (typeof fileMode !== "number" || !isFinite(fileMode) ||
825
+ fileMode < 0 || fileMode > 0o777 || Math.floor(fileMode) !== fileMode) {
826
+ throw new MetricsError("metrics-snapshot/bad-file-mode",
827
+ "metrics.snapshot.startWriter: opts.fileMode must be a POSIX file-mode integer in [0, 0o777], got " +
828
+ fileMode);
829
+ }
760
830
 
761
831
  var doFlush = function () {
762
832
  var snap;
@@ -775,7 +845,12 @@ function snapshotStartWriter(opts) {
775
845
  fields: snap,
776
846
  };
777
847
  try {
778
- atomicFile.writeSync(p, JSON.stringify(payload) + "\n", { fileMode: 0o644 });
848
+ // CRYPTO-6 — default 0o640 (owner rw, group r, world none) so
849
+ // operator-supplied snapshot fields aren't world-readable on a
850
+ // multi-tenant host. Operators with a sidecar reader running as
851
+ // a different group override via opts.fileMode at startWriter
852
+ // construction.
853
+ atomicFile.writeSync(p, JSON.stringify(payload) + "\n", { fileMode: fileMode });
779
854
  } catch (e) {
780
855
  log("snapshot.writeSync failed: " + (e && e.message ? e.message : String(e)));
781
856
  }
@@ -829,7 +904,9 @@ function snapshotRead(p) {
829
904
  // is well above the framework's expected snapshot size (~5-50 KiB)
830
905
  // and the safeJson absolute cap stays within reach.
831
906
  try {
832
- parsed = safeJson.parse(raw, { maxBytes: 4 * 1024 * 1024 }); // allow:raw-byte-literal — 4 MiB snapshot-file ceiling
907
+ // CRYPTO-21 route through C.BYTES.mib(4); the raw byte literal
908
+ // was a drift smell flagged by codebase-patterns.
909
+ parsed = safeJson.parse(raw, { maxBytes: C.BYTES.mib(4) });
833
910
  } catch (e) {
834
911
  throw new MetricsError("metrics-snapshot/bad-json",
835
912
  "metrics.snapshot.read: " + p + " contains invalid JSON: " + (e && e.message ? e.message : String(e)));
@@ -75,6 +75,7 @@ function _nonceManager(rotateSec) {
75
75
  var rotateMs = C.TIME.seconds(rotateSec);
76
76
  var current = null;
77
77
  var previous = null;
78
+ var shutdown = false;
78
79
  function _fresh() {
79
80
  return {
80
81
  nonce: bCrypto.generateBytes(DPOP_NONCE_BYTES).toString("base64url"),
@@ -93,11 +94,43 @@ function _nonceManager(rotateSec) {
93
94
  }
94
95
  }
95
96
  return {
96
- issue: function () { _maybeRotate(); return current.nonce; },
97
+ issue: function () {
98
+ if (shutdown) return null;
99
+ _maybeRotate();
100
+ return current.nonce;
101
+ },
97
102
  accepts: function (n) {
103
+ if (shutdown) return false;
98
104
  _maybeRotate();
99
105
  if (typeof n !== "string" || n.length === 0) return false;
100
- return (current && n === current.nonce) || (previous && n === previous.nonce);
106
+ // Constant-time compare so server-issued nonce probing can't
107
+ // narrow the rolling-pair bytes via response-timing — matches
108
+ // the timingSafeEqual discipline on the DPoP-proof nonce.
109
+ if (current && bCrypto.timingSafeEqual(n, current.nonce)) return true;
110
+ if (previous && bCrypto.timingSafeEqual(n, previous.nonce)) return true;
111
+ return false;
112
+ },
113
+ // AUTH-36 — hot-reload coexistence. Operators redeploying without
114
+ // a clean process restart need a way to drain in-flight clients
115
+ // before swapping the middleware instance. shutdown() returns no
116
+ // fresh nonces and refuses every presented nonce, so the
117
+ // surrounding middleware emits 401 + use_dpop_nonce on the old
118
+ // instance and the new instance owns the trust anchor cleanly.
119
+ shutdown: function () { shutdown = true; current = null; previous = null; },
120
+ // revoke() — rotate both rolling-pair slots, invalidating every
121
+ // outstanding nonce immediately. Useful after a suspected nonce
122
+ // leak. Distinct from shutdown(): the manager keeps serving fresh
123
+ // nonces afterwards.
124
+ revoke: function () {
125
+ previous = null;
126
+ current = _fresh();
127
+ },
128
+ _state: function () {
129
+ return {
130
+ shutdown: shutdown,
131
+ current: current ? current.nonce : null,
132
+ previous: previous ? previous.nonce : null,
133
+ };
101
134
  },
102
135
  };
103
136
  }
@@ -229,7 +262,7 @@ function create(opts) {
229
262
 
230
263
  function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
231
264
 
232
- return async function dpopMiddleware(req, res, next) {
265
+ var middleware = async function dpopMiddleware(req, res, next) {
233
266
  var proofHeader = req.headers && req.headers.dpop;
234
267
  if (typeof proofHeader !== "string" || proofHeader.length === 0) {
235
268
  return _writeUnauthorized(res,
@@ -241,6 +274,18 @@ function create(opts) {
241
274
  return _writeUnauthorized(res, "invalid_dpop_proof",
242
275
  "multiple DPoP headers are not allowed");
243
276
  }
277
+ // AUTH-15 — RFC 9449 §4.1 single-value invariant. node:http
278
+ // collapses repeated headers into a comma-joined string when the
279
+ // client ships `DPoP: proof1, DPoP: proof2`; the Array.isArray
280
+ // check above catches the multi-value array shape but a
281
+ // comma-joined string slips past. Refuse explicitly so a buggy /
282
+ // hostile client can't smuggle two proofs past the verifier (the
283
+ // verify() call below would only see the first one, leaving the
284
+ // second unprocessed).
285
+ if (proofHeader.indexOf(",") !== -1) {
286
+ return _writeUnauthorized(res, "invalid_dpop_proof",
287
+ "multiple DPoP proofs in one header value are not allowed");
288
+ }
244
289
 
245
290
  var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
246
291
  if (!htu) {
@@ -340,6 +385,16 @@ function create(opts) {
340
385
  }
341
386
  return next();
342
387
  };
388
+
389
+ // AUTH-36 — surface the nonce manager's lifecycle hooks on the
390
+ // returned middleware so hot-reload deploys can drain in-flight
391
+ // clients before swapping instances. shutdown() refuses every
392
+ // subsequent proof + issues no fresh nonces; revoke() rotates the
393
+ // rolling pair without disabling the manager (useful after a
394
+ // suspected nonce leak). Both are no-ops when requireNonce is off.
395
+ middleware.shutdown = function () { if (nonceMgr) nonceMgr.shutdown(); };
396
+ middleware.revoke = function () { if (nonceMgr) nonceMgr.revoke(); };
397
+ return middleware;
343
398
  }
344
399
 
345
400
  module.exports = {