@blamejs/core 0.10.5 → 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 +2 -0
- package/lib/crypto.js +30 -0
- package/lib/external-db.js +2 -2
- package/lib/guard-dsn.js +8 -5
- package/lib/guard-list-id.js +14 -10
- package/lib/guard-list-unsubscribe.js +67 -1
- package/lib/guard-message-id.js +26 -0
- package/lib/mail-arc-sign.js +21 -1
- package/lib/mail-auth.js +2 -1
- package/lib/mail-dkim.js +50 -9
- package/lib/mail-server-imap.js +104 -10
- package/lib/mail-server-mx.js +94 -14
- package/lib/mail-server-submission.js +135 -1
- package/lib/mail-store.js +6 -1
- package/lib/network-dns-resolver.js +1 -2
- package/lib/network-dns.js +4 -4
- package/lib/safe-mime.js +51 -4
- package/lib/vendor/MANIFEST.json +2 -2
- 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.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/).
|
|
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/).
|
|
11
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).
|
|
12
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).
|
|
13
15
|
- 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).
|
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
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
|
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:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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(
|
|
290
|
+
return normalized.split("\n\n");
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
function _parseFieldBlock(block, maxHeaderLine) {
|
package/lib/guard-list-id.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
238
|
-
// randomness in the label
|
|
239
|
-
|
|
240
|
-
if (
|
|
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("
|
|
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;
|
package/lib/guard-message-id.js
CHANGED
|
@@ -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;
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
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
|
-
|
|
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 = [];
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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, {
|
|
979
|
+
return mailStore.appendMessage(name, literalBody, {
|
|
980
|
+
actor: state.actor, flags: flags, internalDate: internalDate });
|
|
895
981
|
});
|
|
896
982
|
}
|
|
897
|
-
return mailStore.appendMessage(name, literalBody, {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
|
645
|
-
state.
|
|
646
|
-
state.
|
|
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,
|
|
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
|
|
779
|
-
state.
|
|
780
|
-
state.
|
|
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 {
|
|
863
|
-
|
|
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 {
|
|
872
|
-
|
|
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, {
|
|
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
|
-
|
|
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 =
|
|
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
|
package/lib/network-dns.js
CHANGED
|
@@ -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.
|
|
278
|
-
//
|
|
279
|
-
|
|
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
|
-
|
|
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", "
|
|
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" +
|
|
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/lib/vendor/MANIFEST.json
CHANGED
|
@@ -140,8 +140,8 @@
|
|
|
140
140
|
"source": "https://github.com/PeculiarVentures",
|
|
141
141
|
"_about": "Meta-bundle of @peculiar/x509 + pkijs + reflect-metadata + every transitive ASN.1 schema package. Used by lib/mtls-engine-default.js as the pure-JS CA + PKCS#12 engine wired into b.mtlsCa.",
|
|
142
142
|
"components": {
|
|
143
|
-
"@peculiar/x509": "https://github.com/PeculiarVentures/x509",
|
|
144
|
-
"pkijs": "https://github.com/PeculiarVentures/PKI.js"
|
|
143
|
+
"@peculiar/x509": { "url": "https://github.com/PeculiarVentures/x509", "version": "1.13.0" },
|
|
144
|
+
"pkijs": { "url": "https://github.com/PeculiarVentures/PKI.js", "version": "3.4.0" }
|
|
145
145
|
},
|
|
146
146
|
"exports": [
|
|
147
147
|
"x509",
|
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:62b21ffc-fcde-4782-a950-d9b2db933f5c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.10.7",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.10.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.10.7",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|