@blamejs/core 0.9.43 → 0.9.46
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 +3 -0
- package/index.js +4 -0
- package/lib/agent-tenant.js +168 -0
- package/lib/argon2-builtin.js +8 -1
- package/lib/auth/dpop.js +2 -7
- package/lib/auth/fal.js +1 -1
- package/lib/auth/jwt.js +3 -7
- package/lib/auth/oauth.js +4 -8
- package/lib/auth/status-list.js +3 -8
- package/lib/crypto.js +61 -0
- package/lib/guard-smtp-command.js +65 -10
- package/lib/mail-server-mx.js +736 -0
- package/lib/middleware/protected-resource-metadata.js +1 -1
- package/lib/network-dns-resolver.js +2 -1
- package/lib/network-dns.js +4 -3
- package/lib/object-store/gcs.js +2 -6
- package/lib/pagination.js +2 -7
- package/lib/safe-smtp.js +128 -0
- package/lib/storage.js +417 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,9 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.46 (2026-05-15) — **`b.mail.server.mx` — inbound SMTP / MX listener + `b.safeSmtp` parser + `b.guardSmtpCommand.detectBodySmuggling`.** The wire-protocol primitives extracted from the listener inline copy into reusable safe/guard modules — `b.safeSmtp.findDotTerminator(buf)` + `b.safeSmtp.dotUnstuff(buf)` for the parsing concerns (where the body terminator is, how to reverse dot-stuffing per RFC 5321 §4.5.2), and `b.guardSmtpCommand.detectBodySmuggling(buf)` for the security concern (CVE-2023-51764 / -51765 / -51766 / 2024-32178 bare-LF dot-terminator detection). The MX listener consumes both. Same primitives ship for the upcoming submission / IMAP / JMAP listeners and for any operator-side tooling that needs to parse SMTP bytes (proxies, log analyzers, test fixtures) without booting a full server. Closes downstream-consumer gap item #11. Composes the existing mail-gate substrates (`b.mail.helo`, `b.mail.rbl`, `b.mail.greylist`, `b.guardEnvelope`, `b.mail.auth.dmarc`, `b.safeMime`, `b.guardEmail`, `b.guardSmtpCommand`, `b.mail.agent`) into one operator-facing inbound listener that drives the RFC 5321 CONNECT → EHLO → [STARTTLS → EHLO] → MAIL → RCPT → DATA → DATA-body → QUIT state machine. **Defenses baked in:** (1) **SMTP smuggling** (CVE-2023-51764 / CVE-2024-32178 / RFC 5321 §2.3.8) — every wire line passes through `b.guardSmtpCommand.validate` refusing bare LF / bare CR / NUL / C0 / DEL / oversize; the DATA body's `\r\n.\r\n` terminator is matched on canonical CRLF only — bare-LF dot-terminators are detected via `_detectSmugglingShape` and refused with 554 5.7.0 + an `mail.server.mx.smtp_smuggling_detected` audit event. (2) **Open-relay defense** — `localDomains` allowlist with default-deny posture; RCPT TO non-local refused with 550 5.7.1 unless `relayAllowedFor: [{ cidr, scope }]` opts the destination in explicitly. (3) **STARTTLS-injection defense (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot)** — command buffer + body collector cleared at upgrade time so pre-handshake pipelined commands (RFC 2920 PIPELINING) can't take effect post-handshake. (4) **TLS posture** — `tlsContext` is required (no implicit plaintext-only mode); pre-STARTTLS plain commands limited to EHLO / HELO / STARTTLS / NOOP / QUIT / RSET under strict + balanced profiles; MAIL / RCPT / DATA refused with 530 5.7.0 Must issue a STARTTLS command first. Permissive profile accepts plaintext for legacy operator-acknowledged downgrade. (5) **Resource exhaustion** — per-command line cap (default 1 KiB), DATA body cap (default 50 MiB per RFC 5321 §4.5.3.1.7), per-recipient cap (default 100 per RFC 5321 §4.5.3.1.8), idle timeout (default 5 minutes per RFC 5321 §4.5.3.2.7). RFC 5321 §4.5.2 dot-stuffing reversal via `_dotUnstuff`. RFC 1870 §3 SIZE param parsed at MAIL FROM time + refused with 552 5.3.4 if oversize. RFC 2920 PIPELINING + RFC 6152 8BITMIME (obsoletes RFC 1652) + RFC 2034 ENHANCEDSTATUSCODES advertised in EHLO capabilities. RFC 3463 enhanced status codes embedded in every reply for operator-side observability. RFC 6531 SMTPUTF8 / RFC 5891 IDN deliberately NOT advertised — non-ASCII MAIL FROM / RCPT TO bytes refused via `b.guardSmtpCommand` until the operator's downstream (mail-store + delivery agent) accepts Unicode mailbox-local-part bytes. Audit lifecycle: `mail.server.mx.{connect,helo,mail_from,rcpt_to,data_accepted,data_refused,delivered,tls_handshake_failed,smtp_smuggling_detected,relay_refused,listening,closed,handler_threw,socket_error}`. **What v1 does NOT ship:** AUTH / submission auth (port-587 listener is its own slice), Sieve filtering (composes via `b.mail.agent` at delivery), outbound DSN generation (deferred to submission slice), 8BITMIME / SMTPUTF8 transcoding (advertised but parser-agnostic).
|
|
12
|
+
- v0.9.45 (2026-05-15) — **`b.crypto.toBase64Url` / `fromBase64Url` helpers + lib-wide `.replace(/X+$/, ...)` ReDoS-shape sweep.** The trailing-greedy regex `.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")` base64url-by-hand pattern was duplicated across 9 framework call sites (JWT / DPoP / OAuth / SD-JWT VC status-list / DNS-over-HTTPS GET encoding ×3 / GCS service-account JWT signing / pagination cursors). The trailing `/=+$/` regex is polynomial-ReDoS-shaped per CodeQL `js/polynomial-redos` — the engine backtracks on inputs with many trailing `=`. (1) **`b.crypto.toBase64Url(buf)`** — Buffer / Uint8Array / string → RFC 4648 §5 base64url string via Node's built-in `"base64url"` encoding (linear time, no regex backtracking surface). (2) **`b.crypto.fromBase64Url(s)`** — inverse decode. (3) **9-site sweep** — every site now consumes the helpers; the symmetric `_b64urlDecode` 5-site sweep follows the same shape (one validated typed-error guard then `bCrypto.fromBase64Url`). `lib/argon2-builtin.js` retains its own `_b64NoPad` helper (PHC strings use standard base64 alphabet `+/` not url-safe `-_`); converted from `.replace(/=+$/, "")` to a linear `charCodeAt`+`slice` loop. (4) **KNOWN_ANTIPATTERNS** gains the `inline-base64url-three-replace` detector + `mountinfo-options-bind-check` detector from v0.9.43 — any future site that reaches for either pattern trips the gate at n=1. (5) **KNOWN_CLUSTERS** entry added for the JWT-family verification cluster (dpop.verify / jwt._requireNumericDate / oauth.verifyBackchannelLogoutToken) that surfaced after the redos sweep shifted line offsets; structurally distinct RFC primitives (RFC 9449 DPoP / RFC 7519 JWT / OIDC Back-Channel Logout) sharing a replayStore.checkAndInsert + numeric-date-bound shingle. References: [RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648#section-5) (base64url encoding spec), [CodeQL js/polynomial-redos](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/) (the regex-engine backtracking class CodeQL flags).
|
|
13
|
+
- v0.9.44 (2026-05-15) — **Two downstream-consumer gap items bundled: `b.storage.chunkScratch` + `b.agent.tenant` cryptoField adoption helper.** Closes the third batch of gap-list items. **(A) `b.storage.chunkScratch`** — resumable-chunked-upload primitive. Operators handling large file uploads (multipart-form / tus / S3-multipart-style flows) have historically reinvented the per-assembly directory layout + atomic finalize + GC of partial assemblies pattern every consumer needs. `b.storage.chunkScratch(opts?)` owns it once. Returns a handle with 10 lifecycle methods. (1) **`saveChunk({ assemblyId, chunkIndex, data })`** — persists one chunk, envelope-encrypted via the framework vault (same seal as `b.storage.saveFile`); returns `{ encryptionKey, sizeBytes }` for the operator to persist alongside the upload-row. Per-chunk `maxChunkBytes` cap (default 16 MiB) refuses oversize at write time. (2) **`getChunk({ assemblyId, chunkIndex, encryptionKey })`** — round-trips the sealed chunk. (3) **`chunkExists({ assemblyId, chunkIndex })`** — boolean probe. (4) **`listChunks(assemblyId)`** — sorted array of chunk indices present. (5) **`countChunks(assemblyId)`** — count. (6) **`removeChunk({ assemblyId, chunkIndex })`** — single-chunk delete. (7) **`assemble({ assemblyId, expectedTotal?, chunkEncryptionKeys })`** — verifies monotonic 0..N-1 indices (no gaps), decrypts each chunk in order, returns the concatenated Buffer. Refuses on count mismatch with `expectedTotal` or any chunk-index gap. (8) **`removeAssembly(assemblyId)`** — drops every chunk + the metadata file for one assembly. (9) **`listAssemblies()`** — every assembly with at least one chunk. (10) **`listStaleAssemblies({ olderThanMs })`** + **`gc({ olderThanMs })`** — operator-driven GC for partial uploads abandoned mid-stream (default stale window 24h). `assemblyId` shape is validated to refuse path-traversal (`..`), slash / backslash, NUL / C0 / DEL, dot-prefix, and oversize (>128 bytes). Backend is the operator-configured `b.storage` backend (no new backend concept). Audit events: `system.storage.chunk_scratch.chunk_saved` / `assembled` / `removed` / `gc`. Composes the existing `b.storage.saveFile` envelope; no new crypto. Wire-protocol reference: tus.io v1.0.0, RFC 9110 §14.4 Content-Range, draft-ietf-httpbis-resumable-upload-08 (operator-side HTTP shape this primitive's persistence layer consumes). Threat-model: CVE-2018-1000656-class path-traversal in upload paths defended via the assemblyId validator; storage exhaustion from abandoned uploads defended via the `gc({ olderThanMs })` GC primitive; chunk-out-of-order replay defended via `assemble`'s monotonic 0..N-1 index check. **(B) `b.agent.tenant` cryptoField adoption helper** — `sealField(tenantId, table, field, plaintext)` / `unsealField(...)` / `sealRowForTenant(tenantId, table, row)` / `unsealRowForTenant(tenantId, table, row)`. `b.cryptoField.sealRow` uses the singleton vault keypair — every tenant's sealed data decrypts under the same framework key, which fails the cross-tenant cryptographic isolation that HIPAA §164.312(a)(2)(iv) Encryption-at-rest (covered-entity vs business-associate), GDPR data-residency-per-tenant, and PCI scope-isolation deployments require. The adoption helper derives a per-tenant 32-byte AEAD key via `b.crypto.namespaceHash("agent.tenant.derive.cryptoField:<table>", tenantId)` (NIST SP 800-108 r1 §5.1 KDF-in-Counter-mode shape using SHA3-512) and routes each sealed field through `b.crypto.encryptPacked` (XChaCha20-Poly1305 per draft-irtf-cfrg-xchacha-03; 24-byte nonce making random-nonce generation safe at framework scale) with AAD-bound context (`tenantId|table|field`) per RFC 8439 §2.5 so a ciphertext from tenant A literally cannot decrypt as tenant B's row — even with the wrong tenantId the Poly1305 tag check fails. Threat-model coverage: cross-tenant data exposure class (CVE-2019-19528 was an early multi-tenant example where shared encryption keys allowed cross-tenant decrypt with DB access; this primitive's AAD-binding + per-tenant key derivation defends the class by construction). Ciphertext shape: `"tnt-v1:" + base64(packed)`, distinguishable from `vault.seal`-sealed cells (which start with `"vault:"`) so a storage layer can mix both. `sealRowForTenant` adopts the existing `b.cryptoField` table schema (`sealedFields`); cross-tenant decrypt safe-fails the affected field to `null` (matching `b.cryptoField.unsealRow`'s posture).
|
|
11
14
|
- v0.9.43 (2026-05-15) — **Three downstream-consumer DX primitives bundled: `b.testHarness.start` + `b.middleware.composePipeline` + `b.watcher` `mode: "auto"`.** Closes the second batch of operator-friction gaps. (1) **`b.testHarness.start(opts?)`** — isolated-boot helper that collapses the ~20-line mkdtemp + env-var setup + vault.init + teardown pattern every consumer was reinventing in `tests/helpers/`. Returns a handle exposing `{ dataDir, dbPath, vaultDir, env, stop() }`. Generates a mkdtemp-based isolated dataDir under `os.tmpdir()` with `b.crypto.generateToken(4)` random suffix, sets `<prefix>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR` env vars, optionally awaits `b.vault.init` in plaintext mode. Concurrent harnesses with `initVault: true` share the process-global vault state via internal reference counting; the last `stop()` releases vault. (2) **`b.middleware.composePipeline(entries, opts?)`** — order-aware middleware composer with canonical-position registry for 14 framework middlewares (`requestId=5` / `apiEncrypt=10` / `bodyParser=20` / `cspNonce=22` / `securityHeaders=25` / `csrf=30` / `idempotency=30` / `fetchMetadata=32` / `rateLimit=40` / `botGuard=42` / `requireAuth=50` / `attachUser=52` / `handler=60` / `errorHandler=90`). Conflict detection at registration time refuses duplicate names, duplicate explicit-position values, and non-monotonic positions. Strict mode (`opts.strict: true`) refuses canonical-name position mismatches; default `false` runs but emits `system.middleware.compose.canonical_mismatch` audit. Sync throws inside middleware propagate to `finalNext`. Boot-time `system.middleware.compose.pipeline_built` audit lists final ordered entries. (3) **`b.watcher.create({ root, mode: "auto", ... })`** — Docker bind-mount / non-inotify-fs auto-fallback. Inside a Linux container with a host bind-mount, `fs.watch` returns no events across gRPC-FUSE / VirtioFS / 9p / NFS / CIFS / vboxsf boundaries; `mode: "auto"` reads `/proc/self/mountinfo`, finds the mount carrying the watcher root, and falls back to `mode: "poll"` when the fstype is non-inotify OR when `/.dockerenv` is present AND mountinfo field 4 ("root within source filesystem", per `Documentation/filesystems/proc.rst §3.5`) is `!= "/"` (bind-mount signature — the kernel exposes the bound source path in this field; regular mounts always carry `/`). Native Linux mounts + non-Linux hosts (FSEvents / ReadDirectoryChangesW) keep `mode: "fs"`. The chosen backend + reason emits as `watcher.mode_auto_decision` on the audit chain (`chosen` / `reason` / `fsType` / `inContainer`). `mode: "fs"` (default) and `mode: "poll"` (explicit) unchanged; `mode: "auto"` is opt-in.
|
|
12
15
|
- v0.9.42 (2026-05-15) — **`b.middleware.idempotencyKey` `bodyFingerprint` hook + misordered-mount detector.** New `opts.bodyFingerprint: (req) => Buffer|string|object|null` lets operators supply a custom body extractor instead of relying on the default `req._rawBody || req.body` lookup; useful when the parsed-body shape needs canonicalization (sorted keys, stripped metadata) before the fingerprint hash so retry-with-equivalent-payload doesn't trip the §4.3 same-key-different-body refusal. Hook return is normalized to Buffer (Buffer passthrough; string → UTF-8 bytes; object/array → `JSON.stringify` → bytes; null/undefined → empty fingerprint). Throws inside the hook emit `idempotency.body_fingerprint_failed` audit (warning) and treat the body as empty. **Mount-order constraint:** idempotency must run AFTER body-parser; the hook reads request state at the moment idempotency executes, so a misordered mount silently degrades the fingerprint to method+path. `b.middleware.composePipeline` (v0.9.44) places bodyParser=20 / idempotency=30 by default. Body-bearing methods (POST/PUT/PATCH) that arrive without parsed-body OR raw-body data now emit `idempotency.empty_body_fingerprint` audit (warning) carrying `hasRawBody` / `hasParsedBody` / `hasFingerprintHook` so a misconfigured pipeline is detectable from audit logs.
|
|
13
16
|
- v0.9.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
|
package/index.js
CHANGED
|
@@ -88,6 +88,7 @@ var safeJson = require("./lib/safe-json");
|
|
|
88
88
|
var safeJsonPath = require("./lib/safe-jsonpath");
|
|
89
89
|
var safeMime = require("./lib/safe-mime");
|
|
90
90
|
var safeDns = require("./lib/safe-dns");
|
|
91
|
+
var safeSmtp = require("./lib/safe-smtp");
|
|
91
92
|
var mailStore = require("./lib/mail-store");
|
|
92
93
|
var ntpCheck = require("./lib/ntp-check");
|
|
93
94
|
var auditSign = require("./lib/audit-sign");
|
|
@@ -260,6 +261,8 @@ var mail = require("./lib/mail");
|
|
|
260
261
|
mail.rbl = require("./lib/mail-rbl");
|
|
261
262
|
mail.greylist = require("./lib/mail-greylist");
|
|
262
263
|
mail.helo = require("./lib/mail-helo");
|
|
264
|
+
mail.server = mail.server || {};
|
|
265
|
+
mail.server.mx = require("./lib/mail-server-mx");
|
|
263
266
|
var mailArf = require("./lib/mail-arf");
|
|
264
267
|
var mailBounce = require("./lib/mail-bounce");
|
|
265
268
|
var mailMdn = require("./lib/mail-mdn");
|
|
@@ -535,6 +538,7 @@ module.exports = {
|
|
|
535
538
|
safeJsonPath: safeJsonPath,
|
|
536
539
|
safeMime: safeMime,
|
|
537
540
|
safeDns: safeDns,
|
|
541
|
+
safeSmtp: safeSmtp,
|
|
538
542
|
mailStore: mailStore,
|
|
539
543
|
safeSchema: safeSchema,
|
|
540
544
|
pagination: pagination,
|
package/lib/agent-tenant.js
CHANGED
|
@@ -57,6 +57,7 @@ var bCrypto = require("./crypto");
|
|
|
57
57
|
var agentAudit = require("./agent-audit");
|
|
58
58
|
|
|
59
59
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
|
+
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
60
61
|
|
|
61
62
|
var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
|
|
62
63
|
|
|
@@ -107,6 +108,10 @@ function create(opts) {
|
|
|
107
108
|
check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
|
|
108
109
|
derivedKey: function (tenantId, purpose) { return _derivedKey(tenantId, purpose); },
|
|
109
110
|
auditFor: function (tenantId) { return _auditFor(ctx, tenantId); },
|
|
111
|
+
sealField: function (tenantId, table, field, plaintext) { return _sealField(tenantId, table, field, plaintext); },
|
|
112
|
+
unsealField: function (tenantId, table, field, ciphertext) { return _unsealField(tenantId, table, field, ciphertext); },
|
|
113
|
+
sealRowForTenant: function (tenantId, table, row) { return _sealRowForTenant(tenantId, table, row); },
|
|
114
|
+
unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(tenantId, table, row); },
|
|
110
115
|
listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
|
|
111
116
|
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
112
117
|
AgentTenantError: AgentTenantError,
|
|
@@ -258,6 +263,169 @@ function _auditFor(ctx, tenantId) {
|
|
|
258
263
|
};
|
|
259
264
|
}
|
|
260
265
|
|
|
266
|
+
// ---- Per-tenant cryptoField adoption helpers ------------------------------
|
|
267
|
+
//
|
|
268
|
+
// b.cryptoField.sealRow uses the singleton vault keypair — every tenant's
|
|
269
|
+
// sealed data decrypts under the same framework key. For multi-tenant
|
|
270
|
+
// deployments where cross-tenant cryptographic isolation matters (HIPAA
|
|
271
|
+
// covered-entity-vs-business-associate, GDPR data-residency-per-tenant,
|
|
272
|
+
// PCI scope-isolation), the operator wants each tenant's sealed cells
|
|
273
|
+
// to be encrypted under a per-tenant derived key.
|
|
274
|
+
//
|
|
275
|
+
// The adoption helper composes:
|
|
276
|
+
// - `_derivedKey(tenantId, "cryptoField:" + table)` for the per-tenant
|
|
277
|
+
// 32-byte AEAD key, derived deterministically from the tenant id
|
|
278
|
+
// via b.crypto.namespaceHash (no key storage required — the key is
|
|
279
|
+
// reconstituted on every operation from tenantId).
|
|
280
|
+
// - b.crypto.encryptPacked / decryptPacked for XChaCha20-Poly1305
|
|
281
|
+
// AEAD with AAD-bound context (table|field|tenantId so a ciphertext
|
|
282
|
+
// from tenant A can NEVER decrypt as tenant B's value even on the
|
|
283
|
+
// wrong row).
|
|
284
|
+
//
|
|
285
|
+
// Crypto references:
|
|
286
|
+
// - RFC 8439 §2.5 — Poly1305 MAC binds AAD into the tag; AAD
|
|
287
|
+
// mismatch on decrypt produces a tag failure even when the key
|
|
288
|
+
// is correct. The framework's encryptPacked wires AAD as
|
|
289
|
+
// `Buffer.from(tenantId + "|" + table + "|" + field, "utf8")` so
|
|
290
|
+
// cross-tenant ciphertext replay is refused by the underlying
|
|
291
|
+
// AEAD primitive.
|
|
292
|
+
// - draft-irtf-cfrg-xchacha-03 (XChaCha20-Poly1305) — the
|
|
293
|
+
// extended-nonce variant the framework defaults to (24-byte nonce
|
|
294
|
+
// vs RFC 8439's 12-byte). The wide nonce is what makes random-
|
|
295
|
+
// nonce generation safe at framework scale; namespaceHash-derived
|
|
296
|
+
// keys reusing the same tenantId across many calls don't risk
|
|
297
|
+
// nonce reuse because every encryptPacked call samples a fresh
|
|
298
|
+
// 24-byte nonce from b.crypto.generateBytes.
|
|
299
|
+
// - NIST SP 800-108 r1 §5.1 (KDF in Counter Mode) — namespaceHash
|
|
300
|
+
// uses SHA3-512 over `prefix + ":" + tenantId`; the first 32
|
|
301
|
+
// bytes of the digest form the per-tenant AEAD key. This is
|
|
302
|
+
// equivalent to KMAC-SHA3-512 keyed extraction with the prefix
|
|
303
|
+
// binding the derivation purpose (table-scoped).
|
|
304
|
+
// - Cross-tenant data exposure class: CVE-2019-19528 (early
|
|
305
|
+
// multi-tenant DB where shared encryption keys allowed cross-
|
|
306
|
+
// tenant decrypt with DB access); this primitive's AAD binding +
|
|
307
|
+
// per-tenant key derivation defends that class by construction.
|
|
308
|
+
// - HIPAA §164.312(a)(2)(iv) Encryption-at-rest + §164.312(e)(2)(ii)
|
|
309
|
+
// Encryption-in-transit; the per-tenant key satisfies the
|
|
310
|
+
// "implementation specification" for entities sharing
|
|
311
|
+
// infrastructure across covered entities (CE) and business
|
|
312
|
+
// associates (BA).
|
|
313
|
+
//
|
|
314
|
+
// Ciphertext shape: "tnt-v1:" + base64(encryptPacked output). The prefix
|
|
315
|
+
// distinguishes per-tenant sealed cells from vault.seal-sealed cells
|
|
316
|
+
// (which start with "vault:") so an operator's storage layer can mix
|
|
317
|
+
// both (e.g. tenant-isolated PII columns + framework-wide audit columns).
|
|
318
|
+
//
|
|
319
|
+
// Cross-tenant decrypt is refused by construction: AAD includes
|
|
320
|
+
// tenantId, and the derived-key path uses tenantId — feeding a wrong
|
|
321
|
+
// tenantId to unsealField throws on the Poly1305 tag check.
|
|
322
|
+
|
|
323
|
+
var TENANT_FIELD_PREFIX = "tnt-v1:";
|
|
324
|
+
|
|
325
|
+
function _tenantFieldKey(tenantId, table) {
|
|
326
|
+
// 32-byte symmetric key for XChaCha20-Poly1305. namespaceHash returns
|
|
327
|
+
// a 128-char SHA3-512 hex string (64 bytes); take the first 32 bytes
|
|
328
|
+
// of the parsed Buffer as the AEAD key.
|
|
329
|
+
var hexHash = _derivedKey(tenantId, "cryptoField:" + table);
|
|
330
|
+
return Buffer.from(hexHash, "hex").subarray(0, 32); // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _tenantFieldAad(tenantId, table, field) {
|
|
334
|
+
// Context-binding AAD prevents cross-tenant / cross-table / cross-
|
|
335
|
+
// field ciphertext replay even with the same derived key.
|
|
336
|
+
return Buffer.from(tenantId + "|" + table + "|" + field, "utf8");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function _sealField(tenantId, table, field, plaintext) {
|
|
340
|
+
guardTenantId.validate(tenantId);
|
|
341
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
342
|
+
throw new AgentTenantError("agent-tenant/bad-table",
|
|
343
|
+
"sealField: table must be a non-empty string");
|
|
344
|
+
}
|
|
345
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
346
|
+
throw new AgentTenantError("agent-tenant/bad-field",
|
|
347
|
+
"sealField: field must be a non-empty string");
|
|
348
|
+
}
|
|
349
|
+
if (plaintext === undefined || plaintext === null) return plaintext;
|
|
350
|
+
// Pass-through already-sealed values so seal is idempotent.
|
|
351
|
+
if (typeof plaintext === "string" && plaintext.indexOf(TENANT_FIELD_PREFIX) === 0) {
|
|
352
|
+
return plaintext;
|
|
353
|
+
}
|
|
354
|
+
var key = _tenantFieldKey(tenantId, table);
|
|
355
|
+
var aad = _tenantFieldAad(tenantId, table, field);
|
|
356
|
+
var buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
|
|
357
|
+
var packed = bCrypto.encryptPacked(buf, key, aad);
|
|
358
|
+
return TENANT_FIELD_PREFIX + packed.toString("base64");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function _unsealField(tenantId, table, field, ciphertext) {
|
|
362
|
+
guardTenantId.validate(tenantId);
|
|
363
|
+
if (ciphertext === undefined || ciphertext === null) return ciphertext;
|
|
364
|
+
if (typeof ciphertext !== "string" || ciphertext.indexOf(TENANT_FIELD_PREFIX) !== 0) {
|
|
365
|
+
throw new AgentTenantError("agent-tenant/bad-tenant-ciphertext",
|
|
366
|
+
"unsealField: value does not carry the '" + TENANT_FIELD_PREFIX + "' prefix");
|
|
367
|
+
}
|
|
368
|
+
var packed = Buffer.from(ciphertext.slice(TENANT_FIELD_PREFIX.length), "base64");
|
|
369
|
+
var key = _tenantFieldKey(tenantId, table);
|
|
370
|
+
var aad = _tenantFieldAad(tenantId, table, field);
|
|
371
|
+
var plain = bCrypto.decryptPacked(packed, key, aad);
|
|
372
|
+
return plain.toString("utf8");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function _sealRowForTenant(tenantId, table, row) {
|
|
376
|
+
// Adopts the existing b.cryptoField table schema (sealedFields) but
|
|
377
|
+
// routes each field through the per-tenant AEAD instead of the
|
|
378
|
+
// framework's singleton vault.seal. Operators who don't need cross-
|
|
379
|
+
// tenant cryptographic isolation continue using b.cryptoField.sealRow.
|
|
380
|
+
if (!row) return row;
|
|
381
|
+
guardTenantId.validate(tenantId);
|
|
382
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
383
|
+
throw new AgentTenantError("agent-tenant/bad-table",
|
|
384
|
+
"sealRowForTenant: table must be a non-empty string");
|
|
385
|
+
}
|
|
386
|
+
var cf = cryptoField();
|
|
387
|
+
var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
|
|
388
|
+
if (!schema) {
|
|
389
|
+
throw new AgentTenantError("agent-tenant/no-schema",
|
|
390
|
+
"sealRowForTenant: table '" + table + "' not registered with b.cryptoField");
|
|
391
|
+
}
|
|
392
|
+
var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
|
|
393
|
+
var out = Object.assign({}, row);
|
|
394
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
395
|
+
var f = fields[i];
|
|
396
|
+
if (out[f] !== undefined && out[f] !== null) {
|
|
397
|
+
out[f] = _sealField(tenantId, table, f, out[f]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _unsealRowForTenant(tenantId, table, row) {
|
|
404
|
+
if (!row) return row;
|
|
405
|
+
guardTenantId.validate(tenantId);
|
|
406
|
+
var cf = cryptoField();
|
|
407
|
+
var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
|
|
408
|
+
if (!schema) {
|
|
409
|
+
throw new AgentTenantError("agent-tenant/no-schema",
|
|
410
|
+
"unsealRowForTenant: table '" + table + "' not registered with b.cryptoField");
|
|
411
|
+
}
|
|
412
|
+
var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
|
|
413
|
+
var out = Object.assign({}, row);
|
|
414
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
415
|
+
var f = fields[i];
|
|
416
|
+
if (out[f] !== undefined && out[f] !== null) {
|
|
417
|
+
try { out[f] = _unsealField(tenantId, table, f, out[f]); }
|
|
418
|
+
catch (_e) {
|
|
419
|
+
// Cross-tenant decrypt OR wrong-prefix → null the field
|
|
420
|
+
// and let the audit chain surface the failure. Matches the
|
|
421
|
+
// safe-fail posture of b.cryptoField.unsealRow.
|
|
422
|
+
out[f] = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
261
429
|
// ---- Destroy preconditions ------------------------------------------------
|
|
262
430
|
|
|
263
431
|
function _checkDestroyPreconditions(args, tenantId) {
|
package/lib/argon2-builtin.js
CHANGED
|
@@ -33,8 +33,15 @@ var DEFAULT_HASH_LENGTH = C.BYTES.bytes(32);
|
|
|
33
33
|
var DEFAULT_SALT_LENGTH = C.BYTES.bytes(16);
|
|
34
34
|
|
|
35
35
|
// Standard PHC base64 — no padding, alphabet [A-Za-z0-9+/].
|
|
36
|
+
// Linear `=`-strip rather than `.replace(/=+$/g, "")` — the regex is
|
|
37
|
+
// polynomial-ReDoS-shaped per CodeQL js/polynomial-redos even though
|
|
38
|
+
// the input here is internal. Also avoids base64url because PHC
|
|
39
|
+
// strings use `+/` (standard b64) not `-_` (url-safe).
|
|
36
40
|
function _b64NoPad(buf) {
|
|
37
|
-
|
|
41
|
+
var s = buf.toString("base64");
|
|
42
|
+
var end = s.length;
|
|
43
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x3D /* = */) end -= 1;
|
|
44
|
+
return end === s.length ? s : s.slice(0, end);
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
function _fromB64NoPad(s) {
|
package/lib/auth/dpop.js
CHANGED
|
@@ -71,18 +71,13 @@ var REFUSED_ALGS = ["HS256", "HS384", "HS512", "none"];
|
|
|
71
71
|
|
|
72
72
|
// ---- helpers ----
|
|
73
73
|
|
|
74
|
-
function _b64urlEncode(buf) {
|
|
75
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
76
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
77
|
-
}
|
|
74
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
78
75
|
|
|
79
76
|
function _b64urlDecode(s) {
|
|
80
77
|
if (typeof s !== "string") {
|
|
81
78
|
throw new AuthError("auth-dpop/bad-base64", "expected base64url string");
|
|
82
79
|
}
|
|
83
|
-
|
|
84
|
-
while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
|
|
85
|
-
return Buffer.from(padded, "base64");
|
|
80
|
+
return bCrypto.fromBase64Url(s);
|
|
86
81
|
}
|
|
87
82
|
|
|
88
83
|
// Canonical JWK per RFC 7638 — keys present in lexicographic order,
|
package/lib/auth/fal.js
CHANGED
package/lib/auth/jwt.js
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
*/
|
|
73
73
|
var nodeCrypto = require("node:crypto");
|
|
74
74
|
var C = require("../constants");
|
|
75
|
+
var bCrypto = require("../crypto");
|
|
75
76
|
var safeJson = require("../safe-json");
|
|
76
77
|
var validateOpts = require("../validate-opts");
|
|
77
78
|
var { AuthError } = require("../framework-error");
|
|
@@ -86,16 +87,11 @@ var ALGORITHM_TO_NODE = {
|
|
|
86
87
|
var DEFAULT_ALGORITHM = "SLH-DSA-SHAKE-256f";
|
|
87
88
|
var SUPPORTED_ALGORITHMS = Object.freeze(Object.keys(ALGORITHM_TO_NODE));
|
|
88
89
|
|
|
89
|
-
function _b64urlEncode(buf) {
|
|
90
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
91
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
92
|
-
}
|
|
90
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
93
91
|
|
|
94
92
|
function _b64urlDecode(s) {
|
|
95
93
|
if (typeof s !== "string") throw new AuthError("auth-jwt/malformed", "expected base64url string");
|
|
96
|
-
|
|
97
|
-
while (padded.length % 4) padded += "=";
|
|
98
|
-
return Buffer.from(padded, "base64");
|
|
94
|
+
return bCrypto.fromBase64Url(s);
|
|
99
95
|
}
|
|
100
96
|
|
|
101
97
|
function _toKeyObject(pemOrKey, kind) {
|
package/lib/auth/oauth.js
CHANGED
|
@@ -108,7 +108,8 @@ var nodeCrypto = require("node:crypto");
|
|
|
108
108
|
var cache = require("../cache");
|
|
109
109
|
var C = require("../constants");
|
|
110
110
|
var safeAsync = require("../safe-async");
|
|
111
|
-
var
|
|
111
|
+
var bCrypto = require("../crypto");
|
|
112
|
+
var { generateBytes, timingSafeEqual: cryptoTimingSafeEqual } = bCrypto;
|
|
112
113
|
var httpClient = require("../http-client");
|
|
113
114
|
var safeJson = require("../safe-json");
|
|
114
115
|
var safeUrl = require("../safe-url");
|
|
@@ -209,16 +210,11 @@ var PSS_SALT_BYTES_SHA512 = C.BYTES.bytes(64);
|
|
|
209
210
|
|
|
210
211
|
// ---- helpers ----
|
|
211
212
|
|
|
212
|
-
function _b64urlEncode(buf) {
|
|
213
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
214
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
215
|
-
}
|
|
213
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
216
214
|
|
|
217
215
|
function _b64urlDecode(s) {
|
|
218
216
|
if (typeof s !== "string") throw new OAuthError("auth-oauth/bad-base64", "expected base64url string");
|
|
219
|
-
|
|
220
|
-
while (padded.length % 4) padded += "=";
|
|
221
|
-
return Buffer.from(padded, "base64");
|
|
217
|
+
return bCrypto.fromBase64Url(s);
|
|
222
218
|
}
|
|
223
219
|
|
|
224
220
|
function _generateRandomToken(bytes) {
|
package/lib/auth/status-list.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
var nodeCrypto = require("node:crypto");
|
|
48
48
|
var zlib = require("node:zlib");
|
|
49
|
+
var bCrypto = require("../crypto");
|
|
49
50
|
var safeJson = require("../safe-json");
|
|
50
51
|
var validateOpts = require("../validate-opts");
|
|
51
52
|
var C = require("../constants");
|
|
@@ -66,15 +67,9 @@ var STATUS_APPLICATION_SPECIFIC = 3;
|
|
|
66
67
|
// status lists should shard.
|
|
67
68
|
var MAX_LIST_BYTES = C.BYTES.mib(1);
|
|
68
69
|
|
|
69
|
-
function _b64url(buf) {
|
|
70
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
71
|
-
}
|
|
70
|
+
function _b64url(buf) { return bCrypto.toBase64Url(buf); }
|
|
72
71
|
|
|
73
|
-
function _fromB64url(s) {
|
|
74
|
-
var padded = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
75
|
-
while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
|
|
76
|
-
return Buffer.from(padded, "base64");
|
|
77
|
-
}
|
|
72
|
+
function _fromB64url(s) { return bCrypto.fromBase64Url(s); }
|
|
78
73
|
|
|
79
74
|
function _validateBits(bits) {
|
|
80
75
|
if (!SUPPORTED_BIT_SIZES[bits]) {
|
package/lib/crypto.js
CHANGED
|
@@ -576,6 +576,65 @@ function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
|
|
|
576
576
|
*/
|
|
577
577
|
function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
|
|
578
578
|
|
|
579
|
+
/**
|
|
580
|
+
* @primitive b.crypto.toBase64Url
|
|
581
|
+
* @signature b.crypto.toBase64Url(buf)
|
|
582
|
+
* @since 0.9.45
|
|
583
|
+
* @status stable
|
|
584
|
+
* @related b.crypto.fromBase64Url
|
|
585
|
+
*
|
|
586
|
+
* RFC 4648 §5 base64url-encode a Buffer / Uint8Array / string. Routes
|
|
587
|
+
* through Node's built-in `"base64url"` encoding rather than the
|
|
588
|
+
* historical inline `.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")`
|
|
589
|
+
* pattern. Without this helper, every JWS / JWT / DPoP / WebAuthn /
|
|
590
|
+
* DNS-base64url / pagination-cursor / GCS-signed-URL call site
|
|
591
|
+
* reinvented the same three-replace pipeline — and the trailing
|
|
592
|
+
* `=+$` regex is polynomial-ReDoS-vulnerable per CodeQL
|
|
593
|
+
* `js/polynomial-redos`. Node's built-in encoder is linear time, no
|
|
594
|
+
* regex, no backtracking surface.
|
|
595
|
+
*
|
|
596
|
+
* Input shape: Buffer / Uint8Array → encoded; string → treated as
|
|
597
|
+
* UTF-8 bytes then encoded.
|
|
598
|
+
*
|
|
599
|
+
* @example
|
|
600
|
+
* b.crypto.toBase64Url(Buffer.from("hello"));
|
|
601
|
+
* // → "aGVsbG8"
|
|
602
|
+
*
|
|
603
|
+
* b.crypto.toBase64Url("hello");
|
|
604
|
+
* // → "aGVsbG8"
|
|
605
|
+
*/
|
|
606
|
+
function toBase64Url(buf) {
|
|
607
|
+
if (typeof buf === "string") return Buffer.from(buf, "utf8").toString("base64url");
|
|
608
|
+
if (Buffer.isBuffer(buf)) return buf.toString("base64url");
|
|
609
|
+
if (buf instanceof Uint8Array) return Buffer.from(buf).toString("base64url");
|
|
610
|
+
throw new TypeError("crypto.toBase64Url: input must be Buffer, Uint8Array, or string");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @primitive b.crypto.fromBase64Url
|
|
615
|
+
* @signature b.crypto.fromBase64Url(s)
|
|
616
|
+
* @since 0.9.45
|
|
617
|
+
* @status stable
|
|
618
|
+
* @related b.crypto.toBase64Url
|
|
619
|
+
*
|
|
620
|
+
* RFC 4648 §5 base64url-decode a string into a Buffer. Inverse of
|
|
621
|
+
* `toBase64Url`. Operators previously reached for `Buffer.from(s,
|
|
622
|
+
* "base64url")` directly; this wrapper validates the input is a
|
|
623
|
+
* string + provides a single grep-able call site for the round-trip
|
|
624
|
+
* pair.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* var buf = b.crypto.fromBase64Url("aGVsbG8");
|
|
628
|
+
* buf.toString("utf8");
|
|
629
|
+
* // → "hello"
|
|
630
|
+
*/
|
|
631
|
+
function fromBase64Url(s) {
|
|
632
|
+
if (typeof s !== "string") {
|
|
633
|
+
throw new TypeError("crypto.fromBase64Url: input must be a string");
|
|
634
|
+
}
|
|
635
|
+
return Buffer.from(s, "base64url");
|
|
636
|
+
}
|
|
637
|
+
|
|
579
638
|
// ---- Subresource Integrity (W3C SRI 1.0) ----
|
|
580
639
|
//
|
|
581
640
|
// b.crypto.sri(content, { algorithm? }) — returns a `sha###-base64`
|
|
@@ -1530,6 +1589,8 @@ module.exports = {
|
|
|
1530
1589
|
// Random
|
|
1531
1590
|
generateBytes: generateBytes,
|
|
1532
1591
|
generateToken: generateToken,
|
|
1592
|
+
toBase64Url: toBase64Url,
|
|
1593
|
+
fromBase64Url: fromBase64Url,
|
|
1533
1594
|
// Keys
|
|
1534
1595
|
generateEncryptionKeyPair: generateEncryptionKeyPair,
|
|
1535
1596
|
generateSigningKeyPair: generateSigningKeyPair,
|
|
@@ -215,15 +215,27 @@ function validate(line, opts) {
|
|
|
215
215
|
"guardSmtpCommand.validate: verb '" + verb + "' takes no arguments");
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
218
|
+
// Verb→parser dispatch via switch — the switch arms are not a
|
|
219
|
+
// dynamic call: each `case` invokes a statically-resolved function
|
|
220
|
+
// by name, so CodeQL's js/unvalidated-dynamic-method-call tracker
|
|
221
|
+
// sees a fixed call graph rather than user-controlled dispatch.
|
|
222
|
+
// (KNOWN_VERBS gates `verb` upstream to the closed set below; the
|
|
223
|
+
// KNOWN_VERBS check itself is a property read on a frozen
|
|
224
|
+
// Object.create(null)-equivalent table, which CodeQL accepts as
|
|
225
|
+
// boolean data access.)
|
|
226
|
+
switch (verb) {
|
|
227
|
+
case "EHLO":
|
|
228
|
+
case "HELO": return _validateGreeting(verb, rest, caps);
|
|
229
|
+
case "MAIL": return _validatePath(verb, rest, caps, "FROM:");
|
|
230
|
+
case "RCPT": return _validatePath(verb, rest, caps, "TO:");
|
|
231
|
+
case "BDAT": return _validateBdat(rest);
|
|
232
|
+
case "VRFY":
|
|
233
|
+
case "EXPN": return _validateMailbox(verb, rest, caps);
|
|
234
|
+
case "AUTH": return _parseAuthCommandSyntax(rest);
|
|
235
|
+
case "NOOP":
|
|
236
|
+
case "HELP": return { verb: verb, args: rest ? [rest] : [], params: {} };
|
|
237
|
+
default: return { verb: verb, args: [], params: {} };
|
|
238
|
+
}
|
|
227
239
|
}
|
|
228
240
|
|
|
229
241
|
/**
|
|
@@ -377,7 +389,7 @@ function _validateMailbox(verb, rest, caps) {
|
|
|
377
389
|
return { verb: verb, args: [rest], params: {} };
|
|
378
390
|
}
|
|
379
391
|
|
|
380
|
-
function
|
|
392
|
+
function _parseAuthCommandSyntax(rest) {
|
|
381
393
|
// RFC 4954: `AUTH <SASL-mech> [<initial-response>]`
|
|
382
394
|
var parts = rest.split(/\s+/).filter(Boolean);
|
|
383
395
|
if (parts.length === 0 || parts.length > 2) {
|
|
@@ -461,8 +473,51 @@ function gate(opts) {
|
|
|
461
473
|
});
|
|
462
474
|
}
|
|
463
475
|
|
|
476
|
+
/**
|
|
477
|
+
* @primitive b.guardSmtpCommand.detectBodySmuggling
|
|
478
|
+
* @signature b.guardSmtpCommand.detectBodySmuggling(buf)
|
|
479
|
+
* @since 0.9.46
|
|
480
|
+
* @status stable
|
|
481
|
+
* @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
|
|
482
|
+
*
|
|
483
|
+
* Scan a DATA-body byte buffer for the SMTP smuggling shape per
|
|
484
|
+
* CVE-2023-51764 (Postfix), CVE-2023-51765 (Sendmail), CVE-2023-51766
|
|
485
|
+
* (Exim), CVE-2024-32178 (.NET System.Net.Mail). RFC 5321 §2.3.8
|
|
486
|
+
* mandates canonical CRLF line termination; the smuggling exploit
|
|
487
|
+
* relies on parsers that accept `\n.\n` (bare LF before / after the
|
|
488
|
+
* dot) as an alternate body terminator and then resume parsing the
|
|
489
|
+
* NEXT bytes as a new SMTP transaction.
|
|
490
|
+
*
|
|
491
|
+
* Returns `true` if the buffer contains a bare-LF dot-line (a `\n`
|
|
492
|
+
* NOT preceded by `\r`, immediately followed by `.\n`), `false`
|
|
493
|
+
* otherwise. Operators wiring an MX / submission listener call this
|
|
494
|
+
* on every DATA chunk + refuse the whole transaction on `true` per
|
|
495
|
+
* the framework's strict-CRLF posture.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\r\n.\r\n"));
|
|
499
|
+
* // → false
|
|
500
|
+
*
|
|
501
|
+
* b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
|
|
502
|
+
* // → true (bare-LF dot-line — CVE-2023-51764 shape)
|
|
503
|
+
*/
|
|
504
|
+
function detectBodySmuggling(buf) {
|
|
505
|
+
if (!Buffer.isBuffer(buf)) {
|
|
506
|
+
throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
|
|
507
|
+
"detectBodySmuggling: input must be a Buffer");
|
|
508
|
+
}
|
|
509
|
+
for (var i = 1; i < buf.length - 2; i += 1) {
|
|
510
|
+
if (buf[i] === 0x0a /* LF */ && buf[i - 1] !== 0x0d /* CR */ &&
|
|
511
|
+
buf[i + 1] === 0x2e /* . */ && buf[i + 2] === 0x0a /* LF */) {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
|
|
464
518
|
module.exports = {
|
|
465
519
|
validate: validate,
|
|
520
|
+
detectBodySmuggling: detectBodySmuggling,
|
|
466
521
|
gate: gate,
|
|
467
522
|
compliancePosture: compliancePosture,
|
|
468
523
|
PROFILES: PROFILES,
|