@blamejs/core 0.18.53 → 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.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
@@ -2,6 +2,9 @@
2
2
  // Copyright (c) blamejs contributors
3
3
  "use strict";
4
4
 
5
+ var codepointClass = require("./codepoint-class");
6
+ var bCrypto = require("./crypto");
7
+
5
8
  // mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
6
9
  // servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
7
10
  // those keeps its OWN connection set and close() drain because those diverge:
@@ -14,6 +17,14 @@
14
17
  // EACCES) rejects the listen promise instead of crashing the process. That, plus
15
18
  // the listening/server state, is what createTcpListener owns.
16
19
 
20
+ // The listener's own ceiling when an operator names none. Every mail listener
21
+ // shares it, so a deployment that raises or lowers it does so in one place and
22
+ // the five listeners cannot drift apart on what "too many" means. Set well
23
+ // above any single-host mail deployment's working set and far below what an
24
+ // unbounded accept loop will take: the point is that a ceiling EXISTS, since
25
+ // the per-address cap alone leaves the total at (cap x source addresses).
26
+ var DEFAULT_MAX_CONNECTIONS = 1024;
27
+
17
28
  // createTcpListener(net, cfg) — build a listener lifecycle.
18
29
  // cfg.defaultPort port used when listenOpts.port is omitted (an explicit
19
30
  // 0 is honored, for an ephemeral test bind).
@@ -24,6 +35,15 @@
24
35
  // cfg.listeningEvent the "...listening" audit action.
25
36
  // cfg.listeningExtra optional () => object merged onto the listening event
26
37
  // payload (Submission reports implicitTls).
38
+ // cfg.maxConnections the listener's own ceiling on concurrently accepted
39
+ // sockets. The per-address cap in b.mail.server.rateLimit
40
+ // bounds ONE peer; it says nothing about how many peers
41
+ // there are, so the process-wide total was the per-address
42
+ // cap times however many source addresses the caller could
43
+ // speak from — a number a botnet, a NAT pool or a single
44
+ // v6 /64 makes large. Enforced by the runtime, which closes
45
+ // the excess socket before handleConnection ever sees it,
46
+ // so a refusal costs no descriptor and no state machine.
27
47
  // Returns { listen, getServer, isListening, markClosed } — the server wires its
28
48
  // own close() through getServer()/isListening()/markClosed().
