@blamejs/core 0.13.10 → 0.13.12
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 +4 -0
- package/README.md +1 -1
- package/lib/mail-server-mx.js +138 -43
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
|
|
12
|
+
|
|
13
|
+
- v0.13.11 (2026-05-27) — **Test-suite reliability: replaced fixed-delay waits in the rate-limiter and scheduler suites with condition polling.** No runtime behaviour changes. The rate-limiter, scheduler, and websocket-channel test suites waited for asynchronous work to settle by draining a fixed number of event-loop ticks before asserting. Under heavily parallel CI that budget was occasionally too short, so an assertion read state before the async work (a cluster-backend counter update, a scheduler tick-claim) had landed — an intermittent failure unrelated to the code under test. Those waits now poll the observable condition (helpers.waitUntil) and exit as soon as it holds, with a generous upper bound, so they pass quickly on fast machines and reliably under load. A build gate is added so the fixed-tick-drain shape cannot be reintroduced. **Fixed:** *Flaky fixed-budget waits in the rate-limiter / scheduler / sandbox test suites made contention-tolerant* — The rate-limit-cluster and scheduler-exactly-once suites drained a fixed count of event-loop ticks before asserting on asynchronously-updated state; under contended CI the budget could expire before the work settled, producing intermittent failures. They now wait on the actual observable condition (a written response, a settled counter). The sandbox suite's success-path cases gave the worker a 5 s execution budget that cold worker-thread startup under heavily parallel Windows CI could just exceed; those are raised to the framework's 10 s ceiling. Affects test code only — no change to shipped framework behaviour. The unused tick-drain helper in the websocket-channel suite was removed. **Detectors:** *Build gate rejects the fixed-tick-drain wait shape in tests* — A new test-suite lint rule flags the counted microtask/tick-drain idiom (reassigning a promise to its own `.then()` in a loop to wait a fixed number of ticks), the sibling of the existing fixed-`setTimeout`-sleep rule. A single event-loop yield is unaffected; only the drain-as-wait shape is rejected, directing the wait to condition polling instead.
|
|
14
|
+
|
|
11
15
|
- v0.13.10 (2026-05-27) — **Documented-but-inert options wired up, a non-existent CVE reference removed, and a silent iCalendar cap-bypass fixed.** A sweep for places where a documented option or citation did not match what the code does. The most operator-relevant fix: b.calendar.fromIcal documented a safeIcalOpts option that forwards parser caps (byte size, RRULE limits, nesting depth) to b.safeIcal.parse, but the value was never forwarded — so an operator who set tight caps through it got the default profile instead, silently. That is corrected; the nested options now reach the parser. b.archive.read.zip documented an AbortSignal option that was never honored; it now aborts the read at the entry boundary. b.auth.fal documented a bearerOnly alias that had no effect; it now forces the no-proof-of-possession path and refuses the contradictory combination of bearerOnly:true with a holder-of-key binding. Separately, the auth verification paths cited CVE-2026-23993 (13 places) for the "reject an unknown alg before key lookup" guard — that CVE id does not exist (the registry has no record of it); the citation is replaced with the weakness class (CWE-347 / CWE-757) and the real, verifiable neighboring CVEs. The circuit-breaker error-code note that promised a rename "in v0.10" is corrected to the actual plan (v1.0), and the build gate that catches overdue version promises now also catches two-part version numbers. **Changed:** *`b.auth.fal` `bearerOnly` is now a real alias and refuses contradictions* — `bearerOnly: true` now forces the no-proof-of-possession path (equivalent to `hokBinding: null`), as documented. Passing `bearerOnly: true` together with a non-null `hokBinding` is a contradictory assurance request and is now refused at the call rather than silently resolved one way. **Fixed:** *`b.calendar.fromIcal` now forwards `safeIcalOpts` to the parser* — The documented `safeIcalOpts` option (parser caps: max bytes, RRULE COUNT/BYxxx limits, nesting depth) was not being passed to `b.safeIcal.parse` — when supplied under the documented nested key it was silently ignored and the parser ran with its default profile. Both forms now reach the parser: the documented nested `{ safeIcalOpts: { ... } }` and the top-level `{ profile, ... }` that earlier releases accepted, with the nested form winning on conflict. No caller regresses. · *`b.archive.read.zip` honors the documented `signal` (AbortSignal)* — The `signal` option was documented but never read. A large or slow archive read can now be aborted cooperatively — the reader checks the signal at each entry boundary (`inspect`, `entries`, `extractEntries`, `extract`) and rejects with an `archive-read/aborted` error. · *Removed a non-existent CVE reference from the JWT/JWE verification paths* — The "reject an unknown/unsupported `alg` before any key lookup" guard in `b.auth.jwt.verifyExternal`, `b.auth.oauth.verifyIdToken`, `b.auth.oid4vci`, and `b.auth.sd-jwt-vc` cited a CVE id that the registry has no record of. The behaviour is unchanged; the citation is now the weakness class it defends (CWE-347 improper signature verification / CWE-757 algorithm downgrade) alongside the real, verifiable alg-confusion / JWE-bypass CVEs already cited beside it. **Detectors:** *Overdue-version-promise gate now catches two-part version numbers* — The build gate that flags a deferral whose promised landing version has already shipped previously matched only three-part versions (`vN.N.N`); a two-part promise (`vN.N`) slipped past it. It now matches both. The `b.circuitBreaker` `CIRCUIT_OPEN` error-code note that pointed at a passed version is corrected to its actual plan (rename at v1.0, with a deprecation warning a minor ahead).
|
|
12
16
|
|
|
13
17
|
- v0.13.9 (2026-05-26) — **Corrected CVE citations in source threat annotations + a build gate that refuses malformed CVE identifiers.** Several source-comment threat annotations cited CVE identifiers that were rejected by the numbering authority (never assigned to a real issue), attributed to the wrong product, or structurally malformed (a placeholder with a non-numeric sequence). The annotated defenses are unchanged — every cap, refusal, and constant-time comparison behaves exactly as before; only the reference labels were corrected, each to a verifiable CVE or to the underlying weakness class (CWE / RFC) where no single CVE fits. Notable corrections: the S/MIME SHA-1 / MD5 certificate-signature refusal now cites the SHAttered collision and RFC 8551 §2.5 instead of a rejected candidate id; decompression-output caps cite CWE-409 and CVE-2025-0725 instead of a fabricated placeholder; the iCalendar RRULE / nesting / byte caps describe the calendar-bomb recursion-DoS class instead of an unrelated SSRF advisory; and the SAML signature-wrapping (XSW) defense now cites the actively-exploited CVE-2024-45409 (ruby-saml, CVSS 10.0) and CVE-2025-25291 / -25292 that the duplicate-element refusal defeats. A new build-time detector refuses any CVE token whose sequence number is not all-numeric, so a placeholder identifier can never reach a release again. **Fixed:** *Corrected rejected / misattributed / malformed CVE references in source threat annotations* — Threat-annotation comments across the mail, crypto, auth, guard, and safe modules carried CVE identifiers that were rejected by the CVE numbering authority, attributed to the wrong product, or written as non-numeric placeholders. Each was corrected to a verifiable CVE or to the weakness class (CWE / RFC) it defends. No runtime behaviour changed — the defenses these comments describe are unchanged. The S/MIME certificate check's SHA-1 / MD5 refusal message now names the SHAttered collision and RFC 8551 §2.5; the SAML XSW defense now names CVE-2024-45409 and CVE-2025-25291 / -25292. **Detectors:** *`malformed-cve-identifier` — refuses structurally-invalid CVE tokens at build time* — A CVE identifier's sequence number is always numeric (`CVE-<year>-<digits>`). The new detector refuses any CVE token whose post-year segment contains a letter — the placeholder shape that lets a fabricated reference slip past review. It cannot verify that a well-formed id is real or correctly attributed (that stays a review responsibility), but it makes the structurally-invalid class impossible to ship.
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
169
169
|
- **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
|
|
170
170
|
- **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
|
|
171
171
|
- **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
|
|
172
|
-
- **Mail server listeners** — RFC 5321 MX inbound (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
172
|
+
- **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
173
173
|
- **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
|
|
174
174
|
- **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
|
|
175
175
|
- **Mail-stack agent** — multi-threaded worker pool + queue dispatch + sealed mail-store backed by SQLite FTS5 (`b.mail.agent`, `b.mailStore`)
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -67,9 +67,11 @@
|
|
|
67
67
|
*
|
|
68
68
|
* - `mail.server.mx.connect` — IP, TLS state, FCrDNS hostname
|
|
69
69
|
* - `mail.server.mx.helo` — HELO greeting, helo-gate verdict
|
|
70
|
-
* - `mail.server.mx.
|
|
71
|
-
* - `mail.server.mx.
|
|
72
|
-
* - `mail.server.mx.
|
|
70
|
+
* - `mail.server.mx.helo_gate_refused` — HELO identity refused (gate action)
|
|
71
|
+
* - `mail.server.mx.mail_from` — sender address
|
|
72
|
+
* - `mail.server.mx.rcpt_to` — recipient, rblListed flag, greylist action
|
|
73
|
+
* - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
|
|
74
|
+
* - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
|
|
73
75
|
* - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
|
|
74
76
|
* - `mail.server.mx.delivered` — agent.handoff ack
|
|
75
77
|
* - `mail.server.mx.tls_handshake_failed` — handshake error
|
|
@@ -100,19 +102,30 @@
|
|
|
100
102
|
*
|
|
101
103
|
* Every gate is a primitive that already exists. The MX slice is a
|
|
102
104
|
* state-machine + wire-protocol coordinator — no new crypto, no
|
|
103
|
-
* new parsing, no new RFC-layer primitives.
|
|
104
|
-
* (e.g.
|
|
105
|
-
* skips that phase
|
|
106
|
-
*
|
|
105
|
+
* new parsing, no new RFC-layer primitives. When the operator
|
|
106
|
+
* doesn't wire a gate (e.g. omits `opts.greylist`), the listener
|
|
107
|
+
* skips that phase rather than synthesizing a verdict.
|
|
108
|
+
*
|
|
109
|
+
* Connection-level gates are wired into the live state machine:
|
|
110
|
+
* `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
|
|
111
|
+
* (connecting-IP DNS blocklist, evaluated once per connection) and
|
|
112
|
+
* `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
|
|
113
|
+
* RCPT TO and surface their verdicts on the `rcpt_to` event. The
|
|
114
|
+
* message-authentication gates (`b.guardEnvelope` SPF/DKIM/DMARC
|
|
115
|
+
* alignment) require the inbound SPF + DKIM verification results as
|
|
116
|
+
* inputs; that inbound-auth pipeline composes `b.mail.spf` +
|
|
117
|
+
* `b.mail.dmarc` + DKIM verification and lands as a follow-up, at
|
|
118
|
+
* which point the DATA-phase envelope/DMARC gate wires in. Until
|
|
119
|
+
* then operators run those checks on the delivered message via the
|
|
120
|
+
* agent handoff.
|
|
107
121
|
*
|
|
108
122
|
* @card
|
|
109
123
|
* Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
|
|
110
124
|
* smuggling defense baked into the wire-protocol layer (RFC 5321
|
|
111
125
|
* §2.3.8 + CVE-2023-51764 / 51765 / 51766), open-relay refusal by
|
|
112
126
|
* default, STARTTLS-stripping defense (CVE-2021-38371), and the
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* appropriate phase.
|
|
127
|
+
* connection-level gate cascade (HELO identity / RBL / greylist)
|
|
128
|
+
* running at the appropriate phase.
|
|
116
129
|
*/
|
|
117
130
|
|
|
118
131
|
var net = require("node:net");
|
|
@@ -149,6 +162,7 @@ var REPLY_221_BYE = "221";
|
|
|
149
162
|
var REPLY_250_OK = "250";
|
|
150
163
|
var REPLY_354_START_INPUT = "354";
|
|
151
164
|
var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
|
|
165
|
+
var REPLY_450_MAILBOX_BUSY = "450"; // allow:raw-byte-literal — SMTP transient code (greylist tempfail)
|
|
152
166
|
var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
|
|
153
167
|
var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
|
|
154
168
|
var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
|
|
@@ -177,11 +191,9 @@ var RE_SIZE = /SIZE=(\d+)/i;
|
|
|
177
191
|
* @opts
|
|
178
192
|
* tlsContext: TlsContext, // required — b.network.tls.context() output (no implicit plaintext)
|
|
179
193
|
* greeting: string, // default "blamejs ESMTP" — HELO/EHLO 220-line banner
|
|
180
|
-
* helo: b.mail.helo,
|
|
181
|
-
* rbl: b.mail.rbl,
|
|
182
|
-
* greylist: b.mail.greylist, // optional gate
|
|
183
|
-
* envelope: b.guardEnvelope, // optional gate (SPF/DKIM alignment)
|
|
184
|
-
* dmarc: b.mail.auth.dmarc, // optional gate
|
|
194
|
+
* helo: b.mail.helo, // optional gate — HELO identity (FCrDNS / shape / self-name)
|
|
195
|
+
* rbl: b.mail.rbl.create(…), // optional gate — DNS blocklist on the connecting IP
|
|
196
|
+
* greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
|
|
185
197
|
* agent: b.mail.agent, // optional delivery handoff
|
|
186
198
|
* relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
|
|
187
199
|
* localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
|
|
@@ -199,7 +211,6 @@ var RE_SIZE = /SIZE=(\d+)/i;
|
|
|
199
211
|
* helo: b.mail.helo,
|
|
200
212
|
* rbl: b.mail.rbl.create({ providers: ["zen.spamhaus.org"] }),
|
|
201
213
|
* greylist: b.mail.greylist.create({ store: greylistStore }),
|
|
202
|
-
* envelope: b.guardEnvelope,
|
|
203
214
|
* agent: b.mail.agent.create({ store: mailStore }),
|
|
204
215
|
* localDomains: ["example.com"],
|
|
205
216
|
* });
|
|
@@ -367,6 +378,15 @@ function create(opts) {
|
|
|
367
378
|
var lineBuffer = Buffer.alloc(0);
|
|
368
379
|
var bodyCollector = null;
|
|
369
380
|
var inDataBody = false;
|
|
381
|
+
// Async command pump: gates (HELO / RBL / greylist / envelope /
|
|
382
|
+
// DMARC) may await DNS or a store, so command handling is async.
|
|
383
|
+
// `pumpChain` FIFO-serializes per-chunk processing so a gate
|
|
384
|
+
// resolving cannot let a later pipelined command (RFC 2920) jump
|
|
385
|
+
// ahead of an earlier one — reply ordering + the per-command
|
|
386
|
+
// smuggling defenses stay intact. `connClosed` short-circuits any
|
|
387
|
+
// chunk queued before a teardown.
|
|
388
|
+
var pumpChain = Promise.resolve();
|
|
389
|
+
var connClosed = false;
|
|
370
390
|
|
|
371
391
|
socket.setTimeout(idleTimeoutMs);
|
|
372
392
|
socket.on("timeout", function () {
|
|
@@ -382,6 +402,7 @@ function create(opts) {
|
|
|
382
402
|
});
|
|
383
403
|
|
|
384
404
|
socket.on("close", function () {
|
|
405
|
+
connClosed = true;
|
|
385
406
|
connections.delete(socket);
|
|
386
407
|
});
|
|
387
408
|
|
|
@@ -395,20 +416,35 @@ function create(opts) {
|
|
|
395
416
|
// 220 banner — RFC 5321 §3.1.
|
|
396
417
|
_writeReply(socket, REPLY_220_READY, greeting + " ready");
|
|
397
418
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
419
|
+
// Feed a chunk into the per-connection command pump. Chains each
|
|
420
|
+
// chunk behind the previous one's full (async) processing so command
|
|
421
|
+
// handlers + their gates run strictly in arrival order. Used by BOTH
|
|
422
|
+
// the plaintext `socket.on("data")` path AND the post-STARTTLS
|
|
423
|
+
// TLSSocket onData path — otherwise gate awaits on the upgraded
|
|
424
|
+
// socket would overlap later TLS chunks (the default strict/balanced
|
|
425
|
+
// profiles require STARTTLS before MAIL, so the gates run there) and
|
|
426
|
+
// async gate rejections would go unhandled instead of producing the
|
|
427
|
+
// 421 path. `activeSock` is whichever socket is current (plaintext or
|
|
428
|
+
// TLS) so the 421/close lands on the right transport.
|
|
429
|
+
function _feedChunk(activeSock, chunk) {
|
|
430
|
+
pumpChain = pumpChain.then(function () {
|
|
431
|
+
if (connClosed) return undefined;
|
|
432
|
+
return _ingestBytes(state, activeSock, chunk);
|
|
433
|
+
}).catch(function (err) {
|
|
434
|
+
if (connClosed) return;
|
|
401
435
|
_emit("mail.server.mx.handler_threw",
|
|
402
436
|
{ connectionId: state.id, error: (err && err.message) || String(err) },
|
|
403
437
|
"failure");
|
|
404
|
-
try { _writeReply(
|
|
438
|
+
try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
|
|
405
439
|
catch (_e) { /* socket already gone */ }
|
|
406
|
-
_closeConnection(
|
|
407
|
-
}
|
|
408
|
-
}
|
|
440
|
+
_closeConnection(activeSock);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
socket.on("data", function (chunk) { _feedChunk(socket, chunk); });
|
|
409
445
|
|
|
410
446
|
// ---- Byte-level ingestion --------------------------------------------
|
|
411
|
-
function _ingestBytes(state, socket, chunk) {
|
|
447
|
+
async function _ingestBytes(state, socket, chunk) {
|
|
412
448
|
if (inDataBody) {
|
|
413
449
|
// DATA body — accumulate via boundedChunkCollector, watch for
|
|
414
450
|
// canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
|
|
@@ -444,9 +480,9 @@ function create(opts) {
|
|
|
444
480
|
var endIdx = safeSmtp.findDotTerminator(collected);
|
|
445
481
|
if (endIdx !== -1) {
|
|
446
482
|
var body = collected.subarray(0, endIdx);
|
|
447
|
-
_finalizeDataBody(state, socket, body);
|
|
448
483
|
inDataBody = false;
|
|
449
484
|
bodyCollector = null;
|
|
485
|
+
await _finalizeDataBody(state, socket, body);
|
|
450
486
|
}
|
|
451
487
|
return;
|
|
452
488
|
}
|
|
@@ -464,12 +500,13 @@ function create(opts) {
|
|
|
464
500
|
while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
|
|
465
501
|
var line = lineBuffer.subarray(0, crlf).toString("utf8");
|
|
466
502
|
lineBuffer = lineBuffer.subarray(crlf + 2);
|
|
467
|
-
_handleCommand(state, socket, line);
|
|
503
|
+
await _handleCommand(state, socket, line);
|
|
468
504
|
if (inDataBody) return;
|
|
505
|
+
if (connClosed) return;
|
|
469
506
|
}
|
|
470
507
|
}
|
|
471
508
|
|
|
472
|
-
function _handleCommand(state, socket, line) {
|
|
509
|
+
async function _handleCommand(state, socket, line) {
|
|
473
510
|
// Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
|
|
474
511
|
// BEFORE state-machine dispatch.
|
|
475
512
|
try {
|
|
@@ -494,16 +531,16 @@ function create(opts) {
|
|
|
494
531
|
switch (verb) {
|
|
495
532
|
case "EHLO":
|
|
496
533
|
case "HELO":
|
|
497
|
-
_handleEhlo(state, socket, line, verb);
|
|
534
|
+
await _handleEhlo(state, socket, line, verb);
|
|
498
535
|
return;
|
|
499
536
|
case "STARTTLS":
|
|
500
537
|
_handleStartTls(state, socket);
|
|
501
538
|
return;
|
|
502
539
|
case "MAIL":
|
|
503
|
-
_handleMailFrom(state, socket, line);
|
|
540
|
+
await _handleMailFrom(state, socket, line);
|
|
504
541
|
return;
|
|
505
542
|
case "RCPT":
|
|
506
|
-
_handleRcptTo(state, socket, line);
|
|
543
|
+
await _handleRcptTo(state, socket, line);
|
|
507
544
|
return;
|
|
508
545
|
case "DATA":
|
|
509
546
|
_handleData(state, socket);
|
|
@@ -531,7 +568,7 @@ function create(opts) {
|
|
|
531
568
|
}
|
|
532
569
|
|
|
533
570
|
// ---- EHLO / HELO ------------------------------------------------------
|
|
534
|
-
function _handleEhlo(state, socket, line, verb) {
|
|
571
|
+
async function _handleEhlo(state, socket, line, verb) {
|
|
535
572
|
var helo = line.slice(verb.length).trim();
|
|
536
573
|
if (!helo) {
|
|
537
574
|
_writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
|
|
@@ -552,6 +589,25 @@ function create(opts) {
|
|
|
552
589
|
return;
|
|
553
590
|
}
|
|
554
591
|
}
|
|
592
|
+
// Operator HELO-identity gate (b.mail.helo) — FCrDNS / HELO-shape /
|
|
593
|
+
// self-name spoofing checks. Composed when the operator wires
|
|
594
|
+
// `opts.helo`; skipped silently otherwise (no synthesized verdict).
|
|
595
|
+
// Hard-reject actions (reject-shape / match-self-refused /
|
|
596
|
+
// literal-mismatch) refuse the connection; "accept" and the
|
|
597
|
+
// advisory "soft-*" actions pass (the soft verdict rides the event).
|
|
598
|
+
if (opts.helo && typeof opts.helo.evaluate === "function") {
|
|
599
|
+
var heloGate = await opts.helo.evaluate(
|
|
600
|
+
{ claimedName: helo, ip: state.remoteAddress, tls: state.tls }, {});
|
|
601
|
+
state.heloVerdict = heloGate && heloGate.action;
|
|
602
|
+
if (heloGate && heloGate.action && heloGate.action !== "accept" &&
|
|
603
|
+
heloGate.action.indexOf("soft") !== 0) {
|
|
604
|
+
_emit("mail.server.mx.helo_gate_refused",
|
|
605
|
+
{ connectionId: state.id, helo: helo, action: heloGate.action }, "denied");
|
|
606
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
607
|
+
"5.7.1 " + verb + " identity refused (" + heloGate.action + ")");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
555
611
|
state.helo = helo;
|
|
556
612
|
state.stage = "ehlo";
|
|
557
613
|
// Multi-line 250 capabilities advertisement per RFC 5321 §4.1.1.1.
|
|
@@ -572,7 +628,8 @@ function create(opts) {
|
|
|
572
628
|
_writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
|
|
573
629
|
}
|
|
574
630
|
_emit("mail.server.mx.helo",
|
|
575
|
-
{ connectionId: state.id, verb: verb, helo: helo, tls: state.tls
|
|
631
|
+
{ connectionId: state.id, verb: verb, helo: helo, tls: state.tls,
|
|
632
|
+
heloVerdict: state.heloVerdict || null });
|
|
576
633
|
}
|
|
577
634
|
|
|
578
635
|
// ---- STARTTLS ---------------------------------------------------------
|
|
@@ -604,13 +661,11 @@ function create(opts) {
|
|
|
604
661
|
state.helo = null;
|
|
605
662
|
},
|
|
606
663
|
onData: function (tlsSocket, chunk) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
_closeConnection(tlsSocket);
|
|
613
|
-
}
|
|
664
|
+
// Route the upgraded socket through the SAME serialized pump as
|
|
665
|
+
// the plaintext path — post-STARTTLS is where the gates run in
|
|
666
|
+
// the default strict/balanced profiles, so it MUST be serialized
|
|
667
|
+
// and its async rejections MUST hit the 421 path.
|
|
668
|
+
_feedChunk(tlsSocket, chunk);
|
|
614
669
|
},
|
|
615
670
|
onError: function (err) {
|
|
616
671
|
_emit("mail.server.mx.tls_handshake_failed",
|
|
@@ -626,7 +681,7 @@ function create(opts) {
|
|
|
626
681
|
}
|
|
627
682
|
|
|
628
683
|
// ---- MAIL FROM --------------------------------------------------------
|
|
629
|
-
function _handleMailFrom(state, socket, line) {
|
|
684
|
+
async function _handleMailFrom(state, socket, line) {
|
|
630
685
|
if (!state.tls && _requiresStartTls()) {
|
|
631
686
|
_writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
|
|
632
687
|
return;
|
|
@@ -677,7 +732,7 @@ function create(opts) {
|
|
|
677
732
|
}
|
|
678
733
|
|
|
679
734
|
// ---- RCPT TO ----------------------------------------------------------
|
|
680
|
-
function _handleRcptTo(state, socket, line) {
|
|
735
|
+
async function _handleRcptTo(state, socket, line) {
|
|
681
736
|
if (state.stage !== "rcpt") {
|
|
682
737
|
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
|
|
683
738
|
return;
|
|
@@ -740,9 +795,49 @@ function create(opts) {
|
|
|
740
795
|
return;
|
|
741
796
|
}
|
|
742
797
|
}
|
|
798
|
+
// RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
|
|
799
|
+
// IP. The verdict is per-connection, so it's evaluated once and
|
|
800
|
+
// cached on state; a listed IP refuses with 554. Skipped silently
|
|
801
|
+
// when opts.rbl isn't wired.
|
|
802
|
+
if (opts.rbl && typeof opts.rbl.query === "function") {
|
|
803
|
+
if (state.rblVerdict === undefined) {
|
|
804
|
+
state.rblVerdict = await opts.rbl.query(state.remoteAddress);
|
|
805
|
+
}
|
|
806
|
+
if (state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
|
|
807
|
+
state.rblVerdict.listed.length > 0) {
|
|
808
|
+
_trackRefusedRcpt(state, rcpt, "rbl-listed");
|
|
809
|
+
_emit("mail.server.mx.rbl_refused",
|
|
810
|
+
{ connectionId: state.id, remoteAddress: state.remoteAddress,
|
|
811
|
+
zones: state.rblVerdict.listed.map(function (l) { return l.zone; }) }, "denied");
|
|
812
|
+
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
813
|
+
"5.7.1 Connecting IP is on a DNS blocklist");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// Greylist gate (b.mail.greylist) — defer first sight of an
|
|
818
|
+
// (ip, mailFrom, rcpt) tuple with a 450 tempfail; legitimate
|
|
819
|
+
// senders retry and pass. "defer" → 450; "accept" → continue.
|
|
820
|
+
// Skipped silently when opts.greylist isn't wired.
|
|
821
|
+
var greyVerdict = null;
|
|
822
|
+
if (opts.greylist && typeof opts.greylist.check === "function") {
|
|
823
|
+
greyVerdict = await opts.greylist.check(
|
|
824
|
+
{ ip: state.remoteAddress, mailFrom: state.mailFrom || "", rcptTo: rcpt });
|
|
825
|
+
if (greyVerdict && greyVerdict.action === "defer") {
|
|
826
|
+
_emit("mail.server.mx.greylist_deferred",
|
|
827
|
+
{ connectionId: state.id, remoteAddress: state.remoteAddress,
|
|
828
|
+
mailFrom: state.mailFrom, rcptTo: rcpt,
|
|
829
|
+
reason: greyVerdict.reason }, "denied");
|
|
830
|
+
_writeReply(socket, REPLY_450_MAILBOX_BUSY,
|
|
831
|
+
"4.7.1 Greylisted — please retry shortly");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
743
835
|
state.rcpts.push(rcpt);
|
|
744
836
|
_emit("mail.server.mx.rcpt_to",
|
|
745
|
-
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length
|
|
837
|
+
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
|
|
838
|
+
rblListed: !!(state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
|
|
839
|
+
state.rblVerdict.listed.length > 0),
|
|
840
|
+
greylist: greyVerdict ? greyVerdict.action : null });
|
|
746
841
|
_writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
|
|
747
842
|
}
|
|
748
843
|
|
|
@@ -764,7 +859,7 @@ function create(opts) {
|
|
|
764
859
|
});
|
|
765
860
|
}
|
|
766
861
|
|
|
767
|
-
function _finalizeDataBody(state, socket, body) {
|
|
862
|
+
async function _finalizeDataBody(state, socket, body) {
|
|
768
863
|
// body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
|
|
769
864
|
// §4.5.2 — a single leading "." is doubled on the wire; undo.
|
|
770
865
|
var dedotted = safeSmtp.dotUnstuff(body);
|
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.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:cfe1e116-abd7-4631-96e5-8a2a865e2a16",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-27T11:02:44.192Z",
|
|
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.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.12",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.12",
|
|
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.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.12",
|
|
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.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.12",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|