@blamejs/core 0.18.53 → 0.18.54
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 +118 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +33 -32
- package/lib/guard-managesieve-command.js +49 -9
- package/lib/guard-regex.js +3 -5
- package/lib/guard-yaml.js +60 -15
- package/lib/mail-agent.js +23 -9
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +75 -20
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +121 -55
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +168 -25
- package/lib/mail-server-mx.js +76 -4
- package/lib/mail-server-net.js +126 -0
- package/lib/mail-server-pop3.js +73 -22
- package/lib/mail-server-submission.js +21 -2
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +278 -40
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -261,6 +261,8 @@ function create(opts) {
|
|
|
261
261
|
actor: null,
|
|
262
262
|
pendingLiteral: null, // { verb, name, size, body, plus }
|
|
263
263
|
pendingAuth: null, // { mech, irBytes, irPlus, irBody }
|
|
264
|
+
saslExchange: null, // { mech, step } while a multi-step SASL exchange is live
|
|
265
|
+
awaitingLiteralTerminator: false, // a literal body was read; its line's CRLF has not arrived
|
|
264
266
|
lineBuffer: Buffer.alloc(0),
|
|
265
267
|
};
|
|
266
268
|
|
|
@@ -327,7 +329,26 @@ function create(opts) {
|
|
|
327
329
|
}
|
|
328
330
|
pa.irBody = Buffer.concat([pa.irBody, state.lineBuffer.subarray(0, needA)]);
|
|
329
331
|
state.lineBuffer = state.lineBuffer.subarray(needA);
|
|
330
|
-
//
|
|
332
|
+
// The CRLF that ends the line the literal sat on. It was left in the
|
|
333
|
+
// buffer, so the next pass read it as an empty LINE — harmless while
|
|
334
|
+
// nothing was in flight, and not harmless during a multi-step SASL
|
|
335
|
+
// exchange, where an empty line is a second response and the pipelining
|
|
336
|
+
// guard fails the exchange for it. The bug only appeared when the two
|
|
337
|
+
// writes arrived as separate TCP segments, which is why the host run
|
|
338
|
+
// was green and the container run was not.
|
|
339
|
+
// The CRLF that ends the line the literal sat on. Consumed here when it
|
|
340
|
+
// is already buffered, and otherwise awaited: it may arrive in a later
|
|
341
|
+
// segment, and read as a line it is an EMPTY line. That was harmless
|
|
342
|
+
// while nothing was in flight, and during a multi-step SASL exchange it
|
|
343
|
+
// is a second response — the pipelining guard fails the exchange for
|
|
344
|
+
// it. A peek alone is not enough, because whether the terminator has
|
|
345
|
+
// arrived yet is a property of the network, not of the protocol.
|
|
346
|
+
if (state.lineBuffer.length >= 2 &&
|
|
347
|
+
state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
|
|
348
|
+
state.lineBuffer = state.lineBuffer.subarray(2);
|
|
349
|
+
} else {
|
|
350
|
+
state.awaitingLiteralTerminator = true;
|
|
351
|
+
}
|
|
331
352
|
pa.irBytes = null;
|
|
332
353
|
_completeAuthenticate(state, socket);
|
|
333
354
|
if (state.stage === "closed") return;
|
|
@@ -343,12 +364,27 @@ function create(opts) {
|
|
|
343
364
|
}
|
|
344
365
|
var rawLine = state.lineBuffer.subarray(0, crlf).toString("utf8");
|
|
345
366
|
state.lineBuffer = state.lineBuffer.subarray(crlf + 2);
|
|
367
|
+
// The terminator a literal was still owed, arriving in its own segment.
|
|
368
|
+
// It closes the literal's line rather than opening a new one.
|
|
369
|
+
if (state.awaitingLiteralTerminator) {
|
|
370
|
+
state.awaitingLiteralTerminator = false;
|
|
371
|
+
if (rawLine.length === 0) continue;
|
|
372
|
+
}
|
|
346
373
|
_handleLine(state, socket, rawLine);
|
|
347
374
|
if (state.stage === "closed") return;
|
|
348
375
|
}
|
|
349
376
|
}
|
|
350
377
|
|
|
351
378
|
function _handleLine(state, socket, line) {
|
|
379
|
+
// Mid-SASL: this line is the client's base64 response to the server's
|
|
380
|
+
// challenge, not a ManageSieve verb, so it goes to the exchange rather than
|
|
381
|
+
// the wire guard — which would refuse it as an unknown command. This
|
|
382
|
+
// ordering is why the `overrides` hook could never carry a multi-step
|
|
383
|
+
// mechanism on its own: the guard ran first and the reply never arrived.
|
|
384
|
+
if (state.saslExchange) {
|
|
385
|
+
_continueSaslExchange(state, socket, line);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
352
388
|
var parsed;
|
|
353
389
|
try {
|
|
354
390
|
parsed = guardManageSieveCommand.validate(line, {
|
|
@@ -605,38 +641,145 @@ function create(opts) {
|
|
|
605
641
|
// after.
|
|
606
642
|
}
|
|
607
643
|
|
|
644
|
+
// `{N}` / `{N+}` on a line of its own, the RFC 5804 §1.2 literal form of a
|
|
645
|
+
// string. Returns null for anything else. Scanned rather than matched so the
|
|
646
|
+
// digit run is bounded by the line the caller already length-capped.
|
|
647
|
+
function _parseLiteralMarker(line) {
|
|
648
|
+
if (line.length < 3 || line.charAt(0) !== "{") return null;
|
|
649
|
+
var i = 1;
|
|
650
|
+
var digits = "";
|
|
651
|
+
while (i < line.length && line.charCodeAt(i) >= 0x30 && line.charCodeAt(i) <= 0x39) {
|
|
652
|
+
digits += line.charAt(i);
|
|
653
|
+
i += 1;
|
|
654
|
+
if (digits.length > 9) return null; // absurd count: not a literal we will honour
|
|
655
|
+
}
|
|
656
|
+
if (digits.length === 0) return null;
|
|
657
|
+
var plus = false;
|
|
658
|
+
if (line.charAt(i) === "+") { plus = true; i += 1; }
|
|
659
|
+
if (line.charAt(i) !== "}" || i !== line.length - 1) return null;
|
|
660
|
+
return { bytes: Number(digits), plus: plus };
|
|
661
|
+
}
|
|
662
|
+
|
|
608
663
|
function _completeAuthenticate(state, socket) {
|
|
609
664
|
var pa = state.pendingAuth;
|
|
610
665
|
state.pendingAuth = null;
|
|
611
666
|
if (!pa) return;
|
|
667
|
+
// Resuming a live exchange: the step counter and mechanism carry over, and
|
|
668
|
+
// no new auth_attempt is emitted — this is the same attempt, one round on.
|
|
669
|
+
if (pa.resume && state.saslExchange) {
|
|
670
|
+
_runAuthStep(state, socket, pa.irBody.toString("utf8"));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
612
673
|
_emit("mail.server.managesieve.auth_attempt",
|
|
613
674
|
{ connectionId: state.id, mech: pa.mech, remoteAddress: state.remoteAddress });
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
675
|
+
state.saslExchange = { mech: pa.mech, step: 0 };
|
|
676
|
+
_runAuthStep(state, socket,
|
|
677
|
+
pa.irBody.length > 0 ? pa.irBody.toString("utf8") : null);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// One round of a SASL exchange (RFC 5804 §2.1). The verifier may answer with
|
|
681
|
+
// `{ pending: true, challenge }` to ask for another client response, as it
|
|
682
|
+
// may on the IMAP, POP3 and submission listeners. ManageSieve used to call
|
|
683
|
+
// verify once with no `step`, so a pending verdict landed in the failure
|
|
684
|
+
// branch — and spent the client's authentication-failure budget for what is a
|
|
685
|
+
// normal protocol round trip, weakening the very defence that budget exists
|
|
686
|
+
// to provide.
|
|
687
|
+
function _runAuthStep(state, socket, clientResponse) {
|
|
688
|
+
var ex = state.saslExchange;
|
|
689
|
+
function _fail(reason) {
|
|
690
|
+
state.saslExchange = null;
|
|
691
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
692
|
+
_emit("mail.server.managesieve.auth_failed",
|
|
693
|
+
{ connectionId: state.id, mech: ex.mech, reason: reason }, "denied");
|
|
694
|
+
_writeNo(socket, "Authentication failed");
|
|
695
|
+
}
|
|
696
|
+
mailServerNet.runSaslStep({
|
|
697
|
+
exchange: ex,
|
|
698
|
+
verify: authConfig.verify,
|
|
699
|
+
credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
|
|
700
|
+
clientResponse: clientResponse,
|
|
701
|
+
writeChallenge: function (ch) { return _writeChallenge(socket, ch); },
|
|
702
|
+
onChallengeUnsafe: function () { _fail("challenge-contains-line-terminator"); },
|
|
703
|
+
onSuccess: function (result) {
|
|
704
|
+
state.saslExchange = null;
|
|
705
|
+
state.actor = result.actor;
|
|
706
|
+
state.stage = "authenticated";
|
|
707
|
+
_emit("mail.server.managesieve.auth_success",
|
|
708
|
+
{ connectionId: state.id, mech: ex.mech, tenantId: state.actor.tenantId || null });
|
|
709
|
+
_writeOk(socket, "Authenticated");
|
|
710
|
+
},
|
|
711
|
+
onFailure: function (result) { _fail((result && result.reason) || "verify-returned-fail"); },
|
|
712
|
+
onError: function (err) { _fail((err && err.message) || String(err)); },
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// RFC 5804 §2.1 — the server's SASL challenge is sent as a literal:
|
|
717
|
+
// `{N}\r\n<challenge>\r\n`. Returns false when the challenge carries bytes
|
|
718
|
+
// that would end the line, so the caller fails the exchange rather than
|
|
719
|
+
// emitting a second, smuggled protocol line.
|
|
720
|
+
function _writeChallenge(socket, challenge) {
|
|
721
|
+
var safe = mailServerNet.saslChallengeOrNull(challenge);
|
|
722
|
+
if (safe === null) return false;
|
|
723
|
+
try { socket.write("{" + Buffer.byteLength(safe, "utf8") + "}\r\n" + safe + "\r\n"); }
|
|
724
|
+
catch (_e) { /* socket down */ }
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The client's reply to a challenge, per RFC 5804 §1.2 "string": a quoted
|
|
729
|
+
// string, or (accepted defensively) a bare token. `"*"` cancels the exchange
|
|
730
|
+
// — a withdrawal rather than a failed credential, so it costs no budget.
|
|
731
|
+
function _continueSaslExchange(state, socket, line) {
|
|
732
|
+
// RFC 5804 §1.2 — a "string" is a quoted string OR a literal. A SASL
|
|
733
|
+
// response is base64 and can be long, which is exactly when a client
|
|
734
|
+
// reaches for the literal form, and reading `{N+}` as the response itself
|
|
735
|
+
// then reading its bytes as a second response cannot authenticate anyone.
|
|
736
|
+
// The bytes are collected by the same drain-loop branch the initial
|
|
737
|
+
// AUTHENTICATE response uses, and `resume` tells _completeAuthenticate to
|
|
738
|
+
// continue this exchange rather than start a new one at step 0.
|
|
739
|
+
var lit = _parseLiteralMarker(line);
|
|
740
|
+
if (lit) {
|
|
741
|
+
// The client declaring this size is unauthenticated, and the drain loop
|
|
742
|
+
// will collect toward it. Unbounded, `{999999999+}` both consumes memory
|
|
743
|
+
// and pins the connection open waiting for bytes that never arrive.
|
|
744
|
+
//
|
|
745
|
+
// The bound is the guard's own SASL-token cap, the same one it applies to
|
|
746
|
+
// the AUTHENTICATE initial response. One exchange, one bound, whichever
|
|
747
|
+
// round the token arrives on: two numbers for the same thing is how the
|
|
748
|
+
// two halves of a protocol drift apart.
|
|
749
|
+
if (lit.bytes > guardManageSieveCommand.MAX_SASL_TOKEN_BYTES) {
|
|
750
|
+
var oversizeMech = state.saslExchange.mech;
|
|
751
|
+
state.saslExchange = null;
|
|
631
752
|
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
632
753
|
_emit("mail.server.managesieve.auth_failed",
|
|
633
|
-
{ connectionId: state.id, mech:
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
754
|
+
{ connectionId: state.id, mech: oversizeMech,
|
|
755
|
+
reason: "continuation-literal-too-large" }, "denied");
|
|
756
|
+
_writeNo(socket, "Authentication response too long (cap " +
|
|
757
|
+
guardManageSieveCommand.MAX_SASL_TOKEN_BYTES + ")");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
state.pendingAuth = {
|
|
761
|
+
mech: state.saslExchange.mech,
|
|
762
|
+
irBytes: lit.bytes,
|
|
763
|
+
irPlus: lit.plus,
|
|
764
|
+
irBody: Buffer.alloc(0),
|
|
765
|
+
resume: true,
|
|
766
|
+
};
|
|
767
|
+
// Synchronizing form: the server invites the bytes before the client
|
|
768
|
+
// sends them, exactly as it does for an initial-response literal.
|
|
769
|
+
if (!lit.plus) socket.write("OK\r\n");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// The guard owns the quoted form of the same production — escapes
|
|
773
|
+
// included, and NUL / CR / LF refused inside the quotes. A bare token is
|
|
774
|
+
// accepted defensively for clients that omit the quoting.
|
|
775
|
+
var quoted = guardManageSieveCommand.parseQuotedString(line);
|
|
776
|
+
var body = quoted ? quoted.value : line;
|
|
777
|
+
if (body === "*") {
|
|
778
|
+
state.saslExchange = null;
|
|
779
|
+
_writeNo(socket, "Authentication cancelled");
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
_runAuthStep(state, socket, body);
|
|
640
783
|
}
|
|
641
784
|
|
|
642
785
|
function _requireAuth(state, socket) {
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -75,12 +75,14 @@
|
|
|
75
75
|
* - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
|
|
76
76
|
* - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
|
|
77
77
|
* - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
|
|
78
|
-
* - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC results + action (accept / quarantine / reject / defer) + gate mode
|
|
78
|
+
* - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC/ARC results + action (accept / quarantine / reject / defer) + gate mode
|
|
79
79
|
* - `mail.server.mx.envelope_error` — DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)
|
|
80
80
|
* - `mail.server.mx.delivered` — agent.handoff ack
|
|
81
81
|
* - `mail.server.mx.tls_handshake_failed` — handshake error
|
|
82
82
|
* - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
|
|
83
83
|
* - `mail.server.mx.relay_refused` — open-relay attempt
|
|
84
|
+
* - `mail.server.mx.recipient_refused` — recipientPolicy said the mailbox is unavailable (550 5.1.1)
|
|
85
|
+
* - `mail.server.mx.recipient_policy_threw` — recipientPolicy failed; the recipient is deferred (451 4.7.1)
|
|
84
86
|
*
|
|
85
87
|
* ## What v1 does NOT ship
|
|
86
88
|
*
|
|
@@ -114,11 +116,25 @@
|
|
|
114
116
|
* `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
|
|
115
117
|
* (connecting-IP DNS blocklist, evaluated once per connection) and
|
|
116
118
|
* `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
|
|
117
|
-
* RCPT TO and surface their verdicts on the `rcpt_to` event.
|
|
119
|
+
* RCPT TO and surface their verdicts on the `rcpt_to` event.
|
|
120
|
+
*
|
|
121
|
+
* `opts.recipientPolicy` decides whether a mailbox on a local domain
|
|
122
|
+
* exists, and is the only way to answer "no such user" the way RFC
|
|
123
|
+
* 5321 §3.3 asks. It runs at RCPT TO, so the refusal costs one command
|
|
124
|
+
* rather than a whole message body: `{ ok: false, reason }` becomes
|
|
125
|
+
* 550 5.1.1 with the reason, and a throw becomes 451 4.7.1 (a
|
|
126
|
+
* directory that cannot be reached is not a verdict about the
|
|
127
|
+
* mailbox). Refusals charge the same per-IP recipient-failure budget
|
|
128
|
+
* as a relay refusal, because the 250-vs-550 difference is a
|
|
129
|
+
* mailbox-existence oracle. Unwired, every syntactically valid
|
|
130
|
+
* recipient on a local domain is accepted and the agent handoff is
|
|
131
|
+
* the operator's last chance to reject. The
|
|
132
|
+
|
|
118
133
|
* message-authentication gate (`opts.guardEnvelope`) runs at DATA
|
|
119
134
|
* completion through `b.mail.inbound.verify` — SPF (RFC 7208) on the
|
|
120
135
|
* envelope identity, DKIM (RFC 6376) on the message bytes, DMARC
|
|
121
|
-
* (RFC 9989) policy + alignment on the From-header domain
|
|
136
|
+
* (RFC 9989) policy + alignment on the From-header domain, ARC
|
|
137
|
+
* (RFC 8617) on any chain a forwarder left behind — and in
|
|
122
138
|
* enforce mode refuses before the agent handoff: 550 5.7.26
|
|
123
139
|
* (RFC 7372) when the sender's published policy says reject, 550
|
|
124
140
|
* 5.7.1 on the RFC 9989 §5.3.1 multi-From spoofing shape, 451 4.7.0
|
|
@@ -307,12 +323,13 @@ function _stripForgedAuthResults(messageBuf, authservId) {
|
|
|
307
323
|
* agent: b.mail.agent, // optional delivery handoff
|
|
308
324
|
* relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
|
|
309
325
|
* localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
|
|
326
|
+
* recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
|
|
310
327
|
* maxLineBytes: number, // default 1 KiB — per-command line cap
|
|
311
328
|
* maxMessageBytes: number, // default 50 MiB — DATA body cap
|
|
312
329
|
* maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
|
|
313
330
|
* idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
|
|
314
331
|
* profile: "strict" | "balanced" | "permissive", // gate posture cascade
|
|
315
|
-
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC via b.mail.inbound.verify
|
|
332
|
+
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
|
|
316
333
|
* mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
|
|
317
334
|
* onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
|
|
318
335
|
* authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
|
|
@@ -1035,6 +1052,60 @@ function create(opts) {
|
|
|
1035
1052
|
return;
|
|
1036
1053
|
}
|
|
1037
1054
|
}
|
|
1055
|
+
// Operator-supplied recipient policy — the only place a listener can
|
|
1056
|
+
// answer "no such mailbox" the way RFC 5321 §3.3 asks. Without it, a
|
|
1057
|
+
// local domain accepted every local part and the application first met
|
|
1058
|
+
// the recipient at agent.handoff, after 354 and after the whole message:
|
|
1059
|
+
// from there the choices were 250 (tell the peer it arrived, then owe a
|
|
1060
|
+
// DSN) or 451 (tell a peer holding a permanent condition to retry
|
|
1061
|
+
// forever). Neither is a refusal. Same shape as the submission
|
|
1062
|
+
// listener's hook: `{ ok: true }` accepts, `{ ok: false, reason }`
|
|
1063
|
+
// refuses. Unwired, every syntactically valid recipient on a local
|
|
1064
|
+
// domain is accepted, exactly as before.
|
|
1065
|
+
if (typeof opts.recipientPolicy === "function") {
|
|
1066
|
+
var rcptVerdictPolicy;
|
|
1067
|
+
try {
|
|
1068
|
+
rcptVerdictPolicy = await opts.recipientPolicy({
|
|
1069
|
+
mailFrom: state.mailFrom,
|
|
1070
|
+
rcptTo: rcpt,
|
|
1071
|
+
connectionId: state.id,
|
|
1072
|
+
remoteAddress: state.remoteAddress,
|
|
1073
|
+
tls: state.tls,
|
|
1074
|
+
heloName: state.heloName || null,
|
|
1075
|
+
});
|
|
1076
|
+
} catch (policyErr) {
|
|
1077
|
+
// The operator's directory being unreachable is not a verdict about
|
|
1078
|
+
// this mailbox. A 550 here would permanently reject mail for a
|
|
1079
|
+
// legitimate recipient because a lookup failed, so it defers.
|
|
1080
|
+
_emit("mail.server.mx.recipient_policy_threw",
|
|
1081
|
+
{ connectionId: state.id, rcptTo: rcpt,
|
|
1082
|
+
error: (policyErr && policyErr.message) || String(policyErr) }, "failure");
|
|
1083
|
+
_writeReply(socket, REPLY_451_LOCAL_ERROR,
|
|
1084
|
+
"4.7.1 Recipient policy temporarily unavailable");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (!rcptVerdictPolicy || rcptVerdictPolicy.ok !== true) {
|
|
1088
|
+
// The 250-vs-550 difference is a mailbox-existence oracle, so a
|
|
1089
|
+
// policy refusal charges the same per-IP recipient-failure budget
|
|
1090
|
+
// the relay refusal does. Without that, wiring this hook would hand
|
|
1091
|
+
// a scanner a free enumeration channel the listener did not have.
|
|
1092
|
+
rateLimit.noteRcptFailure(state.remoteAddress);
|
|
1093
|
+
_trackRefusedRcpt(state, rcpt, "recipient-policy");
|
|
1094
|
+
_emit("mail.server.mx.recipient_refused",
|
|
1095
|
+
{ connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
|
|
1096
|
+
reason: (rcptVerdictPolicy && rcptVerdictPolicy.reason) || "policy-refused",
|
|
1097
|
+
remoteAddress: state.remoteAddress }, "denied");
|
|
1098
|
+
// The reason goes onto a line-oriented reply, and a directory wrapper
|
|
1099
|
+
// routinely quotes the address it looked up — which the peer chose.
|
|
1100
|
+
// A CR or LF in it would end the 550 early and let the remainder be
|
|
1101
|
+
// read as a second, forged reply. The refusal still happens; only the
|
|
1102
|
+
// prose falls back.
|
|
1103
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
1104
|
+
"5.1.1 " + mailServerNet.replyTextOrFallback(
|
|
1105
|
+
rcptVerdictPolicy && rcptVerdictPolicy.reason, "Mailbox unavailable"));
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1038
1109
|
state.rcpts.push(rcpt);
|
|
1039
1110
|
_emit("mail.server.mx.rcpt_to",
|
|
1040
1111
|
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
|
|
@@ -1143,6 +1214,7 @@ function create(opts) {
|
|
|
1143
1214
|
spf: inboundVerdict.spf.result,
|
|
1144
1215
|
dkim: dkimSummary,
|
|
1145
1216
|
dmarc: inboundVerdict.dmarc.result,
|
|
1217
|
+
arc: inboundVerdict.arc && inboundVerdict.arc.chainStatus,
|
|
1146
1218
|
action: envAction,
|
|
1147
1219
|
mode: envelopeGate.mode,
|
|
1148
1220
|
}, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
|
package/lib/mail-server-net.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Copyright (c) blamejs contributors
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
|
+
var codepointClass = require("./codepoint-class");
|
|
6
|
+
|
|
5
7
|
// mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
|
|
6
8
|
// servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
|
|
7
9
|
// those keeps its OWN connection set and close() drain because those diverge:
|
|
@@ -171,9 +173,133 @@ function validateDomainHardened(d, label, cfg) {
|
|
|
171
173
|
return verdict;
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
// saslChallengeOrNull(challenge) — the operator's SASL challenge, checked for
|
|
177
|
+
// the bytes that would end the line it is written on.
|
|
178
|
+
//
|
|
179
|
+
// Every listener that supports a multi-step SASL exchange writes this value
|
|
180
|
+
// straight to the wire, and it is not always the operator's own text: a SCRAM
|
|
181
|
+
// or CRAM mechanism composes its challenge from the client's nonce, so client
|
|
182
|
+
// bytes reach this line. A CR, LF or NUL in it terminates the server's reply
|
|
183
|
+
// early and the remainder is read by the client as a second protocol line —
|
|
184
|
+
// the same injection class the outbound SMTP transport refuses at config time
|
|
185
|
+
// (GHSA-c7w3-x93f-qmm8). Returns null when the challenge cannot be written
|
|
186
|
+
// safely; the caller fails the exchange rather than emitting a smuggled line.
|
|
187
|
+
function saslChallengeOrNull(challenge) {
|
|
188
|
+
if (typeof challenge !== "string") return null;
|
|
189
|
+
return codepointClass.firstLineInjectionCharOffset(challenge) === -1 ? challenge : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// replyTextOrFallback(text, fallback) — operator-supplied prose, made safe to
|
|
193
|
+
// write into a line-oriented protocol reply.
|
|
194
|
+
//
|
|
195
|
+
// A refusal reason is the common case, and it is rarely the operator's own
|
|
196
|
+
// words: a directory wrapper answers "No such user: <address>", and the address
|
|
197
|
+
// came from the peer. A CR or LF in it ends the reply line early and everything
|
|
198
|
+
// after is read by the peer as a second server response, so a `550` refusal can
|
|
199
|
+
// carry a forged `250` acceptance.
|
|
200
|
+
//
|
|
201
|
+
// Unlike a SASL challenge, a refusal must still happen: dropping the whole
|
|
202
|
+
// reply would turn an injection attempt into a hang. So the unsafe text is
|
|
203
|
+
// replaced by the caller's fallback and the refusal is delivered.
|
|
204
|
+
function replyTextOrFallback(text, fallback) {
|
|
205
|
+
if (typeof text !== "string" || text.length === 0) return fallback;
|
|
206
|
+
return codepointClass.firstLineInjectionCharOffset(text) === -1 ? text : fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// runSaslStep(cfg) — one round of a multi-step SASL exchange.
|
|
210
|
+
//
|
|
211
|
+
// IMAP, POP3, ManageSieve and submission all run the same loop: call the
|
|
212
|
+
// operator's verifier with the current step and the client's latest response,
|
|
213
|
+
// then either write a challenge and wait, complete the authentication, or fail
|
|
214
|
+
// it. Only the wire syntax of each outcome differs, so the loop lives here once
|
|
215
|
+
// and each listener supplies the four writers. Keeping it in one place is what
|
|
216
|
+
// stops the listeners drifting apart again — the whole reason POP3 and
|
|
217
|
+
// ManageSieve did not honour `pending` was that each had its own copy.
|
|
218
|
+
//
|
|
219
|
+
// cfg.exchange { mech, step } — mutated: step increments each round.
|
|
220
|
+
// cfg.verify the operator's verify(mechanism, credentials).
|
|
221
|
+
// cfg.credentials extra fields merged into the credentials object
|
|
222
|
+
// (tls, remoteAddress).
|
|
223
|
+
// cfg.clientResponse this round's client response, or null.
|
|
224
|
+
// cfg.writeChallenge (challenge) => boolean — write it, false if unsafe.
|
|
225
|
+
// cfg.onChallengeUnsafe () => void — the challenge could not be written.
|
|
226
|
+
// cfg.onSuccess (result) => void — verify returned { ok, actor }.
|
|
227
|
+
// cfg.onFailure (result) => void — verify declined.
|
|
228
|
+
// cfg.onError (err) => void — verify threw.
|
|
229
|
+
//
|
|
230
|
+
// A `pending` verdict is NOT a failure and none of the failure paths run for
|
|
231
|
+
// it: it is a normal protocol round trip, and charging it against an
|
|
232
|
+
// authentication-failure budget would spend the defence that budget exists to
|
|
233
|
+
// provide.
|
|
234
|
+
function runSaslStep(cfg) {
|
|
235
|
+
var ex = cfg.exchange;
|
|
236
|
+
// A client can put several lines in one TCP segment, and a listener's drain
|
|
237
|
+
// loop dispatches them without awaiting. Two SASL responses arriving together
|
|
238
|
+
// therefore each started a verifier call at the SAME `ex.step`: concurrent
|
|
239
|
+
// rounds, results landing out of order, and a later response able to complete
|
|
240
|
+
// authentication before the challenge for it had been issued.
|
|
241
|
+
//
|
|
242
|
+
// A client that answers before it has been asked is not following the
|
|
243
|
+
// protocol, so the second response fails the exchange rather than queueing:
|
|
244
|
+
// queueing would preserve the ordering but still credit a response the server
|
|
245
|
+
// never solicited.
|
|
246
|
+
// Once a pipelining violation has been reported the exchange is DEAD, and
|
|
247
|
+
// stays dead. A listener is expected to tear the connection down, but if it
|
|
248
|
+
// does not, a resumed exchange must not become authenticable just because the
|
|
249
|
+
// violation has scrolled past.
|
|
250
|
+
if (ex.abandoned) {
|
|
251
|
+
cfg.onFailure({ reason: "pipelined-sasl-response" });
|
|
252
|
+
return Promise.resolve();
|
|
253
|
+
}
|
|
254
|
+
if (ex.inFlight) {
|
|
255
|
+
// The round already in flight is ABANDONED, not merely reported. Its
|
|
256
|
+
// verifier has already been called and can still resolve `{ ok: true }`,
|
|
257
|
+
// and invoking onSuccess then authenticates the connection whose pipelined
|
|
258
|
+
// response was just refused — so reporting the refusal would have decided
|
|
259
|
+
// nothing. Every completion below checks this before calling back.
|
|
260
|
+
ex.abandoned = true;
|
|
261
|
+
cfg.onFailure({ reason: "pipelined-sasl-response" });
|
|
262
|
+
return Promise.resolve();
|
|
263
|
+
}
|
|
264
|
+
ex.inFlight = true;
|
|
265
|
+
return Promise.resolve()
|
|
266
|
+
.then(function () {
|
|
267
|
+
var creds = { step: ex.step, clientResponse: cfg.clientResponse };
|
|
268
|
+
if (cfg.credentials) {
|
|
269
|
+
Object.keys(cfg.credentials).forEach(function (k) { creds[k] = cfg.credentials[k]; });
|
|
270
|
+
}
|
|
271
|
+
return cfg.verify(ex.mech, creds);
|
|
272
|
+
})
|
|
273
|
+
.then(function (result) {
|
|
274
|
+
// Cleared before the callbacks run, so the next round — which a
|
|
275
|
+
// challenge invites — is free to start.
|
|
276
|
+
ex.inFlight = false;
|
|
277
|
+
// Abandoned while this was in flight: the listener has already answered
|
|
278
|
+
// the pipelining violation, and a second verdict on a dead exchange is
|
|
279
|
+
// the bypass this guard exists to close.
|
|
280
|
+
if (ex.abandoned) return;
|
|
281
|
+
ex.step += 1;
|
|
282
|
+
if (result && result.pending && typeof result.challenge === "string") {
|
|
283
|
+
if (cfg.writeChallenge(result.challenge)) return;
|
|
284
|
+
cfg.onChallengeUnsafe();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (result && result.ok === true && result.actor) { cfg.onSuccess(result); return; }
|
|
288
|
+
cfg.onFailure(result);
|
|
289
|
+
})
|
|
290
|
+
.catch(function (err) {
|
|
291
|
+
ex.inFlight = false;
|
|
292
|
+
if (ex.abandoned) return; // same reason as the resolve path
|
|
293
|
+
cfg.onError(err);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
174
297
|
module.exports = {
|
|
175
298
|
createTcpListener: createTcpListener,
|
|
299
|
+
runSaslStep: runSaslStep,
|
|
176
300
|
createStoreServer: createStoreServer,
|
|
177
301
|
admitConnection: admitConnection,
|
|
178
302
|
validateDomainHardened: validateDomainHardened,
|
|
303
|
+
saslChallengeOrNull: saslChallengeOrNull,
|
|
304
|
+
replyTextOrFallback: replyTextOrFallback,
|
|
179
305
|
};
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -264,6 +264,7 @@ function create(opts) {
|
|
|
264
264
|
stage: "authorization",
|
|
265
265
|
actor: null,
|
|
266
266
|
tentativeUser: null, // USER name pending PASS
|
|
267
|
+
authPending: null, // in-flight multi-step SASL exchange (RFC 5034 §4)
|
|
267
268
|
dropId: null, // mailStore-issued drop handle on TRANSACTION entry
|
|
268
269
|
lineBuffer: Buffer.alloc(0),
|
|
269
270
|
};
|
|
@@ -307,6 +308,14 @@ function create(opts) {
|
|
|
307
308
|
}
|
|
308
309
|
|
|
309
310
|
function _handleLine(state, socket, line) {
|
|
311
|
+
// Mid-SASL: the client's next line is a base64 response to the server's
|
|
312
|
+
// challenge, not a POP3 verb, so it goes to the exchange rather than the
|
|
313
|
+
// wire guard — which would refuse it as an unknown command. Same ordering
|
|
314
|
+
// the submission listener uses for its own AUTH continuation.
|
|
315
|
+
if (state.authPending) {
|
|
316
|
+
_continueAuthExchange(state, socket, line);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
310
319
|
var parsed;
|
|
311
320
|
try {
|
|
312
321
|
parsed = guardPop3Command.validate(line, {
|
|
@@ -596,28 +605,59 @@ function create(opts) {
|
|
|
596
605
|
}
|
|
597
606
|
var mech = args[0].toUpperCase();
|
|
598
607
|
var initialResp = args.length > 1 ? args.slice(1).join(" ") : null;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
608
|
+
state.authPending = { mech: mech, step: 0 };
|
|
609
|
+
_runAuthStep(state, socket, initialResp);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// One round of a SASL exchange (RFC 5034 §4). The verifier may answer with
|
|
613
|
+
// `{ pending: true, challenge }` to ask for another client response, exactly
|
|
614
|
+
// as it may on the IMAP and submission listeners; POP3 used to call verify
|
|
615
|
+
// once with no `step`, so a pending verdict fell into the failure branch —
|
|
616
|
+
// and spent the client's authentication-failure budget for what is a normal
|
|
617
|
+
// protocol round trip, weakening the defence that budget exists to provide.
|
|
618
|
+
function _runAuthStep(state, socket, clientResponse) {
|
|
619
|
+
var pending = state.authPending;
|
|
620
|
+
function _fail(reason, outcome) {
|
|
621
|
+
state.authPending = null;
|
|
622
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
623
|
+
_emit("mail.server.pop3.auth_failed",
|
|
624
|
+
{ connectionId: state.id, verb: "AUTH", mech: pending.mech, reason: reason },
|
|
625
|
+
outcome);
|
|
626
|
+
_writeErr(socket, "Authentication failed");
|
|
627
|
+
}
|
|
628
|
+
mailServerNet.runSaslStep({
|
|
629
|
+
// `pending` IS the exchange: runSaslStep increments its `step`, and it
|
|
630
|
+
// stays on `state.authPending` across rounds.
|
|
631
|
+
exchange: pending,
|
|
632
|
+
verify: authConfig.verify,
|
|
633
|
+
credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
|
|
634
|
+
clientResponse: clientResponse,
|
|
635
|
+
// RFC 5034 §4 — the server's challenge is a `+ <base64>` line, and the
|
|
636
|
+
// connection stays mid-exchange until the client answers.
|
|
637
|
+
writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
|
|
638
|
+
onChallengeUnsafe: function () { _fail("challenge-contains-line-terminator", "denied"); },
|
|
639
|
+
onSuccess: function (result) {
|
|
640
|
+
state.authPending = null;
|
|
641
|
+
if (!_assertTenantOrRefuse(state, socket, result)) return;
|
|
642
|
+
state.actor = result.actor;
|
|
643
|
+
_enterTransaction(state, socket, "AUTH/" + pending.mech);
|
|
644
|
+
},
|
|
645
|
+
onFailure: function (result) {
|
|
646
|
+
_fail((result && result.reason) || "verify-returned-fail", "denied");
|
|
647
|
+
},
|
|
648
|
+
onError: function (err) { _fail((err && err.message) || String(err), "failure"); },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function _continueAuthExchange(state, socket, line) {
|
|
653
|
+
// RFC 5034 §4 — a bare `*` cancels the exchange. Cancelling is the client
|
|
654
|
+
// withdrawing, not failing to authenticate, so it costs no budget either.
|
|
655
|
+
if (line === "*") {
|
|
656
|
+
state.authPending = null;
|
|
657
|
+
_writeErr(socket, "Authentication cancelled");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
_runAuthStep(state, socket, line);
|
|
621
661
|
}
|
|
622
662
|
|
|
623
663
|
function _enterTransaction(state, socket, verb) {
|
|
@@ -840,6 +880,17 @@ function create(opts) {
|
|
|
840
880
|
|
|
841
881
|
function _writeOk(socket, msg) { try { socket.write("+OK " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
|
|
842
882
|
function _writeErr(socket, msg) { try { socket.write("-ERR " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
|
|
883
|
+
// RFC 5034 §4 SASL continuation — `+ <base64>`, or a bare `+` for an empty
|
|
884
|
+
// challenge. Returns false when the challenge carries bytes that would end
|
|
885
|
+
// the line, so the caller fails the exchange instead of emitting a second,
|
|
886
|
+
// smuggled protocol line.
|
|
887
|
+
function _writeContinuation(socket, challenge) {
|
|
888
|
+
var b64 = mailServerNet.saslChallengeOrNull(challenge);
|
|
889
|
+
if (b64 === null) return false;
|
|
890
|
+
try { socket.write(b64.length > 0 ? "+ " + b64 + "\r\n" : "+\r\n"); }
|
|
891
|
+
catch (_e) { /* socket down */ }
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
843
894
|
function _close(socket) {
|
|
844
895
|
try { socket.end(); } catch (_e) { /* idempotent */ }
|
|
845
896
|
try { socket.destroy(); } catch (_e2) { /* idempotent */ }
|