@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +33 -32
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +60 -15
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/redact.js +13 -3
  38. package/lib/retention.js +22 -2
  39. package/lib/vendor/MANIFEST.json +12 -12
  40. package/lib/vendor/blamejs-pki.cjs +278 -40
  41. package/lib/yaml-lex.js +55 -1
  42. package/package.json +1 -1
  43. 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
- // After literal-IR is gathered, expect CRLF terminator.
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
- Promise.resolve()
615
- .then(function () {
616
- return authConfig.verify(pa.mech, {
617
- clientResponse: pa.irBody.length > 0 ? pa.irBody.toString("utf8") : null,
618
- tls: state.tls,
619
- remoteAddress: state.remoteAddress,
620
- });
621
- })
622
- .then(function (result) {
623
- if (result && result.ok && result.actor) {
624
- state.actor = result.actor;
625
- state.stage = "authenticated";
626
- _emit("mail.server.managesieve.auth_success",
627
- { connectionId: state.id, mech: pa.mech, tenantId: state.actor.tenantId || null });
628
- _writeOk(socket, "Authenticated");
629
- return;
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: pa.mech, reason: "verify-returned-fail" }, "denied");
634
- _writeNo(socket, "Authentication failed");
635
- })
636
- .catch(function () {
637
- rateLimit.noteAuthFailure(state.remoteAddress);
638
- _writeNo(socket, "Authentication failed");
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) {
@@ -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. The
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 — and in
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");
@@ -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
  };
@@ -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
- Promise.resolve()
600
- .then(function () {
601
- return authConfig.verify(mech, {
602
- clientResponse: initialResp,
603
- tls: state.tls,
604
- remoteAddress: state.remoteAddress,
605
- });
606
- })
607
- .then(function (result) {
608
- if (result && result.ok && result.actor) {
609
- if (!_assertTenantOrRefuse(state, socket, result)) return;
610
- state.actor = result.actor;
611
- _enterTransaction(state, socket, "AUTH/" + mech);
612
- return;
613
- }
614
- rateLimit.noteAuthFailure(state.remoteAddress);
615
- _writeErr(socket, "Authentication failed");
616
- })
617
- .catch(function () {
618
- rateLimit.noteAuthFailure(state.remoteAddress);
619
- _writeErr(socket, "Authentication failed");
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 */ }