@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
@@ -133,7 +133,6 @@ var safeBuffer = require("./safe-buffer");
133
133
  var mailServerTls = require("./mail-server-tls");
134
134
  var mailServerNet = require("./mail-server-net");
135
135
  var C = require("./constants");
136
- var bCrypto = require("./crypto");
137
136
  var numericBounds = require("./numeric-bounds");
138
137
  var validateOpts = require("./validate-opts");
139
138
  var guardManageSieveCommand = require("./guard-managesieve-command");
@@ -172,6 +171,7 @@ var ERR_CLAMP = 200;
172
171
  * greeting: string, // default "blamejs ManageSieve"
173
172
  * maxLineBytes: number, // default 8192
174
173
  * idleTimeoutMs: number, // default 5 min
174
+ * maxConnections: number, // default 1024 — listener-wide ceiling
175
175
  * profile: "strict" | "balanced" | "permissive", // default "strict"
176
176
  * auth: {
177
177
  * mechanisms: ["SCRAM-SHA-256", "OAUTHBEARER", ...], // SASL mechs to advertise
@@ -212,7 +212,7 @@ function create(opts) {
212
212
  "operator-supplied backend)");
213
213
  }
214
214
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
215
- ["maxLineBytes", "idleTimeoutMs"],
215
+ ["maxLineBytes", "idleTimeoutMs", "maxConnections"],
216
216
  "mail.server.managesieve.", MailServerManageSieveError, "mail-server-managesieve/bad-bound");
217
217
 
218
218
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -236,22 +236,18 @@ function create(opts) {
236
236
  var _emit = auditEmit.emit;
237
237
 
238
238
  function _handleConnection(rawSocket) {
239
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
239
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
240
+ rateLimit: rateLimit,
241
+ connections: connections,
242
+ emit: _emit,
240
243
  refusedEvent: "mail.server.managesieve.rate_limit_refused",
241
244
  refusalLine: 'NO "Too many connections from your IP"\r\n',
245
+ idPrefix: "msvconn-",
242
246
  });
243
- if (remoteAddress === null) return;
244
- var connectionId = "msvconn-" + bCrypto.generateToken(8); // connection-id length
245
- var socket = rawSocket;
246
- connections.add(socket);
247
- // Single close handler covers BOTH operator-driven `_close(socket)`
248
- // and client-initiated disconnects (TCP FIN / RST). Releases the
249
- // rate-limit slot AND removes the socket from the tracking set so
250
- // long-lived deployments don't accumulate stale entries.
251
- rawSocket.once("close", function () {
252
- rateLimit.releaseConnection(remoteAddress);
253
- connections.delete(socket);
254
- });
247
+ if (accepted === null) return;
248
+ var remoteAddress = accepted.remoteAddress;
249
+ var connectionId = accepted.connectionId;
250
+ var socket = accepted.socket;
255
251
 
