@blamejs/core 0.10.6 → 0.10.7

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.7 (2026-05-17) — **Mail-stack P3 / P4 hardening sweep.** Twenty-plus refusals + observability additions across the four mail listeners, the DKIM verifier, ARC signer, MIME parser, and the DNS / DSN / List-* guards. No new public primitives; one substrate addition (`b.crypto.randomInt`); two new operator-visible opts on the submission listener. **(a) `b.crypto.randomInt(min, max)`** — substrate wrapper that routes every framework integer draw through one greppable primitive. Migrates the inline `nodeCrypto.randomInt` sites in `b.network.dns` / `b.network.dns.resolver` (DNS query-ID), `b.mail.auth` (DMARC `pct` sampling), and `b.externalDb` (transaction-retry jitter) so the audit trail is uniform and future detectors see one shape. **(b) `b.mail.server.submission.create({ requireDkim, dkimRequireMode })`** — outbound DKIM-required gate per Yahoo / Google 2024 bulk-sender alignment. `requireDkim` defaults `true` under `strict` profile (`false` under `balanced` / `permissive`). `dkimRequireMode` is `"self"` (signer's `d=` must match authenticated identity's domain), `"any"` (any signer present), or `"off"` (no gate). Default `"any"`. Submission listener that doesn't carry a `DKIM-Signature:` header at DATA-end refuses with `5.7.20`. **(c) `b.mail.server.{mx,submission}.create({ allowSmtpUtf8 })`** — single per-listener SMTPUTF8 (RFC 6531) switch threaded end-to-end into `guardSmtpCommand.validate`. Default `false`. Operators that accept EAI envelopes flip to `true` and the toggle reaches every wire-line guard call. **(d) DKIM verifier signature-count cap.** `b.mail.dkim.verify` now refuses (`policy` verdict) rather than silently truncating when a message carries more `DKIM-Signature` headers than `maxSignatures` (default 8). The opt is range-checked at config time against a ceiling of 16; out-of-range throws `dkim/bad-max-signatures`. Closes a verifier-fan-out DoS shape per RFC 6376 §6.1. Emits `dkim.verify.signature_count_cap` audit on the refusal so postmasters see DoS attempts in the authentication-results stream. **(e) MX listener size-overrun + observability.** `MAIL FROM SIZE=` is now reconciled against the actual DATA byte count after dot-stuffing reversal — senders that understate `SIZE=` to probe `maxMessageBytes` get `552 5.3.4` rather than silently accepted, with `mail.server.mx.size_overrun` audit. Refused-recipient list (bounded at 32 per transaction) now surfaces in the `data_accepted` / `delivered` audit metadata. Write-backpressure on every reply attaches a once-per-socket `mail.server.mx.write_backpressure` audit so operators see stalled connections without flooding on every reply. **(f) IMAP refinements.** `APPEND mailbox [flags] [date-time] {literal}` now honors the optional RFC 9051 §6.3.12 date-time argument (parsed into `internalDate` ms-epoch, refused with `BAD` rather than silently falling back to `Date.now()`). `FETCH` / `STORE` outside of Selected state now respond `BAD` (RFC 9051 §6.4.5 / §6.4.6 — protocol-context violation, not policy refusal). `LOGIN` quoted-string args honor `\"` / `\\` escape pairs per the RFC 9051 §5.1 grammar (the prior shape terminated at the first `"`, letting a hostile client smuggle `LOGIN "alice\"@example.com" "pw"` past the username binding). **(g) ARC signer hop-count ceiling.** `b.mail.arc.sign` extracts prior hops with the RFC 8617 §5 50-hop cap; an inbound chain claiming >50 hops or an out-of-range `i=` tag is refused rather than enumerated. **(h) MIME parser charset + observability.** `b.safeMime.parse` now decodes `utf-16` (RFC 2781 §3.3 BOM detection + BE default), `utf-16be`, and `utf-16le` end-to-end — the prior shape advertised `utf-16` / `utf-16be` in the allowlist but only decoded `utf-16le`. `binary` Content-Transfer-Encoding is removed from the default allowlist (RFC 3030 §3 — `binary` requires explicit BINARYMIME negotiation; operators that wire BINARYMIME opt back in via `transferEncodingAllowlist: [..., "binary"]`). Control-character refusal errors now report the BYTE offset (via `Buffer.byteLength` on the JS string prefix) rather than the UTF-16 code-unit index, so audit lines align with wire-level inspection. **(i) DKIM / DMARC / ARC / iPrev / DSN tightening.** `b.guardDsn` splits the RFC 3464 §2.1.1 block separator on literal `\r\n\r\n` only (the prior `\n\s*\n` accepted `\v` / `\f` whitespace as a block boundary, letting a hostile sender bend the per-message vs per-recipient boundary). `b.guardMessageId` now validates id-left + id-right against RFC 5322 §3.2.3 dot-atom-text shape under `strict` profile; `b.guardListId` extends the localhost FQDN exception to `.local` (RFC 6762) and `.lan` (draft-chapin-rfc2606bis). **(j) `b.guardListUnsubscribe` SSRF defense.** HTTPS one-click URIs now refuse IP-literal hosts (v4 + v6), reserved-local hostnames (`localhost` / `localhost.localdomain` / `ip6-localhost` / `ip6-loopback`), and reserved-local TLD suffixes (`.local` / `.lan` / `.internal`). New optional `allowedHosts` opt provides a domain allowlist — when supplied, every HTTPS host (or any ancestor) must be on the list. **(k) `b.mailStore` JMAP objectid bump to 128 bits.** RFC 8474 §1.5.1 — the prior 24-char hex prefix cut entropy to 96 bits; full 32-char hex restores 128 bits. **Operator impact:** Submission listeners on `strict` profile WITHOUT operator-side DKIM signing (`b.mail.dkim.sign` pre-relay) now refuse outbound DATA — operators in this state either wire DKIM signing, opt to `dkimRequireMode: "off"`, or step down to `balanced`. `b.mail.dkim.verify` callers passing `maxSignatures > 16` now throw at config time — clamp via the opt or rely on the framework default. `b.safeMime.parse` callers that legitimately receive `binary` Content-Transfer-Encoding (BINARYMIME-aware downstream pipelines) opt back in via `transferEncodingAllowlist`. `b.guardListUnsubscribe.validate` callers that legitimately rely on IP-literal one-click URIs (test harnesses, internal-network operators) opt in via `allowedHosts: ["10.0.0.0/8"]` style ancestor matches. **Deferred to v0.10.8 / v0.10.12:** per-tenant pepper on `b.mailStore` derived hashes (`from_hash` / `message_id_hash`) ships in v0.10.12 alongside the `b.agent.tenant` adoption refactor; the schema migration is too invasive to fold into this patch. `b.mailStore` forensic-recovery columns (original Content-Transfer-Encoding + charset preserved alongside decoded body) defer to v0.10.8 — the schema change carries its own backwards-compatibility surface. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051), [RFC 6376 DKIM §6.1](https://www.rfc-editor.org/rfc/rfc6376#section-6.1), [RFC 6531 SMTPUTF8](https://www.rfc-editor.org/rfc/rfc6531), [RFC 3030 BINARYMIME](https://www.rfc-editor.org/rfc/rfc3030), [RFC 2781 UTF-16 BOM](https://www.rfc-editor.org/rfc/rfc2781), [RFC 1870 SMTP SIZE](https://www.rfc-editor.org/rfc/rfc1870), [RFC 8474 JMAP objectid](https://www.rfc-editor.org/rfc/rfc8474), [RFC 8617 ARC §5](https://www.rfc-editor.org/rfc/rfc8617#section-5), [RFC 3464 DSN §2.1.1](https://www.rfc-editor.org/rfc/rfc3464#section-2.1.1), [RFC 5322 Message Format §3.2.3](https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3), [RFC 6761 Reserved Domain Names](https://www.rfc-editor.org/rfc/rfc6761), [Yahoo / Gmail bulk-sender 2024](https://blog.google/products/gmail/gmail-security-authentication-spam-protection/).
11
12
  - v0.10.6 (2026-05-17) — **Vendored-SBOM CycloneDX 1.6 conformance + cosign verification recipe pin.** Build-side + verification-side improvements; no runtime changes. **(a) `scripts/build-vendored-sbom.js` per-component `cpe` field** — every vendored bundle gets a CPE 2.3 string (`cpe:2.3:a:<vendor>:<product>:<version>:*:*:*:*:*:*:*`). CISA / NVD CVE-matching tools (Dependency-Track, OWASP Dependency-Check, Snyk SBOM Monitor) match CVE advisories against components by CPE; the prior emit had no CPE field, so vendored bundles were invisible to operator-side CVE scanners. **(b) Per-component `supplier` block** — `metadata.supplier` (framework-level) was already populated; each vendored bundle now also carries its own `components[].supplier` with the upstream maintainer / org per [SLSA v1.0 provenance requirements](https://slsa.dev/spec/v1.0/provenance) — operators auditing the SBOM see both the framework supplier (blamejs) AND the vendored bundle's upstream supplier (noble-curves, noble-ciphers, etc.) at the component level. **(c) `metadata.lifecycles[].externalReferences[]`** — CycloneDX 1.6 §4.4.2 requires `lifecycles` entries to carry build-provenance references (workflow URL, run ID); the npm-publish workflow now populates these so the SBOM points back at the SLSA-attesting workflow run that produced the tarball. **(d) Sub-component `dependsOn` graph** — when a vendored bundle exposes sub-components (e.g. `noble-ciphers` exports `xchacha20poly1305` + `aes-gcm` as named sub-modules), each sub-component now emits its own SBOM entry with a `dependencies` edge pointing to its parent (CycloneDX 1.6 §4.7). Operators get the full transitive graph instead of just the top-level vendored bundle. **(e) `_licenseFor()` inline-path fix** — the path-resolution branch that handles vendored bundles whose `package.json` is under `lib/vendor/<name>/package.json` now correctly returns the SPDX `license.id` (was returning `null` for that branch, causing CycloneDX-validator warnings). **(f) `SECURITY.md` cosign verification recipe pinned to workflow path + tag-ref** — the operator-side recipe now constrains `cosign verify-blob --certificate-identity-regexp` to the specific workflow file (`.github/workflows/npm-publish.yml`) + tag-ref shape (`refs/tags/v[0-9]+\.[0-9]+\.[0-9]+`), refusing certificates issued for any other workflow or ref class. Also documents `--rekor-url` for operators running on an air-gapped network with a local transparency log + offline TUF root path for `cosign initialize --root <local-root.json>`. **(g) `.github/workflows/npm-publish.yml` recipe comment** synchronized to match the SECURITY.md recipe so operators copy-pasting from either source see identical verification steps. **Operator impact:** SBOM consumers that previously saw vendored bundles as opaque now see CPE-matched components with proper supplier attribution + transitive sub-component graph. The Sigstore-keyless verification recipe is more restrictive (rejects certificates issued for non-`npm-publish.yml` workflows on this repo) — operators already verifying against the prior recipe see the same successful verification with the tighter identity match. References: [CycloneDX 1.6 §4](https://cyclonedx.org/docs/1.6/json/), [CPE 2.3 spec](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf), [SLSA v1.0 provenance](https://slsa.dev/spec/v1.0/provenance), [Sigstore cosign verify-blob](https://docs.sigstore.dev/cosign/verifying/verify/), [TUF specification](https://theupdateframework.github.io/specification/latest/).
12
13
  - v0.10.5 (2026-05-16) — **`b.mail.server.pop3` APOP cleartext refusal + `b.vendorData` constant-time digest compares.** Two small entry-tier refusals on the mail and vendor-data surfaces. **(a) `b.mail.server.pop3._handleApop`** refuses APOP when the connection is cleartext and the profile is not permissive, symmetric with the existing USER / PASS refusal. APOP transmits `MD5(timestamp+secret)` (not cleartext credentials), but an attacker who captures the digest plus the known greeting timestamp can mount an offline dictionary attack against the shared secret. RFC 1939 §7 explicitly warns about this; the wire MUST be TLS-protected to deny the offline-attack vector. Emits the same `mail.server.pop3.auth_refused_cleartext` audit event + writes `-ERR APOP refused over cleartext (use STLS first; RFC 1939 §7)`. The cleartext-refusal line was advertised in the v0.10.4 release notes but the wire-level enforcement only lands here; operators relying on v0.10.4 saw the comment but not the runtime gate. **(b) `b.vendorData.verifyAll()`** boot-time digest verifies (SHA-256 layer 1, SHA3-512 layer 2, and the SLH-DSA-SHAKE-256f pubkey-fingerprint cross-check) now compare via a length-prechecked `nodeCrypto.timingSafeEqual` instead of `!==`. The framework convention is that every digest / MAC compare is constant-time regardless of whether the value is a secret — reaching for `!==` whenever a value "isn't a secret" is the smell; the convention is the gate. Uses `nodeCrypto.timingSafeEqual` directly (not `b.crypto.timingSafeEqual`) because `b.crypto` is `lazyRequire`'d to break a circular load chain and isn't available during boot-time `verifyAll()`. **Operator impact:** APOP users on plaintext POP3 (port 110) without STLS first now get `-ERR` instead of authenticating — the operator either wires STLS, switches the listener to implicit TLS (port 995), or sets `profile: "permissive"` for the deliberately-open path. `b.vendorData` consumers see no behavioral change — the timing-safe compare returns the same boolean as `!==` for length-equal inputs. References: [RFC 1939 §7](https://www.rfc-editor.org/rfc/rfc1939#section-7), [CWE-208 timing attack](https://cwe.mitre.org/data/definitions/208.html), [NIST SP 800-38B §6.3 MAC verification](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38B.pdf).
13
14
  - 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).
package/lib/crypto.js CHANGED
@@ -390,6 +390,35 @@ function random(byteLength) {
390
390
  .digest();
391
391
  }
392
392
 
393
+ /**
394
+ * @primitive b.crypto.randomInt
395
+ * @signature b.crypto.randomInt(min, max)
396
+ * @since 0.10.7
397
+ * @status stable
398
+ *
399
+ * Cryptographically-secure uniform integer in `[min, max)`. Substrate
400
+ * wrapper that routes every framework integer draw (DNS query-ID,
401
+ * DMARC `pct` sampling, retry jitter) through one greppable primitive.
402
+ * Both bounds must be safe integers, `max > min`, and the half-open
403
+ * span must not exceed 2^48 (the underlying runtime's hard cap).
404
+ *
405
+ * @example
406
+ * var n = b.crypto.randomInt(0, 100);
407
+ * // → integer in [0, 100)
408
+ */
409
+ function randomInt(min, max) {
410
+ if (typeof min !== "number" || !Number.isInteger(min)) {
411
+ throw new TypeError("b.crypto.randomInt: min must be an integer");
412
+ }
413
+ if (typeof max !== "number" || !Number.isInteger(max)) {
414
+ throw new TypeError("b.crypto.randomInt: max must be an integer");
415
+ }
416
+ if (max <= min) {
417
+ throw new RangeError("b.crypto.randomInt: max must be greater than min");
418
+ }
419
+ return nodeCrypto.randomInt(min, max);
420
+ }
421
+
393
422
  function generateKeyPair(algorithm, options) {
394
423
  var pair = nodeCrypto.generateKeyPairSync(algorithm, Object.assign({
395
424
  publicKeyEncoding: { type: "spki", format: "pem" },
@@ -1845,6 +1874,7 @@ module.exports = {
1845
1874
  // Random
1846
1875
  generateBytes: generateBytes,
1847
1876
  generateToken: generateToken,
1877
+ randomInt: randomInt,
1848
1878
  toBase64Url: toBase64Url,
1849
1879
  fromBase64Url: fromBase64Url,
1850
1880
  // Keys
@@ -36,6 +36,7 @@
36
36
  * External-database integration for app data — Postgres / MySQL / SQLite / MongoDB connection pooling, retry, circuit breaker, classification routing, residency enforcement, and audit hooks.
37
37
  */
38
38
  var retryHelper = require("./retry");
39
+ var bCrypto = require("./crypto");
39
40
  var C = require("./constants");
40
41
  var dbRoleContext = require("./db-role-context");
41
42
  var externalDbMigrate = require("./external-db-migrate");
@@ -754,8 +755,7 @@ async function transaction(fn, opts) {
754
755
  if (isTransient && attempt <= maxRetries) {
755
756
  _emitMetric("externaldb.transaction.retry", 1,
756
757
  { backend: b.name, code: txErr.code, attempt: String(attempt) });
757
- var nodeCrypto = require("node:crypto");
758
- var jitter = nodeCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
758
+ var jitter = bCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
759
759
  await safeAsync.sleep(attempt * 5 + jitter); // allow:raw-time-literal — sub-second backoff
760
760
  continue;
761
761
  }
package/lib/guard-dsn.js CHANGED
@@ -279,12 +279,15 @@ function compliancePosture(posture) {
279
279
  }
280
280
 
281
281
  function _splitBlocks(text) {
282
- // RFC 3464 §2.1: blank line separates per-message from
283
- // per-recipient blocks; blank lines also separate consecutive
284
- // per-recipient blocks.
285
- // Normalize CRLF + bare-CR to LF for split.
282
+ // RFC 3464 §2.1.1: block separator is `CRLF CRLF` only — a "blank
283
+ // line" in message-syntax terms. `\n\s*\n` admits `\v` / `\f` /
284
+ // mixed whitespace which a hostile sender can use to bend the
285
+ // block boundary (folded fields drift between per-message and
286
+ // per-recipient blocks). Normalize CR(LF)? → LF, then split on
287
+ // strict `\n\n` (an LF, an empty line, an LF) — anything else is
288
+ // either intra-block CFWS or an intra-field continuation.
286
289
  var normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // allow:regex-no-length-cap — input length already capped
287
- return normalized.split(/\n\s*\n/); // allow:regex-no-length-cap — input length already capped
290
+ return normalized.split("\n\n");
288
291
  }
289
292
 
290
293
  function _parseFieldBlock(block, maxHeaderLine) {
@@ -224,22 +224,26 @@ function validate(headerValue, opts) {
224
224
  }
225
225
  // RFC 2919 §2 requires AT LEAST one `.` (label + namespace);
226
226
  // strict/balanced ALSO require the namespace to be a FQDN, which
227
- // means a minimum of 3 labels total (label + ns-label + ns-tld)
228
- // OR a 2-label list-id where the namespace is `localhost`.
227
+ // means a minimum of 3 labels total (label + ns-label + ns-tld) OR
228
+ // a 2-label list-id where the namespace ends in a reserved-local
229
+ // TLD: `localhost` (RFC 6761 §6.3), `local` (RFC 6762 mDNS), or
230
+ // `lan` (IETF draft-chapin-rfc2606bis). All three are non-routable
231
+ // single-network labels and the FQDN floor doesn't apply.
232
+ var lastLabel = parts[parts.length - 1].toLowerCase();
233
+ var isLocalScopeTld = lastLabel === "localhost" || lastLabel === "local" || lastLabel === "lan";
229
234
  if (caps.requireFqdn) {
230
- var lastLabel = parts[parts.length - 1].toLowerCase();
231
- if (parts.length < 3 && lastLabel !== "localhost") { // allow:raw-byte-literal FQDN requires 3 labels for non-localhost
232
- return _refuse("list-id has < 3 labels for non-localhost namespace (FQDN required under '" +
235
+ if (parts.length < 3 && !isLocalScopeTld) { // allow:raw-byte-literal — FQDN requires ≥ 3 labels for non-local-scope namespace
236
+ return _refuse("list-id has < 3 labels for non-local-scope namespace (FQDN required under '" +
233
237
  (opts.profile || DEFAULT_PROFILE) + "')");
234
238
  }
235
239
  }
236
240
 
237
- // RFC 2919 §3: `localhost` namespace SHOULD carry 32-hex
238
- // randomness in the label.
239
- var isLocalhost = parts[parts.length - 1].toLowerCase() === "localhost";
240
- if (isLocalhost) {
241
+ // RFC 2919 §3: `localhost`-class namespaces SHOULD carry 32-hex
242
+ // randomness in the label so cross-host listserv operators can't
243
+ // collide. Applies to all three reserved-local TLDs.
244
+ if (isLocalScopeTld) {
241
245
  if (caps.requireRandomForLocalhost && !RANDOM_HEX_RE.test(listId)) { // allow:regex-no-length-cap — listId length-bounded above
242
- return _refuse("localhost namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
246
+ return _refuse("local-scope namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
243
247
  }
244
248
  }
245
249
 
@@ -134,6 +134,59 @@ var DANGEROUS_SCHEMES = Object.freeze({
134
134
  "blob:": true,
135
135
  });
136
136
 
137
+ // IP-literal + reserved-hostname refusal for HTTPS one-click URIs.
138
+ // One-click receivers POST to the URI without further operator gate;
139
+ // an attacker-supplied List-Unsubscribe URI pointing at `127.0.0.1`
140
+ // / `169.254.169.254` (cloud metadata) / `[::1]` / `localhost` lets
141
+ // the mailbox provider's auto-fetcher target the operator's own
142
+ // infrastructure — classic SSRF. The check is wholly host-name-shape
143
+ // based (no DNS resolution); DNS-rebinding defense is left to the
144
+ // fetcher (which should pin the IP across resolution + request).
145
+ var IPV4_LITERAL_RE = /^\d+\.\d+\.\d+\.\d+$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
146
+ var IPV6_LITERAL_RE = /^\[[0-9A-Fa-f:.]+\]$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
147
+ var RESERVED_LOCAL_HOSTS = Object.freeze({
148
+ "localhost": true,
149
+ "localhost.localdomain": true,
150
+ "ip6-localhost": true,
151
+ "ip6-loopback": true,
152
+ });
153
+
154
+ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
155
+ if (typeof hostname !== "string" || hostname.length === 0) return "missing-host";
156
+ // Normalize the trailing root-zone dot BEFORE comparison — RFC 1034
157
+ // §3.1: `foo.` is the absolute form of `foo` (both resolve to the
158
+ // same target). A naive byte-equality check against `localhost`
159
+ // would let an attacker bypass the gate by appending the dot. Same
160
+ // for any reserved-local suffix family. Multiple trailing dots are
161
+ // not valid DNS but we strip them anyway to keep the gate robust
162
+ // against any URL parser that leaves them in `hostname`.
163
+ var lower = hostname.toLowerCase();
164
+ while (lower.length > 0 && lower.charAt(lower.length - 1) === ".") {
165
+ lower = lower.slice(0, -1);
166
+ }
167
+ if (lower.length === 0) return "missing-host";
168
+ if (IPV4_LITERAL_RE.test(lower) || IPV6_LITERAL_RE.test(lower)) return "ip-literal";
169
+ if (RESERVED_LOCAL_HOSTS[lower]) return "reserved-local-host";
170
+ // Hostname suffix refusal — RFC 6761 reserved / mDNS / single-network.
171
+ if (lower === "local" || lower.endsWith(".local")) return "reserved-local-suffix";
172
+ if (lower === "lan" || lower.endsWith(".lan")) return "reserved-local-suffix";
173
+ if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
174
+ // Optional operator allowlist — when supplied, hostname (or any
175
+ // ancestor domain) MUST be present.
176
+ if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
177
+ var matched = false;
178
+ for (var i = 0; i < allowedHosts.length; i += 1) {
179
+ var allowed = String(allowedHosts[i]).toLowerCase();
180
+ if (lower === allowed || lower.endsWith("." + allowed)) {
181
+ matched = true;
182
+ break;
183
+ }
184
+ }
185
+ if (!matched) return "not-on-allowlist";
186
+ }
187
+ return null;
188
+ }
189
+
137
190
  /**
138
191
  * @primitive b.guardListUnsubscribe.validate
139
192
  * @signature b.guardListUnsubscribe.validate(headers, opts?)
@@ -225,12 +278,25 @@ function validate(headers, opts) {
225
278
  { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
226
279
  }
227
280
  if (scheme === "https:") {
281
+ var parsed;
228
282
  try {
229
- safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
283
+ parsed = safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
230
284
  } catch (e) {
231
285
  return _verdict("refuse", "HTTPS URI '" + _trunc(u) + "' failed safeUrl parse: " + (e && e.message || String(e)),
232
286
  { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
233
287
  }
288
+ // SSRF defense — refuse IP-literal hosts, loopback names,
289
+ // reserved-local TLDs, and (when operator supplied
290
+ // `allowedHosts`) anything outside the allowlist. The mailbox
291
+ // provider's auto-fetcher POSTs without our involvement; the
292
+ // header is the only place this can be stopped.
293
+ var refusedHostReason = _isRefusedAutoFetchHost(parsed.hostname, opts.allowedHosts);
294
+ if (refusedHostReason) {
295
+ return _verdict("refuse",
296
+ "HTTPS URI '" + _trunc(u) + "' host '" + parsed.hostname +
297
+ "' refused (" + refusedHostReason + "; auto-fetch SSRF defense)",
298
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
299
+ }
234
300
  hasHttpsUri = true;
235
301
  } else if (scheme === "mailto:") {
236
302
  hasMailtoUri = true;
@@ -74,6 +74,13 @@ var COMPLIANCE_POSTURES = Object.freeze({
74
74
  // CVE-2021-42574 RTLO class in mail header context).
75
75
  var BIDI_RE = /[؜‎‏‪-‮⁦-⁩]/;
76
76
 
77
+ // RFC 5322 §3.2.3 dot-atom-text — used at strict profile to validate
78
+ // the id-left and id-right shape inside the bracketed Message-Id.
79
+ // `atext` = ALPHA / DIGIT / "!#$%&'*+-/=?^_`{|}~"; `dot-atom-text` is
80
+ // 1*atext *("." 1*atext). Length-bounded by the maxBytes cap above so
81
+ // the regex CPU is amortised; pattern is single-pass linear.
82
+ var DOT_ATOM_TEXT_RE = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+(?:\.[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
83
+
77
84
  /**
78
85
  * @primitive b.guardMessageId.validate
79
86
  * @signature b.guardMessageId.validate(value, opts?)
@@ -154,6 +161,25 @@ function validate(value, opts) {
154
161
  throw new GuardMessageIdError("message-id/nested-brackets",
155
162
  "guardMessageId.validate: nested angle brackets refused");
156
163
  }
164
+ // RFC 5322 §3.6.4: id-left and id-right MUST conform to
165
+ // dot-atom-text shape (§3.2.3). A second `@` inside id-left or
166
+ // id-right falls out of dot-atom-text and is refused here. The
167
+ // last `@` is the local/domain separator — `lastIndexOf` rather
168
+ // than `indexOf` handles `a@b@c` correctly: id-left would be
169
+ // `a@b` which fails dot-atom-text on the `@` character.
170
+ var atLast = inner.lastIndexOf("@");
171
+ var idLeft = inner.slice(0, atLast);
172
+ var idRight = inner.slice(atLast + 1);
173
+ if (!DOT_ATOM_TEXT_RE.test(idLeft)) { // allow:regex-no-length-cap — idLeft length-bounded by maxBytes above
174
+ throw new GuardMessageIdError("message-id/id-left-shape",
175
+ "guardMessageId.validate: id-left '" + idLeft +
176
+ "' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
177
+ }
178
+ if (!DOT_ATOM_TEXT_RE.test(idRight)) { // allow:regex-no-length-cap — idRight length-bounded by maxBytes above
179
+ throw new GuardMessageIdError("message-id/id-right-shape",
180
+ "guardMessageId.validate: id-right '" + idRight +
181
+ "' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
182
+ }
157
183
  }
158
184
 
159
185
  return value;
@@ -128,10 +128,19 @@ function _bodyHashB64(body, algorithm) {
128
128
  return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
129
129
  }
130
130
 
131
+ // RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
132
+ // caps in `mail-auth.js` (`ARC_MAX_HOPS`); the signer's prior-hop
133
+ // extractor needs the same ceiling so a message arriving with a
134
+ // hostile chain (51+ instances) doesn't expand the per-hop walk to
135
+ // unbounded work before the signer's own validation catches up.
136
+ var ARC_MAX_HOPS_FOR_EXTRACT = 50; // allow:raw-byte-literal — RFC 8617 §5 chain bound
137
+
131
138
  function _arcExtractPriorHops(parsedHeaders) {
132
139
  // Walk parsedHeaders; for each ARC-Authentication-Results /
133
140
  // ARC-Message-Signature / ARC-Seal entry, extract instance via i=N
134
- // and group by hop.
141
+ // and group by hop. The `i=` value is bounded against the RFC's
142
+ // 50-hop ceiling before being used as a map key, so an attacker-
143
+ // chosen `i=999999` can't allocate a sparse map.
135
144
  var hopMap = {};
136
145
  for (var i = 0; i < parsedHeaders.length; i += 1) {
137
146
  var h = parsedHeaders[i];
@@ -142,11 +151,22 @@ function _arcExtractPriorHops(parsedHeaders) {
142
151
  var iMatch = h.value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — ARC header bounded by RFC 5322 §2.1.1
143
152
  if (!iMatch) continue;
144
153
  var instance = parseInt(iMatch[1], 10);
154
+ if (!isFinite(instance) || instance < 1 || instance > ARC_MAX_HOPS_FOR_EXTRACT) {
155
+ // Out-of-spec instance number — refuse to consider it. Upstream
156
+ // `sign` will see `priorHops.length !== opts.instance - 1` and
157
+ // refuse the message.
158
+ continue;
159
+ }
145
160
  if (!hopMap[instance]) hopMap[instance] = { instance: instance };
146
161
  hopMap[instance][lcName] = h.value;
147
162
  }
148
163
  var hops = [];
149
164
  var keys = Object.keys(hopMap).sort(function (a, b) { return Number(a) - Number(b); });
165
+ if (keys.length > ARC_MAX_HOPS_FOR_EXTRACT) {
166
+ throw new MailAuthError("arc-sign/chain-too-long",
167
+ "_arcExtractPriorHops: chain has " + keys.length +
168
+ " hops, exceeds RFC 8617 §5 ceiling of " + ARC_MAX_HOPS_FOR_EXTRACT);
169
+ }
150
170
  for (var k = 0; k < keys.length; k += 1) hops.push(hopMap[keys[k]]);
151
171
  return hops;
152
172
  }
package/lib/mail-auth.js CHANGED
@@ -37,6 +37,7 @@ var net = require("node:net");
37
37
  var nodeCrypto = require("node:crypto");
38
38
  var lazyRequire = require("./lazy-require");
39
39
  var validateOpts = require("./validate-opts");
40
+ var bCrypto = require("./crypto");
40
41
  var C = require("./constants");
41
42
  var dkim = require("./mail-dkim");
42
43
  var safeXml = require("./parsers/safe-xml");
@@ -722,7 +723,7 @@ async function dmarcEvaluate(opts) {
722
723
  var u32 = (hash[0] << 24 >>> 0) + (hash[1] << 16) + (hash[2] << 8) + hash[3]; // allow:raw-byte-literal — uint32 bit assembly
723
724
  sampleRoll = u32 % 100; // allow:raw-byte-literal — pct sample roll
724
725
  } else {
725
- sampleRoll = nodeCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
726
+ sampleRoll = bCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
726
727
  }
727
728
  var sampled = !pass && pct < 100 && sampleRoll >= pct;
728
729
  var recommendedAction = pass ? "deliver" :
package/lib/mail-dkim.js CHANGED
@@ -525,6 +525,11 @@ var DKIM_KEY_CACHE_MAX_ENTRIES = 1024;
525
525
  // receivers cap at 5–8. Operators that legitimately accept more
526
526
  // override via verify({ maxSignatures }).
527
527
  var DKIM_MAX_SIGNATURES_PER_MESSAGE = 8; // allow:raw-byte-literal — receiver-fan-out DoS bound
528
+ // Operator-supplied `maxSignatures` opt is range-checked against this
529
+ // ceiling. RFC 6376 §6.1 sets no upper bound; 16 is generous headroom
530
+ // for legitimate relay chains with hop signatures while keeping the
531
+ // verify-fan-out within a CPU-DoS envelope.
532
+ var DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING = 16; // allow:raw-byte-literal — operator-opt range ceiling
528
533
 
529
534
  function _cacheGet(qname) {
530
535
  var ent = DKIM_KEY_CACHE.get(qname);
@@ -836,6 +841,7 @@ async function verify(rfc822, opts) {
836
841
  opts = opts || {};
837
842
  validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
838
843
  "minRsaBits"], "mail.dkim.verify");
844
+ var auditOn = opts.audit !== false;
839
845
 
840
846
  // Bounded clock skew: refuse non-numeric / negative / infinite /
841
847
  // beyond-ceiling. Throwing on bad config-time input per the
@@ -855,10 +861,27 @@ async function verify(rfc822, opts) {
855
861
  clockSkewMs = Math.floor(opts.clockSkewMs);
856
862
  }
857
863
 
858
- var maxSignatures = (typeof opts.maxSignatures === "number" &&
859
- isFinite(opts.maxSignatures) && opts.maxSignatures >= 1)
860
- ? Math.floor(opts.maxSignatures)
861
- : DKIM_MAX_SIGNATURES_PER_MESSAGE;
864
+ // RFC 6376 §6.1 verifier MUST handle multiple signatures but the
865
+ // RFC sets no count cap. An unbounded count is a CPU-DoS surface
866
+ // (each sig forces a DNS fetch + cryptographic verify). Range 1-16
867
+ // — mainstream receivers (Gmail/Yahoo/MS 2024 bulk-sender guidance)
868
+ // cite 2-3 valid signatures per message as the operational ceiling;
869
+ // 16 is generous headroom for relay chains with hop signatures. The
870
+ // operator opt is range-checked at config time — values < 1 or > 16
871
+ // throw rather than silently clamp so an over-large config doesn't
872
+ // re-introduce the DoS surface.
873
+ var maxSignatures = DKIM_MAX_SIGNATURES_PER_MESSAGE;
874
+ if (opts.maxSignatures !== undefined) {
875
+ if (typeof opts.maxSignatures !== "number" ||
876
+ !isFinite(opts.maxSignatures) ||
877
+ opts.maxSignatures < 1 ||
878
+ opts.maxSignatures > DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING) {
879
+ throw new DkimError("dkim/bad-max-signatures",
880
+ "verify: maxSignatures must be an integer in [1, " +
881
+ DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING + "] (got " + opts.maxSignatures + ")");
882
+ }
883
+ maxSignatures = Math.floor(opts.maxSignatures);
884
+ }
862
885
  var verifyOpts = { minRsaBits: opts.minRsaBits };
863
886
 
864
887
  var split = _splitHeadersBody(rfc822);
@@ -867,12 +890,30 @@ async function verify(rfc822, opts) {
867
890
  if (sigHeaders.length === 0) {
868
891
  return [{ result: "none", errors: ["no DKIM-Signature headers"] }];
869
892
  }
870
- // RFC 6376 §6.1 verifier MUST handle multiple signatures but the
871
- // RFC sets no count cap. An unbounded count is a CPU-DoS surface
872
- // (each sig forces a DNS fetch + cryptographic verify). Cap and
873
- // surface the truncation in the result for operator visibility.
893
+ // When the message carries more signatures than the cap allows,
894
+ // surface a `policy` verdict before any cryptographic work runs.
895
+ // The prior `slice(0, maxSignatures)` shape silently truncated; an
896
+ // operator-visible refusal lets postmasters see DoS attempts in
897
+ // their authentication-results stream.
874
898
  if (sigHeaders.length > maxSignatures) {
875
- sigHeaders = sigHeaders.slice(0, maxSignatures);
899
+ if (auditOn) {
900
+ try {
901
+ audit().safeEmit({
902
+ action: "dkim.verify.signature_count_cap",
903
+ outcome: "denied",
904
+ actor: null,
905
+ metadata: {
906
+ sigCount: sigHeaders.length,
907
+ maxSignatures: maxSignatures,
908
+ severity: "warning",
909
+ },
910
+ });
911
+ } catch (_e) { /* drop-silent */ }
912
+ }
913
+ return [{ result: "policy",
914
+ errors: ["DKIM-Signature count " + sigHeaders.length +
915
+ " exceeds maxSignatures=" + maxSignatures +
916
+ " (RFC 6376 §6.1; verifier DoS cap)"] }];
876
917
  }
877
918
 
878
919
  var results = [];
@@ -144,6 +144,51 @@ var pkgVersion = require("../package.json").version;
144
144
  var ERR_CLAMP = 200; // allow:raw-byte-literal — protocol-reply error-message clamp
145
145
  var LINE_PREVIEW = 80; // allow:raw-byte-literal — audit-line preview clamp
146
146
 
147
+ // RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
148
+ // Format: `DD-Mon-YYYY HH:MM:SS ±HHMM` where Mon is the 3-letter
149
+ // English month abbreviation (case-insensitive on parse, but the IMAP
150
+ // spec emits canonical mixed-case `Jan`/`Feb`/...). Returns the
151
+ // millisecond epoch, or null on any parse failure — the caller emits
152
+ // `BAD` rather than silently using `Date.now()`.
153
+ var IMAP_MONTHS = Object.freeze({
154
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, // allow:raw-byte-literal — month-index table (0-5)
155
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, // allow:raw-byte-literal — month-index table (6-11)
156
+ });
157
+ var IMAP_DT_RE = /^\s*(\d{1,2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+([+-])(\d{2})(\d{2})\s*$/;
158
+ function _parseImapDateTime(s) {
159
+ if (typeof s !== "string") return null;
160
+ var m = s.match(IMAP_DT_RE); // allow:regex-no-length-cap — input bounded by IMAP literal cap
161
+ if (!m) return null;
162
+ var day = parseInt(m[1], 10);
163
+ var month = IMAP_MONTHS[m[2].toLowerCase()];
164
+ if (month === undefined) return null;
165
+ var year = parseInt(m[3], 10);
166
+ var hour = parseInt(m[4], 10);
167
+ var min = parseInt(m[5], 10);
168
+ var sec = parseInt(m[6], 10);
169
+ var sign = m[7] === "-" ? -1 : 1;
170
+ var tzH = parseInt(m[8], 10);
171
+ var tzM = parseInt(m[9], 10);
172
+ if (day < 1 || day > 31 || hour > 23 || min > 59 || sec > 59 || tzH > 23 || tzM > 59) return null;
173
+ var utcMs = Date.UTC(year, month, day, hour, min, sec);
174
+ if (!isFinite(utcMs)) return null;
175
+ // RFC 5322 §3.3 — date-time MUST be a real calendar date. `Date.UTC`
176
+ // silently normalises impossible inputs (`Feb 31 2026` → `Mar 3 2026`);
177
+ // round-trip via the calendar fields and refuse any drift so a
178
+ // hostile client can't smuggle a different internalDate than the
179
+ // wire suggests.
180
+ var probe = new Date(utcMs);
181
+ if (probe.getUTCFullYear() !== year ||
182
+ probe.getUTCMonth() !== month ||
183
+ probe.getUTCDate() !== day ||
184
+ probe.getUTCHours() !== hour ||
185
+ probe.getUTCMinutes() !== min ||
186
+ probe.getUTCSeconds() !== sec) {
187
+ return null;
188
+ }
189
+ return utcMs - sign * (tzH * C.TIME.hours(1) + tzM * C.TIME.minutes(1));
190
+ }
191
+
147
192
  // Mailbox name validator. RFC 9051 §5.1 — UTF-8 hierarchy. Refuse
148
193
  // path-traversal (`..`), NUL, C0 controls, leading/trailing slash,
149
194
  // oversize.
@@ -707,15 +752,39 @@ function create(opts) {
707
752
 
708
753
  function _parseLoginArgs(args) {
709
754
  if (typeof args !== "string") return null;
710
- // Quoted or atom — simple parser sufficient for happy path.
755
+ // Quoted or atom — RFC 9051 §5.1 quoted ABNF. Inside a quoted
756
+ // string `\"` and `\\` are escape sequences for `"` and `\`
757
+ // respectively; any other `\<chr>` is invalid. The earlier shape
758
+ // terminated the quoted string at the first `"`, so a hostile
759
+ // client passing `LOGIN "alice\"@example.com" "pw"` would have
760
+ // its username truncated at `alice` and the rest of the line
761
+ // reparsed as the password / literal — wrong identity bound to
762
+ // the AUTH state.
711
763
  var rest = args.trim();
712
764
  function _take() {
713
765
  if (rest[0] === "\"") {
714
- var end = rest.indexOf("\"", 1);
715
- if (end === -1) return null;
716
- var v = rest.slice(1, end);
717
- rest = rest.slice(end + 1).trim();
718
- return v;
766
+ // Walk the quoted-string body, accumulating into `out` while
767
+ // honoring the `\"` / `\\` escape pairs. A bare `\` followed
768
+ // by any other character is refused (parse fails → null).
769
+ var out = "";
770
+ var i = 1;
771
+ while (i < rest.length) {
772
+ var ch = rest.charAt(i);
773
+ if (ch === "\\") {
774
+ var esc = rest.charAt(i + 1);
775
+ if (esc !== "\"" && esc !== "\\") return null;
776
+ out += esc;
777
+ i += 2;
778
+ continue;
779
+ }
780
+ if (ch === "\"") {
781
+ rest = rest.slice(i + 1).trim();
782
+ return out;
783
+ }
784
+ out += ch;
785
+ i += 1;
786
+ }
787
+ return null; // unterminated quoted string
719
788
  }
720
789
  var sp = rest.indexOf(" ");
721
790
  var v2 = sp === -1 ? rest : rest.slice(0, sp);
@@ -857,6 +926,22 @@ function create(opts) {
857
926
  }
858
927
  var name = _unquote(match[1]);
859
928
  var flags = match[2] ? match[2].split(/\s+/).filter(Boolean) : [];
929
+ // RFC 9051 §6.3.12 — optional date-time argument sets INTERNALDATE
930
+ // on the appended message. Earlier shape captured the token but
931
+ // never threaded it; backends now receive it as `internalDate`
932
+ // (ms-since-epoch) and the mail-store applies it instead of the
933
+ // append-time clock. Refused as syntax error when the date-time
934
+ // can't be parsed (rather than silently using the clock).
935
+ var dateTimeArg = match[3] ? _unquote(match[3]) : null;
936
+ var internalDate = null;
937
+ if (dateTimeArg) {
938
+ internalDate = _parseImapDateTime(dateTimeArg);
939
+ if (internalDate === null) {
940
+ _writeTagged(socket, tag, "BAD APPEND date-time '" + dateTimeArg +
941
+ "' not in RFC 9051 §6.3.12 / RFC 5322 §3.3 date-time grammar");
942
+ return;
943
+ }
944
+ }
860
945
  if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
861
946
  _writeTagged(socket, tag, "BAD Mailbox name refused");
862
947
  return;
@@ -891,10 +976,12 @@ function create(opts) {
891
976
  err.limit = q.capBytes;
892
977
  throw err;
893
978
  }
894
- return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
979
+ return mailStore.appendMessage(name, literalBody, {
980
+ actor: state.actor, flags: flags, internalDate: internalDate });
895
981
  });
896
982
  }
897
- return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
983
+ return mailStore.appendMessage(name, literalBody, {
984
+ actor: state.actor, flags: flags, internalDate: internalDate });
898
985
  })
899
986
  .then(function (info) {
900
987
  _emit("mail.server.imap.append",
@@ -947,7 +1034,10 @@ function create(opts) {
947
1034
 
948
1035
  function _handleFetch(state, socket, tag, args, useUid) {
949
1036
  if (!state.selectedMailbox) {
950
- _writeTagged(socket, tag, "NO No mailbox selected");
1037
+ // RFC 9051 §6.4.5 FETCH outside of Selected state is a
1038
+ // protocol-context violation, not a server-policy refusal.
1039
+ // BAD signals the client to fix its dialog rather than retry.
1040
+ _writeTagged(socket, tag, "BAD FETCH only valid in Selected state (RFC 9051 §6.4.5)");
951
1041
  return;
952
1042
  }
953
1043
  if (typeof mailStore.fetchRange !== "function") {
@@ -988,7 +1078,11 @@ function create(opts) {
988
1078
 
989
1079
  function _handleStore(state, socket, tag, args, useUid) {
990
1080
  if (!state.selectedMailbox) {
991
- _writeTagged(socket, tag, "NO No mailbox selected");
1081
+ // RFC 9051 §6.4.6 STORE outside of Selected state is a
1082
+ // protocol-context violation. BAD (not NO) is the correct
1083
+ // response per the IMAP grammar; UID STORE has the same rule
1084
+ // since the verb is just a `UID` prefix on STORE.
1085
+ _writeTagged(socket, tag, "BAD STORE only valid in Selected state (RFC 9051 §6.4.6)");
992
1086
  return;
993
1087
  }
994
1088
  if (state.selectedReadOnly) {
@@ -236,6 +236,13 @@ function create(opts) {
236
236
  var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
237
237
  var relayAllowedFor = opts.relayAllowedFor || [];
238
238
  var profile = opts.profile || "strict";
239
+ // SMTPUTF8 (RFC 6531) — single switch threaded end-to-end. The MX
240
+ // listener doesn't advertise SMTPUTF8 to the peer regardless, so
241
+ // this defaults `false` (refuse non-ASCII bytes in every command
242
+ // line). Operators that want to accept SMTPUTF8 for downstream
243
+ // relay flip this `true` and the same switch reaches every
244
+ // `guardSmtpCommand.validate` call.
245
+ var allowSmtpUtf8 = opts.allowSmtpUtf8 === true;
239
246
 
240
247
  // Default-on per-IP rate limit. Operators pass `rateLimit: false` to
241
248
  // disable (only for tests / closed networks), pass a rate-limit
@@ -331,6 +338,15 @@ function create(opts) {
331
338
  var connectionId = "mxconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
332
339
  connections.add(socket);
333
340
 
341
+ // Backpressure observer — `_writeReply` flips `_bpEmitted` after
342
+ // the first audit emission per socket to bound the audit volume.
343
+ socket._bpEmit = function () {
344
+ _emit("mail.server.mx.write_backpressure",
345
+ { connectionId: connectionId, remoteAddress: remoteAddress,
346
+ stage: state && state.stage, bufferedBytes: socket.writableLength || 0 },
347
+ "warning");
348
+ };
349
+
334
350
  var state = {
335
351
  id: connectionId,
336
352
  remoteAddress: remoteAddress,
@@ -452,7 +468,11 @@ function create(opts) {
452
468
  // Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
453
469
  // BEFORE state-machine dispatch.
454
470
  try {
455
- guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
471
+ guardSmtpCommand.validate(line, {
472
+ profile: profile,
473
+ maxLineBytes: maxLineBytes,
474
+ allowSmtpUtf8: allowSmtpUtf8,
475
+ });
456
476
  } catch (err) {
457
477
  if (err.code === "guard-smtp-command/bare-lf" ||
458
478
  err.code === "guard-smtp-command/bare-cr" ||
@@ -633,17 +653,19 @@ function create(opts) {
633
653
  }
634
654
  var paramStr = match[2] || "";
635
655
  var sizeMatch = paramStr.match(RE_SIZE);
656
+ var declaredSize = null;
636
657
  if (sizeMatch) {
637
- var declaredSize = parseInt(sizeMatch[1], 10);
658
+ declaredSize = parseInt(sizeMatch[1], 10);
638
659
  if (declaredSize > maxMessageBytes) {
639
660
  _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
640
661
  "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
641
662
  return;
642
663
  }
643
664
  }
644
- state.mailFrom = mailFrom;
645
- state.stage = "rcpt";
646
- state.rcpts = [];
665
+ state.mailFrom = mailFrom;
666
+ state.declaredSize = declaredSize;
667
+ state.stage = "rcpt";
668
+ state.rcpts = [];
647
669
  _emit("mail.server.mx.mail_from",
648
670
  { connectionId: state.id, mailFrom: mailFrom });
649
671
  _writeReply(socket, REPLY_250_OK, "2.1.0 Sender OK");
@@ -692,6 +714,7 @@ function create(opts) {
692
714
  var rcptVerdict = _validateDomainHardened(rcptDomain, "rcpt_to");
693
715
  if (!rcptVerdict.ok) {
694
716
  rateLimit.noteRcptFailure(state.remoteAddress);
717
+ _trackRefusedRcpt(state, rcpt, "domain-refused");
695
718
  _writeReply(socket, REPLY_501_BAD_ARGS,
696
719
  "5.5.4 RCPT TO domain refused (" +
697
720
  (rcptVerdict.issues && rcptVerdict.issues[0] && rcptVerdict.issues[0].kind) + ")");
@@ -704,6 +727,7 @@ function create(opts) {
704
727
  if (localDomains.indexOf(rcptDomain) === -1 &&
705
728
  !_isRelayAllowed(state.remoteAddress, rcpt)) {
706
729
  rateLimit.noteRcptFailure(state.remoteAddress);
730
+ _trackRefusedRcpt(state, rcpt, "relay-denied");
707
731
  _emit("mail.server.mx.relay_refused",
708
732
  { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
709
733
  remoteAddress: state.remoteAddress }, "denied");
@@ -739,9 +763,32 @@ function create(opts) {
739
763
  // body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
740
764
  // §4.5.2 — a single leading "." is doubled on the wire; undo.
741
765
  var dedotted = safeSmtp.dotUnstuff(body);
766
+ // RFC 1870 §6.3 — reconcile MAIL FROM SIZE= against the actual
767
+ // DATA byte count. The pre-DATA reservation at MAIL FROM time
768
+ // (above) is advisory; the sender's declared size is a HINT,
769
+ // not a guarantee. If the actual unstuffed body exceeds the
770
+ // declared SIZE= (with a small slack to absorb header lines the
771
+ // sender didn't count), refuse with 552 — defends against
772
+ // senders that probe maxMessageBytes by understating SIZE.
773
+ if (typeof state.declaredSize === "number" && isFinite(state.declaredSize)) {
774
+ if (dedotted.length > state.declaredSize) {
775
+ _emit("mail.server.mx.size_overrun", {
776
+ connectionId: state.id,
777
+ mailFrom: state.mailFrom,
778
+ declaredSize: state.declaredSize,
779
+ actualSize: dedotted.length,
780
+ }, "denied");
781
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
782
+ "5.3.4 Message exceeds declared SIZE=" + state.declaredSize +
783
+ " bytes (got " + dedotted.length + "; RFC 1870 §6.3)");
784
+ _resetTransaction(state);
785
+ return;
786
+ }
787
+ }
742
788
  // operator-supplied agent handoff — when wired, persist via
743
789
  // agent + write the 250 reply. When not wired, accept-and-drop
744
790
  // (audit-only mode useful for staging deployments).
791
+ var refusedSnapshot = Array.isArray(state.refusedRcpts) ? state.refusedRcpts.slice() : [];
745
792
  if (opts.agent && typeof opts.agent.handoff === "function") {
746
793
  opts.agent.handoff({
747
794
  mailFrom: state.mailFrom,
@@ -753,7 +800,8 @@ function create(opts) {
753
800
  connectionId: state.id,
754
801
  }).then(function (ack) {
755
802
  _emit("mail.server.mx.delivered",
756
- { connectionId: state.id, messageId: ack && ack.messageId, sizeBytes: dedotted.length });
803
+ { connectionId: state.id, messageId: ack && ack.messageId,
804
+ sizeBytes: dedotted.length, refusedRcpts: refusedSnapshot });
757
805
  _writeReply(socket, REPLY_250_OK,
758
806
  "2.6.0 Message accepted" + (ack && ack.messageId ? " <" + ack.messageId + ">" : ""));
759
807
  _resetTransaction(state);
@@ -769,18 +817,32 @@ function create(opts) {
769
817
  }
770
818
  _emit("mail.server.mx.data_accepted",
771
819
  { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length,
772
- sizeBytes: dedotted.length });
820
+ sizeBytes: dedotted.length, refusedRcpts: refusedSnapshot });
773
821
  _writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
774
822
  _resetTransaction(state);
775
823
  }
776
824
 
777
825
  function _resetTransaction(state) {
778
- state.mailFrom = null;
779
- state.rcpts = [];
780
- state.stage = "ehlo";
826
+ state.mailFrom = null;
827
+ state.declaredSize = null;
828
+ state.rcpts = [];
829
+ state.refusedRcpts = [];
830
+ state.stage = "ehlo";
781
831
  state.messageBytes = 0;
782
832
  }
783
833
 
834
+ // Track up to MAX_REFUSED_RCPTS_PER_TXN refused recipients so the
835
+ // `data_accepted` / `delivered` audit can surface the bounded list
836
+ // for observability. Bounded to keep the audit metadata size
837
+ // predictable; the per-IP recipient-failure rate-limit elsewhere
838
+ // bounds long-run scanner damage.
839
+ var MAX_REFUSED_RCPTS_PER_TXN = 32; // allow:raw-byte-literal — bounded audit-metadata list cap
840
+ function _trackRefusedRcpt(state, rcpt, reason) {
841
+ if (!Array.isArray(state.refusedRcpts)) state.refusedRcpts = [];
842
+ if (state.refusedRcpts.length >= MAX_REFUSED_RCPTS_PER_TXN) return;
843
+ state.refusedRcpts.push({ rcptTo: rcpt, reason: reason });
844
+ }
845
+
784
846
  function _requiresStartTls() {
785
847
  // Strict / balanced require STARTTLS before MAIL FROM.
786
848
  // Permissive accepts plaintext — operator-acknowledged downgrade
@@ -857,10 +919,26 @@ function create(opts) {
857
919
 
858
920
  // ---- Wire-protocol helpers --------------------------------------------------
859
921
 
922
+ // Write back-pressure observability — when `socket.write()` returns
923
+ // false the kernel send-buffer is full and the server is dropping
924
+ // behind the network. Listeners attach a `_bpEmit` function to the
925
+ // socket; we invoke it once per socket-lifetime on the first
926
+ // backpressure event so the audit log surfaces stalled connections
927
+ // without flooding on every reply.
928
+ function _observeBackpressure(socket, ok) {
929
+ if (ok) return;
930
+ if (typeof socket._bpEmit !== "function") return;
931
+ if (socket._bpEmitted) return;
932
+ socket._bpEmitted = true;
933
+ try { socket._bpEmit(socket); } catch (_e) { /* drop-silent */ }
934
+ }
935
+
860
936
  function _writeReply(socket, code, text) {
861
937
  // Single-line reply per RFC 5321 §4.2 — code SP text CRLF.
862
- try { socket.write(code + " " + text + "\r\n"); }
863
- catch (_e) { /* socket already closed */ }
938
+ try {
939
+ var ok = socket.write(code + " " + text + "\r\n");
940
+ _observeBackpressure(socket, ok);
941
+ } catch (_e) { /* socket already closed */ }
864
942
  }
865
943
 
866
944
  function _writeMultiline(socket, code, lines) {
@@ -868,8 +946,10 @@ function _writeMultiline(socket, code, lines) {
868
946
  // continuation, code SP text CRLF for the final line.
869
947
  for (var i = 0; i < lines.length; i += 1) {
870
948
  var sep = i === lines.length - 1 ? " " : "-";
871
- try { socket.write(code + sep + lines[i] + "\r\n"); }
872
- catch (_e) { /* socket already closed */ }
949
+ try {
950
+ var ok = socket.write(code + sep + lines[i] + "\r\n");
951
+ _observeBackpressure(socket, ok);
952
+ } catch (_e) { /* socket already closed */ }
873
953
  }
874
954
  }
875
955
 
@@ -153,6 +153,69 @@ var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
153
153
  var RE_SIZE = /SIZE=(\d+)/i;
154
154
  var RE_AUTH = /^AUTH\s+([A-Za-z0-9_-]{1,32})(?:\s+(.*))?$/i;
155
155
 
156
+ // Header/body boundary scanner. RFC 5322 §2.1 — header section ends
157
+ // at the first empty line (CRLF CRLF). `Buffer#indexOf` runs a
158
+ // SIMD-accelerated needle scan over the haystack without an
159
+ // interpreter-level char-by-char walk, and the 4-byte literal
160
+ // `_CRLF_CRLF` is a module-level singleton so the JIT folds it.
161
+ var _CRLF_CRLF = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a]); // allow:raw-byte-literal — RFC 5322 §2.1 header/body separator
162
+ function _findHeaderEnd(buf) {
163
+ return buf.indexOf(_CRLF_CRLF);
164
+ }
165
+
166
+ // Walk a header block and return every unfolded `DKIM-Signature:`
167
+ // value. RFC 5322 §2.2.3 / RFC 6376 §3.5 — DKIM signatures are
168
+ // permitted to fold and a message MAY carry multiple signatures.
169
+ function _extractDkimSignatures(headerBlock) {
170
+ var lines = headerBlock.replace(/\r\n/g, "\n").split("\n"); // allow:regex-no-length-cap — headerBlock length bounded by maxMessageBytes
171
+ var result = [];
172
+ var current = null;
173
+ for (var i = 0; i < lines.length; i += 1) {
174
+ var line = lines[i];
175
+ if (line.length === 0) break; // end of header block
176
+ if (line.charAt(0) === " " || line.charAt(0) === "\t") {
177
+ if (current !== null) current += " " + line.replace(/^[ \t]+/, ""); // allow:regex-no-length-cap — line length bounded by maxLineBytes // allow:duplicate-regex — RFC 5322 header continuation trim
178
+ continue;
179
+ }
180
+ if (current !== null) {
181
+ result.push(current);
182
+ current = null;
183
+ }
184
+ if (/^DKIM-Signature\s*:/i.test(line)) { // allow:regex-no-length-cap — line length bounded by maxLineBytes
185
+ current = line.slice(line.indexOf(":") + 1).replace(/^\s+/, ""); // allow:regex-no-length-cap — line length bounded by maxLineBytes // allow:duplicate-regex — leading-WS trim
186
+ }
187
+ }
188
+ if (current !== null) result.push(current);
189
+ return result;
190
+ }
191
+
192
+ // Pull the `d=` (signing domain) tag out of a DKIM-Signature value.
193
+ // RFC 6376 §3.5 — tag-list `tag=value` separated by `;`. Returns
194
+ // null if not present.
195
+ function _extractDkimDTag(sigValue) {
196
+ var tags = sigValue.split(";");
197
+ for (var i = 0; i < tags.length; i += 1) {
198
+ var t = tags[i].replace(/^\s+|\s+$/g, ""); // allow:regex-no-length-cap — tag length bounded by header line cap // allow:duplicate-regex — trim shape
199
+ if (t.length > 2 && t.charAt(0) === "d" && t.charAt(1) === "=") {
200
+ return t.slice(2).replace(/\s+/g, ""); // allow:regex-no-length-cap — value length bounded by tag length // allow:duplicate-regex — internal-WS strip
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ // Domain part of the authenticated identity, falling back to the
207
+ // envelope-sender domain when the actor doesn't carry one.
208
+ function _actorDomain(actor, mailFrom) {
209
+ if (actor && typeof actor.domain === "string" && actor.domain.length > 0) return actor.domain;
210
+ if (actor && typeof actor.id === "string" && actor.id.indexOf("@") !== -1) {
211
+ return actor.id.slice(actor.id.lastIndexOf("@") + 1);
212
+ }
213
+ if (typeof mailFrom === "string" && mailFrom.indexOf("@") !== -1) {
214
+ return mailFrom.slice(mailFrom.lastIndexOf("@") + 1);
215
+ }
216
+ return null;
217
+ }
218
+
156
219
  /**
157
220
  * @primitive b.mail.server.submission.create
158
221
  * @signature b.mail.server.submission.create(opts)
@@ -208,6 +271,30 @@ function create(opts) {
208
271
  "mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
209
272
 
210
273
  var profile = opts.profile || "strict";
274
+ // SMTPUTF8 (RFC 6531) — single switch threaded end-to-end into
275
+ // `guardSmtpCommand.validate`. Defaults `false`; submission
276
+ // operators that accept EAI envelopes flip this `true`.
277
+ var allowSmtpUtf8 = opts.allowSmtpUtf8 === true;
278
+
279
+ // Outbound DKIM-required gate (Yahoo / Google 2024 bulk-sender
280
+ // alignment + RFC 6376 §1). Under `strict` profile the listener
281
+ // refuses outbound DATA that doesn't carry at least one
282
+ // `DKIM-Signature:` header; `dkimRequireMode` chooses whether the
283
+ // signer must match the authenticated identity's domain (`self`)
284
+ // or just be present (`any`). Operators that act as a smarthost
285
+ // relay for downstream MTAs that DKIM-sign themselves want `any`;
286
+ // primary senders want `self`. Default-off outside strict so
287
+ // unauthenticated `permissive` profiles don't break.
288
+ var requireDkim = opts.requireDkim === undefined
289
+ ? (profile === "strict")
290
+ : opts.requireDkim === true;
291
+ var dkimRequireMode = opts.dkimRequireMode || "any";
292
+ if (dkimRequireMode !== "self" && dkimRequireMode !== "any" && dkimRequireMode !== "off") {
293
+ throw new MailServerSubmissionError("mail-server-submission/bad-dkim-require-mode",
294
+ "mail.server.submission.create: dkimRequireMode must be 'self', 'any', or 'off' (got '" +
295
+ dkimRequireMode + "')");
296
+ }
297
+ if (dkimRequireMode === "off") requireDkim = false;
211
298
 
212
299
  if (profile !== "permissive" && !opts.auth) {
213
300
  throw new MailServerSubmissionError("mail-server-submission/no-auth",
@@ -424,7 +511,11 @@ function create(opts) {
424
511
 
425
512
  // guardSmtpCommand check (smuggling + shape).
426
513
  try {
427
- guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
514
+ guardSmtpCommand.validate(line, {
515
+ profile: profile,
516
+ maxLineBytes: maxLineBytes,
517
+ allowSmtpUtf8: allowSmtpUtf8,
518
+ });
428
519
  } catch (err) {
429
520
  if (err.code === "guard-smtp-command/bare-lf" ||
430
521
  err.code === "guard-smtp-command/bare-cr" ||
@@ -911,6 +1002,49 @@ function create(opts) {
911
1002
 
912
1003
  function _finalizeDataBody(state, socket, body) {
913
1004
  var dedotted = safeSmtp.dotUnstuff(body);
1005
+
1006
+ // Outbound DKIM-required gate. Scan the header block for a
1007
+ // `DKIM-Signature:` line; under `self` mode also require at
1008
+ // least one signature whose `d=` tag matches the authenticated
1009
+ // identity's domain part.
1010
+ if (requireDkim) {
1011
+ var headerEnd = _findHeaderEnd(dedotted);
1012
+ var headerBlock = headerEnd === -1
1013
+ ? dedotted.toString("utf8")
1014
+ : dedotted.subarray(0, headerEnd).toString("utf8");
1015
+ var dkimSigs = _extractDkimSignatures(headerBlock);
1016
+ var dkimOk = false;
1017
+ if (dkimSigs.length > 0) {
1018
+ if (dkimRequireMode === "any") {
1019
+ dkimOk = true;
1020
+ } else if (dkimRequireMode === "self") {
1021
+ var actorDomain = _actorDomain(state.actor, state.mailFrom);
1022
+ for (var i = 0; i < dkimSigs.length; i += 1) {
1023
+ var d = _extractDkimDTag(dkimSigs[i]);
1024
+ if (d && actorDomain && d.toLowerCase() === actorDomain.toLowerCase()) {
1025
+ dkimOk = true;
1026
+ break;
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ if (!dkimOk) {
1032
+ _emit("mail.server.submission.data_refused", {
1033
+ connectionId: state.id,
1034
+ reason: "dkim-required",
1035
+ dkimRequireMode: dkimRequireMode,
1036
+ mailFrom: state.mailFrom,
1037
+ sigCount: dkimSigs.length,
1038
+ actor: state.actor && state.actor.id,
1039
+ }, "denied");
1040
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
1041
+ "5.7.20 DKIM-Signature required on outbound submission " +
1042
+ "(dkimRequireMode='" + dkimRequireMode + "'; RFC 6376; bulk-sender 2024)");
1043
+ _resetTransaction(state);
1044
+ return;
1045
+ }
1046
+ }
1047
+
914
1048
  if (opts.agent && typeof opts.agent.handoff === "function") {
915
1049
  opts.agent.handoff({
916
1050
  mailFrom: state.mailFrom,
package/lib/mail-store.js CHANGED
@@ -368,7 +368,12 @@ function _appendMessage(args) {
368
368
  });
369
369
 
370
370
  // Allocate objectid + modseq atomically.
371
- var objectid = "obj_" + bCrypto.generateToken(16).slice(0, 24); // allow:raw-byte-literal 16-byte token, 24-char hex prefix as JMAP objectid (RFC 8474)
371
+ // RFC 8474 §1.5.1: objectid SHOULD be sufficiently long to make
372
+ // collision improbable across the lifetime of the account. 16-byte
373
+ // token = 32-char hex = 128 bits, well above the birthday bound
374
+ // for any plausible message corpus. The prior `.slice(0, 24)` cut
375
+ // entropy to 96 bits; removed.
376
+ var objectid = "obj_" + bCrypto.generateToken(16); // allow:raw-byte-literal — 16-byte token, 32-char hex JMAP objectid (RFC 8474 §1.5.1)
372
377
  var modseq = (folder.modseq_max || 0) + 1;
373
378
  if (!threadRootId) threadRootId = objectid; // root of new thread
374
379
 
@@ -106,7 +106,6 @@
106
106
 
107
107
  var C = require("./constants");
108
108
  var https = require("node:https");
109
- var nodeCrypto = require("node:crypto");
110
109
  var bCrypto = require("./crypto");
111
110
  var { defineClass } = require("./framework-error");
112
111
  var networkDns = require("./network-dns");
@@ -499,7 +498,7 @@ function _encodeWireQuery(name, qtype) {
499
498
  var nameLen = 1;
500
499
  for (var i = 0; i < parts.length; i += 1) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
501
500
  var buf = Buffer.alloc(12 + nameLen + 4); // allow:raw-byte-literal — RFC 1035 §4.1.1 header (12) + question tail (4) + name
502
- var id = nodeCrypto.randomInt(0, 0x10000);
501
+ var id = bCrypto.randomInt(0, 0x10000); // allow:raw-byte-literal — RFC 1035 §4.1.1 16-bit query ID space
503
502
  buf.writeUInt16BE(id, 0);
504
503
  buf.writeUInt16BE(0x0100, 2); // allow:raw-byte-literal — RFC 1035 §4.1.1 RD=1 flags
505
504
  buf.writeUInt16BE(1, 4); // allow:raw-byte-literal — RFC 1035 §4.1.1 qdcount
@@ -2,7 +2,6 @@
2
2
 
3
3
  var dns = require("node:dns");
4
4
  var net = require("node:net");
5
- var nodeCrypto = require("node:crypto");
6
5
  var https = require("node:https");
7
6
  var nodeTls = require("node:tls");
8
7
  var dnsPromises = dns.promises;
@@ -274,9 +273,10 @@ function _encodeDnsQuery(host, qtype) {
274
273
  for (var i = 0; i < parts.length; i++) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
275
274
  var buf = Buffer.alloc(12 + nameLen + 4);
276
275
  // Cryptographic RNG for the 16-bit DNS query ID — frustrates poisoning
277
- // attempts that guess the transaction ID. Math.random would be technically
278
- // acceptable (id is non-secret) but we prefer the framework's RNG path.
279
- var id = nodeCrypto.randomInt(0, 0x10000);
276
+ // attempts that guess the transaction ID. Routes through `b.crypto.randomInt`
277
+ // (which wraps nodeCrypto.randomInt) so every framework random-int draw
278
+ // is greppable through one substrate.
279
+ var id = bCrypto.randomInt(0, 0x10000);
280
280
  buf.writeUInt16BE(id, 0);
281
281
  buf.writeUInt16BE(0x0100, 2);
282
282
  buf.writeUInt16BE(1, 4);
package/lib/safe-mime.js CHANGED
@@ -53,7 +53,15 @@ var DEFAULT_MAX_PARTS = 64; // allow:raw-byte-l
53
53
  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
- var DEFAULT_MAX_HEADER_LINE = 998; // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap
56
+ // RFC 5322 §2.1.1 line cap. The spec defines TWO limits: a SHOULD of
57
+ // 78 bytes (the readability target) and a MUST of 998 bytes (the
58
+ // hard ceiling). The 78-byte SHOULD is intentionally NOT enforced
59
+ // here — modern senders routinely emit header lines longer than 78
60
+ // bytes (long URLs in List-Unsubscribe, EAI display names) and a
61
+ // strict 78-byte refusal would reject legitimate mail. We enforce
62
+ // only the 998-byte MUST. Future drift attempting to "fix" this to
63
+ // 78 would be a regression and should fail the audit gate.
64
+ var DEFAULT_MAX_HEADER_LINE = 998; // allow:raw-byte-literal — RFC 5322 §2.1.1 MUST (998); the SHOULD (78) is by design not enforced
57
65
  // Per-message header-count cap. RFC 5322 places no upper bound on
58
66
  // the number of headers in a message; without one, a sender can pack
59
67
  // tens of thousands of one-byte headers into the maxHeaderBytes budget
@@ -77,8 +85,15 @@ var DEFAULT_CHARSETS = Object.freeze([
77
85
  "euc-kr", "euc-jp",
78
86
  ]);
79
87
 
88
+ // RFC 3030 §3 — `binary` CTE on receive REQUIRES the receiving MTA
89
+ // to have advertised BINARYMIME during ESMTP negotiation. Inbound
90
+ // flows without explicit BINARYMIME wiring must refuse `binary`
91
+ // because consumers downstream (DKIM canonicalization, message
92
+ // rewriting) assume CRLF line structure that `binary` doesn't
93
+ // guarantee. Operators that wire BINARYMIME end-to-end opt back in
94
+ // via `transferEncodingAllowlist: ["7bit", ..., "binary"]`.
80
95
  var DEFAULT_TRANSFER_ENCODINGS = Object.freeze([
81
- "7bit", "8bit", "binary", "quoted-printable", "base64",
96
+ "7bit", "8bit", "quoted-printable", "base64",
82
97
  ]);
83
98
 
84
99
  /**
@@ -453,12 +468,18 @@ function _parseHeaders(buf, ctx) {
453
468
  // Refuse NUL, CR, LF, and other C0 control chars in header values.
454
469
  // Tab (0x09) is allowed (header folding). C1 control range
455
470
  // (0x80-0x9F) NOT refused — legitimate non-ASCII via EAI/RFC 2047
456
- // decoded-words can produce bytes in that range.
471
+ // decoded-words can produce bytes in that range. Error metadata
472
+ // surfaces the BYTE offset (via `Buffer.byteLength` on the JS
473
+ // string prefix) rather than the UTF-16 code-unit index, so the
474
+ // operator audit log lines up with the wire-level byte stream
475
+ // they're inspecting.
457
476
  for (var hci = 0; hci < value.length; hci += 1) {
458
477
  var hcc = value.charCodeAt(hci);
459
478
  if ((hcc < 0x20 && hcc !== 0x09) || hcc === 0x7F) { // allow:raw-byte-literal — C0 control char + DEL refusal
479
+ var byteOffset = Buffer.byteLength(value.slice(0, hci), "utf8");
460
480
  throw new SafeMimeError("safe-mime/control-char-in-header",
461
- "safeMime.parse: header '" + name + "' contains control char 0x" + hcc.toString(16));
481
+ "safeMime.parse: header '" + name + "' contains control char 0x" +
482
+ hcc.toString(16) + " at byte offset " + byteOffset); // allow:raw-byte-literal — toString radix 16 hex, not bytes
462
483
  }
463
484
  }
464
485
  value = _decodeRfc2047Words(value);
@@ -673,9 +694,35 @@ function _decodeBufferAs(buf, charset) {
673
694
  if (c === "us-ascii" || c === "ascii") return buf.toString("ascii");
674
695
  if (c === "iso-8859-1" || c === "latin1") return buf.toString("latin1");
675
696
  if (c === "utf-16le") return buf.toString("utf16le");
697
+ if (c === "utf-16be") return _decodeUtf16BE(buf);
698
+ if (c === "utf-16") {
699
+ // RFC 2781 §3.3 — `utf-16` with a leading BOM (FE FF = BE, FF FE
700
+ // = LE). When no BOM is present the spec defaults to BE; Node
701
+ // doesn't speak BE natively so we transcode either way.
702
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
703
+ return buf.subarray(2).toString("utf16le");
704
+ }
705
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
706
+ return _decodeUtf16BE(buf.subarray(2));
707
+ }
708
+ return _decodeUtf16BE(buf); // RFC 2781 §3.3 BE default with no BOM
709
+ }
676
710
  return buf.toString("utf8");
677
711
  }
678
712
 
713
+ // utf-16be → utf-16le swap (Node has no direct utf-16be decoder).
714
+ // Byte-pair endian flip into a temporary buffer, then decode as
715
+ // utf-16le. Allocates a single buffer (no per-character churn).
716
+ function _decodeUtf16BE(buf) {
717
+ var n = buf.length & ~1; // allow:raw-byte-literal — pair alignment mask
718
+ var swapped = Buffer.alloc(n);
719
+ for (var i = 0; i < n; i += 2) {
720
+ swapped[i] = buf[i + 1];
721
+ swapped[i + 1] = buf[i];
722
+ }
723
+ return swapped.toString("utf16le");
724
+ }
725
+
679
726
  function _materializeText(part) {
680
727
  return {
681
728
  contentType: part.leaf.contentType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
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:eeaab12e-3641-448e-8bad-29a2842f802b",
5
+ "serialNumber": "urn:uuid:62b21ffc-fcde-4782-a950-d9b2db933f5c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-17T18:41:38.896Z",
8
+ "timestamp": "2026-05-17T20:52:27.570Z",
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.6",
22
+ "bom-ref": "@blamejs/core@0.10.7",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.6",
25
+ "version": "0.10.7",
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.6",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.7",
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.6",
57
+ "ref": "@blamejs/core@0.10.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]