@blamejs/core 0.18.54 → 0.18.55
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 +110 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/agent-audit.js +27 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-filename.js +8 -1
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +24 -3
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +6 -4
- package/lib/mail-auth.js +59 -2
- package/lib/mail-server-imap.js +65 -34
- package/lib/mail-server-managesieve.js +50 -37
- package/lib/mail-server-mx.js +115 -34
- package/lib/mail-server-net.js +155 -1
- package/lib/mail-server-pop3.js +16 -19
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +162 -33
- package/lib/mail-server-tls.js +48 -3
- package/lib/mcp.js +11 -3
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-tls.js +18 -0
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +396 -37
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-filename.js
CHANGED
|
@@ -561,7 +561,14 @@ function _detectIssues(input, opts) {
|
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
// 14. Extension allowlist.
|
|
564
|
-
|
|
564
|
+
//
|
|
565
|
+
// Gated on "is an allowlist supplied at all", NOT on "is it non-empty". The
|
|
566
|
+
// profiles spell "no restriction" as `null`, so an operator who computes the
|
|
567
|
+
// list and gets back an empty one is saying nothing is permitted — and an
|
|
568
|
+
// empty list that silently permitted everything inverted exactly that. An
|
|
569
|
+
// allowlist that disappears when empty is a firewall rule set that opens when
|
|
570
|
+
// the last rule is deleted.
|
|
571
|
+
if (Array.isArray(opts.extensionAllowlist)) {
|
|
565
572
|
var split = _splitExt(name);
|
|
566
573
|
var ext = split.ext.toLowerCase();
|
|
567
574
|
var allowed = opts.extensionAllowlist.map(function (e) { return e.toLowerCase(); });
|
package/lib/guard-html.js
CHANGED
|
@@ -556,7 +556,12 @@ function _detectIssues(input, opts) {
|
|
|
556
556
|
location: tok.start,
|
|
557
557
|
snippet: "dangerous tag <" + tok.name + ">",
|
|
558
558
|
});
|
|
559
|
-
|
|
559
|
+
// No `length > 0` guard: the resolved profile always supplies a tag set
|
|
560
|
+
// (balanced carries 52), so an EMPTY set here can only be a caller who
|
|
561
|
+
// passed `allowedTags: []` — which means "permit nothing", not "permit
|
|
562
|
+
// everything". An allowlist that disappears when empty is a firewall rule
|
|
563
|
+
// set that opens when the last rule is deleted.
|
|
564
|
+
} else if (!allowedTags[tok.name]) {
|
|
560
565
|
issues.push({
|
|
561
566
|
kind: "non-allowlisted-tag", severity: "high", ruleId: "html.tag",
|
|
562
567
|
location: tok.start,
|
|
@@ -734,7 +739,10 @@ function _sanitize(input, opts) {
|
|
|
734
739
|
var an = a.name.toLowerCase();
|
|
735
740
|
if (_isEventHandlerAttr(an)) continue;
|
|
736
741
|
if (DANGEROUS_ATTRS.indexOf(an) !== -1) continue;
|
|
737
|
-
|
|
742
|
+
// Same reasoning as the tag allowlist above: the resolved profile always
|
|
743
|
+
// supplies an attribute set, so an empty one is a caller asking that no
|
|
744
|
+
// attribute be permitted.
|
|
745
|
+
if (!allowedAttrs[an]) continue;
|
|
738
746
|
if (a.value && Buffer.byteLength(a.value, "utf8") > opts.maxAttrValueBytes) continue;
|
|
739
747
|
if (_isUrlAttr(an)) {
|
|
740
748
|
var scheme = _extractScheme(a.value);
|
|
@@ -211,7 +211,12 @@ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
|
|
|
211
211
|
if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
|
|
212
212
|
// Optional operator allowlist — when supplied, hostname (or any
|
|
213
213
|
// ancestor domain) MUST be present.
|
|
214
|
-
|
|
214
|
+
// Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
|
|
215
|
+
// the option is how "any host" is spelled; an operator who computes the list
|
|
216
|
+
// and gets an empty one is saying no host may be auto-fetched, and reading
|
|
217
|
+
// that as "any host" inverts it. An allowlist that disappears when empty is a
|
|
218
|
+
// firewall rule set that opens when the last rule is deleted.
|
|
219
|
+
if (Array.isArray(allowedHosts)) {
|
|
215
220
|
var matched = false;
|
|
216
221
|
for (var i = 0; i < allowedHosts.length; i += 1) {
|
|
217
222
|
var allowed = String(allowedHosts[i]).toLowerCase();
|
|
@@ -333,8 +333,9 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
|
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
}
|
|
336
|
-
var literalBytes
|
|
337
|
-
var literalPlus
|
|
336
|
+
var literalBytes = null;
|
|
337
|
+
var literalPlus = false;
|
|
338
|
+
var initialResponse = null;
|
|
338
339
|
if (trailing) {
|
|
339
340
|
// Optional initial-response — either `{N+?}` literal or a quoted
|
|
340
341
|
// base64 string.
|
|
@@ -365,9 +366,29 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
|
|
|
365
366
|
"guardManageSieveCommand.validate: AUTHENTICATE initial-response must be a " +
|
|
366
367
|
"literal `{N}` / `{N+}` or quoted base64 string");
|
|
367
368
|
}
|
|
369
|
+
// Returned rather than discarded. RFC 5804 §2.1 allows the initial
|
|
370
|
+
// response inline, and this branch already parsed and validated it; the
|
|
371
|
+
// value was then dropped, so a client that used the quoted form was
|
|
372
|
+
// answered as though it had sent no initial response at all.
|
|
373
|
+
if (inner.value.length > MAX_SASL_TOKEN_BYTES) {
|
|
374
|
+
throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
|
|
375
|
+
"guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
|
|
376
|
+
inner.value.length + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
|
|
377
|
+
}
|
|
378
|
+
initialResponse = inner.value;
|
|
368
379
|
}
|
|
369
380
|
}
|
|
370
|
-
return {
|
|
381
|
+
return {
|
|
382
|
+
verb: "AUTHENTICATE",
|
|
383
|
+
args: [mech],
|
|
384
|
+
literalBytes: literalBytes,
|
|
385
|
+
literalPlus: literalPlus,
|
|
386
|
+
// The quoted form's value, or null when the client sent none or sent it as
|
|
387
|
+
// a literal (whose bytes arrive on the following line). One field, so a
|
|
388
|
+
// caller asks "was there an initial response" once rather than per
|
|
389
|
+
// representation.
|
|
390
|
+
initialResponse: initialResponse,
|
|
391
|
+
};
|
|
371
392
|
}
|
|
372
393
|
|
|
373
394
|
function _validateNoop(rest, caps) {
|
|
@@ -500,7 +500,7 @@ function gate(opts) {
|
|
|
500
500
|
|
|
501
501
|
/**
|
|
502
502
|
* @primitive b.guardSmtpCommand.detectBodySmuggling
|
|
503
|
-
* @signature b.guardSmtpCommand.detectBodySmuggling(buf)
|
|
503
|
+
* @signature b.guardSmtpCommand.detectBodySmuggling(buf, isBodyStart?, precededByCr?)
|
|
504
504
|
* @since 0.9.46
|
|
505
505
|
* @status stable
|
|
506
506
|
* @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
|
|
@@ -526,11 +526,25 @@ function gate(opts) {
|
|
|
526
526
|
* b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
|
|
527
527
|
* // → true (bare-LF dot-line — CVE-2023-51764 shape)
|
|
528
528
|
*/
|
|
529
|
-
|
|
529
|
+
// `isBodyStart` defaults true, which is the whole-body call every existing
|
|
530
|
+
// caller makes. An incremental scanner passes false for every window after the
|
|
531
|
+
// first: the dot-at-offset-0 case below is about a dot that begins the BODY,
|
|
532
|
+
// and a window whose index 0 is mid-line would otherwise report one that is not
|
|
533
|
+
// there.
|
|
534
|
+
//
|
|
535
|
+
// `precededByCr` supplies the one byte a window cannot carry. Deciding whether
|
|
536
|
+
// an LF is bare means reading the byte before it, and the buffer has that byte
|
|
537
|
+
// for every offset except zero. Overlapping windows does not supply it — it
|
|
538
|
+
// only moves which byte lands at offset zero — so a scanner whose window opens
|
|
539
|
+
// on the LF of a canonical `\r\n` had no way to say so, and the boundary read
|
|
540
|
+
// as bare. Default false: at a true body start there is no preceding byte, and
|
|
541
|
+
// a leading LF there really is bare.
|
|
542
|
+
function detectBodySmuggling(buf, isBodyStart, precededByCr) {
|
|
530
543
|
if (!Buffer.isBuffer(buf)) {
|
|
531
544
|
throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
|
|
532
545
|
"detectBodySmuggling: input must be a Buffer");
|
|
533
546
|
}
|
|
547
|
+
var atStart = isBodyStart === undefined ? true : isBodyStart === true;
|
|
534
548
|
// The CVE-2023-51764 / 51765 / 51766 class is any
|
|
535
549
|
// dot-line whose line boundary is anything OTHER than canonical
|
|
536
550
|
// \r\n on BOTH sides of the dot. The canonical-and-only terminator
|
|
@@ -548,14 +562,16 @@ function detectBodySmuggling(buf) {
|
|
|
548
562
|
// count: a dot at byte 0 followed by `\n` would terminate any
|
|
549
563
|
// receiver that accepts bare-LF dot.
|
|
550
564
|
// 0x0a = LF, 0x0d = CR, 0x2e = `.`
|
|
551
|
-
if (buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
|
|
565
|
+
if (atStart && buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
|
|
552
566
|
// Walk every LF in the buffer. The previous byte must be CR for the
|
|
553
567
|
// line boundary to be canonical; otherwise the line started with
|
|
554
568
|
// bare-LF. If the next bytes are `.` followed by ANY of (LF, CRLF),
|
|
555
569
|
// the shape is a smuggling candidate.
|
|
556
570
|
for (var i = 0; i < buf.length - 1; i += 1) {
|
|
557
571
|
if (buf[i] !== 0x0a) continue;
|
|
558
|
-
var leadingBareLf = (i === 0)
|
|
572
|
+
var leadingBareLf = (i === 0)
|
|
573
|
+
? (precededByCr !== true)
|
|
574
|
+
: (buf[i - 1] !== 0x0d);
|
|
559
575
|
if (buf[i + 1] !== 0x2e) continue;
|
|
560
576
|
// Trailing terminator shape after the dot:
|
|
561
577
|
// buf[i+2] == LF → bare-LF terminator (always smuggling)
|
package/lib/guard-svg.js
CHANGED
|
@@ -561,7 +561,12 @@ function _detectIssues(input, opts) {
|
|
|
561
561
|
});
|
|
562
562
|
continue;
|
|
563
563
|
}
|
|
564
|
-
|
|
564
|
+
// No `length > 0` guard: the resolved profile always supplies a tag set
|
|
565
|
+
// (balanced carries 54), so an EMPTY set here can only be a caller who
|
|
566
|
+
// passed `allowedTags: []` — which means "permit nothing", not "permit
|
|
567
|
+
// everything". An allowlist that disappears when empty is a firewall rule
|
|
568
|
+
// set that opens when the last rule is deleted.
|
|
569
|
+
} else if (!allowedTags[tok.name]) {
|
|
565
570
|
issues.push({
|
|
566
571
|
kind: "non-allowlisted-tag", severity: "high", ruleId: "svg.tag",
|
|
567
572
|
location: tok.start,
|
package/lib/http-client.js
CHANGED
|
@@ -621,7 +621,13 @@ function _fromH2Headers(h2Headers) {
|
|
|
621
621
|
// { host: "api.x.com", methods: ["GET","HEAD"] }
|
|
622
622
|
// method-restricted; methods omitted = any method
|
|
623
623
|
function hostAllowed(host, allowedHosts, method) {
|
|
624
|
-
|
|
624
|
+
// A NON-array means no pin was supplied, so nothing is constrained. An EMPTY
|
|
625
|
+
// array is a pin that permits nothing, and falls through to the `false` at
|
|
626
|
+
// the end rather than short-circuiting to `true` here — an allowlist that
|
|
627
|
+
// disappears when empty is a firewall rule set that opens when the last rule
|
|
628
|
+
// is deleted. Every caller enforcing this pin routes through here, so the
|
|
629
|
+
// distinction has to live at this function and not at each of them.
|
|
630
|
+
if (!Array.isArray(allowedHosts)) return true;
|
|
625
631
|
var wanted = String(host || "").toLowerCase();
|
|
626
632
|
var verb = String(method || "GET").toUpperCase();
|
|
627
633
|
for (var ai = 0; ai < allowedHosts.length; ai++) {
|
|
@@ -720,7 +726,9 @@ function pinnedClient(client, allowedHosts) {
|
|
|
720
726
|
// redirect the wrapper never sees is a hop none of that applied to. Returning
|
|
721
727
|
// the client untouched when no pin was named would leave a 307 from https to
|
|
722
728
|
// http free to resend the credentials that were checked onto the first hop.
|
|
723
|
-
|
|
729
|
+
// An EMPTY allowlist is a real pin meaning "reach nothing", not the absence
|
|
730
|
+
// of one — same reasoning as the request path above.
|
|
731
|
+
var pin = Array.isArray(allowedHosts) ? allowedHosts : null;
|
|
724
732
|
return {
|
|
725
733
|
request: function (opts) {
|
|
726
734
|
var target = opts && opts.url;
|
|
@@ -1746,7 +1754,13 @@ function _requestSingle(opts) {
|
|
|
1746
1754
|
// A disallowed call rejects with HOST_DISALLOWED AND emits an
|
|
1747
1755
|
// audit event when opts.audit is wired (operator gets a structured
|
|
1748
1756
|
// signal that the application tried to reach somewhere it shouldn't).
|
|
1749
|
-
|
|
1757
|
+
// Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
|
|
1758
|
+
// the option is how "no egress pin" is spelled; an operator who builds the
|
|
1759
|
+
// list from config and gets an empty one is saying this process may reach
|
|
1760
|
+
// nothing, and reading that as "no pin" inverts the request exactly. An
|
|
1761
|
+
// allowlist that disappears when empty is a firewall rule set that opens when
|
|
1762
|
+
// the last rule is deleted.
|
|
1763
|
+
if (Array.isArray(opts.allowedHosts)) {
|
|
1750
1764
|
var host = u.hostname.toLowerCase();
|
|
1751
1765
|
var method = (opts.method || "GET").toUpperCase();
|
|
1752
1766
|
var ok = hostAllowed(host, opts.allowedHosts, method);
|
package/lib/mail-agent.js
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
var lazyRequire = require("./lazy-require");
|
|
78
78
|
var validateOpts = require("./validate-opts");
|
|
79
79
|
var C = require("./constants");
|
|
80
|
+
var agentAudit = require("./agent-audit");
|
|
80
81
|
var { defineClass } = require("./framework-error");
|
|
81
82
|
var guardMailQuery = require("./guard-mail-query");
|
|
82
83
|
var guardMailCompose = require("./guard-mail-compose");
|
|
@@ -789,10 +790,11 @@ function _auditEmitter(auditOverride) {
|
|
|
789
790
|
};
|
|
790
791
|
}
|
|
791
792
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
793
|
+
// Through the shared shaper rather than a local copy. This module had its own,
|
|
794
|
+
// and the two drifted in exactly the way that matters: both emitted `{ id,
|
|
795
|
+
// roles }` while `b.audit.record` reads `actor.userId`, so every row either
|
|
796
|
+
// produced landed unattributable. One shaper means one place to be right.
|
|
797
|
+
var _actorShape = agentAudit.actorShape;
|
|
796
798
|
|
|
797
799
|
module.exports = {
|
|
798
800
|
create: create,
|
package/lib/mail-auth.js
CHANGED
|
@@ -2088,6 +2088,15 @@ async function arcVerify(rfc822, opts) {
|
|
|
2088
2088
|
// 3. Per-hop AMS + AS verification.
|
|
2089
2089
|
var perHop = [];
|
|
2090
2090
|
var anyFail = false;
|
|
2091
|
+
// A hop that failed for a reason OTHER than the key being unreachable. RFC
|
|
2092
|
+
// 8601 §2.7 keeps `temperror` apart from `fail` for exactly this reason: one
|
|
2093
|
+
// is a statement about the message, the other about the resolver. Collapsing
|
|
2094
|
+
// them tells a consumer a seal was forged when a DNS query timed out.
|
|
2095
|
+
//
|
|
2096
|
+
// Tracked as the hard case rather than the transient one, because `anyFail`
|
|
2097
|
+
// without `anyHardFail` already means every non-passing hop was a temperror,
|
|
2098
|
+
// and a second flag could disagree with the first.
|
|
2099
|
+
var anyHardFail = false;
|
|
2091
2100
|
// RFC 8617 §5.2 — operator-tunable clock skew on t= (signing
|
|
2092
2101
|
// timestamp) and x= (expiration) tags. Default 5 min.
|
|
2093
2102
|
// A present clockSkewMs must be a non-negative finite integer; an Infinity /
|
|
@@ -2147,7 +2156,16 @@ async function arcVerify(rfc822, opts) {
|
|
|
2147
2156
|
amsErrors: amsResult.errors,
|
|
2148
2157
|
asErrors: asResult.errors,
|
|
2149
2158
|
});
|
|
2150
|
-
if (amsResult.result !== "pass" || asResult.result !== "pass")
|
|
2159
|
+
if (amsResult.result !== "pass" || asResult.result !== "pass") {
|
|
2160
|
+
anyFail = true;
|
|
2161
|
+
// Accumulated across ALL hops, not decided per hop: a temperror on one
|
|
2162
|
+
// hop and a genuine failure on another is a genuine failure, whichever
|
|
2163
|
+
// order they arrive in.
|
|
2164
|
+
if ((amsResult.result !== "pass" && amsResult.result !== "temperror") ||
|
|
2165
|
+
(asResult.result !== "pass" && asResult.result !== "temperror")) {
|
|
2166
|
+
anyHardFail = true;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2151
2169
|
}
|
|
2152
2170
|
|
|
2153
2171
|
// 4. Chain Validation per RFC 8617 §5.2.
|
|
@@ -2190,12 +2208,37 @@ async function arcVerify(rfc822, opts) {
|
|
|
2190
2208
|
var lastCv = perHopCv[perHopCv.length - 1];
|
|
2191
2209
|
var chainStatus;
|
|
2192
2210
|
var reasonOut = null;
|
|
2211
|
+
// Whether this verdict is worth retrying. A consumer that lets the chain
|
|
2212
|
+
// influence a disposition must not read a resolver outage as a forgery.
|
|
2213
|
+
var transientOut = false;
|
|
2193
2214
|
if (hopRuleViolation) {
|
|
2194
2215
|
chainStatus = "fail";
|
|
2195
2216
|
reasonOut = hopRuleViolation;
|
|
2196
2217
|
} else if (anyFail) {
|
|
2197
2218
|
chainStatus = "fail";
|
|
2198
|
-
|
|
2219
|
+
// The chain does not validate either way, so the wire token stays "fail" —
|
|
2220
|
+
// RFC 8617 §5.2 gives the chain none / pass / fail and nothing else, and
|
|
2221
|
+
// putting a word on the wire that receivers have no rule for would be a
|
|
2222
|
+
// worse answer than a coarse one. The distinction a consumer needs lives in
|
|
2223
|
+
// the structured verdict beside it.
|
|
2224
|
+
// A cv=fail recorded by a hop is TERMINAL — RFC 8617 §5.2 makes the chain
|
|
2225
|
+
// unrecoverable from that point, so it is reported as the reason even when
|
|
2226
|
+
// an upstream lookup also failed, and it is never transient. Retrying DNS
|
|
2227
|
+
// cannot turn it into a pass, and calling it transient tells a consumer to
|
|
2228
|
+
// defer and re-deliver mail that will never validate.
|
|
2229
|
+
//
|
|
2230
|
+
// Believing that claim requires the hop making it to have proved it made
|
|
2231
|
+
// it. cv= is a token in a header the sender wrote; only the ARC-Seal
|
|
2232
|
+
// covering that header says the named hop is who wrote it. So terminality
|
|
2233
|
+
// is gated on the LAST hop's seal verifying: when its own key lookup
|
|
2234
|
+
// temperrored, the token is unauthenticated text, and honouring it would
|
|
2235
|
+
// let anyone who can stall one DNS query stamp cv=fail on a chain and turn
|
|
2236
|
+
// a retryable outage into a permanent rejection.
|
|
2237
|
+
var lastSeal = perHop.length ? perHop[perHop.length - 1].asResult : null;
|
|
2238
|
+
var terminalCv = lastCv === "fail" && lastSeal === "pass";
|
|
2239
|
+
reasonOut = terminalCv ? "last-as-cv=fail"
|
|
2240
|
+
: (anyHardFail ? "signature-verification-failed" : "key-lookup-unavailable");
|
|
2241
|
+
transientOut = !terminalCv && !anyHardFail;
|
|
2199
2242
|
} else if (lastCv === "fail") {
|
|
2200
2243
|
chainStatus = "fail";
|
|
2201
2244
|
reasonOut = "last-as-cv=fail";
|
|
@@ -2216,6 +2259,7 @@ async function arcVerify(rfc822, opts) {
|
|
|
2216
2259
|
hops: perHop,
|
|
2217
2260
|
};
|
|
2218
2261
|
if (reasonOut) out.reason = reasonOut;
|
|
2262
|
+
if (transientOut) out.transient = true;
|
|
2219
2263
|
return out;
|
|
2220
2264
|
}
|
|
2221
2265
|
|
|
@@ -2490,6 +2534,9 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
2490
2534
|
breakAt: null,
|
|
2491
2535
|
};
|
|
2492
2536
|
if (verdict.reason) out.reason = verdict.reason;
|
|
2537
|
+
// Carried for the same reason `reason` is: a caller deciding what to do about
|
|
2538
|
+
// a chain that did not validate needs to know whether the answer is stable.
|
|
2539
|
+
if (verdict.transient) out.transient = true;
|
|
2493
2540
|
|
|
2494
2541
|
// Re-extract per-hop d= (signing domain on AS) AND the AAR text from
|
|
2495
2542
|
// the original headers — the verify-result shape doesn't carry
|
|
@@ -2719,6 +2766,16 @@ function authResultsEmit(opts) {
|
|
|
2719
2766
|
// // → { spf, dkim, from, dmarc, arc, authResults }
|
|
2720
2767
|
// if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
|
|
2721
2768
|
//
|
|
2769
|
+
// `arc.chainStatus` is "none" / "pass" / "fail" — the vocabulary RFC 8617 §5.2
|
|
2770
|
+
// gives the chain, and the token that goes on the wire in
|
|
2771
|
+
// Authentication-Results. A "fail" carries `arc.reason`, and `arc.transient` is
|
|
2772
|
+
// true when the chain did not validate because a key could not be looked up
|
|
2773
|
+
// rather than because a seal did not verify. Those want opposite responses: one
|
|
2774
|
+
// is a statement about the sender, the other about a resolver, and a consumer
|
|
2775
|
+
// that lets the chain influence a disposition must not read an outage as a
|
|
2776
|
+
// forgery. The header cannot carry the distinction, which is why the structured
|
|
2777
|
+
// verdict does.
|
|
2778
|
+
//
|
|
2722
2779
|
// From-header discipline (RFC 9989 §5.3.1, RFC 7489 §6.6.1 before it):
|
|
2723
2780
|
// DMARC evaluates exactly one author domain. A message with zero From fields, several From fields,
|
|
2724
2781
|
// or several author addresses in one field is the header-duplication
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -98,10 +98,18 @@
|
|
|
98
98
|
*
|
|
99
99
|
* ## What v1 does NOT ship
|
|
100
100
|
*
|
|
101
|
-
* - **SEARCH** — operator
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
101
|
+
* - **SEARCH / COPY / MOVE** — operator-domain logic against the
|
|
102
|
+
* mailStore index, supplied through `opts.overrides`; until then
|
|
103
|
+
* the listener answers `NO <verb> not configured`. A handler
|
|
104
|
+
* supplied once serves BOTH the plain and the `UID` form: the UID
|
|
105
|
+
* verb dispatches back through the same registry entry and sets
|
|
106
|
+
* `parsed.useUid`, which the handler reads to answer in unique
|
|
107
|
+
* identifiers per RFC 9051 §6.4.9.
|
|
108
|
+
* - **UID EXPUNGE (RFC 4315)** — refused unless the operator
|
|
109
|
+
* supplies an EXPUNGE handler that reads the uid-set. The shipped
|
|
110
|
+
* EXPUNGE takes no set and expunges every message flagged
|
|
111
|
+
* `\Deleted`, so serving the UID form from it would delete
|
|
112
|
+
* messages the client did not name.
|
|
105
113
|
* - **NOTIFY (RFC 5465)**, **METADATA (RFC 5464)**, **CATENATE
|
|
106
114
|
* (RFC 4469)**, **URLAUTH (RFC 4467)**, **IMAPSIEVE (RFC 6785)**,
|
|
107
115
|
* **COMPRESS=DEFLATE (RFC 4978)** — opt-in / refused.
|
|
@@ -121,7 +129,6 @@
|
|
|
121
129
|
var net = require("node:net");
|
|
122
130
|
var safeBuffer = require("./safe-buffer");
|
|
123
131
|
var C = require("./constants");
|
|
124
|
-
var bCrypto = require("./crypto");
|
|
125
132
|
var numericBounds = require("./numeric-bounds");
|
|
126
133
|
var validateOpts = require("./validate-opts");
|
|
127
134
|
var guardImapCommand = require("./guard-imap-command");
|
|
@@ -229,6 +236,7 @@ function _validateMailboxName(name, opts) {
|
|
|
229
236
|
* maxLineBytes: number, // default 8192
|
|
230
237
|
* maxLiteralBytes: number, // default 64 MiB
|
|
231
238
|
* idleTimeoutMs: number, // default 30 min
|
|
239
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
232
240
|
* profile: "strict" | "balanced" | "permissive",
|
|
233
241
|
* auth: {
|
|
234
242
|
* mechanisms: ["PLAIN", "LOGIN", "SCRAM-SHA-256", "EXTERNAL", "XOAUTH2"],
|
|
@@ -274,7 +282,7 @@ function create(opts) {
|
|
|
274
282
|
"mail.server.imap.create: mailStore is required (compose b.mailStore.create({ backend: ... }))");
|
|
275
283
|
}
|
|
276
284
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
277
|
-
["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs"],
|
|
285
|
+
["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs", "maxConnections"],
|
|
278
286
|
"mail.server.imap.", MailServerImapError, "mail-server-imap/bad-bound");
|
|
279
287
|
|
|
280
288
|
var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
|
|
@@ -293,16 +301,18 @@ function create(opts) {
|
|
|
293
301
|
var _emit = auditEmit.emit;
|
|
294
302
|
|
|
295
303
|
function _handleConnection(rawSocket) {
|
|
296
|
-
var
|
|
304
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
305
|
+
rateLimit: rateLimit,
|
|
306
|
+
connections: connections,
|
|
307
|
+
emit: _emit,
|
|
297
308
|
refusedEvent: "mail.server.imap.rate_limit_refused",
|
|
298
309
|
refusalLine: "* BAD Too many connections from your IP\r\n",
|
|
310
|
+
idPrefix: "imapconn-",
|
|
299
311
|
});
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
var
|
|
304
|
-
var socket = rawSocket;
|
|
305
|
-
connections.add(socket);
|
|
312
|
+
if (accepted === null) return;
|
|
313
|
+
var remoteAddress = accepted.remoteAddress;
|
|
314
|
+
var connectionId = accepted.connectionId;
|
|
315
|
+
var socket = accepted.socket;
|
|
306
316
|
|
|
307
317
|
var state = {
|
|
308
318
|
id: connectionId,
|
|
@@ -1733,29 +1743,49 @@ function create(opts) {
|
|
|
1733
1743
|
var subArgs = sub[2];
|
|
1734
1744
|
if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
|
|
1735
1745
|
if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
|
|
1736
|
-
// RFC 9051 §6.4.9
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
// the searchRange path threaded through `useUid: true`.
|
|
1741
|
-
// COPY: composes with the existing _handleCopy path; needs
|
|
1742
|
-
// the mailStore.copyRange opt accepted.
|
|
1743
|
-
// MOVE: RFC 6851; same shape as COPY plus an atomic-delete
|
|
1744
|
-
// step on the source mailbox.
|
|
1745
|
-
// EXPUNGE: RFC 4315 UIDPLUS; expunges by uid-set instead of by
|
|
1746
|
-
// \Deleted-flag scan.
|
|
1746
|
+
// Every other sub-command RFC 9051 §6.4.9 defines — SEARCH, COPY, MOVE,
|
|
1747
|
+
// EXPUNGE — goes back through the registry to the SAME entry the
|
|
1748
|
+
// unprefixed verb dispatches to, carrying `useUid` so the handler answers
|
|
1749
|
+
// in unique identifiers.
|
|
1747
1750
|
//
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1751
|
+
// The listener ships no search, copy or move of its own: those are
|
|
1752
|
+
// operator-domain, and the registry holds a "not configured" default that
|
|
1753
|
+
// a consumer replaces through `opts.overrides`. Dispatching the UID forms
|
|
1754
|
+
// here instead of through the registry made that seam reachable from one
|
|
1755
|
+
// side only — a consumer who supplied SEARCH got the sequence form served
|
|
1756
|
+
// and the UID form refused, and the only way to supply the UID form was to
|
|
1757
|
+
// replace the whole UID verb, taking the working UID FETCH and UID STORE
|
|
1758
|
+
// with it. A client that keeps a cross-session cache asks for the UID form
|
|
1759
|
+
// precisely because a sequence number does not survive the session, so the
|
|
1760
|
+
// refusal fell on the clients doing the durable thing.
|
|
1752
1761
|
//
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
|
|
1758
|
-
|
|
1762
|
+
// Going through the registry also keeps the tenant check, the guard
|
|
1763
|
+
// validation and the audit emission on the UID path, which a direct call
|
|
1764
|
+
// from here would have skipped.
|
|
1765
|
+
// An allowlist, not "whatever the registry holds". UID takes the five
|
|
1766
|
+
// sub-commands §6.4.9 names plus EXPUNGE from RFC 4315; sending anything
|
|
1767
|
+
// else through would dispatch a real handler that knows nothing about UIDs
|
|
1768
|
+
// — `UID SELECT INBOX` would select a mailbox, and `UID UID …` would
|
|
1769
|
+
// recurse.
|
|
1770
|
+
if (subVerb !== "SEARCH" && subVerb !== "COPY" &&
|
|
1771
|
+
subVerb !== "MOVE" && subVerb !== "EXPUNGE") {
|
|
1772
|
+
_writeTagged(socket, tag, "BAD UID " + subVerb + " is not a UID sub-command");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
// EXPUNGE is the one that cannot be forwarded blind. RFC 4315 §2.1 makes
|
|
1776
|
+
// `UID EXPUNGE <uid-set>` expunge ONLY the named messages, while the
|
|
1777
|
+
// listener's own EXPUNGE expunges every message flagged \Deleted and takes
|
|
1778
|
+
// no set at all. Forwarding to it would delete messages the client did not
|
|
1779
|
+
// name, which is worse than not answering. A consumer who supplies their
|
|
1780
|
+
// own EXPUNGE reads `useUid` and the set, so theirs can serve it.
|
|
1781
|
+
if (subVerb === "EXPUNGE" && _registry.source("EXPUNGE") !== "operator-override") {
|
|
1782
|
+
_writeTagged(socket, tag,
|
|
1783
|
+
"NO UID EXPUNGE needs a uid-set-aware EXPUNGE handler; " +
|
|
1784
|
+
"the default expunges by \\Deleted flag and would exceed the set");
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
return _registry.dispatch(subVerb, state, socket,
|
|
1788
|
+
{ tag: tag, args: subArgs, useUid: true });
|
|
1759
1789
|
}
|
|
1760
1790
|
|
|
1761
1791
|
function _handleIdle(state, socket, tag) {
|
|
@@ -1855,6 +1885,7 @@ function create(opts) {
|
|
|
1855
1885
|
// ---- Lifecycle ----------------------------------------------------------
|
|
1856
1886
|
return mailServerNet.createStoreServer(net, {
|
|
1857
1887
|
defaultPort: 143, // RFC 9051 IMAP port (IANA)
|
|
1888
|
+
maxConnections: opts.maxConnections,
|
|
1858
1889
|
handleConnection: _handleConnection,
|
|
1859
1890
|
errorClass: MailServerImapError,
|
|
1860
1891
|
errorCodePrefix: "mail-server-imap/",
|