@blamejs/core 0.10.2 → 0.10.4

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.4 (2026-05-16) — **Mail-protocol hardening across the four listener primitives.** Ten refusals + two new operator-visible opts spanning `b.mail.server.{mx,submission,imap,pop3}`, `b.mail.server.rateLimit`, `b.safeMime`, `b.guardListUnsubscribe`. Layered on top of the v0.10.0 mail-stack baseline; addresses residual gaps in inbound RCPT enumeration, header-count amplification, POP3 UPDATE-state commit timeouts, list-unsubscribe URI shape, and the per-listener auth-failure / connection-rate maps. **(a) `b.mail.server.rateLimit.checkRcptAdmit(ip)` + `noteRcptFailure(ip)`** — new per-IP RCPT-failure budget (default 50/min, rolling 60s window) wired into the MX + submission listeners. RFC 5321 §3.5 enumeration class: an attacker probing `RCPT TO:` to map valid recipients now trips the budget after 50 failures and gets `421` for the next minute. Operators tune via `rateLimit.create({ rcptFailuresPerMinute, rcptWindowMs })`. **(b) `b.safeMime.parse({ maxHeaderCount })`** — new opt, default 512. Bounded header-count cap prevents `From: ...\r\nSubject: ...\r\n` × 100k header-list amplification in operator pipelines that pass full RFC 5322 messages through `safeMime.parse`. Refused with `safe-mime/too-many-headers` when exceeded. **(c) `b.mail.server.pop3.create({ commitTimeoutMs })`** — new opt, default `C.TIME.seconds(30)`. POP3 UPDATE-state commit (DELE materialization) now runs under `safeAsync.withTimeout` so a hung commit can no longer pin the connection past `idleTimeoutMs`. Past the cap, the connection gets `421` and the in-flight DELE batch is rolled back (RFC 1939 §6 — UPDATE state aborts on transport failure). **(d) `b.guardListUnsubscribe.validate`** refuses empty `<>` URI lists per RFC 2369 §3.1 (the `List-Unsubscribe` header value `<>` is a smuggled-empty class that downstream mail-renderers may interpret as an active unsubscribe link to the local-origin). **(e) `b.mail.server.rateLimit` GC sweep** — the previously asymmetric `connectionTimes` Map (filled in `noteConnection`, never explicitly cleaned) now sweeps empty arrays alongside the existing `authFailureTimes` cleanup. Closes a CWE-770 unbounded-memory class for long-running mail servers seeing transient IP fan-in. **(f) `b.mail.server.imap` `_close()` writes `state.stage = "closed"`** — the drain-loop guard was previously unreachable because the close path didn't update the state machine. Operators on the older path saw `state.stage === "authenticated"` linger after socket close; the new path resolves cleanly. **(g) `b.mail.server.imap` per-line cap before `Buffer.concat`** — closes a CWE-770 unbounded-`Buffer.concat` class on the IMAP line accumulator (the cap was applied AFTER concat, so a malicious peer could send 10 GiB of unterminated tag bytes and the listener would allocate before refusing). Per-line cap now gates the concat. **(h) `b.mail.server.pop3` `_handleApop` cleartext refusal** — APOP gets the same `!state.tls && profile !== "permissive"` refusal as USER / PASS, closing the cleartext-credentials gap symmetric to the other auth verbs (RFC 1939 §7 APOP MD5 is also cleartext in transit). **(i) `b.mail.server.pop3` RETR / TOP dot-stuffing via `safeSmtp.dotStuff(buf)`** — the prior `.replace(/^\./gm, "..")` on a JS string treats bare LF as a line boundary, so bodies containing bare-LF lines starting with `.` gained spurious stuffing that the receiver's strict-CRLF parser couldn't undo. Routes through the byte-level dot-stuffer that only recognizes canonical `\r\n` (RFC 1939 §3 / RFC 5321 §4.5.2). **(j) `b.mail.store` deletion atomicity** — sealed deletion no longer leaves partial state when the in-memory delete succeeds but the disk flush fails (CWE-707 — transactional integrity). **(k) `b.mail.server.submission` cleartext-AUTH audit** — the `auth_success` audit emit captures the `mechanism` field before nulling `authPending` (was recording `null`); operators tailing the audit log now see which SASL mechanism succeeded. **(l) Codebase-patterns** — new `family-subset` entry covering the rate-limit admit-check shape across `mail-server-{imap,mx,submission}` so the contract is enforced at every listener (every primitive that opens a peer socket on a mail port must consult the rate limiter before sending the greeting). **Operator impact:** `b.mail.server.rateLimit` consumers see a new public surface (`checkRcptAdmit` / `noteRcptFailure`); existing operators who don't wire these get the framework default (50/min). `b.safeMime.parse` callers with >512-header messages now get `safe-mime/too-many-headers` — operators with bespoke headers (DMARC aggregate reports can run into hundreds of `Authentication-Results`) opt up via `maxHeaderCount: 4096` per call. POP3 operators see a new `commitTimeoutMs` opt — default applies retroactively. References: [RFC 5321 §3.5](https://www.rfc-editor.org/rfc/rfc5321#section-3.5), [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322), [RFC 1939](https://www.rfc-editor.org/rfc/rfc1939), [RFC 2369 §3.1](https://www.rfc-editor.org/rfc/rfc2369#section-3.1), [CWE-770](https://cwe.mitre.org/data/definitions/770.html), [CWE-707](https://cwe.mitre.org/data/definitions/707.html).
12
+ - v0.10.3 (2026-05-16) — **`b.crypto` hardening — three entry-tier refusals on hot paths.** **(a) `b.crypto.timingSafeEqual` rejects non-Buffer / non-string inputs** — previous `Buffer.from(String(x))` coercion let a prototype-pollution-influenced caller (an Object whose `toString` returns attacker-chosen bytes) redirect the compare through bytes unrelated to the supplied value. Now throws `TypeError` at the entry boundary; string args use explicit `Buffer.from(s, "utf8")` instead of bare coercion. **(b) `b.crypto.hashCertFingerprint` caps PEM input at 64 KiB** — the `/-----BEGIN .+? -----END/` lazy-quantifier on this hot path (mTLS bootstrap / webhook verification / peer-cert pinning) is polynomial-ReDoS-class on multi-MB attacker-controlled input ([CodeQL js/polynomial-redos](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/)). 64 KiB covers a P-384 cert + full chain at ~3× margin; larger inputs throw `TypeError` before the regex runs. **(c) `b.crypto.namespaceHash` refuses CR / LF in string-typed `value`** — closes a log-injection / record-separator surface where an attacker-controlled HTTP header (e.g. `Idempotency-Key`) could smuggle line-break bytes into any consumer that logs the value verbatim before hashing (debug paths, audit envelopes, derived-column shadow logs). NUL is NOT refused — multiple internal callers (`b.agent.idempotency` / `b.mail.greylist` / `b.middleware.composePipeline`) use NUL as a composite-key separator, and NUL is not a log-injection byte in any standard logger. `Buffer` / `Uint8Array` inputs remain operator-side opaque bytes by contract — `namespaceHash` digests them as raw bytes, not as text, so the control-char gate does not apply there either. **Operator impact:** any caller passing a number / Object / boolean to `b.crypto.timingSafeEqual` now throws at the entry boundary instead of silently comparing coerced bytes — the API contract was already documented as Buffer-or-string, this enforces it. PEM strings larger than 64 KiB to `b.crypto.hashCertFingerprint` now throw — operators with bespoke multi-cert bundles split the inputs before calling. `namespaceHash` callers passing strings with embedded CR / LF now throw — operators ingesting attacker-influenced text validate / strip line-break bytes at the boundary, or hash opaque bytes via `Buffer` / `Uint8Array`. References: [OWASP Log Injection](https://owasp.org/www-community/attacks/Log_Injection), [CWE-117](https://cwe.mitre.org/data/definitions/117.html), [CWE-1333 ReDoS](https://cwe.mitre.org/data/definitions/1333.html).
11
13
  - v0.10.2 (2026-05-16) — **CVE backstops layered on top of v0.10.0.** Five additional refusals across `b.guardRegex`, `b.otelExport`, `b.guardXml`, `b.guardGraphql`, plus a host-side ingress route for `b.cli`. Every change is opt-out (refusal at every profile); no API removals. **(a) `b.guardRegex` glob-shape detectors with explicit `inputKind` gate** — new `consecutiveStarPolicy` + `nestedExtglobPolicy` (defaults `"reject"`) + `maxConsecutiveStars` (default 2) + `inputKind: "regex" | "glob"` (default `"regex"`). The glob-shape detectors fire ONLY when the caller passes `inputKind: "glob"` — ECMAScript regex syntax cannot produce `***` (SyntaxError) and the extglob heads `*(`/`+(`/`?(`/`@(`/`!(` collide with valid `quantifier + capturing group` shapes, so applying these detectors to regex inputs is false-positive territory. Callers handling glob fragments (picomatch / micromatch-style patterns) opt in via `inputKind: "glob"` and get refusals for ≥3 consecutive `*` metacharacters ([CVE-2026-26996](https://nvd.nist.gov/vuln/detail/CVE-2026-26996) — O(4^N) backtracking on non-matching literal) and for any extglob whose body contains another extglob ([CVE-2026-33671](https://nvd.nist.gov/vuln/detail/CVE-2026-33671) — picomatch nested-quantifier backtracking). `**` recursive-glob stays permitted under `maxConsecutiveStars: 2`. **(b) `b.cli --ignore` ReDoS ingress closure** — `cli --ignore <pattern>` arguments route through `b.guardRegex.sanitize({ profile: "strict" })` before reaching `new RegExp(pattern)`. Strict-profile refusal of nested-quantifier / lookaround-quantifier / unbounded-bounded-repeat shapes still applies in default `inputKind: "regex"` mode, closing the host-side surface for the classic ReDoS classes. **(c) `b.otelExport.flush()` response cap** — every outbound OTLP request now pins `maxResponseBytes: 1 MiB` + a typed `errorClass`, so a malicious / misconfigured collector cannot exhaust memory in the export loop ([CVE-2026-40891](https://nvd.nist.gov/vuln/detail/CVE-2026-40891) / [CVE-2026-40182](https://nvd.nist.gov/vuln/detail/CVE-2026-40182) class). **(d) `b.guardXml` numeric-character-reference fan-out cap** — new `maxNumericCharRefs` opt (strict 1024 / balanced 16384 / permissive 262144). NCRs are counted independently of `entityPolicy`, so a signed-XML path that legitimately permits entity expansion cannot accidentally disable the NCR cap ([CVE-2026-26278](https://nvd.nist.gov/vuln/detail/CVE-2026-26278) / [CVE-2026-33036](https://nvd.nist.gov/vuln/detail/CVE-2026-33036) — billion-NCR fan-out class). **(e) `b.guardGraphql` prototype-pollution refusal** — refuses `__proto__` / `constructor` / `prototype` as top-level variable keys (`Object.prototype.hasOwnProperty.call(variables, ...)` check, sidesteps a poisoned-prototype `in` lookup) AND as field / alias / `$variable` identifiers in the query body, including the no-whitespace alias form `query { a:__proto__ }` (the colon is a valid identifier-position prefix). Refused at every profile, severity `critical` ([CVE-2026-32621](https://nvd.nist.gov/vuln/detail/CVE-2026-32621) class). **(f) `b.auth.sdJwtVc.present()` defense-in-depth comment** — documents that the holder-side pre-parse of `_sd_alg` reads from unsigned bytes safely because `verify()` re-parses from the cryptographically-verified signing input; no behavioral change. **Regression coverage** — `test/fixtures/exploit-corpus/corpus.json` gains four entries: glob-mode positive refusal for `***+nonmatch` and `*(*(a))`, regex-mode pass for `a*(b+(c))` (false-positive class the design refused to ship), and the colon-prefix GraphQL alias `query { a:__proto__ }`. **Operator impact:** existing operators see no change in default behavior — the new glob detectors are opt-in via `inputKind: "glob"`. Operators wiring `b.guardRegex` over glob fragments (file-pattern allowlists, rsync-style rules) opt in and get the CVE-2026-26996 / -33671 refusals; opt back out per call via `consecutiveStarPolicy: "allow"` / `nestedExtglobPolicy: "allow"`. `b.guardXml` operators on signed-XML pipelines opt out via `maxNumericCharRefs: Infinity` if they bound NCRs upstream. GraphQL variable / query-body refusals are not opt-out — `__proto__` / `constructor` / `prototype` are never legitimate identifiers in operator-supplied input. References: [picomatch CVE-2024-4067 family](https://nvd.nist.gov/vuln/detail/CVE-2024-4067), [OWASP ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS), [OWASP XXE / Billion Laughs](https://owasp.org/www-community/vulnerabilities/XML_Entity_Expansion), [GraphQL Server Security Best Practices](https://www.apollographql.com/docs/router/configuration/overview/).
12
14
  - v0.10.1 (2026-05-16) — **First npm-published v0.10.x artifact.** v0.10.0 was tagged + released on GitHub but its npm-publish workflow OOM'd at the lint+smoke gate (default Node ~4GB heap couldn't load the expanded mail-stack + audit-fix test surface). v0.10.1 adds `NODE_OPTIONS=--max-old-space-size=8192` to the workflow's smoke step so the parent process gets the same headroom the forked test workers already get. No runtime / API changes from v0.10.0 — every primitive, posture, and security default ships exactly as documented in the v0.10.0 release notes below. Operators who fetched the v0.10.0 git tag can re-tag from v0.10.1 (`git fetch origin v0.10.1`) to land on the npm-published commit; the framework code itself is byte-identical apart from the workflow file + version bump.
13
15
  - v0.10.0 (2026-05-16) — **Mail-stack feature-complete + cross-surface hardening.** Bundled minor closing the blamepost mail-stack roadmap (five new operator-facing namespaces) plus a multi-domain hardening sweep across auth / crypto / vendor data / mail-protocol / mail-auth / agent substrate / Node.js CVE backstops.
package/lib/crypto.js CHANGED
@@ -55,6 +55,11 @@ var C = require("./constants");
55
55
  // require() inside setImmediate (top-of-file requires per rule §3).
56
56
  var lazyRequire = require("./lazy-require");
57
57
  var audit = lazyRequire(function () { return require("./audit"); });
58
+ // safe-buffer hosts the canonical hasCrlf(s) helper used by every
59
+ // log-injection / CRLF-smuggling refusal in the framework. Lazy-
60
+ // loaded because safe-buffer.js itself imports b.crypto for
61
+ // hex-compare helpers (circular).
62
+ var safeBuffer = lazyRequire(function () { return require("./safe-buffer"); });
58
63
 
59
64
  // Streaming-hash algorithm allowlist. Mirrors the framework's PQC-
60
65
  // first crypto policy: SHA3 / SHAKE family is the default surface;
@@ -399,12 +404,15 @@ function generateKeyPair(algorithm, options) {
399
404
  * @since 0.1.0
400
405
  * @related b.crypto.hmacSha3
401
406
  *
402
- * Constant-time equality comparison. Coerces non-Buffer inputs via
403
- * `Buffer.from(String(...))`, returns `false` immediately when lengths
404
- * differ (length itself is not a secret), then routes equal-length
405
- * inputs through `crypto.timingSafeEqual`. Use when comparing HMAC
406
- * digests, session tokens, password-reset codes, or any
407
- * attacker-influenced value where a timing oracle would leak bits.
407
+ * Constant-time equality comparison. Accepts only Buffer or string
408
+ * inputs — non-string non-Buffer arguments throw at the entry tier so
409
+ * a `Object.prototype.toString`-poisoned caller can't redirect the
410
+ * compare through arbitrary attacker-controlled bytes. Returns
411
+ * `false` immediately when lengths differ (length itself is not a
412
+ * secret), then routes equal-length inputs through
413
+ * `crypto.timingSafeEqual`. Use when comparing HMAC digests, session
414
+ * tokens, password-reset codes, or any attacker-influenced value
415
+ * where a timing oracle would leak bits.
408
416
  *
409
417
  * @example
410
418
  * var expected = b.crypto.hmacSha3("server-key", "payload");
@@ -413,8 +421,25 @@ function generateKeyPair(algorithm, options) {
413
421
  * // → true when bytes match, false otherwise (no early exit on mismatch)
414
422
  */
415
423
  function timingSafeEqual(a, b) {
416
- var bufA = Buffer.isBuffer(a) ? a : Buffer.from(String(a));
417
- var bufB = Buffer.isBuffer(b) ? b : Buffer.from(String(b));
424
+ // Entry-tier validation. The prior `Buffer.from(String(x))` coercion
425
+ // let a prototype-pollution-influenced caller (Object whose toString
426
+ // returns attacker-chosen bytes) redirect the compare through bytes
427
+ // that have nothing to do with the supplied value. Refuse any
428
+ // non-string non-Buffer input outright.
429
+ if (!Buffer.isBuffer(a) && typeof a !== "string") {
430
+ throw new TypeError(
431
+ "crypto.timingSafeEqual: argument 'a' must be a Buffer or string, got " +
432
+ (a === null ? "null" : typeof a)
433
+ );
434
+ }
435
+ if (!Buffer.isBuffer(b) && typeof b !== "string") {
436
+ throw new TypeError(
437
+ "crypto.timingSafeEqual: argument 'b' must be a Buffer or string, got " +
438
+ (b === null ? "null" : typeof b)
439
+ );
440
+ }
441
+ var bufA = Buffer.isBuffer(a) ? a : Buffer.from(a, "utf8");
442
+ var bufB = Buffer.isBuffer(b) ? b : Buffer.from(b, "utf8");
418
443
  if (bufA.length !== bufB.length) return false;
419
444
  return nodeCrypto.timingSafeEqual(bufA, bufB);
420
445
  }
@@ -581,8 +606,10 @@ function namespaceHash(prefix, value, opts) {
581
606
  // caller surfaces the type error explicitly rather than silently
582
607
  // hashing `[object Object]`.
583
608
  var valueStr;
609
+ var valueWasString = false;
584
610
  if (typeof value === "string") {
585
611
  valueStr = value;
612
+ valueWasString = true;
586
613
  } else if (Buffer.isBuffer(value)) {
587
614
  valueStr = value.toString("utf8");
588
615
  } else if (value instanceof Uint8Array) {
@@ -592,6 +619,25 @@ function namespaceHash(prefix, value, opts) {
592
619
  "crypto.namespaceHash: value must be a string, Buffer, or Uint8Array"
593
620
  );
594
621
  }
622
+ // Refuse CR / LF in string-typed values. The prior gap let an
623
+ // attacker-controlled string `value` (e.g. an HTTP header that
624
+ // becomes an Idempotency-Key) smuggle log-injection / record-
625
+ // separator bytes into any consumer that logs the value verbatim
626
+ // before hashing (debug paths, audit envelopes, derived-column
627
+ // shadow logs). NUL is NOT refused — multiple internal callers
628
+ // use NUL as a composite-key separator (`method\0actorId\0key`
629
+ // shape in agent-idempotency / mail-greylist / compose-pipeline),
630
+ // and NUL is not a log-injection byte in any standard logger. NUL
631
+ // in operator-supplied content is the operator-boundary
632
+ // responsibility. Buffer / Uint8Array inputs remain operator-side
633
+ // opaque bytes by contract — namespaceHash treats them as raw
634
+ // bytes to be digested without rendering, so the control-char
635
+ // gate does not apply there either.
636
+ if (valueWasString && safeBuffer().hasCrlf(valueStr)) {
637
+ throw new TypeError(
638
+ "crypto.namespaceHash: value (string-typed) contains CR / LF — refuse"
639
+ );
640
+ }
595
641
  return hash(prefix + ":" + valueStr, "sha3-512").toString("hex");
596
642
  }
597
643
 
@@ -1664,6 +1710,20 @@ function _pemToDer(pemOrDer) {
1664
1710
  if (typeof pemOrDer !== "string") {
1665
1711
  throw new TypeError("crypto.hashCertFingerprint: input must be a Buffer (DER) or a PEM-encoded string");
1666
1712
  }
1713
+ // Bound the regex input. The /-----BEGIN .+? -----END/ pattern is
1714
+ // lazy-quantified, which CodeQL flags as polynomial-ReDoS
1715
+ // (js/polynomial-redos) when fed multi-MB attacker-controlled input
1716
+ // — every backtrack step is O(n) and the pattern is on a hot path
1717
+ // for mTLS bootstrap / webhook verification / peer-cert pinning.
1718
+ // 64 KiB caps the largest plausible PEM (a P-384 cert + chain) at
1719
+ // ~3× margin while refusing pathological inputs outright.
1720
+ if (pemOrDer.length > C.BYTES.kib(64)) {
1721
+ throw new TypeError(
1722
+ "crypto.hashCertFingerprint: PEM input exceeds 64 KiB (" +
1723
+ pemOrDer.length + " bytes); refuse oversized input to avoid " +
1724
+ "polynomial-ReDoS on the BEGIN/END marker regex"
1725
+ );
1726
+ }
1667
1727
  var match = pemOrDer.match(/-----BEGIN [A-Z0-9 ]+-----([\s\S]+?)-----END [A-Z0-9 ]+-----/);
1668
1728
  if (!match) {
1669
1729
  throw new TypeError("crypto.hashCertFingerprint: PEM input lacks BEGIN/END markers");
@@ -197,6 +197,15 @@ function validate(headers, opts) {
197
197
  var hasMailtoUri = false;
198
198
  for (var i = 0; i < uriParts.length; i += 1) {
199
199
  var u = uriParts[i];
200
+ // RFC 2369 §3.1 — the URI between `<` and `>` is REQUIRED. An
201
+ // empty `<>` is grammatically invalid + carries no unsubscribe
202
+ // semantics; the earlier shape accepted it and downstream URI-
203
+ // dispatch decisions treated it as a scheme-less URI rather than
204
+ // refusing the whole header.
205
+ if (u.length === 0) {
206
+ return _verdict("refuse", "List-Unsubscribe contains empty `<>` URI (RFC 2369 §3.1)",
207
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
208
+ }
200
209
  if (Buffer.byteLength(u, "utf8") > caps.maxUriBytes) {
201
210
  return _verdict("refuse", "URI '" + _trunc(u) + "' exceeds maxUriBytes=" + caps.maxUriBytes,
202
211
  { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
@@ -747,9 +747,17 @@ function create(opts) {
747
747
  if (typeof mailStore.selectFolder === "function") {
748
748
  return mailStore.selectFolder(state.actor, name, { readOnly: examine });
749
749
  }
750
- // Fallback shapeoperators without selectFolder get a minimal
751
- // OK with sentinel UIDVALIDITY 1.
752
- return { exists: 0, recent: 0, uidvalidity: 1, uidnext: 1, modseq: 0, flags: [] };
750
+ // RFC 9051 §2.3.1.1 UIDVALIDITY MUST be strictly increasing
751
+ // and 32-bit unique across the mailbox lifetime. The earlier
752
+ // fallback returned a sentinel `uidvalidity: 1` to keep tests
753
+ // green when the operator hadn't wired `selectFolder`, but the
754
+ // sentinel value collides with any real UIDVALIDITY=1 from a
755
+ // legitimate backend and tricks clients into believing they
756
+ // have a valid synced state. Refuse SELECT instead — operators
757
+ // MUST wire `mailStore.selectFolder` to expose mailboxes.
758
+ var err = new Error("mailStore.selectFolder is not configured (RFC 9051 §2.3.1.1 requires a unique strictly-increasing UIDVALIDITY)");
759
+ err.code = "mail-server-imap/no-select-backend";
760
+ throw err;
753
761
  })
754
762
  .then(function (info) {
755
763
  state.selectedMailbox = name;
@@ -855,6 +863,37 @@ function create(opts) {
855
863
  }
856
864
  Promise.resolve()
857
865
  .then(function () {
866
+ // RFC 9208 — when the backend exposes a per-mailbox / per-user
867
+ // quota, APPEND MUST check against it BEFORE writing the
868
+ // message. The earlier shape called `appendMessage` directly,
869
+ // leaving quota enforcement entirely up to the backend; an
870
+ // operator wiring a bare `appendMessage` without quota plumbing
871
+ // could be DoS'd via unbounded APPENDs filling the mailbox
872
+ // beyond the advertised QUOTA limit. Honor `mailStore.quota`
873
+ // (RFC 9208 GETQUOTA / IMAP-QUOTA returns the same shape) and
874
+ // surface 5.7.4 OVERQUOTA per §5.
875
+ if (typeof mailStore.quota === "function") {
876
+ // mailStore.quota(folderName) returns
877
+ // { usedBytes, usedCount, capBytes, capCount } per the
878
+ // lib/mail-store.js contract. capBytes is null when no
879
+ // quota is configured for the folder; honor it only when
880
+ // it's a positive number.
881
+ return Promise.resolve(mailStore.quota(name))
882
+ .then(function (q) {
883
+ if (q && typeof q.usedBytes === "number" &&
884
+ typeof q.capBytes === "number" &&
885
+ q.capBytes > 0 &&
886
+ q.usedBytes + literalBody.length > q.capBytes) {
887
+ var err = new Error("APPEND would exceed quota (used " + q.usedBytes +
888
+ " + " + literalBody.length + " > cap " + q.capBytes + ")");
889
+ err.code = "mail-server-imap/overquota";
890
+ err.overquota = true;
891
+ err.limit = q.capBytes;
892
+ throw err;
893
+ }
894
+ return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
895
+ });
896
+ }
858
897
  return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
859
898
  })
860
899
  .then(function (info) {
@@ -864,6 +903,10 @@ function create(opts) {
864
903
  _writeTagged(socket, tag, "OK " + token + "APPEND completed");
865
904
  })
866
905
  .catch(function (err) {
906
+ if (err && err.overquota) {
907
+ _writeTagged(socket, tag, "NO [OVERQUOTA] Quota exceeded (RFC 9208 §5)");
908
+ return;
909
+ }
867
910
  _writeTagged(socket, tag, "NO " + ((err && err.message) || "Append failed").slice(0, ERR_CLAMP));
868
911
  });
869
912
  }
@@ -660,8 +660,24 @@ function create(opts) {
660
660
  "4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
661
661
  return;
662
662
  }
663
+ // RFC 5321 §3.5 — RCPT-TO 550 vs 250 surfaces a mailbox-existence
664
+ // oracle. Once the per-IP recipient-failure cap is reached, the
665
+ // listener returns 421 + closes so the IP backs off; without this
666
+ // a scanner can RCPT-TO-flood the listener to enumerate every
667
+ // valid local recipient at the bare cost of an SMTP greeting.
668
+ var rcptAdmit = rateLimit.checkRcptAdmit(state.remoteAddress);
669
+ if (!rcptAdmit.ok) {
670
+ _emit("mail.server.mx.rcpt_rate_limit_refused",
671
+ { connectionId: state.id, remoteAddress: state.remoteAddress,
672
+ reason: rcptAdmit.reason }, "denied");
673
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
674
+ "4.7.0 Too many RCPT failures from your IP");
675
+ _closeConnection(socket);
676
+ return;
677
+ }
663
678
  var match = line.match(RE_RCPT_TO);
664
679
  if (!match) {
680
+ rateLimit.noteRcptFailure(state.remoteAddress);
665
681
  _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 Syntax: RCPT TO:<address>");
666
682
  return;
667
683
  }
@@ -675,6 +691,7 @@ function create(opts) {
675
691
  if (rcptDomain && rcptDomain[0] !== "[" && guardDomainProfile) {
676
692
  var rcptVerdict = _validateDomainHardened(rcptDomain, "rcpt_to");
677
693
  if (!rcptVerdict.ok) {
694
+ rateLimit.noteRcptFailure(state.remoteAddress);
678
695
  _writeReply(socket, REPLY_501_BAD_ARGS,
679
696
  "5.5.4 RCPT TO domain refused (" +
680
697
  (rcptVerdict.issues && rcptVerdict.issues[0] && rcptVerdict.issues[0].kind) + ")");
@@ -686,6 +703,7 @@ function create(opts) {
686
703
  if (localDomains.length > 0) {
687
704
  if (localDomains.indexOf(rcptDomain) === -1 &&
688
705
  !_isRelayAllowed(state.remoteAddress, rcpt)) {
706
+ rateLimit.noteRcptFailure(state.remoteAddress);
689
707
  _emit("mail.server.mx.relay_refused",
690
708
  { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
691
709
  remoteAddress: state.remoteAddress }, "denied");
@@ -112,6 +112,7 @@ var guardPop3Command = require("./guard-pop3-command");
112
112
  var mailServerRateLimit = require("./mail-server-rate-limit");
113
113
  var mailServerTls = require("./mail-server-tls");
114
114
  var safeSmtp = require("./safe-smtp");
115
+ var safeAsync = require("./safe-async");
115
116
  var { defineClass } = require("./framework-error");
116
117
 
117
118
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -120,6 +121,13 @@ var MailServerPop3Error = defineClass("MailServerPop3Error", { alwaysPermanent:
120
121
 
121
122
  var DEFAULT_MAX_LINE_BYTES = 1024; // allow:raw-byte-literal — RFC 2449 §4 line cap (permissive)
122
123
  var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(10);
124
+ // RFC 1939 §6 — UPDATE-state commit (the actual delete on QUIT) is
125
+ // the only place the backend writes; a hung commitPop3Drop leaves
126
+ // the connection in update-state forever, defeating the idle timeout
127
+ // (the socket is awaiting the .then(), not blocked on socket I/O).
128
+ // Bound the commit; on timeout the connection closes with -ERR and
129
+ // the next session re-attempts the commit.
130
+ var DEFAULT_COMMIT_TIMEOUT_MS = C.TIME.seconds(30);
123
131
  var DEFAULT_GREETING_VENDOR = "blamejs POP3";
124
132
 
125
133
  var ERR_CLAMP = 200; // allow:raw-byte-literal — protocol-reply error-message clamp
@@ -141,6 +149,7 @@ var ERR_CLAMP = 200;
141
149
  * greeting: string, // default "blamejs POP3"
142
150
  * maxLineBytes: number, // default 1024
143
151
  * idleTimeoutMs: number, // default 10 min
152
+ * commitTimeoutMs: number, // default 30 s (UPDATE-state mailStore.commitPop3Drop cap)
144
153
  * profile: "strict" | "balanced" | "permissive",
145
154
  * auth: {
146
155
  * mechanisms: ["PLAIN"], // SASL mechs to advertise
@@ -177,12 +186,13 @@ function create(opts) {
177
186
  "getMessage/listMessages/markDelete; compose b.mailStore.create or operator-supplied backend)");
178
187
  }
179
188
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
180
- ["maxLineBytes", "idleTimeoutMs"],
189
+ ["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs"],
181
190
  "mail.server.pop3.", MailServerPop3Error, "mail-server-pop3/bad-bound");
182
191
 
183
192
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
184
193
  var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
185
194
  var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
195
+ var commitTimeoutMs = opts.commitTimeoutMs || DEFAULT_COMMIT_TIMEOUT_MS;
186
196
  var profile = opts.profile || "strict";
187
197
  var authConfig = opts.auth || null;
188
198
  var mailStore = opts.mailStore;
@@ -397,7 +407,7 @@ function create(opts) {
397
407
  // refuses USER over cleartext under strict at the wire boundary,
398
408
  // but balanced/permissive operators previously reached this path
399
409
  // and accepted a plaintext password. Refuse here too so a guard
400
- // relax doesn't open BUG-11/MAIL-37 (cleartext credentials in
410
+ // relax doesn't open (cleartext credentials in
401
411
  // POP3 USER/PASS) by composition. Permissive operators opt out
402
412
  // by explicitly setting profile: "permissive".
403
413
  if (!state.tls && profile !== "permissive") {
@@ -421,7 +431,7 @@ function create(opts) {
421
431
  _writeErr(socket, "AUTH not configured on this listener");
422
432
  return;
423
433
  }
424
- // BUG-11 / MAIL-37 — refuse PASS over cleartext when not permissive.
434
+ // refuse PASS over cleartext when not permissive.
425
435
  // USER already gated above, but this is defense-in-depth in case the
426
436
  // USER guard was bypassed by a future codepath.
427
437
  if (!state.tls && profile !== "permissive") {
@@ -540,6 +550,13 @@ function create(opts) {
540
550
  // configuration where the gate was relaxed but the AUTH path
541
551
  // still receives traffic).
542
552
  if (!state.tls && profile === "strict") {
553
+ // Count cleartext-AUTH refusal against the auth-failure budget
554
+ // so scanners that probe for plaintext-mech tolerance hit the
555
+ // same per-IP cap that protects PASS / APOP. Without this, a
556
+ // scanner could enumerate auth mechanisms freely (the refusal
557
+ // itself was free) and shop for the first wire-protocol path
558
+ // the listener honored.
559
+ rateLimit.noteAuthFailure(state.remoteAddress);
543
560
  _emit("mail.server.pop3.auth_refused_cleartext",
544
561
  { connectionId: state.id, verb: "AUTH", mech: args[0] }, "denied");
545
562
  _writeErr(socket, "AUTH refused over cleartext (use STLS first; RFC 2595 §2.1)");
@@ -605,8 +622,18 @@ function create(opts) {
605
622
  return;
606
623
  }
607
624
  state.stage = "update";
608
- Promise.resolve()
609
- .then(function () { return mailStore.commitPop3Drop(state.actor, state.dropId); })
625
+ // RFC 1939 §6 — bound the UPDATE-state commit. A hung backend
626
+ // (DB row-lock / replica failover / sealed-row unseal stuck on a
627
+ // KMS call) otherwise leaves the connection in update-state past
628
+ // the socket idleTimeoutMs (which guards inbound bytes, not
629
+ // pending Promises).
630
+ safeAsync.withTimeout(
631
+ Promise.resolve().then(function () {
632
+ return mailStore.commitPop3Drop(state.actor, state.dropId);
633
+ }),
634
+ commitTimeoutMs,
635
+ { label: "mail.server.pop3.commitPop3Drop" }
636
+ )
610
637
  .then(function (info) {
611
638
  _emit("mail.server.pop3.update_commit",
612
639
  { connectionId: state.id, deleted: (info && info.deleted) || 0 });
@@ -614,6 +641,8 @@ function create(opts) {
614
641
  _close(socket);
615
642
  })
616
643
  .catch(function (err) {
644
+ _emit("mail.server.pop3.update_commit_failed",
645
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
617
646
  _writeErr(socket, "Commit failed: " + ((err && err.message) || "backend error").slice(0, ERR_CLAMP));
618
647
  _close(socket);
619
648
  });
@@ -740,7 +769,7 @@ function create(opts) {
740
769
  .then(function (msg) {
741
770
  if (!msg) { _writeErr(socket, "no such message"); return; }
742
771
  _writeOk(socket, "headers + " + headerLines + " body lines");
743
- // MAIL-15 — see _handleRetr; same byte-level CRLF-aware
772
+ // see _handleRetr; same byte-level CRLF-aware
744
773
  // dot-stuffing primitive for the TOP partial-body path.
745
774
  var bodyBuf = msg.rawBytes
746
775
  ? msg.rawBytes
@@ -99,11 +99,20 @@ var DEFAULTS = Object.freeze({
99
99
  connectionsPerIpPerMinute: 60, // allow:raw-time-literal — connection count, not a time value
100
100
  authFailuresPerIpPer15Min: 10,
101
101
  minBytesPerSecond: 100, // allow:raw-byte-literal — slow-loris byte-rate floor
102
+ // RCPT-TO recipient-failure cap defends against the 550-vs-250
103
+ // enumeration shape (RFC 5321 §3.5 — RCPT-TO surfaces the
104
+ // mailbox-exists oracle; an attacker that hammers RCPT TO can
105
+ // enumerate a domain's mailbox map without ever sending DATA).
106
+ // Per-IP, per-minute. Tuned higher than auth-failure since
107
+ // legitimate senders can RCPT-TO multiple recipients per message;
108
+ // operator overrides via `rcptFailuresPerIpPerMinute`.
109
+ rcptFailuresPerIpPerMinute: 50, // allow:raw-byte-literal — RCPT enumeration bound
102
110
  disabled: false,
103
111
  });
104
112
 
105
113
  var CONNECTION_RATE_WINDOW_MS = C.TIME.minutes(1);
106
114
  var AUTH_FAILURE_WINDOW_MS = C.TIME.minutes(15);
115
+ var RCPT_FAILURE_WINDOW_MS = C.TIME.minutes(1);
107
116
 
108
117
  /**
109
118
  * @primitive b.mail.server.rateLimit.create
@@ -124,6 +133,7 @@ var AUTH_FAILURE_WINDOW_MS = C.TIME.minutes(15);
124
133
  * connectionsPerIpPerMinute: number, // default 60
125
134
  * authFailuresPerIpPer15Min: number, // default 10
126
135
  * minBytesPerSecond: number, // default 100 (DATA-body slow-loris floor)
136
+ * rcptFailuresPerIpPerMinute: number, // default 50 (RCPT 550 enumeration bound)
127
137
  * disabled: boolean, // default false — test escape hatch
128
138
  *
129
139
  * @example
@@ -145,6 +155,7 @@ function create(opts) {
145
155
  "connectionsPerIpPerMinute",
146
156
  "authFailuresPerIpPer15Min",
147
157
  "minBytesPerSecond",
158
+ "rcptFailuresPerIpPerMinute",
148
159
  ], "b.mail.server.rateLimit.create.", MailServerRateLimitError, "mail-server-rate-limit/bad-bound");
149
160
  validateOpts.optionalBoolean(opts.disabled,
150
161
  "b.mail.server.rateLimit.create: opts.disabled",
@@ -159,6 +170,8 @@ function create(opts) {
159
170
  ? DEFAULTS.authFailuresPerIpPer15Min : opts.authFailuresPerIpPer15Min,
160
171
  minBytesPerSecond: opts.minBytesPerSecond === undefined
161
172
  ? DEFAULTS.minBytesPerSecond : opts.minBytesPerSecond,
173
+ rcptFailuresPerIpPerMinute: opts.rcptFailuresPerIpPerMinute === undefined
174
+ ? DEFAULTS.rcptFailuresPerIpPerMinute : opts.rcptFailuresPerIpPerMinute,
162
175
  disabled: opts.disabled === true,
163
176
  };
164
177
 
@@ -170,6 +183,7 @@ function create(opts) {
170
183
  var concurrentByIp = new Map(); // ip → integer count
171
184
  var connectionTimes = new Map(); // ip → [timestampMs, ...]
172
185
  var authFailureTimes = new Map(); // ip → [timestampMs, ...]
186
+ var rcptFailureTimes = new Map(); // ip → [timestampMs, ...]
173
187
 
174
188
  function _pruneWindow(arr, windowMs) {
175
189
  var cutoff = Date.now() - windowMs;
@@ -210,7 +224,7 @@ function create(opts) {
210
224
  var concurrent = concurrentByIp.get(ip) || 0;
211
225
  if (concurrent <= 1) concurrentByIp.delete(ip);
212
226
  else concurrentByIp.set(ip, concurrent - 1);
213
- // BUG-2 — CWE-400. authFailureTimes auto-deletes when its array
227
+ // CWE-400. authFailureTimes auto-deletes when its array
214
228
  // empties in checkAuthAdmit; connectionTimes was the asymmetric
215
229
  // case. Sweep this IP's rate-window now that it has released its
216
230
  // last concurrent slot: if the per-minute window has fully
@@ -249,6 +263,36 @@ function create(opts) {
249
263
  times.push(Date.now());
250
264
  }
251
265
 
266
+ // RFC 5321 §3.5 — RCPT TO 550 vs 250 responses surface a mailbox-
267
+ // existence oracle. An IP that issues many RCPT-TO commands receiving
268
+ // 550 should hit the same admit / refuse shape used for AUTH failures.
269
+ // checkRcptAdmit returns ok=false once the per-minute cap is reached;
270
+ // listeners then return a transient 421 (close + back off) instead of
271
+ // continuing to surface the per-recipient oracle.
272
+ function checkRcptAdmit(ip) {
273
+ if (cfg.disabled) return { ok: true };
274
+ var times = rcptFailureTimes.get(ip);
275
+ if (!times) return { ok: true };
276
+ _pruneWindow(times, RCPT_FAILURE_WINDOW_MS);
277
+ if (times.length === 0) {
278
+ rcptFailureTimes.delete(ip);
279
+ return { ok: true };
280
+ }
281
+ if (times.length >= cfg.rcptFailuresPerIpPerMinute) {
282
+ _audit("mail.server.rate_limit.rcpt_refused", "denied",
283
+ { reason: "rcpt-failures-per-ip", ip: ip, cap: cfg.rcptFailuresPerIpPerMinute });
284
+ return { ok: false, reason: "rcpt-failures-per-ip" };
285
+ }
286
+ return { ok: true };
287
+ }
288
+
289
+ function noteRcptFailure(ip) {
290
+ if (cfg.disabled) return;
291
+ var times = rcptFailureTimes.get(ip);
292
+ if (!times) { times = []; rcptFailureTimes.set(ip, times); }
293
+ times.push(Date.now());
294
+ }
295
+
252
296
  function minBytesPerSecond() { return cfg.disabled ? 0 : cfg.minBytesPerSecond; }
253
297
  function isDisabled() { return cfg.disabled; }
254
298
 
@@ -257,6 +301,8 @@ function create(opts) {
257
301
  releaseConnection: releaseConnection,
258
302
  checkAuthAdmit: checkAuthAdmit,
259
303
  noteAuthFailure: noteAuthFailure,
304
+ checkRcptAdmit: checkRcptAdmit,
305
+ noteRcptFailure: noteRcptFailure,
260
306
  minBytesPerSecond: minBytesPerSecond,
261
307
  isDisabled: isDisabled,
262
308
  };
@@ -726,16 +726,25 @@ function create(opts) {
726
726
  }
727
727
 
728
728
  // Identity binding — under strict profile, MAIL FROM MUST match
729
- // an entry in the authenticated actor's mailbox set.
729
+ // an entry in the authenticated actor's mailbox set. An actor
730
+ // whose mailbox set is empty MUST also be refused: an empty
731
+ // allowlist is "no mailboxes" (account has no send-as identity
732
+ // assigned), NOT "all mailboxes." The earlier shape allowed any
733
+ // MAIL FROM when allowed.length === 0, turning a missing-config
734
+ // case (operator hasn't assigned mailboxes to the actor) into
735
+ // an open relay binding.
730
736
  if (state.authenticated && identityBinding === "strict") {
731
737
  var allowed = _actorMailboxes(state.actor);
732
- if (allowed.length > 0 && allowed.indexOf(mailFrom) === -1) {
738
+ if (allowed.length === 0 || allowed.indexOf(mailFrom) === -1) {
733
739
  _emit("mail.server.submission.identity_mismatch", {
734
740
  connectionId: state.id, authIdentity: state.actor.id || null,
735
741
  mailFrom: mailFrom, allowed: allowed,
742
+ reason: allowed.length === 0 ? "actor-has-no-mailboxes" : "mail-from-not-in-actor-set",
736
743
  }, "denied");
737
744
  _writeReply(socket, REPLY_553_SENDER_REJECTED,
738
- "5.7.1 Sender address rejected: not owned by authenticated identity");
745
+ allowed.length === 0
746
+ ? "5.7.1 Sender address rejected: authenticated identity has no assigned mailboxes"
747
+ : "5.7.1 Sender address rejected: not owned by authenticated identity");
739
748
  return;
740
749
  }
741
750
  }
package/lib/mail-store.js CHANGED
@@ -322,7 +322,28 @@ function _appendMessage(args) {
322
322
  }
323
323
  }
324
324
  var inReplyTo = _extractMessageId(tree, "in-reply-to");
325
- var referencesCsv = _extractReferences(tree);
325
+ if (inReplyTo) {
326
+ try { guardMessageId.validate(inReplyTo); }
327
+ catch (e) {
328
+ throw new MailStoreError("mail-store/bad-in-reply-to",
329
+ "appendMessage: In-Reply-To refused: " + e.message);
330
+ }
331
+ }
332
+ // RFC 5322 §3.6.4 — References is `1*msg-id`; each entry MUST satisfy
333
+ // the same msg-id grammar as Message-Id. Pre-this-patch the framework
334
+ // validated Message-Id but accepted any whitespace-separated token list
335
+ // in References / In-Reply-To, leaving an injection surface where
336
+ // attacker-controlled bytes reached the threading hash + JMAP
337
+ // `references` array. Loop the full list through the same guard.
338
+ var refList = _extractReferencesList(tree);
339
+ for (var __ri = 0; __ri < refList.length; __ri += 1) {
340
+ try { guardMessageId.validate(refList[__ri]); }
341
+ catch (e2) {
342
+ throw new MailStoreError("mail-store/bad-references",
343
+ "appendMessage: References entry refused: " + e2.message);
344
+ }
345
+ }
346
+ var referencesCsv = refList.join(",");
326
347
  var subject = tree.headers.get("subject") || "";
327
348
  var fromAddr = tree.headers.get("from") || "";
328
349
  var toAddrs = (tree.headers.getAll("to") || []).join(", ");
@@ -501,12 +522,19 @@ function _setFlags(args) {
501
522
  if (args.objectids.length > 0 && (setFlags.length > 0 || unsetFlags.length > 0)) {
502
523
  // Bulk-update via IN-clause. SQLite caps IN-clause at 32766 (max
503
524
  // bound parameters); chunk for very large operands.
525
+ // Prepare ONCE per chunk shape — the earlier shape called
526
+ // args.db.prepare(sql) twice in the same expression (once for the
527
+ // function reference, once for the `this` binding to .apply()),
528
+ // which both leaks a prepared-statement handle per chunk and
529
+ // doubles the SQL parse cost. Hold the stmt on a local + invoke
530
+ // .run() directly with the bound argument list.
504
531
  var CHUNK = 500; // allow:raw-byte-literal — IN-clause chunk size, not bytes
505
532
  for (var i = 0; i < args.objectids.length; i += CHUNK) {
506
533
  var chunk = args.objectids.slice(i, i + CHUNK);
507
534
  var placeholders = chunk.map(function () { return "?"; }).join(",");
508
535
  var sql = "UPDATE " + args.qMsgs + " SET modseq = ? WHERE objectid IN (" + placeholders + ")";
509
- args.db.prepare(sql).run.apply(args.db.prepare(sql), [newModseq].concat(chunk));
536
+ var stmt = args.db.prepare(sql);
537
+ stmt.run.apply(stmt, [newModseq].concat(chunk));
510
538
  }
511
539
  }
512
540
  args.stmtBumpFolderModseq.run(newModseq, args.folderName);
@@ -547,9 +575,13 @@ function _extractMessageId(tree, headerName) {
547
575
  }
548
576
 
549
577
  function _extractReferences(tree) {
578
+ return _extractReferencesList(tree).join(",");
579
+ }
580
+
581
+ function _extractReferencesList(tree) {
550
582
  var raw = tree.headers.get("references");
551
- if (!raw) return "";
552
- return String(raw).split(/\s+/).filter(function (s) { return s.length > 0; }).join(",");
583
+ if (!raw) return [];
584
+ return String(raw).split(/\s+/).filter(function (s) { return s.length > 0; });
553
585
  }
554
586
 
555
587
  function _normalizeAddr(s) {
@@ -244,7 +244,7 @@ function composePipeline(entries, opts) {
244
244
  }
245
245
 
246
246
  var pipelineId = bCrypto.namespaceHash("system.middleware.compose.pipeline",
247
- resolved.map(function (r) { return r.name; }).join("\n"));
247
+ resolved.map(function (r) { return r.name; }).join("\0"));
248
248
 
249
249
  _emitAudit("system.middleware.compose.pipeline_built", {
250
250
  pipelineId: pipelineId,
package/lib/safe-mime.js CHANGED
@@ -54,6 +54,16 @@ var DEFAULT_MAX_NESTING_DEPTH = 16;
54
54
  var DEFAULT_MAX_BOUNDARY = 70; // RFC 2046 §5.1.1
55
55
  var DEFAULT_MAX_HEADER_BYTES = C.BYTES.kib(64);
56
56
  var DEFAULT_MAX_HEADER_LINE = 998; // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap
57
+ // Per-message header-count cap. RFC 5322 places no upper bound on
58
+ // the number of headers in a message; without one, a sender can pack
59
+ // tens of thousands of one-byte headers into the maxHeaderBytes budget
60
+ // and force O(N²) lookup cost across every consumer that walks the
61
+ // header list (DKIM verify, IMAP FETCH, JMAP serializer). Mainstream
62
+ // MTAs (Postfix `header_size_limit`, Exim `received_headers_max`,
63
+ // Microsoft 365 `MaxRecipientEnvelopePerMessage`) cap in the low
64
+ // hundreds; the framework picks 512 as a generous default with
65
+ // `maxHeaderCount` exposed for operators that legitimately need more.
66
+ var DEFAULT_MAX_HEADER_COUNT = 512; // allow:raw-byte-literal — DoS bound, not bytes
57
67
  var DEFAULT_MAX_BODY_BYTES = C.BYTES.mib(25);
58
68
  var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(50);
59
69
 
@@ -87,7 +97,8 @@ var DEFAULT_TRANSFER_ENCODINGS = Object.freeze([
87
97
  * `oversize-nesting` / `oversize-boundary` / `oversize-headers` /
88
98
  * `oversize-header-line` / `oversize-body` / `unknown-charset` /
89
99
  * `unknown-transfer-encoding` / `malformed-boundary` /
90
- * `malformed-headers` / `control-char-in-header` / `bad-input`.
100
+ * `too-many-headers` / `malformed-headers` /
101
+ * `control-char-in-header` / `bad-input`.
91
102
  *
92
103
  * @opts
93
104
  * maxParts: number, // default 64
@@ -95,6 +106,7 @@ var DEFAULT_TRANSFER_ENCODINGS = Object.freeze([
95
106
  * maxBoundary: number, // default 70 (RFC 2046 §5.1.1)
96
107
  * maxHeaderBytes: number, // default 64 KiB
97
108
  * maxHeaderLineBytes: number, // default 998 (RFC 5322 §2.1.1)
109
+ * maxHeaderCount: number, // default 512 (DoS bound)
98
110
  * maxBodyBytes: number, // default 25 MiB
99
111
  * maxMessageBytes: number, // default 50 MiB
100
112
  * charsetAllowlist: string[], // default UTF-8 / US-ASCII / common legacy 8-bit
@@ -113,6 +125,7 @@ function parse(bytes, opts) {
113
125
  var maxBoundary = _intOpt(opts, "maxBoundary", DEFAULT_MAX_BOUNDARY);
114
126
  var maxHeaderBytes = _intOpt(opts, "maxHeaderBytes", DEFAULT_MAX_HEADER_BYTES);
115
127
  var maxHeaderLine = _intOpt(opts, "maxHeaderLineBytes", DEFAULT_MAX_HEADER_LINE);
128
+ var maxHeaderCount = _intOpt(opts, "maxHeaderCount", DEFAULT_MAX_HEADER_COUNT);
116
129
  var maxBodyBytes = _intOpt(opts, "maxBodyBytes", DEFAULT_MAX_BODY_BYTES);
117
130
  var maxMessageBytes = _intOpt(opts, "maxMessageBytes", DEFAULT_MAX_MESSAGE_BYTES);
118
131
  var charsets = _normalizeStringSet(opts.charsetAllowlist || DEFAULT_CHARSETS);
@@ -130,6 +143,7 @@ function parse(bytes, opts) {
130
143
  maxBoundary: maxBoundary,
131
144
  maxHeaderBytes: maxHeaderBytes,
132
145
  maxHeaderLine: maxHeaderLine,
146
+ maxHeaderCount: maxHeaderCount,
133
147
  maxBodyBytes: maxBodyBytes,
134
148
  charsets: charsets,
135
149
  encodings: encodings,
@@ -346,6 +360,15 @@ function _parsePart(buf, ctx, depth) {
346
360
  "safeMime.parse: boundary length " + boundary.length + " exceeds maxBoundary=" + ctx.maxBoundary +
347
361
  " (RFC 2046 §5.1.1)");
348
362
  }
363
+ // RFC 2046 §5.1.1 — boundary value MUST match the `bcharsnospace
364
+ // *bchars` grammar (max 70 chars, no CR/LF/NUL, no leading or
365
+ // trailing SP). A boundary containing newline bytes lets an
366
+ // attacker confuse multipart engines that re-split on different
367
+ // EOL forms downstream.
368
+ if (!_isValidMimeBoundary(boundary)) {
369
+ throw new SafeMimeError("safe-mime/malformed-boundary",
370
+ "safeMime.parse: multipart boundary does not match RFC 2046 §5.1.1 bcharsnospace *bchars grammar");
371
+ }
349
372
  var partBuffers = _splitMultipart(bodyBytes, boundary);
350
373
  var parts = [];
351
374
  for (var i = 0; i < partBuffers.length; i += 1) {
@@ -409,6 +432,14 @@ function _findHeaderBodySep(buf) {
409
432
 
410
433
  function _parseHeaders(buf, ctx) {
411
434
  var lines = _splitHeaderLines(buf, ctx);
435
+ // Per-message header-count cap (DoS bound). RFC 5322 does not bound
436
+ // header count; without a cap, a sender packs many short headers
437
+ // into the byte budget and forces quadratic walk cost downstream.
438
+ if (lines.length > ctx.maxHeaderCount) {
439
+ throw new SafeMimeError("safe-mime/too-many-headers",
440
+ "safeMime.parse: header count " + lines.length +
441
+ " exceeds maxHeaderCount=" + ctx.maxHeaderCount);
442
+ }
412
443
  var headerMap = Object.create(null);
413
444
  for (var i = 0; i < lines.length; i += 1) {
414
445
  var line = lines[i];
@@ -507,6 +538,11 @@ function _splitMultipart(buf, boundary) {
507
538
  var prevEnd = idx;
508
539
  if (prevEnd >= 2 && buf[prevEnd - 2] === 0x0D && buf[prevEnd - 1] === 0x0A) prevEnd -= 2;
509
540
  else if (prevEnd >= 1 && buf[prevEnd - 1] === 0x0A) prevEnd -= 1;
541
+ // RFC 2046 §5.1.1 — a malformed body where a final boundary
542
+ // immediately follows the opening of a part (no body bytes
543
+ // between them) MUST NOT produce a negative-length slice.
544
+ // Clamp to the part's start so the slice is well-formed.
545
+ if (prevEnd < prev.start) prevEnd = prev.start;
510
546
  parts[parts.length - 1] = buf.subarray(prev.start, prevEnd);
511
547
  }
512
548
  break;
@@ -518,6 +554,7 @@ function _splitMultipart(buf, boundary) {
518
554
  var prevEnd2 = idx;
519
555
  if (prevEnd2 >= 2 && buf[prevEnd2 - 2] === 0x0D && buf[prevEnd2 - 1] === 0x0A) prevEnd2 -= 2;
520
556
  else if (prevEnd2 >= 1 && buf[prevEnd2 - 1] === 0x0A) prevEnd2 -= 1;
557
+ if (prevEnd2 < prev2.start) prevEnd2 = prev2.start;
521
558
  parts[parts.length - 1] = buf.subarray(prev2.start, prevEnd2);
522
559
  }
523
560
  parts.push({ start: lineEnd });
@@ -529,6 +566,23 @@ function _splitMultipart(buf, boundary) {
529
566
  });
530
567
  }
531
568
 
569
+ // RFC 2046 §5.1.1 — boundary param grammar is `bcharsnospace *bchars`
570
+ // where `bcharsnospace = DIGIT / ALPHA / "'" / "(" / ")" / "+" / "_" /
571
+ // "," / "-" / "." / "/" / ":" / "=" / "?"` and `bchars = bcharsnospace
572
+ // / " "` (max 70 chars). Without validating against this set the
573
+ // parser would happily accept a boundary containing CR / LF / NUL /
574
+ // `--` which can be wielded to confuse downstream multipart engines.
575
+ var _BOUNDARY_BCHARSNOSPACE = /^[0-9A-Za-z'()+_,./:=?-]+$/; // allow:regex-no-length-cap — length checked separately
576
+ var _BOUNDARY_BCHARS_WITH_SP = /^[0-9A-Za-z'()+_,./:=? -]+$/; // allow:regex-no-length-cap — length checked separately
577
+ function _isValidMimeBoundary(value) {
578
+ if (typeof value !== "string" || value.length === 0 || value.length > 70) return false; // allow:raw-byte-literal — RFC 2046 §5.1.1 bound
579
+ // First char MUST be bcharsnospace; remainder MAY be bchars (which
580
+ // permits SP). Last char MUST also be bcharsnospace (no trailing SP).
581
+ if (!_BOUNDARY_BCHARSNOSPACE.test(value.charAt(0))) return false;
582
+ if (!_BOUNDARY_BCHARSNOSPACE.test(value.charAt(value.length - 1))) return false;
583
+ return _BOUNDARY_BCHARS_WITH_SP.test(value);
584
+ }
585
+
532
586
  // Find `--<boundary>` at a position preceded by CRLF, LF, or buf start.
533
587
  // Walks via indexOf scans + verifies the line-start invariant; loops
534
588
  // past non-line-start hits.
@@ -721,6 +775,7 @@ module.exports = {
721
775
  maxBoundary: DEFAULT_MAX_BOUNDARY,
722
776
  maxHeaderBytes: DEFAULT_MAX_HEADER_BYTES,
723
777
  maxHeaderLineBytes: DEFAULT_MAX_HEADER_LINE,
778
+ maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
724
779
  maxBodyBytes: DEFAULT_MAX_BODY_BYTES,
725
780
  maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,
726
781
  charsetAllowlist: DEFAULT_CHARSETS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:c1165275-044f-4a5a-b7dc-061727bfd076",
5
+ "serialNumber": "urn:uuid:a7f8cb0b-12df-4ba4-a501-86a191573153",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-16T23:28:39.659Z",
8
+ "timestamp": "2026-05-17T06:19:36.522Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.10.2",
22
+ "bom-ref": "@blamejs/core@0.10.4",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.2",
25
+ "version": "0.10.4",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.10.2",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.4",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.10.2",
57
+ "ref": "@blamejs/core@0.10.4",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]