@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.
- package/CHANGELOG.md +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)));
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -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 () {
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|