@blamejs/core 0.10.3 → 0.10.5
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 +2 -0
- package/lib/guard-list-unsubscribe.js +9 -0
- package/lib/mail-server-imap.js +46 -3
- package/lib/mail-server-mx.js +18 -0
- package/lib/mail-server-pop3.js +50 -6
- package/lib/mail-server-rate-limit.js +47 -1
- package/lib/mail-server-submission.js +12 -3
- package/lib/mail-store.js +36 -4
- package/lib/safe-mime.js +56 -1
- package/lib/vendor-data.js +44 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.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).
|
|
12
|
+
- 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).
|
|
11
13
|
- 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).
|
|
12
14
|
- 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/).
|
|
13
15
|
- 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.
|
|
@@ -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 });
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -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
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
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
|
}
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -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");
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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") {
|
|
@@ -485,6 +495,21 @@ function create(opts) {
|
|
|
485
495
|
_writeErr(socket, "AUTH not configured");
|
|
486
496
|
return;
|
|
487
497
|
}
|
|
498
|
+
// Defense-in-depth, symmetric with USER / PASS. APOP transmits
|
|
499
|
+
// MD5(timestamp+secret), not cleartext, but an
|
|
500
|
+
// attacker who captures the digest + the known greeting timestamp
|
|
501
|
+
// can mount an offline dictionary attack against the shared secret.
|
|
502
|
+
// RFC 1939 §7 explicitly warns about this; balanced/permissive
|
|
503
|
+
// operators reach this path only when they opted in, but the
|
|
504
|
+
// wire MUST be TLS-protected to deny the offline-attack vector.
|
|
505
|
+
if (!state.tls && profile !== "permissive") {
|
|
506
|
+
_emit("mail.server.pop3.auth_refused_cleartext",
|
|
507
|
+
{ connectionId: state.id, verb: "APOP", remoteAddress: state.remoteAddress },
|
|
508
|
+
"denied");
|
|
509
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
510
|
+
_writeErr(socket, "APOP refused over cleartext (use STLS first; RFC 1939 §7)");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
488
513
|
var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
|
|
489
514
|
if (!authAdmit.ok) {
|
|
490
515
|
_writeErr(socket, "Too many AUTH failures from your IP");
|
|
@@ -540,6 +565,13 @@ function create(opts) {
|
|
|
540
565
|
// configuration where the gate was relaxed but the AUTH path
|
|
541
566
|
// still receives traffic).
|
|
542
567
|
if (!state.tls && profile === "strict") {
|
|
568
|
+
// Count cleartext-AUTH refusal against the auth-failure budget
|
|
569
|
+
// so scanners that probe for plaintext-mech tolerance hit the
|
|
570
|
+
// same per-IP cap that protects PASS / APOP. Without this, a
|
|
571
|
+
// scanner could enumerate auth mechanisms freely (the refusal
|
|
572
|
+
// itself was free) and shop for the first wire-protocol path
|
|
573
|
+
// the listener honored.
|
|
574
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
543
575
|
_emit("mail.server.pop3.auth_refused_cleartext",
|
|
544
576
|
{ connectionId: state.id, verb: "AUTH", mech: args[0] }, "denied");
|
|
545
577
|
_writeErr(socket, "AUTH refused over cleartext (use STLS first; RFC 2595 §2.1)");
|
|
@@ -605,8 +637,18 @@ function create(opts) {
|
|
|
605
637
|
return;
|
|
606
638
|
}
|
|
607
639
|
state.stage = "update";
|
|
608
|
-
|
|
609
|
-
|
|
640
|
+
// RFC 1939 §6 — bound the UPDATE-state commit. A hung backend
|
|
641
|
+
// (DB row-lock / replica failover / sealed-row unseal stuck on a
|
|
642
|
+
// KMS call) otherwise leaves the connection in update-state past
|
|
643
|
+
// the socket idleTimeoutMs (which guards inbound bytes, not
|
|
644
|
+
// pending Promises).
|
|
645
|
+
safeAsync.withTimeout(
|
|
646
|
+
Promise.resolve().then(function () {
|
|
647
|
+
return mailStore.commitPop3Drop(state.actor, state.dropId);
|
|
648
|
+
}),
|
|
649
|
+
commitTimeoutMs,
|
|
650
|
+
{ label: "mail.server.pop3.commitPop3Drop" }
|
|
651
|
+
)
|
|
610
652
|
.then(function (info) {
|
|
611
653
|
_emit("mail.server.pop3.update_commit",
|
|
612
654
|
{ connectionId: state.id, deleted: (info && info.deleted) || 0 });
|
|
@@ -614,6 +656,8 @@ function create(opts) {
|
|
|
614
656
|
_close(socket);
|
|
615
657
|
})
|
|
616
658
|
.catch(function (err) {
|
|
659
|
+
_emit("mail.server.pop3.update_commit_failed",
|
|
660
|
+
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
617
661
|
_writeErr(socket, "Commit failed: " + ((err && err.message) || "backend error").slice(0, ERR_CLAMP));
|
|
618
662
|
_close(socket);
|
|
619
663
|
});
|
|
@@ -740,7 +784,7 @@ function create(opts) {
|
|
|
740
784
|
.then(function (msg) {
|
|
741
785
|
if (!msg) { _writeErr(socket, "no such message"); return; }
|
|
742
786
|
_writeOk(socket, "headers + " + headerLines + " body lines");
|
|
743
|
-
//
|
|
787
|
+
// see _handleRetr; same byte-level CRLF-aware
|
|
744
788
|
// dot-stuffing primitive for the TOP partial-body path.
|
|
745
789
|
var bodyBuf = msg.rawBytes
|
|
746
790
|
? 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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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; })
|
|
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) {
|
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
|
-
* `
|
|
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/lib/vendor-data.js
CHANGED
|
@@ -83,6 +83,39 @@ var _MODULES = {
|
|
|
83
83
|
|
|
84
84
|
var VendorDataError = defineClass("VendorDataError", { alwaysPermanent: true });
|
|
85
85
|
|
|
86
|
+
// Boot-time-safe constant-time hex compare. Framework convention
|
|
87
|
+
// is timingSafeEqual for every digest / MAC compare even
|
|
88
|
+
// when the value is a public tag (a `!==` here is the smell, not
|
|
89
|
+
// the secret/public distinction). b.crypto.timingSafeEqual would
|
|
90
|
+
// match the convention but it's lazyRequire'd to break a circular
|
|
91
|
+
// load chain and is not available during verifyAll()'s boot-time
|
|
92
|
+
// pass; nodeCrypto.timingSafeEqual is the direct primitive.
|
|
93
|
+
//
|
|
94
|
+
// Both inputs MUST be hex strings (0-9a-fA-F only). The hex shape
|
|
95
|
+
// guard sidesteps the UTF-16 / UTF-8 byte-length skew that would
|
|
96
|
+
// otherwise let `Buffer.from(non-ascii-string, "utf8")` produce
|
|
97
|
+
// different byte lengths for equal-string-length inputs — which
|
|
98
|
+
// would make nodeCrypto.timingSafeEqual throw
|
|
99
|
+
// ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH instead of returning false,
|
|
100
|
+
// surfacing an unexpected boot-time error on a tampered metadata
|
|
101
|
+
// instead of the documented VendorDataError. Refuse non-hex with
|
|
102
|
+
// a plain false return so the caller's `!equal` branch throws the
|
|
103
|
+
// VendorDataError as designed.
|
|
104
|
+
// Accept bare hex digests (Layer 1 / Layer 2 SHA outputs) and
|
|
105
|
+
// `<alg>:<hex>` fingerprint shapes (Layer 3 — `sha256:abc...`). All
|
|
106
|
+
// chars are ASCII so UTF-8 byte length equals string length.
|
|
107
|
+
var ASCII_TAG_RE_VD = /^[0-9a-zA-Z:]+$/;
|
|
108
|
+
function _timingSafeHexEqual(a, b) {
|
|
109
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
110
|
+
if (a.length !== b.length) return false;
|
|
111
|
+
if (!ASCII_TAG_RE_VD.test(a) || !ASCII_TAG_RE_VD.test(b)) return false;
|
|
112
|
+
// Hex chars are ASCII; UTF-8 encode is identity, so equal string
|
|
113
|
+
// length now guarantees equal byte length.
|
|
114
|
+
var ba = Buffer.from(a, "utf8");
|
|
115
|
+
var bb = Buffer.from(b, "utf8");
|
|
116
|
+
return nodeCrypto.timingSafeEqual(ba, bb); // allow:raw-timing-safe-equal — hex + length pre-check above guarantees same-length ASCII inputs; b.crypto wrapper would be circular at boot
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
// KNOWN_VENDOR_DATA — the canonical list of vendored data names. Each
|
|
87
120
|
// entry carries the canary token the payload must contain after parse
|
|
88
121
|
// (where applicable) and a description for the inventory surface.
|
|
@@ -159,17 +192,23 @@ function _loadAndVerify(name) {
|
|
|
159
192
|
"File is hand-edited or corrupted; re-run vendor-update.sh --refresh-data.");
|
|
160
193
|
}
|
|
161
194
|
|
|
162
|
-
// Layer 1: SHA-256 self-verify
|
|
195
|
+
// Layer 1: SHA-256 self-verify. Timing-safe compare matches the
|
|
196
|
+
// framework convention (every other digest / MAC compare uses
|
|
197
|
+
// timing-safe regardless of whether the value is a secret). The SHA
|
|
198
|
+
// is a public tag here, but reaching for `!==` whenever a value
|
|
199
|
+
// "isn't a secret" is the smell — the convention is the gate.
|
|
200
|
+
// nodeCrypto direct (not lazy bCrypto) because this runs at boot
|
|
201
|
+
// before b.crypto is initialized on the module-graph hot path.
|
|
163
202
|
var actual256 = nodeCrypto.createHash("sha256").update(mod.payload).digest("hex");
|
|
164
|
-
if (actual256
|
|
203
|
+
if (!_timingSafeHexEqual(actual256, meta.sha256)) {
|
|
165
204
|
throw new VendorDataError("vendor-data/sha256-mismatch",
|
|
166
205
|
"vendorData: '" + name + "' SHA-256 mismatch — payload tampered. " +
|
|
167
206
|
"expected=" + meta.sha256.slice(0, 12) + "… got=" + actual256.slice(0, 12) + "…");
|
|
168
207
|
}
|
|
169
208
|
|
|
170
|
-
// Layer 2: SHA3-512 self-verify
|
|
209
|
+
// Layer 2: SHA3-512 self-verify (timing-safe compare; see Layer 1).
|
|
171
210
|
var actual3 = nodeCrypto.createHash("sha3-512").update(mod.payload).digest("hex");
|
|
172
|
-
if (actual3
|
|
211
|
+
if (!_timingSafeHexEqual(actual3, meta.sha3_512)) {
|
|
173
212
|
throw new VendorDataError("vendor-data/sha3-512-mismatch",
|
|
174
213
|
"vendorData: '" + name + "' SHA3-512 mismatch — payload tampered. " +
|
|
175
214
|
"expected=" + meta.sha3_512.slice(0, 12) + "… got=" + actual3.slice(0, 12) + "…");
|
|
@@ -186,7 +225,7 @@ function _loadAndVerify(name) {
|
|
|
186
225
|
var actualPubkeyFp = _actualPubkeyFingerprint();
|
|
187
226
|
if (typeof meta.publicKeyFingerprint === "string" &&
|
|
188
227
|
meta.publicKeyFingerprint.length > 0 &&
|
|
189
|
-
meta.publicKeyFingerprint
|
|
228
|
+
!_timingSafeHexEqual(meta.publicKeyFingerprint, actualPubkeyFp)) {
|
|
190
229
|
throw new VendorDataError("vendor-data/pubkey-fingerprint-mismatch",
|
|
191
230
|
"vendorData: '" + name + "' declared publicKeyFingerprint '" +
|
|
192
231
|
meta.publicKeyFingerprint + "' does not match the inlined pubkey " +
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:b04fba78-2d19-4469-a4dc-ecdf4677187f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-17T16:21:47.795Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.10.5",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.10.
|
|
25
|
+
"version": "0.10.5",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.10.5",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.10.5",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|