256
252
  var state = {
257
253
  id: connectionId,
@@ -261,6 +257,8 @@ function create(opts) {
261
257
  actor: null,
262
258
  pendingLiteral: null, // { verb, name, size, body, plus }
263
259
  pendingAuth: null, // { mech, irBytes, irPlus, irBody }
260
+ saslExchange: null, // { mech, step } while a multi-step SASL exchange is live
261
+ awaitingLiteralTerminator: false, // a literal body was read; its line's CRLF has not arrived
264
262
  lineBuffer: Buffer.alloc(0),
265
263
  };
266
264
 
@@ -300,6 +298,31 @@ function create(opts) {
300
298
  // AUTHENTICATE with a non-synchronizing initial-response), the next
301
299
  // N bytes are the literal-payload; accumulate them before resuming
302
300
  // line-mode dispatch.
301
+ // The CRLF that ends the LINE a literal sat on. RFC 5804 §4 puts it after
302
+ // the payload, outside the declared octet count, so every literal owes one
303
+ // and no literal's size includes it.
304
+ //
305
+ // Consumed here when it is already buffered, and otherwise awaited: it may
306
+ // arrive in a later segment, and read as a line it is an EMPTY line. On the
307
+ // SASL path an empty line is a second response and the pipelining guard
308
+ // fails the exchange for it; on the PUTSCRIPT path it draws a spurious
309
+ // "empty command line" refusal AFTER the script was accepted, so one command
310
+ // is answered twice and a client that reads one reply per command spends the
311
+ // rest of the session attributing each answer to the command before it.
312
+ //
313
+ // A peek alone is not enough, because whether the terminator has arrived yet
314
+ // is a property of the network rather than of the protocol. Shared by both
315
+ // literal paths: one of them used to do this and the other did not, which is
316
+ // the entire defect.
317
+ function _consumeLiteralTerminator(state) {
318
+ if (state.lineBuffer.length >= 2 &&
319
+ state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
320
+ state.lineBuffer = state.lineBuffer.subarray(2);
321
+ } else {
322
+ state.awaitingLiteralTerminator = true;
323
+ }
324
+ }
325
+
303
326
  function _drainBuffer(state, socket) {
304
327
  while (true) {
305
328
  if (state.pendingLiteral) {
@@ -312,6 +335,7 @@ function create(opts) {
312
335
  }
313
336
  pl.body = Buffer.concat([pl.body, state.lineBuffer.subarray(0, need)]);
314
337
  state.lineBuffer = state.lineBuffer.subarray(need);
338
+ _consumeLiteralTerminator(state);
315
339
  state.pendingLiteral = null;
316
340
  _completePutscript(state, socket, pl);
317
341
  if (state.stage === "closed") return;
@@ -327,7 +351,7 @@ function create(opts) {
327
351
  }
328
352
  pa.irBody = Buffer.concat([pa.irBody, state.lineBuffer.subarray(0, needA)]);
329
353
  state.lineBuffer = state.lineBuffer.subarray(needA);
330
- // After literal-IR is gathered, expect CRLF terminator.
354
+ _consumeLiteralTerminator(state);
331
355
  pa.irBytes = null;
332
356
  _completeAuthenticate(state, socket);
333
357
  if (state.stage === "closed") return;
@@ -343,12 +367,27 @@ function create(opts) {
343
367
  }
344
368
  var rawLine = state.lineBuffer.subarray(0, crlf).toString("utf8");
345
369
  state.lineBuffer = state.lineBuffer.subarray(crlf + 2);
370
+ // The terminator a literal was still owed, arriving in its own segment.
371
+ // It closes the literal's line rather than opening a new one.
372
+ if (state.awaitingLiteralTerminator) {
373
+ state.awaitingLiteralTerminator = false;
374
+ if (rawLine.length === 0) continue;
375
+ }
346
376
  _handleLine(state, socket, rawLine);
347
377
  if (state.stage === "closed") return;
348
378
  }
349
379
  }
350
380
 
351
381
  function _handleLine(state, socket, line) {
382
+ // Mid-SASL: this line is the client's base64 response to the server's
383
+ // challenge, not a ManageSieve verb, so it goes to the exchange rather than
384
+ // the wire guard — which would refuse it as an unknown command. This
385
+ // ordering is why the `overrides` hook could never carry a multi-step
386
+ // mechanism on its own: the guard ran first and the reply never arrived.
387
+ if (state.saslExchange) {
388
+ _continueSaslExchange(state, socket, line);
389
+ return;
390
+ }
352
391
  var parsed;
353
392
  try {
354
393
  parsed = guardManageSieveCommand.validate(line, {
@@ -589,10 +628,19 @@ function create(opts) {
589
628
  mech: mech,
590
629
  irBytes: parsed.literalBytes,
591
630
  irPlus: parsed.literalPlus,
592
- irBody: Buffer.alloc(0),
631
+ // RFC 5804 §2.1 lets the initial response come inline as a quoted string
632
+ // instead of a literal. It arrives complete on this line, so there is
633
+ // nothing to wait for — the exchange runs now, with the response the
634
+ // client actually sent. This used to fall through to the no-initial-
635
+ // response branch and answer a conforming client as though it had said
636
+ // nothing.
637
+ irBody: parsed.initialResponse === null || parsed.initialResponse === undefined
638
+ ? Buffer.alloc(0)
639
+ : Buffer.from(parsed.initialResponse, "utf8"),
593
640
  };
594
641
  if (parsed.literalBytes === null) {
595
- // No initial-response call verify with empty client response.
642
+ // Either an inline initial response, complete above, or none at all
643
+ // in which case verify is called with an empty client response.
596
644
  _completeAuthenticate(state, socket);
597
645
  return;
598
646
  }
@@ -605,38 +653,145 @@ function create(opts) {
605
653
  // after.
606
654
  }
607
655
 
656
+ // `{N}` / `{N+}` on a line of its own, the RFC 5804 §1.2 literal form of a
657
+ // string. Returns null for anything else. Scanned rather than matched so the
658
+ // digit run is bounded by the line the caller already length-capped.
659
+ function _parseLiteralMarker(line) {
660
+ if (line.length < 3 || line.charAt(0) !== "{") return null;
661
+ var i = 1;
662
+ var digits = "";
663
+ while (i < line.length && line.charCodeAt(i) >= 0x30 && line.charCodeAt(i) <= 0x39) {
664
+ digits += line.charAt(i);
665
+ i += 1;
666
+ if (digits.length > 9) return null; // absurd count: not a literal we will honour
667
+ }
668
+ if (digits.length === 0) return null;
669
+ var plus = false;
670
+ if (line.charAt(i) === "+") { plus = true; i += 1; }
671
+ if (line.charAt(i) !== "}" || i !== line.length - 1) return null;
672
+ return { bytes: Number(digits), plus: plus };
673
+ }
674
+
608
675
  function _completeAuthenticate(state, socket) {
609
676
  var pa = state.pendingAuth;
610
677
  state.pendingAuth = null;
611
678
  if (!pa) return;
679
+ // Resuming a live exchange: the step counter and mechanism carry over, and
680
+ // no new auth_attempt is emitted — this is the same attempt, one round on.
681
+ if (pa.resume && state.saslExchange) {
682
+ _runAuthStep(state, socket, pa.irBody.toString("utf8"));
683
+ return;
684
+ }
612
685
  _emit("mail.server.managesieve.auth_attempt",
613
686
  { 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
- }
687
+ state.saslExchange = { mech: pa.mech, step: 0 };
688
+ _runAuthStep(state, socket,
689
+ pa.irBody.length > 0 ? pa.irBody.toString("utf8") : null);
690
+ }
691
+
692
+ // One round of a SASL exchange (RFC 5804 §2.1). The verifier may answer with
693
+ // `{ pending: true, challenge }` to ask for another client response, as it
694
+ // may on the IMAP, POP3 and submission listeners. ManageSieve used to call
695
+ // verify once with no `step`, so a pending verdict landed in the failure
696
+ // branch and spent the client's authentication-failure budget for what is a
697
+ // normal protocol round trip, weakening the very defence that budget exists
698
+ // to provide.
699
+ function _runAuthStep(state, socket, clientResponse) {
700
+ var ex = state.saslExchange;
701
+ function _fail(reason) {
702
+ state.saslExchange = null;
703
+ rateLimit.noteAuthFailure(state.remoteAddress);
704
+ _emit("mail.server.managesieve.auth_failed",
705
+ { connectionId: state.id, mech: ex.mech, reason: reason }, "denied");
706
+ _writeNo(socket, "Authentication failed");
707
+ }
708
+ mailServerNet.runSaslStep({
709
+ exchange: ex,
710
+ verify: authConfig.verify,
711
+ credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
712
+ clientResponse: clientResponse,
713
+ writeChallenge: function (ch) { return _writeChallenge(socket, ch); },
714
+ onChallengeUnsafe: function () { _fail("challenge-contains-line-terminator"); },
715
+ onSuccess: function (result) {
716
+ state.saslExchange = null;
717
+ state.actor = result.actor;
718
+ state.stage = "authenticated";
719
+ _emit("mail.server.managesieve.auth_success",
720
+ { connectionId: state.id, mech: ex.mech, tenantId: state.actor.tenantId || null });
721
+ _writeOk(socket, "Authenticated");
722
+ },
723
+ onFailure: function (result) { _fail((result && result.reason) || "verify-returned-fail"); },
724
+ onError: function (err) { _fail((err && err.message) || String(err)); },
725
+ });
726
+ }
727
+
728
+ // RFC 5804 §2.1 — the server's SASL challenge is sent as a literal:
729
+ // `{N}\r\n<challenge>\r\n`. Returns false when the challenge carries bytes
730
+ // that would end the line, so the caller fails the exchange rather than
731
+ // emitting a second, smuggled protocol line.
732
+ function _writeChallenge(socket, challenge) {
733
+ var safe = mailServerNet.saslChallengeOrNull(challenge);
734
+ if (safe === null) return false;
735
+ try { socket.write("{" + Buffer.byteLength(safe, "utf8") + "}\r\n" + safe + "\r\n"); }
736
+ catch (_e) { /* socket down */ }
737
+ return true;
738
+ }
739
+
740
+ // The client's reply to a challenge, per RFC 5804 §1.2 "string": a quoted
741
+ // string, or (accepted defensively) a bare token. `"*"` cancels the exchange
742
+ // — a withdrawal rather than a failed credential, so it costs no budget.
743
+ function _continueSaslExchange(state, socket, line) {
744
+ // RFC 5804 §1.2 — a "string" is a quoted string OR a literal. A SASL
745
+ // response is base64 and can be long, which is exactly when a client
746
+ // reaches for the literal form, and reading `{N+}` as the response itself
747
+ // then reading its bytes as a second response cannot authenticate anyone.
748
+ // The bytes are collected by the same drain-loop branch the initial
749
+ // AUTHENTICATE response uses, and `resume` tells _completeAuthenticate to
750
+ // continue this exchange rather than start a new one at step 0.
751
+ var lit = _parseLiteralMarker(line);
752
+ if (lit) {
753
+ // The client declaring this size is unauthenticated, and the drain loop
754
+ // will collect toward it. Unbounded, `{999999999+}` both consumes memory
755
+ // and pins the connection open waiting for bytes that never arrive.
756
+ //
757
+ // The bound is the guard's own SASL-token cap, the same one it applies to
758
+ // the AUTHENTICATE initial response. One exchange, one bound, whichever
759
+ // round the token arrives on: two numbers for the same thing is how the
760
+ // two halves of a protocol drift apart.
761
+ if (lit.bytes > guardManageSieveCommand.MAX_SASL_TOKEN_BYTES) {
762
+ var oversizeMech = state.saslExchange.mech;
763
+ state.saslExchange = null;
631
764
  rateLimit.noteAuthFailure(state.remoteAddress);
632
765
  _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
- });
766
+ { connectionId: state.id, mech: oversizeMech,
767
+ reason: "continuation-literal-too-large" }, "denied");
768
+ _writeNo(socket, "Authentication response too long (cap " +
769
+ guardManageSieveCommand.MAX_SASL_TOKEN_BYTES + ")");
770
+ return;
771
+ }
772
+ state.pendingAuth = {
773
+ mech: state.saslExchange.mech,
774
+ irBytes: lit.bytes,
775
+ irPlus: lit.plus,
776
+ irBody: Buffer.alloc(0),
777
+ resume: true,
778
+ };
779
+ // Synchronizing form: the server invites the bytes before the client
780
+ // sends them, exactly as it does for an initial-response literal.
781
+ if (!lit.plus) socket.write("OK\r\n");
782
+ return;
783
+ }
784
+ // The guard owns the quoted form of the same production — escapes
785
+ // included, and NUL / CR / LF refused inside the quotes. A bare token is
786
+ // accepted defensively for clients that omit the quoting.
787
+ var quoted = guardManageSieveCommand.parseQuotedString(line);
788
+ var body = quoted ? quoted.value : line;
789
+ if (body === "*") {
790
+ state.saslExchange = null;
791
+ _writeNo(socket, "Authentication cancelled");
792
+ return;
793
+ }
794
+ _runAuthStep(state, socket, body);
640
795
  }
641
796
 
642
797
  function _requireAuth(state, socket) {
@@ -844,6 +999,7 @@ function create(opts) {
844
999
  // ---- Lifecycle ----------------------------------------------------------
845
1000
  return mailServerNet.createStoreServer(net, {
846
1001
  defaultPort: DEFAULT_PORT,
1002
+ maxConnections: opts.maxConnections,
847
1003
  handleConnection: _handleConnection,
848
1004
  errorClass: MailServerManageSieveError,
849
1005
  errorCodePrefix: "mail-server-managesieve/",