29
49
  function createTcpListener(net, cfg) {
@@ -38,6 +58,7 @@ function createTcpListener(net, cfg) {
38
58
  var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
39
59
  var address = listenOpts.address || "0.0.0.0";
40
60
  server = net.createServer(function (socket) { cfg.handleConnection(socket); });
61
+ server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
41
62
  return new Promise(function (resolve, reject) {
42
63
  server.once("error", reject);
43
64
  server.listen(port, address, function () {
@@ -108,6 +129,7 @@ function createStoreServer(net, cfg) {
108
129
  var ErrorClass = cfg.errorClass;
109
130
  var listener = createTcpListener(net, {
110
131
  defaultPort: cfg.defaultPort,
132
+ maxConnections: cfg.maxConnections,
111
133
  handleConnection: cfg.handleConnection,
112
134
  errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
113
135
  emit: cfg.emit,
@@ -120,7 +142,137 @@ function createStoreServer(net, cfg) {
120
142
  closedEvent: cfg.eventBase + ".closed",
121
143
  });
122
144
  }
123
- return { listen: listener.listen, close: close };
145
+ return {
146
+ listen: listener.listen,
147
+ close: close,
148
+ connectionCount: function () { return cfg.connections.size; },
149
+ };
150
+ }
151
+
152
+ // trackConnection(socket, cfg) — the other half of admitConnection, and the
153
+ // reason it is here rather than written out per listener.
154
+ //
155
+ // A connection occupies two ledgers: the rate limiter's per-address count, and
156
+ // the listener's live-socket set that shutdown drains. Both are released by the
157
+ // SAME event — the socket closing — and it does not matter who closed it. Five
158
+ // listeners each wrote that pairing by hand and one of them registered only the
159
+ // release, so a peer that opened a connection, took the greeting and dropped TCP
160
+ // freed its rate-limit slot (and could reconnect at once) while its set entry
161
+ // stayed forever. Nothing authenticates before that point, so the growth was
162
+ // unauthenticated and unbounded.
163
+ //
164
+ // Registering both in one handler is what makes the pair impossible to
165
+ // half-write.
166
+ // cfg.connections the listener's live-socket Set.
167
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
168
+ // cfg.remoteAddress the address admitConnection returned.
169
+ // cfg.closeSource optional socket whose "close" drives teardown, when the
170
+ // tracked socket is a wrapper (Submission's implicit-TLS
171
+ // path tracks the TLSSocket but the raw socket is the one
172
+ // that carries the FIN).
173
+ function trackConnection(socket, cfg) {
174
+ cfg.connections.add(socket);
175
+ var source = cfg.closeSource || socket;
176
+ source.once("close", function () {
177
+ cfg.rateLimit.releaseConnection(cfg.remoteAddress);
178
+ cfg.connections.delete(socket);
179
+ });
180
+ }
181
+
182
+ // createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
183
+ // windows instead of the whole transfer.
184
+ //
185
+ // A lifetime average lets an early burst pay for an arbitrarily slow tail: at
186
+ // the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
187
+ // six, which a peer spends holding a connection and its slot in the per-address
188
+ // cap while sending a byte at a time. The floor was enforced and still
189
+ // bypassable, which is the worse of the two states — it reads as covered.
190
+ //
191
+ // Each window has to meet the floor on its own, so nothing sent earlier pays
192
+ // for what is sent now. A window only rolls forward once it has run long enough
193
+ // to be a real judgement; inside that stretch no verdict is reached, so a
194
+ // sender pausing to read from its own spool is not cut off for the pause, and a
195
+ // fast chunk cannot keep resetting the clock to dodge the next measurement.
196
+ //
197
+ // `now` is a parameter rather than a call to the clock so the whole thing can
198
+ // be driven across days of simulated time without waiting for them.
199
+ // rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
200
+ function createBodyRateWindow(rateLimit) {
201
+ var windowStart = 0;
202
+ var windowBytes = 0;
203
+ return {
204
+ // Open the first window. `bytesSeen` is the caller's running total AT THIS
205
+ // MOMENT, which becomes the baseline every later reading is measured
206
+ // against — so the caller is free to keep one connection-lifetime counter
207
+ // incremented at the wire boundary rather than a per-transfer one. That
208
+ // matters where a listener re-feeds part of a chunk through its own parser:
209
+ // a counter maintained inside the parser credits those bytes twice, and a
210
+ // peer that pipelines a one-byte payload with the next command gets roughly
211
+ // double the rate it is actually sending.
212
+ start: function (now, bytesSeen) {
213
+ windowStart = now;
214
+ windowBytes = bytesSeen || 0;
215
+ },
216
+ // `bytesSeen` is the running total for the whole body; the window's own
217
+ // count is derived, so the caller keeps one counter rather than two.
218
+ starved: function (bytesSeen, now) {
219
+ var elapsed = now - windowStart;
220
+ if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
221
+ // The limiter says how long a window must run before rolling, because the
222
+ // limiter is what decides when a measurement is old enough to mean
223
+ // something. Rolling on a number held HERE would ask a limiter that
224
+ // judges over a longer stretch only ever before it can answer: every call
225
+ // returning "too early", the window resetting underneath it, and its rate
226
+ // protection silently disabled while looking wired.
227
+ if (elapsed >= rateLimit.bodyRateWindowMs()) {
228
+ windowStart = now;
229
+ windowBytes = bytesSeen;
230
+ }
231
+ return false;
232
+ },
233
+ };
234
+ }
235
+
236
+ // acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
237
+ // accept and its own state machine: gate the address, mint the connection id,
238
+ // wrap the socket if the protocol starts in TLS, and enter both ledgers.
239
+ //
240
+ // Every listener performed these four steps in the same order, and the parts
241
+ // that differ between them are three strings and an optional wrap. Keeping the
242
+ // order in one place is what stops a fifth listener from being written with one
243
+ // of the steps missing, which is how the tracking-set entry and the rate-limit
244
+ // slot came apart in the first place.
245
+ //
246
+ // Returns null when the address was refused — the caller returns immediately;
247
+ // the refusal line and teardown are already done.
248
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
249
+ // cfg.connections the listener's live-socket Set.
250
+ // cfg.emit the listener's audit emitter.
251
+ // cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
252
+ // cfg.refusalLine the protocol's refusal bytes.
253
+ // cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
254
+ // cfg.wrap optional (rawSocket) => socket, for a protocol that
255
+ // starts inside TLS. The RAW socket still drives teardown:
256
+ // a handshake that never completes closes it without the
257
+ // wrapper ever being established.
258
+ function acceptConnection(rawSocket, cfg) {
259
+ var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
260
+ refusedEvent: cfg.refusedEvent,
261
+ refusalLine: cfg.refusalLine,
262
+ });
263
+ if (remoteAddress === null) return null;
264
+ var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
265
+ trackConnection(socket, {
266
+ connections: cfg.connections,
267
+ rateLimit: cfg.rateLimit,
268
+ remoteAddress: remoteAddress,
269
+ closeSource: socket === rawSocket ? null : rawSocket,
270
+ });
271
+ return {
272
+ socket: socket,
273
+ remoteAddress: remoteAddress,
274
+ connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
275
+ };
124
276
  }
125
277
 
126
278
  // admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
@@ -171,9 +323,137 @@ function validateDomainHardened(d, label, cfg) {
171
323
  return verdict;
172
324
  }
173
325
 
326
+ // saslChallengeOrNull(challenge) — the operator's SASL challenge, checked for
327
+ // the bytes that would end the line it is written on.
328
+ //
329
+ // Every listener that supports a multi-step SASL exchange writes this value
330
+ // straight to the wire, and it is not always the operator's own text: a SCRAM
331
+ // or CRAM mechanism composes its challenge from the client's nonce, so client
332
+ // bytes reach this line. A CR, LF or NUL in it terminates the server's reply
333
+ // early and the remainder is read by the client as a second protocol line —
334
+ // the same injection class the outbound SMTP transport refuses at config time
335
+ // (GHSA-c7w3-x93f-qmm8). Returns null when the challenge cannot be written
336
+ // safely; the caller fails the exchange rather than emitting a smuggled line.
337
+ function saslChallengeOrNull(challenge) {
338
+ if (typeof challenge !== "string") return null;
339
+ return codepointClass.firstLineInjectionCharOffset(challenge) === -1 ? challenge : null;
340
+ }
341
+
342
+ // replyTextOrFallback(text, fallback) — operator-supplied prose, made safe to
343
+ // write into a line-oriented protocol reply.
344
+ //
345
+ // A refusal reason is the common case, and it is rarely the operator's own
346
+ // words: a directory wrapper answers "No such user: <address>", and the address
347
+ // came from the peer. A CR or LF in it ends the reply line early and everything
348
+ // after is read by the peer as a second server response, so a `550` refusal can
349
+ // carry a forged `250` acceptance.
350
+ //
351
+ // Unlike a SASL challenge, a refusal must still happen: dropping the whole
352
+ // reply would turn an injection attempt into a hang. So the unsafe text is
353
+ // replaced by the caller's fallback and the refusal is delivered.
354
+ function replyTextOrFallback(text, fallback) {
355
+ if (typeof text !== "string" || text.length === 0) return fallback;
356
+ return codepointClass.firstLineInjectionCharOffset(text) === -1 ? text : fallback;
357
+ }
358
+
359
+ // runSaslStep(cfg) — one round of a multi-step SASL exchange.
360
+ //
361
+ // IMAP, POP3, ManageSieve and submission all run the same loop: call the
362
+ // operator's verifier with the current step and the client's latest response,
363
+ // then either write a challenge and wait, complete the authentication, or fail
364
+ // it. Only the wire syntax of each outcome differs, so the loop lives here once
365
+ // and each listener supplies the four writers. Keeping it in one place is what
366
+ // stops the listeners drifting apart again — the whole reason POP3 and
367
+ // ManageSieve did not honour `pending` was that each had its own copy.
368
+ //
369
+ // cfg.exchange { mech, step } — mutated: step increments each round.
370
+ // cfg.verify the operator's verify(mechanism, credentials).
371
+ // cfg.credentials extra fields merged into the credentials object
372
+ // (tls, remoteAddress).
373
+ // cfg.clientResponse this round's client response, or null.
374
+ // cfg.writeChallenge (challenge) => boolean — write it, false if unsafe.
375
+ // cfg.onChallengeUnsafe () => void — the challenge could not be written.
376
+ // cfg.onSuccess (result) => void — verify returned { ok, actor }.
377
+ // cfg.onFailure (result) => void — verify declined.
378
+ // cfg.onError (err) => void — verify threw.
379
+ //
380
+ // A `pending` verdict is NOT a failure and none of the failure paths run for
381
+ // it: it is a normal protocol round trip, and charging it against an
382
+ // authentication-failure budget would spend the defence that budget exists to
383
+ // provide.
384
+ function runSaslStep(cfg) {
385
+ var ex = cfg.exchange;
386
+ // A client can put several lines in one TCP segment, and a listener's drain
387
+ // loop dispatches them without awaiting. Two SASL responses arriving together
388
+ // therefore each started a verifier call at the SAME `ex.step`: concurrent
389
+ // rounds, results landing out of order, and a later response able to complete
390
+ // authentication before the challenge for it had been issued.
391
+ //
392
+ // A client that answers before it has been asked is not following the
393
+ // protocol, so the second response fails the exchange rather than queueing:
394
+ // queueing would preserve the ordering but still credit a response the server
395
+ // never solicited.
396
+ // Once a pipelining violation has been reported the exchange is DEAD, and
397
+ // stays dead. A listener is expected to tear the connection down, but if it
398
+ // does not, a resumed exchange must not become authenticable just because the
399
+ // violation has scrolled past.
400
+ if (ex.abandoned) {
401
+ cfg.onFailure({ reason: "pipelined-sasl-response" });
402
+ return Promise.resolve();
403
+ }
404
+ if (ex.inFlight) {
405
+ // The round already in flight is ABANDONED, not merely reported. Its
406
+ // verifier has already been called and can still resolve `{ ok: true }`,
407
+ // and invoking onSuccess then authenticates the connection whose pipelined
408
+ // response was just refused — so reporting the refusal would have decided
409
+ // nothing. Every completion below checks this before calling back.
410
+ ex.abandoned = true;
411
+ cfg.onFailure({ reason: "pipelined-sasl-response" });
412
+ return Promise.resolve();
413
+ }
414
+ ex.inFlight = true;
415
+ return Promise.resolve()
416
+ .then(function () {
417
+ var creds = { step: ex.step, clientResponse: cfg.clientResponse };
418
+ if (cfg.credentials) {
419
+ Object.keys(cfg.credentials).forEach(function (k) { creds[k] = cfg.credentials[k]; });
420
+ }
421
+ return cfg.verify(ex.mech, creds);
422
+ })
423
+ .then(function (result) {
424
+ // Cleared before the callbacks run, so the next round — which a
425
+ // challenge invites — is free to start.
426
+ ex.inFlight = false;
427
+ // Abandoned while this was in flight: the listener has already answered
428
+ // the pipelining violation, and a second verdict on a dead exchange is
429
+ // the bypass this guard exists to close.
430
+ if (ex.abandoned) return;
431
+ ex.step += 1;
432
+ if (result && result.pending && typeof result.challenge === "string") {
433
+ if (cfg.writeChallenge(result.challenge)) return;
434
+ cfg.onChallengeUnsafe();
435
+ return;
436
+ }
437
+ if (result && result.ok === true && result.actor) { cfg.onSuccess(result); return; }
438
+ cfg.onFailure(result);
439
+ })
440
+ .catch(function (err) {
441
+ ex.inFlight = false;
442
+ if (ex.abandoned) return; // same reason as the resolve path
443
+ cfg.onError(err);
444
+ });
445
+ }
446
+
174
447
  module.exports = {
175
448
  createTcpListener: createTcpListener,
449
+ runSaslStep: runSaslStep,
176
450
  createStoreServer: createStoreServer,
177
451
  admitConnection: admitConnection,
452
+ trackConnection: trackConnection,
453
+ acceptConnection: acceptConnection,
454
+ createBodyRateWindow: createBodyRateWindow,
455
+ DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
178
456
  validateDomainHardened: validateDomainHardened,
457
+ saslChallengeOrNull: saslChallengeOrNull,
458
+ replyTextOrFallback: replyTextOrFallback,
179
459
  };
@@ -52,9 +52,10 @@
52
52
  * `authFailuresPerIpPer15Min` cap applies to USER+PASS / APOP /
53
53
  * AUTH refusals.
54
54
  *
55
- * - **Slow-loris on RETR / TOP** — per-connection `idleTimeoutMs`
56
- * bounds dead connections; `b.mail.server.rateLimit.minBytesPerSecond`
57
- * bounds trickle-receive class.
55
+ * - **Slow-loris** — per-connection `idleTimeoutMs` bounds a peer that
56
+ * stops making progress, in either direction: a client that sends no
57
+ * further command, and one that stops taking a RETR / TOP response.
58
+ * `maxLineBytes` bounds a command line that never ends.
58
59
  *
59
60
  * ## Audit lifecycle
60
61
  *
@@ -107,7 +108,6 @@
107
108
  var net = require("node:net");
108
109
  var safeBuffer = require("./safe-buffer");
109
110
  var C = require("./constants");
110
- var bCrypto = require("./crypto");
111
111
  var numericBounds = require("./numeric-bounds");
112
112
  var validateOpts = require("./validate-opts");
113
113
  var guardPop3Command = require("./guard-pop3-command");
@@ -152,6 +152,7 @@ var ERR_CLAMP = 200;
152
152
  * greeting: string, // default "blamejs POP3"
153
153
  * maxLineBytes: number, // default 1024
154
154
  * idleTimeoutMs: number, // default 10 min
155
+ * maxConnections: number, // default 1024 — listener-wide ceiling
155
156
  * commitTimeoutMs: number, // default 30 s (UPDATE-state mailStore.commitPop3Drop cap)
156
157
  * profile: "strict" | "balanced" | "permissive",
157
158
  * auth: {
@@ -189,7 +190,7 @@ function create(opts) {
189
190
  "getMessage/listMessages/markDelete; compose b.mailStore.create or operator-supplied backend)");
190
191
  }
191
192
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
192
- ["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs"],
193
+ ["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs", "maxConnections"],
193
194
  "mail.server.pop3.", MailServerPop3Error, "mail-server-pop3/bad-bound");
194
195
 
195
196
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -239,23 +240,18 @@ function create(opts) {
239
240
  var _emit = auditEmit.emit;
240
241
 
241
242
  function _handleConnection(rawSocket) {
242
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
243
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
244
+ rateLimit: rateLimit,
245
+ connections: connections,
246
+ emit: _emit,
243
247
  refusedEvent: "mail.server.pop3.rate_limit_refused",
244
248
  refusalLine: "-ERR Too many connections from your IP\r\n",
249
+ idPrefix: "pop3conn-",
245
250
  });
246
- if (remoteAddress === null) return;
247
- var connectionId = "pop3conn-" + bCrypto.generateToken(8); // connection-id length
248
- var socket = rawSocket;
249
- connections.add(socket);
250
- // Single close handler covers BOTH operator-driven `_close(socket)`
251
- // and client-initiated disconnects (TCP FIN / RST without a
252
- // server-side close call) — releases the rate-limit slot AND
253
- // removes the socket from the tracking set so it can't accumulate
254
- // stale entries across long-lived deployments.
255
- rawSocket.once("close", function () {
256
- rateLimit.releaseConnection(remoteAddress);
257
- connections.delete(socket);
258
- });
251
+ if (accepted === null) return;
252
+ var remoteAddress = accepted.remoteAddress;
253
+ var connectionId = accepted.connectionId;
254
+ var socket = accepted.socket;
259
255
 
260
256
  var state = {
261
257
  id: connectionId,
@@ -264,6 +260,7 @@ function create(opts) {
264
260
  stage: "authorization",
265
261
  actor: null,
266
262
  tentativeUser: null, // USER name pending PASS
263
+ authPending: null, // in-flight multi-step SASL exchange (RFC 5034 §4)
267
264
  dropId: null, // mailStore-issued drop handle on TRANSACTION entry
268
265
  lineBuffer: Buffer.alloc(0),
269
266
  };
@@ -307,6 +304,14 @@ function create(opts) {
307
304
  }
308
305
 
309
306
  function _handleLine(state, socket, line) {
307
+ // Mid-SASL: the client's next line is a base64 response to the server's
308
+ // challenge, not a POP3 verb, so it goes to the exchange rather than the
309
+ // wire guard — which would refuse it as an unknown command. Same ordering
310
+ // the submission listener uses for its own AUTH continuation.
311
+ if (state.authPending) {
312
+ _continueAuthExchange(state, socket, line);
313
+ return;
314
+ }
310
315
  var parsed;
311
316
  try {
312
317
  parsed = guardPop3Command.validate(line, {
@@ -596,28 +601,59 @@ function create(opts) {
596
601
  }
597
602
  var mech = args[0].toUpperCase();
598
603
  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
- });
604
+ state.authPending = { mech: mech, step: 0 };
605
+ _runAuthStep(state, socket, initialResp);
606
+ }
607
+
608
+ // One round of a SASL exchange (RFC 5034 §4). The verifier may answer with
609
+ // `{ pending: true, challenge }` to ask for another client response, exactly
610
+ // as it may on the IMAP and submission listeners; POP3 used to call verify
611
+ // once with no `step`, so a pending verdict fell into the failure branch —
612
+ // and spent the client's authentication-failure budget for what is a normal
613
+ // protocol round trip, weakening the defence that budget exists to provide.
614
+ function _runAuthStep(state, socket, clientResponse) {
615
+ var pending = state.authPending;
616
+ function _fail(reason, outcome) {
617
+ state.authPending = null;
618
+ rateLimit.noteAuthFailure(state.remoteAddress);
619
+ _emit("mail.server.pop3.auth_failed",
620
+ { connectionId: state.id, verb: "AUTH", mech: pending.mech, reason: reason },
621
+ outcome);
622
+ _writeErr(socket, "Authentication failed");
623
+ }
624
+ mailServerNet.runSaslStep({
625
+ // `pending` IS the exchange: runSaslStep increments its `step`, and it
626
+ // stays on `state.authPending` across rounds.
627
+ exchange: pending,
628
+ verify: authConfig.verify,
629
+ credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
630
+ clientResponse: clientResponse,
631
+ // RFC 5034 §4 — the server's challenge is a `+ <base64>` line, and the
632
+ // connection stays mid-exchange until the client answers.
633
+ writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
634
+ onChallengeUnsafe: function () { _fail("challenge-contains-line-terminator", "denied"); },
635
+ onSuccess: function (result) {
636
+ state.authPending = null;
637
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
638
+ state.actor = result.actor;
639
+ _enterTransaction(state, socket, "AUTH/" + pending.mech);
640
+ },
641
+ onFailure: function (result) {
642
+ _fail((result && result.reason) || "verify-returned-fail", "denied");
643
+ },
644
+ onError: function (err) { _fail((err && err.message) || String(err), "failure"); },
645
+ });
646
+ }
647
+
648
+ function _continueAuthExchange(state, socket, line) {
649
+ // RFC 5034 §4 — a bare `*` cancels the exchange. Cancelling is the client
650
+ // withdrawing, not failing to authenticate, so it costs no budget either.
651
+ if (line === "*") {
652
+ state.authPending = null;
653
+ _writeErr(socket, "Authentication cancelled");
654
+ return;
655
+ }
656
+ _runAuthStep(state, socket, line);
621
657
  }
622
658
 
623
659
  function _enterTransaction(state, socket, verb) {
@@ -840,6 +876,17 @@ function create(opts) {
840
876
 
841
877
  function _writeOk(socket, msg) { try { socket.write("+OK " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
842
878
  function _writeErr(socket, msg) { try { socket.write("-ERR " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
879
+ // RFC 5034 §4 SASL continuation — `+ <base64>`, or a bare `+` for an empty
880
+ // challenge. Returns false when the challenge carries bytes that would end
881
+ // the line, so the caller fails the exchange instead of emitting a second,
882
+ // smuggled protocol line.
883
+ function _writeContinuation(socket, challenge) {
884
+ var b64 = mailServerNet.saslChallengeOrNull(challenge);
885
+ if (b64 === null) return false;
886
+ try { socket.write(b64.length > 0 ? "+ " + b64 + "\r\n" : "+\r\n"); }
887
+ catch (_e) { /* socket down */ }
888
+ return true;
889
+ }
843
890
  function _close(socket) {
844
891
  try { socket.end(); } catch (_e) { /* idempotent */ }
845
892
  try { socket.destroy(); } catch (_e2) { /* idempotent */ }
@@ -849,6 +896,7 @@ function create(opts) {
849
896
  // ---- Lifecycle ----------------------------------------------------------
850
897
  return mailServerNet.createStoreServer(net, {
851
898
  defaultPort: 110, // RFC 1939 POP3 port (IANA)
899
+ maxConnections: opts.maxConnections,
852
900
  handleConnection: _handleConnection,
853
901
  errorClass: MailServerPop3Error,
854
902
  errorCodePrefix: "mail-server-pop3/",