@blamejs/core 0.10.6 → 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/index.js +8 -1
- package/lib/ai-content-detect.js +268 -0
- package/lib/ai-input.js +58 -8
- package/lib/ai-model-manifest.js +363 -0
- package/lib/atomic-file.js +83 -0
- package/lib/audit.js +3 -0
- package/lib/content-credentials.js +140 -0
- package/lib/crypto.js +30 -0
- package/lib/external-db.js +2 -2
- package/lib/guard-dsn.js +8 -5
- package/lib/guard-list-id.js +14 -10
- package/lib/guard-list-unsubscribe.js +67 -1
- package/lib/guard-message-id.js +26 -0
- package/lib/mail-arc-sign.js +21 -1
- package/lib/mail-auth.js +2 -1
- package/lib/mail-dkim.js +50 -9
- package/lib/mail-server-imap.js +104 -10
- package/lib/mail-server-mx.js +94 -14
- package/lib/mail-server-submission.js +135 -1
- package/lib/mail-store.js +6 -1
- package/lib/network-dns-resolver.js +1 -2
- package/lib/network-dns.js +4 -4
- package/lib/promise-pool.js +162 -0
- package/lib/safe-mime.js +51 -4
- package/lib/sd-notify.js +269 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/external-db.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* External-database integration for app data — Postgres / MySQL / SQLite / MongoDB connection pooling, retry, circuit breaker, classification routing, residency enforcement, and audit hooks.
|
|
37
37
|
*/
|
|
38
38
|
var retryHelper = require("./retry");
|
|
39
|
+
var bCrypto = require("./crypto");
|
|
39
40
|
var C = require("./constants");
|
|
40
41
|
var dbRoleContext = require("./db-role-context");
|
|
41
42
|
var externalDbMigrate = require("./external-db-migrate");
|
|
@@ -754,8 +755,7 @@ async function transaction(fn, opts) {
|
|
|
754
755
|
if (isTransient && attempt <= maxRetries) {
|
|
755
756
|
_emitMetric("externaldb.transaction.retry", 1,
|
|
756
757
|
{ backend: b.name, code: txErr.code, attempt: String(attempt) });
|
|
757
|
-
var
|
|
758
|
-
var jitter = nodeCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
|
|
758
|
+
var jitter = bCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
|
|
759
759
|
await safeAsync.sleep(attempt * 5 + jitter); // allow:raw-time-literal — sub-second backoff
|
|
760
760
|
continue;
|
|
761
761
|
}
|
package/lib/guard-dsn.js
CHANGED
|
@@ -279,12 +279,15 @@ function compliancePosture(posture) {
|
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
function _splitBlocks(text) {
|
|
282
|
-
// RFC 3464 §2.1:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
282
|
+
// RFC 3464 §2.1.1: block separator is `CRLF CRLF` only — a "blank
|
|
283
|
+
// line" in message-syntax terms. `\n\s*\n` admits `\v` / `\f` /
|
|
284
|
+
// mixed whitespace which a hostile sender can use to bend the
|
|
285
|
+
// block boundary (folded fields drift between per-message and
|
|
286
|
+
// per-recipient blocks). Normalize CR(LF)? → LF, then split on
|
|
287
|
+
// strict `\n\n` (an LF, an empty line, an LF) — anything else is
|
|
288
|
+
// either intra-block CFWS or an intra-field continuation.
|
|
286
289
|
var normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // allow:regex-no-length-cap — input length already capped
|
|
287
|
-
return normalized.split(
|
|
290
|
+
return normalized.split("\n\n");
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
function _parseFieldBlock(block, maxHeaderLine) {
|
package/lib/guard-list-id.js
CHANGED
|
@@ -224,22 +224,26 @@ function validate(headerValue, opts) {
|
|
|
224
224
|
}
|
|
225
225
|
// RFC 2919 §2 requires AT LEAST one `.` (label + namespace);
|
|
226
226
|
// strict/balanced ALSO require the namespace to be a FQDN, which
|
|
227
|
-
// means a minimum of 3 labels total (label + ns-label + ns-tld)
|
|
228
|
-
//
|
|
227
|
+
// means a minimum of 3 labels total (label + ns-label + ns-tld) OR
|
|
228
|
+
// a 2-label list-id where the namespace ends in a reserved-local
|
|
229
|
+
// TLD: `localhost` (RFC 6761 §6.3), `local` (RFC 6762 mDNS), or
|
|
230
|
+
// `lan` (IETF draft-chapin-rfc2606bis). All three are non-routable
|
|
231
|
+
// single-network labels and the FQDN floor doesn't apply.
|
|
232
|
+
var lastLabel = parts[parts.length - 1].toLowerCase();
|
|
233
|
+
var isLocalScopeTld = lastLabel === "localhost" || lastLabel === "local" || lastLabel === "lan";
|
|
229
234
|
if (caps.requireFqdn) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return _refuse("list-id has < 3 labels for non-localhost namespace (FQDN required under '" +
|
|
235
|
+
if (parts.length < 3 && !isLocalScopeTld) { // allow:raw-byte-literal — FQDN requires ≥ 3 labels for non-local-scope namespace
|
|
236
|
+
return _refuse("list-id has < 3 labels for non-local-scope namespace (FQDN required under '" +
|
|
233
237
|
(opts.profile || DEFAULT_PROFILE) + "')");
|
|
234
238
|
}
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
// RFC 2919 §3: `localhost
|
|
238
|
-
// randomness in the label
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
+
// RFC 2919 §3: `localhost`-class namespaces SHOULD carry 32-hex
|
|
242
|
+
// randomness in the label so cross-host listserv operators can't
|
|
243
|
+
// collide. Applies to all three reserved-local TLDs.
|
|
244
|
+
if (isLocalScopeTld) {
|
|
241
245
|
if (caps.requireRandomForLocalhost && !RANDOM_HEX_RE.test(listId)) { // allow:regex-no-length-cap — listId length-bounded above
|
|
242
|
-
return _refuse("
|
|
246
|
+
return _refuse("local-scope namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
|
|
243
247
|
}
|
|
244
248
|
}
|
|
245
249
|
|
|
@@ -134,6 +134,59 @@ var DANGEROUS_SCHEMES = Object.freeze({
|
|
|
134
134
|
"blob:": true,
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
// IP-literal + reserved-hostname refusal for HTTPS one-click URIs.
|
|
138
|
+
// One-click receivers POST to the URI without further operator gate;
|
|
139
|
+
// an attacker-supplied List-Unsubscribe URI pointing at `127.0.0.1`
|
|
140
|
+
// / `169.254.169.254` (cloud metadata) / `[::1]` / `localhost` lets
|
|
141
|
+
// the mailbox provider's auto-fetcher target the operator's own
|
|
142
|
+
// infrastructure — classic SSRF. The check is wholly host-name-shape
|
|
143
|
+
// based (no DNS resolution); DNS-rebinding defense is left to the
|
|
144
|
+
// fetcher (which should pin the IP across resolution + request).
|
|
145
|
+
var IPV4_LITERAL_RE = /^\d+\.\d+\.\d+\.\d+$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
|
|
146
|
+
var IPV6_LITERAL_RE = /^\[[0-9A-Fa-f:.]+\]$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
|
|
147
|
+
var RESERVED_LOCAL_HOSTS = Object.freeze({
|
|
148
|
+
"localhost": true,
|
|
149
|
+
"localhost.localdomain": true,
|
|
150
|
+
"ip6-localhost": true,
|
|
151
|
+
"ip6-loopback": true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function _isRefusedAutoFetchHost(hostname, allowedHosts) {
|
|
155
|
+
if (typeof hostname !== "string" || hostname.length === 0) return "missing-host";
|
|
156
|
+
// Normalize the trailing root-zone dot BEFORE comparison — RFC 1034
|
|
157
|
+
// §3.1: `foo.` is the absolute form of `foo` (both resolve to the
|
|
158
|
+
// same target). A naive byte-equality check against `localhost`
|
|
159
|
+
// would let an attacker bypass the gate by appending the dot. Same
|
|
160
|
+
// for any reserved-local suffix family. Multiple trailing dots are
|
|
161
|
+
// not valid DNS but we strip them anyway to keep the gate robust
|
|
162
|
+
// against any URL parser that leaves them in `hostname`.
|
|
163
|
+
var lower = hostname.toLowerCase();
|
|
164
|
+
while (lower.length > 0 && lower.charAt(lower.length - 1) === ".") {
|
|
165
|
+
lower = lower.slice(0, -1);
|
|
166
|
+
}
|
|
167
|
+
if (lower.length === 0) return "missing-host";
|
|
168
|
+
if (IPV4_LITERAL_RE.test(lower) || IPV6_LITERAL_RE.test(lower)) return "ip-literal";
|
|
169
|
+
if (RESERVED_LOCAL_HOSTS[lower]) return "reserved-local-host";
|
|
170
|
+
// Hostname suffix refusal — RFC 6761 reserved / mDNS / single-network.
|
|
171
|
+
if (lower === "local" || lower.endsWith(".local")) return "reserved-local-suffix";
|
|
172
|
+
if (lower === "lan" || lower.endsWith(".lan")) return "reserved-local-suffix";
|
|
173
|
+
if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
|
|
174
|
+
// Optional operator allowlist — when supplied, hostname (or any
|
|
175
|
+
// ancestor domain) MUST be present.
|
|
176
|
+
if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
|
|
177
|
+
var matched = false;
|
|
178
|
+
for (var i = 0; i < allowedHosts.length; i += 1) {
|
|
179
|
+
var allowed = String(allowedHosts[i]).toLowerCase();
|
|
180
|
+
if (lower === allowed || lower.endsWith("." + allowed)) {
|
|
181
|
+
matched = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!matched) return "not-on-allowlist";
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
137
190
|
/**
|
|
138
191
|
* @primitive b.guardListUnsubscribe.validate
|
|
139
192
|
* @signature b.guardListUnsubscribe.validate(headers, opts?)
|
|
@@ -225,12 +278,25 @@ function validate(headers, opts) {
|
|
|
225
278
|
{ uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
|
|
226
279
|
}
|
|
227
280
|
if (scheme === "https:") {
|
|
281
|
+
var parsed;
|
|
228
282
|
try {
|
|
229
|
-
safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
|
|
283
|
+
parsed = safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
|
|
230
284
|
} catch (e) {
|
|
231
285
|
return _verdict("refuse", "HTTPS URI '" + _trunc(u) + "' failed safeUrl parse: " + (e && e.message || String(e)),
|
|
232
286
|
{ uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
|
|
233
287
|
}
|
|
288
|
+
// SSRF defense — refuse IP-literal hosts, loopback names,
|
|
289
|
+
// reserved-local TLDs, and (when operator supplied
|
|
290
|
+
// `allowedHosts`) anything outside the allowlist. The mailbox
|
|
291
|
+
// provider's auto-fetcher POSTs without our involvement; the
|
|
292
|
+
// header is the only place this can be stopped.
|
|
293
|
+
var refusedHostReason = _isRefusedAutoFetchHost(parsed.hostname, opts.allowedHosts);
|
|
294
|
+
if (refusedHostReason) {
|
|
295
|
+
return _verdict("refuse",
|
|
296
|
+
"HTTPS URI '" + _trunc(u) + "' host '" + parsed.hostname +
|
|
297
|
+
"' refused (" + refusedHostReason + "; auto-fetch SSRF defense)",
|
|
298
|
+
{ uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
|
|
299
|
+
}
|
|
234
300
|
hasHttpsUri = true;
|
|
235
301
|
} else if (scheme === "mailto:") {
|
|
236
302
|
hasMailtoUri = true;
|
package/lib/guard-message-id.js
CHANGED
|
@@ -74,6 +74,13 @@ var COMPLIANCE_POSTURES = Object.freeze({
|
|
|
74
74
|
// CVE-2021-42574 RTLO class in mail header context).
|
|
75
75
|
var BIDI_RE = /[--]/;
|
|
76
76
|
|
|
77
|
+
// RFC 5322 §3.2.3 dot-atom-text — used at strict profile to validate
|
|
78
|
+
// the id-left and id-right shape inside the bracketed Message-Id.
|
|
79
|
+
// `atext` = ALPHA / DIGIT / "!#$%&'*+-/=?^_`{|}~"; `dot-atom-text` is
|
|
80
|
+
// 1*atext *("." 1*atext). Length-bounded by the maxBytes cap above so
|
|
81
|
+
// the regex CPU is amortised; pattern is single-pass linear.
|
|
82
|
+
var DOT_ATOM_TEXT_RE = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+(?:\.[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
|
|
83
|
+
|
|
77
84
|
/**
|
|
78
85
|
* @primitive b.guardMessageId.validate
|
|
79
86
|
* @signature b.guardMessageId.validate(value, opts?)
|
|
@@ -154,6 +161,25 @@ function validate(value, opts) {
|
|
|
154
161
|
throw new GuardMessageIdError("message-id/nested-brackets",
|
|
155
162
|
"guardMessageId.validate: nested angle brackets refused");
|
|
156
163
|
}
|
|
164
|
+
// RFC 5322 §3.6.4: id-left and id-right MUST conform to
|
|
165
|
+
// dot-atom-text shape (§3.2.3). A second `@` inside id-left or
|
|
166
|
+
// id-right falls out of dot-atom-text and is refused here. The
|
|
167
|
+
// last `@` is the local/domain separator — `lastIndexOf` rather
|
|
168
|
+
// than `indexOf` handles `a@b@c` correctly: id-left would be
|
|
169
|
+
// `a@b` which fails dot-atom-text on the `@` character.
|
|
170
|
+
var atLast = inner.lastIndexOf("@");
|
|
171
|
+
var idLeft = inner.slice(0, atLast);
|
|
172
|
+
var idRight = inner.slice(atLast + 1);
|
|
173
|
+
if (!DOT_ATOM_TEXT_RE.test(idLeft)) { // allow:regex-no-length-cap — idLeft length-bounded by maxBytes above
|
|
174
|
+
throw new GuardMessageIdError("message-id/id-left-shape",
|
|
175
|
+
"guardMessageId.validate: id-left '" + idLeft +
|
|
176
|
+
"' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
|
|
177
|
+
}
|
|
178
|
+
if (!DOT_ATOM_TEXT_RE.test(idRight)) { // allow:regex-no-length-cap — idRight length-bounded by maxBytes above
|
|
179
|
+
throw new GuardMessageIdError("message-id/id-right-shape",
|
|
180
|
+
"guardMessageId.validate: id-right '" + idRight +
|
|
181
|
+
"' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
|
|
182
|
+
}
|
|
157
183
|
}
|
|
158
184
|
|
|
159
185
|
return value;
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -128,10 +128,19 @@ function _bodyHashB64(body, algorithm) {
|
|
|
128
128
|
return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
|
|
132
|
+
// caps in `mail-auth.js` (`ARC_MAX_HOPS`); the signer's prior-hop
|
|
133
|
+
// extractor needs the same ceiling so a message arriving with a
|
|
134
|
+
// hostile chain (51+ instances) doesn't expand the per-hop walk to
|
|
135
|
+
// unbounded work before the signer's own validation catches up.
|
|
136
|
+
var ARC_MAX_HOPS_FOR_EXTRACT = 50; // allow:raw-byte-literal — RFC 8617 §5 chain bound
|
|
137
|
+
|
|
131
138
|
function _arcExtractPriorHops(parsedHeaders) {
|
|
132
139
|
// Walk parsedHeaders; for each ARC-Authentication-Results /
|
|
133
140
|
// ARC-Message-Signature / ARC-Seal entry, extract instance via i=N
|
|
134
|
-
// and group by hop.
|
|
141
|
+
// and group by hop. The `i=` value is bounded against the RFC's
|
|
142
|
+
// 50-hop ceiling before being used as a map key, so an attacker-
|
|
143
|
+
// chosen `i=999999` can't allocate a sparse map.
|
|
135
144
|
var hopMap = {};
|
|
136
145
|
for (var i = 0; i < parsedHeaders.length; i += 1) {
|
|
137
146
|
var h = parsedHeaders[i];
|
|
@@ -142,11 +151,22 @@ function _arcExtractPriorHops(parsedHeaders) {
|
|
|
142
151
|
var iMatch = h.value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — ARC header bounded by RFC 5322 §2.1.1
|
|
143
152
|
if (!iMatch) continue;
|
|
144
153
|
var instance = parseInt(iMatch[1], 10);
|
|
154
|
+
if (!isFinite(instance) || instance < 1 || instance > ARC_MAX_HOPS_FOR_EXTRACT) {
|
|
155
|
+
// Out-of-spec instance number — refuse to consider it. Upstream
|
|
156
|
+
// `sign` will see `priorHops.length !== opts.instance - 1` and
|
|
157
|
+
// refuse the message.
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
145
160
|
if (!hopMap[instance]) hopMap[instance] = { instance: instance };
|
|
146
161
|
hopMap[instance][lcName] = h.value;
|
|
147
162
|
}
|
|
148
163
|
var hops = [];
|
|
149
164
|
var keys = Object.keys(hopMap).sort(function (a, b) { return Number(a) - Number(b); });
|
|
165
|
+
if (keys.length > ARC_MAX_HOPS_FOR_EXTRACT) {
|
|
166
|
+
throw new MailAuthError("arc-sign/chain-too-long",
|
|
167
|
+
"_arcExtractPriorHops: chain has " + keys.length +
|
|
168
|
+
" hops, exceeds RFC 8617 §5 ceiling of " + ARC_MAX_HOPS_FOR_EXTRACT);
|
|
169
|
+
}
|
|
150
170
|
for (var k = 0; k < keys.length; k += 1) hops.push(hopMap[keys[k]]);
|
|
151
171
|
return hops;
|
|
152
172
|
}
|
package/lib/mail-auth.js
CHANGED
|
@@ -37,6 +37,7 @@ var net = require("node:net");
|
|
|
37
37
|
var nodeCrypto = require("node:crypto");
|
|
38
38
|
var lazyRequire = require("./lazy-require");
|
|
39
39
|
var validateOpts = require("./validate-opts");
|
|
40
|
+
var bCrypto = require("./crypto");
|
|
40
41
|
var C = require("./constants");
|
|
41
42
|
var dkim = require("./mail-dkim");
|
|
42
43
|
var safeXml = require("./parsers/safe-xml");
|
|
@@ -722,7 +723,7 @@ async function dmarcEvaluate(opts) {
|
|
|
722
723
|
var u32 = (hash[0] << 24 >>> 0) + (hash[1] << 16) + (hash[2] << 8) + hash[3]; // allow:raw-byte-literal — uint32 bit assembly
|
|
723
724
|
sampleRoll = u32 % 100; // allow:raw-byte-literal — pct sample roll
|
|
724
725
|
} else {
|
|
725
|
-
sampleRoll =
|
|
726
|
+
sampleRoll = bCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
|
|
726
727
|
}
|
|
727
728
|
var sampled = !pass && pct < 100 && sampleRoll >= pct;
|
|
728
729
|
var recommendedAction = pass ? "deliver" :
|
package/lib/mail-dkim.js
CHANGED
|
@@ -525,6 +525,11 @@ var DKIM_KEY_CACHE_MAX_ENTRIES = 1024;
|
|
|
525
525
|
// receivers cap at 5–8. Operators that legitimately accept more
|
|
526
526
|
// override via verify({ maxSignatures }).
|
|
527
527
|
var DKIM_MAX_SIGNATURES_PER_MESSAGE = 8; // allow:raw-byte-literal — receiver-fan-out DoS bound
|
|
528
|
+
// Operator-supplied `maxSignatures` opt is range-checked against this
|
|
529
|
+
// ceiling. RFC 6376 §6.1 sets no upper bound; 16 is generous headroom
|
|
530
|
+
// for legitimate relay chains with hop signatures while keeping the
|
|
531
|
+
// verify-fan-out within a CPU-DoS envelope.
|
|
532
|
+
var DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING = 16; // allow:raw-byte-literal — operator-opt range ceiling
|
|
528
533
|
|
|
529
534
|
function _cacheGet(qname) {
|
|
530
535
|
var ent = DKIM_KEY_CACHE.get(qname);
|
|
@@ -836,6 +841,7 @@ async function verify(rfc822, opts) {
|
|
|
836
841
|
opts = opts || {};
|
|
837
842
|
validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
|
|
838
843
|
"minRsaBits"], "mail.dkim.verify");
|
|
844
|
+
var auditOn = opts.audit !== false;
|
|
839
845
|
|
|
840
846
|
// Bounded clock skew: refuse non-numeric / negative / infinite /
|
|
841
847
|
// beyond-ceiling. Throwing on bad config-time input per the
|
|
@@ -855,10 +861,27 @@ async function verify(rfc822, opts) {
|
|
|
855
861
|
clockSkewMs = Math.floor(opts.clockSkewMs);
|
|
856
862
|
}
|
|
857
863
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
864
|
+
// RFC 6376 §6.1 — verifier MUST handle multiple signatures but the
|
|
865
|
+
// RFC sets no count cap. An unbounded count is a CPU-DoS surface
|
|
866
|
+
// (each sig forces a DNS fetch + cryptographic verify). Range 1-16
|
|
867
|
+
// — mainstream receivers (Gmail/Yahoo/MS 2024 bulk-sender guidance)
|
|
868
|
+
// cite 2-3 valid signatures per message as the operational ceiling;
|
|
869
|
+
// 16 is generous headroom for relay chains with hop signatures. The
|
|
870
|
+
// operator opt is range-checked at config time — values < 1 or > 16
|
|
871
|
+
// throw rather than silently clamp so an over-large config doesn't
|
|
872
|
+
// re-introduce the DoS surface.
|
|
873
|
+
var maxSignatures = DKIM_MAX_SIGNATURES_PER_MESSAGE;
|
|
874
|
+
if (opts.maxSignatures !== undefined) {
|
|
875
|
+
if (typeof opts.maxSignatures !== "number" ||
|
|
876
|
+
!isFinite(opts.maxSignatures) ||
|
|
877
|
+
opts.maxSignatures < 1 ||
|
|
878
|
+
opts.maxSignatures > DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING) {
|
|
879
|
+
throw new DkimError("dkim/bad-max-signatures",
|
|
880
|
+
"verify: maxSignatures must be an integer in [1, " +
|
|
881
|
+
DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING + "] (got " + opts.maxSignatures + ")");
|
|
882
|
+
}
|
|
883
|
+
maxSignatures = Math.floor(opts.maxSignatures);
|
|
884
|
+
}
|
|
862
885
|
var verifyOpts = { minRsaBits: opts.minRsaBits };
|
|
863
886
|
|
|
864
887
|
var split = _splitHeadersBody(rfc822);
|
|
@@ -867,12 +890,30 @@ async function verify(rfc822, opts) {
|
|
|
867
890
|
if (sigHeaders.length === 0) {
|
|
868
891
|
return [{ result: "none", errors: ["no DKIM-Signature headers"] }];
|
|
869
892
|
}
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
893
|
+
// When the message carries more signatures than the cap allows,
|
|
894
|
+
// surface a `policy` verdict before any cryptographic work runs.
|
|
895
|
+
// The prior `slice(0, maxSignatures)` shape silently truncated; an
|
|
896
|
+
// operator-visible refusal lets postmasters see DoS attempts in
|
|
897
|
+
// their authentication-results stream.
|
|
874
898
|
if (sigHeaders.length > maxSignatures) {
|
|
875
|
-
|
|
899
|
+
if (auditOn) {
|
|
900
|
+
try {
|
|
901
|
+
audit().safeEmit({
|
|
902
|
+
action: "dkim.verify.signature_count_cap",
|
|
903
|
+
outcome: "denied",
|
|
904
|
+
actor: null,
|
|
905
|
+
metadata: {
|
|
906
|
+
sigCount: sigHeaders.length,
|
|
907
|
+
maxSignatures: maxSignatures,
|
|
908
|
+
severity: "warning",
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
} catch (_e) { /* drop-silent */ }
|
|
912
|
+
}
|
|
913
|
+
return [{ result: "policy",
|
|
914
|
+
errors: ["DKIM-Signature count " + sigHeaders.length +
|
|
915
|
+
" exceeds maxSignatures=" + maxSignatures +
|
|
916
|
+
" (RFC 6376 §6.1; verifier DoS cap)"] }];
|
|
876
917
|
}
|
|
877
918
|
|
|
878
919
|
var results = [];
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -144,6 +144,51 @@ var pkgVersion = require("../package.json").version;
|
|
|
144
144
|
var ERR_CLAMP = 200; // allow:raw-byte-literal — protocol-reply error-message clamp
|
|
145
145
|
var LINE_PREVIEW = 80; // allow:raw-byte-literal — audit-line preview clamp
|
|
146
146
|
|
|
147
|
+
// RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
|
|
148
|
+
// Format: `DD-Mon-YYYY HH:MM:SS ±HHMM` where Mon is the 3-letter
|
|
149
|
+
// English month abbreviation (case-insensitive on parse, but the IMAP
|
|
150
|
+
// spec emits canonical mixed-case `Jan`/`Feb`/...). Returns the
|
|
151
|
+
// millisecond epoch, or null on any parse failure — the caller emits
|
|
152
|
+
// `BAD` rather than silently using `Date.now()`.
|
|
153
|
+
var IMAP_MONTHS = Object.freeze({
|
|
154
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, // allow:raw-byte-literal — month-index table (0-5)
|
|
155
|
+
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, // allow:raw-byte-literal — month-index table (6-11)
|
|
156
|
+
});
|
|
157
|
+
var IMAP_DT_RE = /^\s*(\d{1,2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+([+-])(\d{2})(\d{2})\s*$/;
|
|
158
|
+
function _parseImapDateTime(s) {
|
|
159
|
+
if (typeof s !== "string") return null;
|
|
160
|
+
var m = s.match(IMAP_DT_RE); // allow:regex-no-length-cap — input bounded by IMAP literal cap
|
|
161
|
+
if (!m) return null;
|
|
162
|
+
var day = parseInt(m[1], 10);
|
|
163
|
+
var month = IMAP_MONTHS[m[2].toLowerCase()];
|
|
164
|
+
if (month === undefined) return null;
|
|
165
|
+
var year = parseInt(m[3], 10);
|
|
166
|
+
var hour = parseInt(m[4], 10);
|
|
167
|
+
var min = parseInt(m[5], 10);
|
|
168
|
+
var sec = parseInt(m[6], 10);
|
|
169
|
+
var sign = m[7] === "-" ? -1 : 1;
|
|
170
|
+
var tzH = parseInt(m[8], 10);
|
|
171
|
+
var tzM = parseInt(m[9], 10);
|
|
172
|
+
if (day < 1 || day > 31 || hour > 23 || min > 59 || sec > 59 || tzH > 23 || tzM > 59) return null;
|
|
173
|
+
var utcMs = Date.UTC(year, month, day, hour, min, sec);
|
|
174
|
+
if (!isFinite(utcMs)) return null;
|
|
175
|
+
// RFC 5322 §3.3 — date-time MUST be a real calendar date. `Date.UTC`
|
|
176
|
+
// silently normalises impossible inputs (`Feb 31 2026` → `Mar 3 2026`);
|
|
177
|
+
// round-trip via the calendar fields and refuse any drift so a
|
|
178
|
+
// hostile client can't smuggle a different internalDate than the
|
|
179
|
+
// wire suggests.
|
|
180
|
+
var probe = new Date(utcMs);
|
|
181
|
+
if (probe.getUTCFullYear() !== year ||
|
|
182
|
+
probe.getUTCMonth() !== month ||
|
|
183
|
+
probe.getUTCDate() !== day ||
|
|
184
|
+
probe.getUTCHours() !== hour ||
|
|
185
|
+
probe.getUTCMinutes() !== min ||
|
|
186
|
+
probe.getUTCSeconds() !== sec) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return utcMs - sign * (tzH * C.TIME.hours(1) + tzM * C.TIME.minutes(1));
|
|
190
|
+
}
|
|
191
|
+
|
|
147
192
|
// Mailbox name validator. RFC 9051 §5.1 — UTF-8 hierarchy. Refuse
|
|
148
193
|
// path-traversal (`..`), NUL, C0 controls, leading/trailing slash,
|
|
149
194
|
// oversize.
|
|
@@ -707,15 +752,39 @@ function create(opts) {
|
|
|
707
752
|
|
|
708
753
|
function _parseLoginArgs(args) {
|
|
709
754
|
if (typeof args !== "string") return null;
|
|
710
|
-
// Quoted or atom —
|
|
755
|
+
// Quoted or atom — RFC 9051 §5.1 quoted ABNF. Inside a quoted
|
|
756
|
+
// string `\"` and `\\` are escape sequences for `"` and `\`
|
|
757
|
+
// respectively; any other `\<chr>` is invalid. The earlier shape
|
|
758
|
+
// terminated the quoted string at the first `"`, so a hostile
|
|
759
|
+
// client passing `LOGIN "alice\"@example.com" "pw"` would have
|
|
760
|
+
// its username truncated at `alice` and the rest of the line
|
|
761
|
+
// reparsed as the password / literal — wrong identity bound to
|
|
762
|
+
// the AUTH state.
|
|
711
763
|
var rest = args.trim();
|
|
712
764
|
function _take() {
|
|
713
765
|
if (rest[0] === "\"") {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
766
|
+
// Walk the quoted-string body, accumulating into `out` while
|
|
767
|
+
// honoring the `\"` / `\\` escape pairs. A bare `\` followed
|
|
768
|
+
// by any other character is refused (parse fails → null).
|
|
769
|
+
var out = "";
|
|
770
|
+
var i = 1;
|
|
771
|
+
while (i < rest.length) {
|
|
772
|
+
var ch = rest.charAt(i);
|
|
773
|
+
if (ch === "\\") {
|
|
774
|
+
var esc = rest.charAt(i + 1);
|
|
775
|
+
if (esc !== "\"" && esc !== "\\") return null;
|
|
776
|
+
out += esc;
|
|
777
|
+
i += 2;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (ch === "\"") {
|
|
781
|
+
rest = rest.slice(i + 1).trim();
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
out += ch;
|
|
785
|
+
i += 1;
|
|
786
|
+
}
|
|
787
|
+
return null; // unterminated quoted string
|
|
719
788
|
}
|
|
720
789
|
var sp = rest.indexOf(" ");
|
|
721
790
|
var v2 = sp === -1 ? rest : rest.slice(0, sp);
|
|
@@ -857,6 +926,22 @@ function create(opts) {
|
|
|
857
926
|
}
|
|
858
927
|
var name = _unquote(match[1]);
|
|
859
928
|
var flags = match[2] ? match[2].split(/\s+/).filter(Boolean) : [];
|
|
929
|
+
// RFC 9051 §6.3.12 — optional date-time argument sets INTERNALDATE
|
|
930
|
+
// on the appended message. Earlier shape captured the token but
|
|
931
|
+
// never threaded it; backends now receive it as `internalDate`
|
|
932
|
+
// (ms-since-epoch) and the mail-store applies it instead of the
|
|
933
|
+
// append-time clock. Refused as syntax error when the date-time
|
|
934
|
+
// can't be parsed (rather than silently using the clock).
|
|
935
|
+
var dateTimeArg = match[3] ? _unquote(match[3]) : null;
|
|
936
|
+
var internalDate = null;
|
|
937
|
+
if (dateTimeArg) {
|
|
938
|
+
internalDate = _parseImapDateTime(dateTimeArg);
|
|
939
|
+
if (internalDate === null) {
|
|
940
|
+
_writeTagged(socket, tag, "BAD APPEND date-time '" + dateTimeArg +
|
|
941
|
+
"' not in RFC 9051 §6.3.12 / RFC 5322 §3.3 date-time grammar");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
860
945
|
if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
|
|
861
946
|
_writeTagged(socket, tag, "BAD Mailbox name refused");
|
|
862
947
|
return;
|
|
@@ -891,10 +976,12 @@ function create(opts) {
|
|
|
891
976
|
err.limit = q.capBytes;
|
|
892
977
|
throw err;
|
|
893
978
|
}
|
|
894
|
-
return mailStore.appendMessage(name, literalBody, {
|
|
979
|
+
return mailStore.appendMessage(name, literalBody, {
|
|
980
|
+
actor: state.actor, flags: flags, internalDate: internalDate });
|
|
895
981
|
});
|
|
896
982
|
}
|
|
897
|
-
return mailStore.appendMessage(name, literalBody, {
|
|
983
|
+
return mailStore.appendMessage(name, literalBody, {
|
|
984
|
+
actor: state.actor, flags: flags, internalDate: internalDate });
|
|
898
985
|
})
|
|
899
986
|
.then(function (info) {
|
|
900
987
|
_emit("mail.server.imap.append",
|
|
@@ -947,7 +1034,10 @@ function create(opts) {
|
|
|
947
1034
|
|
|
948
1035
|
function _handleFetch(state, socket, tag, args, useUid) {
|
|
949
1036
|
if (!state.selectedMailbox) {
|
|
950
|
-
|
|
1037
|
+
// RFC 9051 §6.4.5 — FETCH outside of Selected state is a
|
|
1038
|
+
// protocol-context violation, not a server-policy refusal.
|
|
1039
|
+
// BAD signals the client to fix its dialog rather than retry.
|
|
1040
|
+
_writeTagged(socket, tag, "BAD FETCH only valid in Selected state (RFC 9051 §6.4.5)");
|
|
951
1041
|
return;
|
|
952
1042
|
}
|
|
953
1043
|
if (typeof mailStore.fetchRange !== "function") {
|
|
@@ -988,7 +1078,11 @@ function create(opts) {
|
|
|
988
1078
|
|
|
989
1079
|
function _handleStore(state, socket, tag, args, useUid) {
|
|
990
1080
|
if (!state.selectedMailbox) {
|
|
991
|
-
|
|
1081
|
+
// RFC 9051 §6.4.6 — STORE outside of Selected state is a
|
|
1082
|
+
// protocol-context violation. BAD (not NO) is the correct
|
|
1083
|
+
// response per the IMAP grammar; UID STORE has the same rule
|
|
1084
|
+
// since the verb is just a `UID` prefix on STORE.
|
|
1085
|
+
_writeTagged(socket, tag, "BAD STORE only valid in Selected state (RFC 9051 §6.4.6)");
|
|
992
1086
|
return;
|
|
993
1087
|
}
|
|
994
1088
|
if (state.selectedReadOnly) {
|