@blamejs/core 0.15.67 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.16.x
10
+
11
+ - v0.16.0 (2026-07-03) — **Raises the minimum Node.js engine to 24.18.0; the status-list verifier now binds the token type against a typ-confusion replay; and CI supply-chain pinning is consolidated behind committed dev/example lockfiles, `npm ci`, and a single aggregating pin tool that closes the OpenSSF Scorecard pinned-dependency gaps.** This minor raises the supported Node.js floor from 24.16.0 to 24.18.0 — operators must run Node 24.18.0 or newer (24.17.0 was a security release; 24.18.0 bundles SQLite 3.53.1 and a backup-path fix, and guarantees the native OpenSSL 3.5 SLH-DSA surface). b.auth.statusList.fromJwt now enforces RFC 8725 §3.11 by binding the JWS header typ to the "statuslist+jwt" that toJwt stamps, so a token minted for another purpose can't be replayed as a status list. The build's supply-chain pinning is reworked so OpenSSF Scorecard sees pinned installs everywhere it can: the dev-tool, example, and fuzz manifests now ship committed lockfiles and CI installs them with `npm ci` (Scorecard keys pinning on the `ci` verb, not on an `@version` specifier), the vendored fuzz toolchain and the oss-fuzz base image are pinned, and a new scripts/pin-all.js aggregates every pin — regenerating all lockfiles, pinning the GitHub Action SHAs, and syncing the Docker base-image digests in one `--fix`, with a `--check` gate wired into CI and the release flow so no pin can silently drift. The committed lockfiles are dev/example-only and excluded from the published tarball; the zero-npm-RUNTIME-deps rule is unchanged. **Added:** *scripts/pin-all.js — aggregated supply-chain pin tool* — One command re-pins every supply-chain segment: `--fix` regenerates the committed lockfiles (root / examples/wiki / fuzz), pins the GitHub Action SHAs, and syncs the Docker base-image digests; `--check` (wired into CI and the release flow) verifies the lockfile and digest segments — including each lockfile's package version — are in sync and fails closed on drift. Contributor tooling — it does not ship in the tarball. · *Project-governance and assurance documentation; OpenSSF Best Practices silver badge* — Adds a ROADMAP.md (documented direction plus an explicit out-of-scope list), a Developer Certificate of Origin policy in CONTRIBUTING.md (contributions carry a Signed-off-by; releases sign off through the release tooling), and an explicit security assurance case in SECURITY.md (a top security claim decomposed into evidence-backed sub-claims, with a residual-risk statement). The project earned the OpenSSF Best Practices silver badge, linked from the README. **Changed:** *Minimum Node.js engine raised to 24.18.0* — engines.node is now >=24.18.0 (was >=24.16.0). Operators must run Node 24.18.0 or newer. 24.17.0 was a security release and 24.18.0 bundles SQLite 3.53.1 plus a backup-keepalive fix; the floor also guarantees the native OpenSSL 3.5 SLH-DSA sign/verify surface. CI, the release container, and the docs are updated to the new floor. · *Supply-chain pinning consolidated behind committed lockfiles + `npm ci` + one aggregating pin tool* — The root dev-tool (esbuild, postject), examples/wiki, and fuzz (jazzer.js) manifests now ship committed package-lock.json files, and CI installs them with `npm ci` instead of `npm install pkg@version` — OpenSSF Scorecard's Pinned-Dependencies check treats only the `npm ci` verb as pinned, so this is what actually clears those alerts (an exact `@version` specifier does not). The vendored fuzz toolchain is pinned to an exact jazzer version and the oss-fuzz base image is digest-pinned (mirroring the Dependabot-tracked .clusterfuzzlite digest). A new scripts/pin-all.js aggregates every pinning segment — it regenerates all committed lockfiles, pins the GitHub Action SHAs (via check-actions-currency), and syncs the Docker base-image digests in one `--fix`; a `--check` mode runs in CI and scripts/release.js so a stale lockfile or drifted digest fails closed. The committed lockfiles are dev/example-only, excluded from the published tarball's files allowlist; the zero-npm-RUNTIME-deps rule is unchanged. **Security:** *Status-list verification binds the token type (RFC 8725 §3.11 typ-confusion)* — b.auth.statusList.fromJwt now passes expectedTyp "statuslist+jwt" to the JWT verifier, matching the typ that b.auth.statusList.toJwt stamps and every sibling verifier enforces. A token minted for another purpose — even one that happens to carry a status_list.lst claim — is now refused on the type binding before the claim is read, closing the typ-confusion class where such a token could be replayed as a status list.
12
+
9
13
  ## v0.15.x
10
14
 
15
+ - v0.15.68 (2026-07-02) — **A security-hardening release: mail delivery-status / return-receipt / ARC builders reject CR, LF, and NUL in header-line fields (header injection); the XML canonicalizer's nesting cap now actually fires (stack-overflow DoS on deeply-nested SAML/WebDAV input); and the Idempotency-Key replay cache is scoped to the authenticated principal (cross-actor response disclosure) — plus archive/SRS byte hardening.** b.mail composes RFC 3464 delivery-status notifications (b.mail.send.deliver(...).buildDsn, b.mailBounce.dsn.build), RFC 8098 message-disposition notifications (b.mailMdn.build), and RFC 8617 ARC signatures (b.mail.arc.sign) by interpolating operator- and peer-supplied values — recipient addresses, the reporting-MTA name, the remote server's 5xx diagnostic reply, and the ARC authserv-id / domain / selector — into `Name: value` header lines. A value containing a CR or LF let a hostile original sender (whose message is being bounced) or a malicious peer MX (whose 5xx reply is echoed into the DSN) inject an extra header or forge an additional multipart/report part. Structured fields (addresses, MTA names, identifiers) are now rejected outright, and the free-text 5xx diagnostic — which is legitimately multi-line — is folded to a single line. The same class is closed in two adjacent places: tar and zip entry names refuse CR/LF (a bare LF in an over-long tar name flowed into the POSIX pax extended header, where a crafted name could forge a `path=` record that overrides the ustar name and escapes the extraction directory), and SRS envelope-from rewriting refuses control characters in the address it re-emits as a MAIL FROM. A new b.safeBuffer.assertHeaderSafe primitive centralizes the reject-CR/LF/NUL check every header builder now composes. **Added:** *b.safeBuffer.assertHeaderSafe(value, label, ErrorClass, code)* — Throws when a string bound for a CRLF-delimited protocol line contains CR, LF, or a NUL byte, in the caller's own error domain. Structured header fields (addresses, domains, identifiers, MTA names) compose it to reject. · *b.safeBuffer.foldHeaderText(value, replacement?)* — Folds free-text bound for a header line onto one line — replaces every CR and LF with the replacement (default a space) and removes every NUL. Used for values that may legitimately wrap, such as a multi-line SMTP 5xx reply echoed into a DSN diagnostic line, where rejecting would be too strict; unlike a plain CR/LF strip it also removes NUL, which is never valid in a header value and is treated specially by downstream mail parsers. **Changed:** *Refreshed the vendored public-suffix list and the pinned CI action SHAs* — The vendored Mozilla Public Suffix List (which backs registrable-domain boundaries in b.guardDomain and cookie-scope checks) is refreshed to its current upstream revision, and the SHA-pinned GitHub Actions in the release and scanning workflows are bumped to their latest upstream releases. No operator action required. · *Concurrent DNS cache misses are coalesced into a single upstream lookup* — b.network.dns.resolver now single-flights in-flight queries: when several callers miss the cache for the same name at once, one upstream lookup is issued and the others await its result, instead of each firing its own request (a cache stampede that amplified load on the upstream resolver). Each caller still applies its own DNSSEC validate gate against the shared answer. **Security:** *Delivery-status notifications refuse header-injecting fields; the 5xx diagnostic is folded* — b.mail.send.deliver(...).buildDsn and b.mailBounce.dsn.build build an RFC 3464 multipart/report message from the failing recipient, the original sender, the reporting-MTA name, the enhanced status code, and the remote server's 5xx diagnostic reply. The 5xx reply is attacker-influenceable (it comes from the peer MX) and the addresses can come from a message being bounced. A CR or LF in any of these smuggled a new header or forged a report part into a message the operator then relays. Structured fields now reject CR/LF/NUL (the composer throws rather than emit an ambiguous message), and the free-text diagnostic is folded to one line so its text is preserved without introducing a line break. · *Message-disposition notifications refuse header-injecting fields* — b.mailMdn.build places the final recipient, original recipient, original message-id, reporting user-agent, and the From/To/Subject of the return receipt on header lines. Because an MDN is generated in response to an inbound message, these fields can carry attacker-chosen content; a CR or LF injected a header into the receipt. Each structured field now rejects CR/LF/NUL before the message is built. · *ARC signing refuses CR/LF/NUL in the authentication-results identity* — b.mail.arc.sign already refused CR/LF in the authentication-results value, but the ARC authserv-id, signing domain, and selector — interpolated verbatim into the ARC-Authentication-Results, ARC-Seal, and ARC-Message-Signature header block — were only checked for emptiness. A CR or LF in one of them injected a header into the signed block. All three now reject CR/LF/NUL. · *Archive entry names refuse CR/LF, closing a pax path-override escape* — tar and zip entry names already rejected a NUL byte and `..` segments but not CR/LF. In a tar archive, a bare LF in an over-long name flowed into the POSIX pax extended header (`len key=value\n`); because a pax `path=` record overrides the ustar header's name, a crafted name could inject an absolute `path=` record and write outside the extraction directory — an escape the `..` check does not catch. Both the tar and zip name normalizers now reject CR/LF. · *SRS envelope rewriting refuses control characters in the address* — b.mail.srs rewrite / srs1Rewrite / reverse embed the address they are given into the SRS0/SRS1 envelope address that becomes a subsequent SMTP MAIL FROM. They now reject CR/LF/NUL in the input address so a malformed address cannot be smuggled into an envelope command. · *The XML canonicalizer's nesting-depth cap now actually fires (stack-overflow DoS)* — b.xmlC14n.parse — the recursive-descent parser behind SAML and WebDAV canonicalization — carried a documented 200-level nesting cap that was dead: the depth counter was a per-frame-local incremented then decremented around each single recursive call, so it never exceeded 1 at the check. Deeply-nested untrusted XML recursed unbounded and overflowed the stack, crashing the process — a pre-authentication denial of service for any endpoint that canonicalizes attacker-supplied XML. The depth is now threaded through the recursion and enforced at entry, so nesting past the cap is refused before it can exhaust the stack. · *The Idempotency-Key replay cache is scoped to the authenticated principal* — b.middleware.idempotencyKey keyed its replay/response cache slot solely on the client-supplied Idempotency-Key header, with no binding to the authenticated principal. Two principals presenting the same (shared or guessed) key collided: one was served the other's cached response (cross-actor disclosure), or an attacker could pre-seed a key to force a 422 on the real client (cross-actor denial). The slot is now scoped to the authenticated principal, resolved via b.requestHelpers.extractActorContext and overridable with the new opts.scopeFn. Mount the middleware after authentication so the request carries the principal; slots for unauthenticated requests share a single anonymous scope. · *Mail and data parsers reject reserved prototype keys in untrusted input* — b.csv.parse, b.mime-parse (Content-Type parameters), b.eat (Entity Attestation Token claims), b.mailBimi (tiny-PS attributes), b.mailArf (feedback-report fields), and b.safeIcap (ICAP response headers) built plain objects keyed by names taken directly from untrusted input. A key of __proto__, constructor, or prototype would shadow or re-parent the object, letting an attacker taint a downstream type check. Every one of these sites now refuses the reserved keys — CSV throws csv/forbidden-header (an operator DDL surface); the parsers that must stay lenient drop the poisoned key and keep parsing. · *Verifiable-credential JOSE verification binds the algorithm to the key's curve* — b.vc.verify / b.vc.verifyPresentation resolved the public key and then verified with the algorithm named in the attacker-controlled JWS header, with no check that the two agree. A header claiming ES384 (or EdDSA) could be verified against a P-256 key an ECDSA curve/type confusion (CWE-347, RFC 7518 §3.4). The verifier now rejects any alg whose required curve/key-type doesn't match the resolved key, before the signature check. · *Span-event names are redacted on the telemetry wire* — The OTLP span exporter ran span names and exception messages through the telemetry redactor but passed span-EVENT names through verbatim, so a value-shape secret (a connection string, a token) placed in an event name egressed unredacted. Event names now route through the same redactor as span names, on both the JSON and protobuf wire paths. · *Session refresh enforces the idle and absolute timeout floors* — b.session.touch refreshed a session's activity timestamp without re-checking the idle / absolute timeout floors that b.session.verify enforces, so a floor-expired session could be resurrected by a refresh instead of being killed. Both floors are now evaluated on the refresh path (as on verify), and a session past either floor is deleted rather than extended.
16
+
11
17
  - v0.15.67 (2026-06-29) — **Path-scoped router middleware can no longer be bypassed by percent-encoding the request path: the router refuses an encoded path separator or null byte and decodes the path once, so a security gate and the resource it guards always agree on the path.** b.router supports path-scoped middleware — router.use('/admin', gate) runs a gate (requireAal, bearerAuth, requireMtls, csrfProtect, …) only for requests under a prefix. The gate matched the request path with its percent-escapes intact, while downstream consumers such as b.staticServe and router.serveStatic percent-decode the path before resolving the file. Because the gate and the consumer disagreed on decoding, an attacker could encode a character in the guarded segment (/%61dmin/secret) or hide a separator (/admin%2fsecret) so the gate's segment match missed while the consumer still reached the protected resource — an authentication, authorization, CSRF, or mTLS bypass for any resource served under a scoped gate. The router now refuses a request whose path contains an encoded path separator (%2f, %5c) or null byte (%00), and decodes the remaining escapes exactly once, so the gate, the route matcher, and every consumer act on a single canonical path. A request that legitimately needs those bytes was always ambiguous and is now rejected with 400 rather than silently routed two different ways. Route parameters captured from the path are now percent-decoded, matching the conventional behavior. **Changed:** *Encoded path separators and null bytes in the request path are refused; route params are decoded* — A request whose path contains %2f, %5c, or %00 is answered with 400 Bad Request — these have no unambiguous routing meaning (a consumer would treat them as a separator or terminator), and a request that needs a literal slash in a value should carry it in a query parameter. Path parameters captured by a route pattern (for example :id) are now percent-decoded before they reach the handler in req.params, matching conventional router behavior; handlers that previously received a still-encoded value will now receive the decoded form. **Security:** *Path-scoped middleware is no longer bypassable via percent-encoded paths* — A gate mounted with router.use(prefix, mw) matched req.pathname with percent-escapes preserved, but b.staticServe and router.serveStatic decode the path before resolving the resource. An attacker could percent-encode a character in the guarded segment (/%61dmin/secret, where %61 is 'a') so the segment compare saw '%61dmin' and skipped the gate, or hide a separator (/admin%2fsecret) so the gate saw one segment while the file resolver saw two — in both cases the protected resource was served without the gate running. The router now rejects a path containing an encoded separator (%2f/%5c) or null byte (%00) with 400, and percent-decodes the path exactly once before any path-scoped decision, so the gate and the resource it protects always resolve the same path. Operators who mounted authentication, authorization, CSRF, or mTLS gates with the scoped form no longer have a silent bypass beneath them.
12
18
 
13
19
  - v0.15.66 (2026-06-29) — **Recursive serializers and parsers refuse pathologically deep input with a typed error instead of overflowing the stack and crashing the process.** Several of the framework's recursive walkers over attacker-reachable input lacked an effective nesting cap, so a deeply nested value could exhaust the V8 call stack and throw an uncaught RangeError — crashing the process (a denial of service). b.canonicalJson is the most exposed: content-credentials verification canonicalises an untrusted manifest before it checks the signature, so a hostile credential could crash a verifier pre-authentication. b.jsonSchema.validate had a depth guard, but its limit was set so high the stack overflowed before the guard could fire, so validating a request body against a recursive schema crashed rather than returning a typed error. b.i18n.messageFormat parsed and rendered nested plural/select cases with no cap. Each walker now throws a typed framework error well before native overflow, and legitimate (even deeply nested) input is unaffected. The release also corrects DNSSEC signature-window comparison to the RFC 1982 serial-number arithmetic the spec requires, and makes the Redis client treat a malformed reply frame as a connection fault rather than letting it crash the host. **Fixed:** *DNSSEC compares RRSIG validity windows with RFC 1982 serial arithmetic* — b.network.dns.dnssec compared an RRSIG's inception and expiration against the current time by magnitude. RFC 4034 §3.1.5 requires the 32-bit timestamps to be interpreted with RFC 1982 serial-number arithmetic, which agrees with a plain comparison only while the values stay below 2^31. A signature whose window straddles the 2^31 (January 2038) or 2^32 (February 2106) boundary was mis-ordered — an in-window signature rejected, or a stale one accepted. The comparison now masks the clock into the same 32-bit serial space and tests the wrapped signed delta. **Security:** *Canonical JSON refuses excessively nested input before it can overflow the stack* — b.canonicalJson.stringify and stringifyJcs walk the value recursively. They detected circular references but had no nesting cap, so a deeply nested (acyclic) object or array overflowed the V8 stack with an uncaught RangeError. This is reachable before authentication: content-credentials verification canonicalises the untrusted manifest to hash it before verifying the signature, so a hostile credential could crash the verifier. Both serializers now throw a typed nesting-depth error at a depth far beyond any legitimate signed document and well short of native overflow. · *JSON Schema validation depth guard now fires before the stack overflows* — b.jsonSchema.validate caps subschema-validation nesting to defend against a recursive schema (for example items pointing back at the root with $ref) applied to a deeply nested instance — both attacker-controlled when validating a request body. The cap was set so high the V8 stack overflowed first, so the guard never ran and a crafted body crashed the validator with an uncaught RangeError instead of the typed reference-depth error. The limit is now well under native overflow; legitimate documents — deeply nested or wide — continue to validate. · *ICU MessageFormat refuses pathologically nested templates* — b.i18n.messageFormat.parse and format recurse once per nested plural/select case, with no depth cap. A template nested thousands of levels deep overflowed the stack. format() and parse() are public and b.i18n.t can render operator-supplied entries, so a hostile template is a denial-of-service vector; it now fails as a typed bad-template error. Real-world nesting (a handful of levels) is unaffected. · *Redis client treats a malformed reply frame as a connection fault, not a crash* — The RESP decoder recurses on nested arrays with no depth cap, and the data handler did not guard the parse, so a malformed or hostilely deep frame from a server threw out of the socket callback and could crash the host. The decoder now caps reply nesting and any parse fault tears the socket down and rejects in-flight commands for a reconnect — the same path as any other lost connection.
package/README.md CHANGED
@@ -13,6 +13,7 @@ One install. One upgrade path. One place to look when something breaks — no bl
13
13
  [![CI](https://img.shields.io/github/actions/workflow/status/blamejs/blamejs/ci.yml?branch=main&label=CI)](https://github.com/blamejs/blamejs/actions/workflows/ci.yml)
14
14
  [![release](https://img.shields.io/github/v/release/blamejs/blamejs?include_prereleases&sort=semver)](https://github.com/blamejs/blamejs/releases)
15
15
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/blamejs/blamejs/badge)](https://scorecard.dev/viewer/?uri=github.com/blamejs/blamejs)
16
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13471/badge)](https://www.bestpractices.dev/projects/13471)
16
17
  [![SLSA Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
17
18
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
18
19
  [![node](https://img.shields.io/node/v/@blamejs/core.svg)](https://nodejs.org)
@@ -52,7 +53,7 @@ var b = require("@blamejs/core");
52
53
  })();
53
54
  ```
54
55
 
55
- **Requirements:** Node.js 24.16+ (current active LTS line; 24.14.1 fixed CVE-2026-21713 non-constant-time HMAC compare, 24.16 is the current patch level). Node 26 satisfies the floor and the framework test suite runs cleanly on it today; the floor itself will bump to `>=26.x` when Node 26 promotes to Active LTS. Two Node 26 platform changes operators integrating with blamejs should know about: the new `localStorage` global (the framework's storage backend is `b.backup.diskStorage`; the legacy `b.backup.localStorage` alias was removed in v0.11.20 — update call sites accordingly), and the seed-only ML-KEM / ML-DSA PKCS8 export shape (sealed material from Node 24 re-imports cleanly on Node 26; new material from Node 26 in the seed-only shape). See [SECURITY.md](SECURITY.md#node-26-compatibility) for the details.
56
+ **Requirements:** Node.js 24.18+ (current active LTS line; 24.14.1 fixed CVE-2026-21713 non-constant-time HMAC compare, 24.18 is the current patch level). Node 26 satisfies the floor and the framework test suite runs cleanly on it today; the floor itself will bump to `>=26.x` when Node 26 promotes to Active LTS. Two Node 26 platform changes operators integrating with blamejs should know about: the new `localStorage` global (the framework's storage backend is `b.backup.diskStorage`; the legacy `b.backup.localStorage` alias was removed in v0.11.20 — update call sites accordingly), and the seed-only ML-KEM / ML-DSA PKCS8 export shape (sealed material from Node 24 re-imports cleanly on Node 26; new material from Node 26 in the seed-only shape). See [SECURITY.md](SECURITY.md#node-26-compatibility) for the details.
56
57
 
57
58
  ## What ships in the box
58
59
 
@@ -70,6 +70,7 @@ var nodeStream = require("node:stream");
70
70
  var streamPromises = require("node:stream/promises");
71
71
  var C = require("./constants");
72
72
  var lazyRequire = require("./lazy-require");
73
+ var safeBuffer = require("./safe-buffer");
73
74
  var { defineClass } = require("./framework-error");
74
75
 
75
76
  var TarError = defineClass("TarError", { alwaysPermanent: true });
@@ -240,6 +241,12 @@ function _normalizeName(name) {
240
241
  if (name.indexOf("\u0000") !== -1) {
241
242
  throw new TarError("archive-tar/bad-name", "addFile: name contains null byte");
242
243
  }
244
+ // A bare LF flows into the POSIX pax extended header (`len key=value\n`),
245
+ // where a crafted name can forge a `path=` record that OVERRIDES the
246
+ // ustar name and escapes the extraction root; reject CR/LF outright.
247
+ if (safeBuffer.hasCrlf(name)) {
248
+ throw new TarError("archive-tar/bad-name", "addFile: name contains CR/LF");
249
+ }
243
250
  var normalized = name.replace(/\\/g, "/").replace(/^\/+/, "");
244
251
  var segs = normalized.split("/");
245
252
  for (var i = 0; i < segs.length; i += 1) {
package/lib/archive.js CHANGED
@@ -59,6 +59,7 @@ var C = require("./constants");
59
59
  var { defineClass } = require("./framework-error");
60
60
  var auditEmit = require("./audit-emit");
61
61
  var atomicFile = require("./atomic-file");
62
+ var safeBuffer = require("./safe-buffer");
62
63
 
63
64
  var ArchiveError = defineClass("ArchiveError", { alwaysPermanent: true });
64
65
 
@@ -219,6 +220,9 @@ function zip() {
219
220
  if (name.indexOf("\0") !== -1) {
220
221
  throw new ArchiveError("archive/bad-name", "addFile: name contains null byte");
221
222
  }
223
+ if (safeBuffer.hasCrlf(name)) {
224
+ throw new ArchiveError("archive/bad-name", "addFile: name contains CR/LF");
225
+ }
222
226
  // No path traversal — relative paths only, no leading slash, no ".." segments.
223
227
  var normalized = name.replace(/\\/g, "/").replace(/^\/+/, "");
224
228
  var segs = normalized.split("/");
@@ -219,6 +219,11 @@ async function fromJwt(token, opts) {
219
219
  audience: opts.expectedAudience,
220
220
  clockToleranceSec: opts.clockToleranceSec,
221
221
  now: opts.now,
222
+ // RFC 8725 §3.11 — bind the header typ to the one toJwt() stamps
223
+ // ("statuslist+jwt"), so a token minted for another purpose can't be
224
+ // replayed here as a status list even before the status_list.lst claim
225
+ // check. Every sibling verifier enforces its typ; this closes the gap.
226
+ expectedTyp: "statuslist+jwt",
222
227
  });
223
228
  var sl = claims.status_list;
224
229
  if (!sl || typeof sl !== "object" || typeof sl.lst !== "string") {
@@ -102,7 +102,7 @@ function boundedMap(opts) {
102
102
 
103
103
  // getOrInsert(map, key, factory, opts?) — Map.prototype.getOrInsertComputed(key,
104
104
  // factory) polyfill. The native method lands in Node 26 but the framework floor
105
- // is 24.16, so the framework's request-keyed Maps hand-roll `var v = m.get(k);
105
+ // is 24.18, so the framework's request-keyed Maps hand-roll `var v = m.get(k);
106
106
  // if (!v) { v = ...; m.set(k, v); }` everywhere. This is the ONE place that
107
107
  // shape lives, so the floor-bump sweep swaps the body for the native method in
108
108
  // a single edit instead of N call sites. Returns the existing value; otherwise
package/lib/csv.js CHANGED
@@ -43,6 +43,7 @@
43
43
  */
44
44
  var C = require("./constants");
45
45
  var numericBounds = require("./numeric-bounds");
46
+ var pick = require("./pick");
46
47
  var { defineClass } = require("./framework-error");
47
48
 
48
49
  /**
@@ -292,7 +293,17 @@ function parse(input, opts) {
292
293
  "row " + r + " has " + rec.length + " fields, header has " + header.length);
293
294
  }
294
295
  var obj = {};
295
- for (var i = 0; i < header.length; i++) obj[header[i]] = rec[i];
296
+ for (var i = 0; i < header.length; i++) {
297
+ // A header column named __proto__ / constructor / prototype would be
298
+ // written straight onto the row object, shadowing (constructor) or
299
+ // dropping/re-parenting (__proto__) the inherited slot — an attacker
300
+ // then controls a downstream `row.constructor` / type check. Refuse.
301
+ if (pick.isPoisonedKey(header[i])) {
302
+ throw new CsvError("csv/forbidden-header",
303
+ "header column '" + header[i] + "' is a reserved prototype key");
304
+ }
305
+ obj[header[i]] = rec[i];
306
+ }
296
307
  out.push(obj);
297
308
  }
298
309
  return out;
package/lib/eat.js CHANGED
@@ -48,6 +48,7 @@
48
48
 
49
49
  var cwt = require("./cwt");
50
50
  var bCrypto = require("./crypto");
51
+ var pick = require("./pick");
51
52
  var validateOpts = require("./validate-opts");
52
53
  var { defineClass } = require("./framework-error");
53
54
 
@@ -229,7 +230,7 @@ async function verify(eat, opts) {
229
230
  if (name === "dbgstat" && typeof v === "number" && Object.prototype.hasOwnProperty.call(DBGSTAT_BY_VALUE, v)) {
230
231
  v = DBGSTAT_BY_VALUE[v];
231
232
  }
232
- claims[name] = v;
233
+ if (!pick.isPoisonedKey(name)) claims[name] = v;
233
234
  });
234
235
 
235
236
  return { claims: claims, raw: raw, alg: out.alg, protectedHeaders: out.protectedHeaders };
@@ -188,12 +188,21 @@ function sign(opts) {
188
188
  throw new MailAuthError("arc-sign/bad-instance",
189
189
  "sign: instance must be an integer in [1, 50] — got " + JSON.stringify(opts.instance));
190
190
  }
191
- validateOpts.requireNonEmptyString(opts.authservId,
192
- "sign: authservId", MailAuthError, "arc-sign/bad-authserv");
193
- validateOpts.requireNonEmptyString(opts.domain,
194
- "sign: domain", MailAuthError, "arc-sign/bad-domain");
195
- validateOpts.requireNonEmptyString(opts.selector,
196
- "sign: selector", MailAuthError, "arc-sign/bad-selector");
191
+ // authservId / domain / selector / authResults are required non-empty
192
+ // strings AND are interpolated verbatim into the ARC header block
193
+ // (ARC-Authentication-Results / ARC-Seal / ARC-Message-Signature), so they
194
+ // can never carry a CR / LF / NUL — that would smuggle a header.
195
+ var arcHeaderFields = [
196
+ ["authservId", opts.authservId, "arc-sign/bad-authserv"],
197
+ ["domain", opts.domain, "arc-sign/bad-domain"],
198
+ ["selector", opts.selector, "arc-sign/bad-selector"],
199
+ ["authResults", opts.authResults, "arc-sign/bad-auth-results"],
200
+ ];
201
+ for (var fi = 0; fi < arcHeaderFields.length; fi += 1) {
202
+ var af = arcHeaderFields[fi];
203
+ validateOpts.requireNonEmptyString(af[1], "sign: " + af[0], MailAuthError, af[2]);
204
+ safeBuffer.assertHeaderSafe(af[1], af[0], MailAuthError, af[2]);
205
+ }
197
206
  if (!opts.privateKey || (typeof opts.privateKey !== "string" &&
198
207
  typeof opts.privateKey !== "object")) {
199
208
  throw new MailAuthError("arc-sign/missing-private-key",
@@ -216,12 +225,6 @@ function sign(opts) {
216
225
  throw new MailAuthError("arc-sign/cv-rule",
217
226
  "sign: i>=2 disallows cv=none — must be cv=pass or cv=fail (per RFC 8617 §5.1.1)");
218
227
  }
219
- validateOpts.requireNonEmptyString(opts.authResults, "sign: authResults",
220
- MailAuthError, "arc-sign/bad-auth-results");
221
- if (safeBuffer.hasCrlf(opts.authResults)) {
222
- throw new MailAuthError("arc-sign/bad-auth-results",
223
- "sign: authResults contains CR/LF (header injection refused)");
224
- }
225
228
  var headersToSign = opts.headersToSign || DEFAULT_HEADERS;
226
229
  if (!Array.isArray(headersToSign) || headersToSign.length === 0) {
227
230
  throw new MailAuthError("arc-sign/bad-headers",
package/lib/mail-arf.js CHANGED
@@ -39,6 +39,7 @@
39
39
 
40
40
  var lazyRequire = require("./lazy-require");
41
41
  var mimeParse = require("./mime-parse");
42
+ var pick = require("./pick");
42
43
  var C = require("./constants");
43
44
  var safeBuffer = require("./safe-buffer");
44
45
  var { MailArfError } = require("./framework-error");
@@ -237,6 +238,7 @@ function parse(rawMessage, opts) {
237
238
  var f = reportFields[fi];
238
239
  if (!f || !f.name) continue;
239
240
  var lcName = f.name.toLowerCase();
241
+ if (pick.isPoisonedKey(lcName)) continue;
240
242
  fieldMap[lcName] = f.value;
241
243
  }
242
244
 
package/lib/mail-bimi.js CHANGED
@@ -52,6 +52,7 @@ var nodeCrypto = require("node:crypto");
52
52
 
53
53
  var asn1 = require("./asn1-der");
54
54
  var C = require("./constants");
55
+ var pick = require("./pick");
55
56
  var httpClient = require("./http-client");
56
57
  var lazyRequire = require("./lazy-require");
57
58
  var networkDnsResolver = lazyRequire(function () { return require("./network-dns-resolver"); });
@@ -572,7 +573,7 @@ function _parseTinyPsAttrs(src) {
572
573
  while ((m = re.exec(src)) !== null) {
573
574
  var name = m[1];
574
575
  var value = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : (m[5] || ""));
575
- attrs[name] = value;
576
+ if (!pick.isPoisonedKey(name)) attrs[name] = value;
576
577
  }
577
578
  return attrs;
578
579
  }
@@ -812,7 +812,11 @@ function _parseDsn(rawMessage) {
812
812
 
813
813
  function _foldFieldValue(name, value) {
814
814
  // RFC 5322 §2.2.3 — long lines fold at WSP. Keep it simple: emit
815
- // `Name: value` and let downstream MTAs handle further folding.
815
+ // `Name: value` and let downstream MTAs handle further folding. This
816
+ // choke point emits a single `Name: value` line, so reject CR / LF /
817
+ // NUL in the value — an embedded terminator would smuggle a new field
818
+ // or forge a report part.
819
+ safeBuffer.assertHeaderSafe(value, name, MailBounceError, "bounce/bad-dsn-field");
816
820
  return name + ": " + value + "\r\n";
817
821
  }
818
822
 
@@ -836,10 +840,21 @@ function _buildDsn(opts) {
836
840
  String(opts.status) + "'");
837
841
  }
838
842
 
843
+ // CRLF/NUL header-injection guard. Recipients and MTA names are
844
+ // structured fields that can never legitimately carry CR / LF / NUL, so
845
+ // reject — a DSN built from a hostile recipient or peer must fail closed
846
+ // rather than smuggle a header or forge a report part. Diagnostic-Code
847
+ // echoes the remote server's SMTP reply (free text, legitimately
848
+ // multi-line), so fold it to a single line instead of rejecting.
849
+ safeBuffer.assertHeaderSafe(opts.finalRecipient, "finalRecipient", MailBounceError, "bounce/bad-dsn-field");
850
+ if (opts.originalRecipient != null) {
851
+ safeBuffer.assertHeaderSafe(opts.originalRecipient, "originalRecipient", MailBounceError, "bounce/bad-dsn-field");
852
+ }
839
853
  var reportingMta = opts.reportingMta || "dns; localhost";
840
854
  var arrivalDate = opts.arrivalDate || new Date().toUTCString();
841
855
  var originalMessage = opts.originalMessage || null;
842
- var diagnosticCode = opts.diagnosticCode || null;
856
+ var diagnosticCode = opts.diagnosticCode != null
857
+ ? safeBuffer.foldHeaderText(String(opts.diagnosticCode), " ") : null;
843
858
  var remoteMta = opts.remoteMta || null;
844
859
  var humanText = opts.humanText || (
845
860
  "This is the mail system at " + reportingMta + ".\r\n\r\n" +
@@ -854,10 +869,10 @@ function _buildDsn(opts) {
854
869
  var lines = [];
855
870
  lines.push("MIME-Version: 1.0");
856
871
  lines.push('Content-Type: multipart/report; report-type=delivery-status; boundary="' + boundary + '"');
857
- if (opts.from) lines.push("From: " + opts.from);
858
- if (opts.to) lines.push("To: " + opts.to);
859
- if (opts.subject) lines.push("Subject: " + opts.subject);
860
- if (opts.messageId) lines.push("Message-ID: " + opts.messageId);
872
+ if (opts.from) lines.push("From: " + safeBuffer.assertHeaderSafe(opts.from, "from", MailBounceError, "bounce/bad-dsn-field"));
873
+ if (opts.to) lines.push("To: " + safeBuffer.assertHeaderSafe(opts.to, "to", MailBounceError, "bounce/bad-dsn-field"));
874
+ if (opts.subject) lines.push("Subject: " + safeBuffer.assertHeaderSafe(opts.subject, "subject", MailBounceError, "bounce/bad-dsn-field"));
875
+ if (opts.messageId) lines.push("Message-ID: " + safeBuffer.assertHeaderSafe(opts.messageId, "messageId", MailBounceError, "bounce/bad-dsn-field"));
861
876
  lines.push("");
862
877
 
863
878
  // Part 1 - human-readable description.
package/lib/mail-mdn.js CHANGED
@@ -31,6 +31,7 @@
31
31
  var bCrypto = require("./crypto");
32
32
  var lazyRequire = require("./lazy-require");
33
33
  var mimeParse = require("./mime-parse");
34
+ var safeBuffer = require("./safe-buffer");
34
35
  var structuredFields = require("./structured-fields");
35
36
  var audit = lazyRequire(function () { return require("./audit"); });
36
37
  var C = require("./constants");
@@ -210,6 +211,20 @@ function build(opts) {
210
211
  "and opts.requireUserConfirmation is not explicitly false (RFC 3798 §2.1)");
211
212
  }
212
213
 
214
+ // CRLF/NUL header-injection guard. Every structured MDN field
215
+ // (recipients, message-id, envelope headers, reporting UA) is a
216
+ // single-line header value that can never legitimately carry CR / LF /
217
+ // NUL — an inbound message must not be able to smuggle headers into the
218
+ // return receipt. Guard before addressType() so a hostile address fails
219
+ // closed with the mdn error, not an address-parse error.
220
+ safeBuffer.assertHeaderSafe(opts.finalRecipient, "finalRecipient", MailMdnError, "mdn/bad-header-field");
221
+ safeBuffer.assertHeaderSafe(opts.originalMessageId, "originalMessageId", MailMdnError, "mdn/bad-header-field");
222
+ if (opts.originalRecipient != null) safeBuffer.assertHeaderSafe(opts.originalRecipient, "originalRecipient", MailMdnError, "mdn/bad-header-field");
223
+ if (opts.reportingUserAgent != null) safeBuffer.assertHeaderSafe(opts.reportingUserAgent, "reportingUserAgent", MailMdnError, "mdn/bad-header-field");
224
+ if (opts.from != null) safeBuffer.assertHeaderSafe(opts.from, "from", MailMdnError, "mdn/bad-header-field");
225
+ if (opts.to != null) safeBuffer.assertHeaderSafe(opts.to, "to", MailMdnError, "mdn/bad-header-field");
226
+ if (opts.subject != null) safeBuffer.assertHeaderSafe(opts.subject, "subject", MailMdnError, "mdn/bad-header-field");
227
+
213
228
  var boundary = _generateBoundary();
214
229
  var recipType = mimeParse.addressType(opts.finalRecipient);
215
230
  var origRecipType = opts.originalRecipient ? mimeParse.addressType(opts.originalRecipient) : recipType;
@@ -62,6 +62,7 @@
62
62
 
63
63
  var nodeDns = require("node:dns").promises;
64
64
  var bCrypto = require("./crypto");
65
+ var safeBuffer = require("./safe-buffer");
65
66
  var validateOpts = require("./validate-opts");
66
67
  var lazyRequire = require("./lazy-require");
67
68
  var { defineClass } = require("./framework-error");
@@ -115,10 +116,19 @@ function _classifySmtpOutcome(err, response) {
115
116
  // headers per RFC 3462. Returns a raw RFC 5322 message ready to hand
116
117
  // to whatever transport the operator uses for DSN delivery.
117
118
  function _buildDsnMessage(opts) {
118
- var from = opts.dsnFrom;
119
- var to = opts.originalFrom;
120
- var failedRecipient = opts.recipient;
121
- var reason = opts.reason || "permanent failure";
119
+ // CRLF/NUL header-injection guard. Structured fields (addresses, the
120
+ // reporting-MTA name, the enhanced status code) can never legitimately
121
+ // carry CR / LF / NUL, so reject — a bounce built from a hostile
122
+ // original sender, or from a malicious peer MX, must fail closed rather
123
+ // than smuggle DSN headers or forge report parts. The 5xx `reason` is
124
+ // echoed from the peer's SMTP reply and is legitimately multi-line, so
125
+ // fold it to a single line instead of rejecting.
126
+ var from = safeBuffer.assertHeaderSafe(opts.dsnFrom, "dsnFrom", DeliverError, "deliver/bad-dsn-field");
127
+ var to = safeBuffer.assertHeaderSafe(opts.originalFrom, "originalFrom", DeliverError, "deliver/bad-dsn-field");
128
+ var failedRecipient = safeBuffer.assertHeaderSafe(opts.recipient, "recipient", DeliverError, "deliver/bad-dsn-field");
129
+ var reportingMta = safeBuffer.assertHeaderSafe(opts.reportingMta, "reportingMta", DeliverError, "deliver/bad-dsn-field");
130
+ var statusCode = safeBuffer.assertHeaderSafe(opts.statusCode, "statusCode", DeliverError, "deliver/bad-dsn-field");
131
+ var reason = safeBuffer.foldHeaderText(opts.reason || "permanent failure", " ");
122
132
  var origHeaders = opts.originalHeaders || "";
123
133
  var boundary = "dsn-" + bCrypto.generateToken(12);
124
134
  var nowIso = new Date().toUTCString();
@@ -134,7 +144,7 @@ function _buildDsnMessage(opts) {
134
144
  "--" + boundary + "\r\n" +
135
145
  "Content-Type: text/plain; charset=utf-8\r\n" +
136
146
  "\r\n" +
137
- "This is the mail delivery system at " + (opts.reportingMta || from) + ".\r\n" +
147
+ "This is the mail delivery system at " + (reportingMta || from) + ".\r\n" +
138
148
  "\r\n" +
139
149
  "Your message to " + failedRecipient + " could not be delivered:\r\n" +
140
150
  "\r\n" +
@@ -147,12 +157,12 @@ function _buildDsnMessage(opts) {
147
157
  // (it falls back to the bounce-from's domain); it drives no auth decision or
148
158
  // delivery routing, so the leftmost-@ segment is acceptable here.
149
159
  // allow:leftmost-domain-informational
150
- "Reporting-MTA: dns; " + (opts.reportingMta || from.split("@")[1] || "") + "\r\n" +
160
+ "Reporting-MTA: dns; " + (reportingMta || from.split("@")[1] || "") + "\r\n" +
151
161
  "Arrival-Date: " + nowIso + "\r\n" +
152
162
  "\r\n" +
153
163
  "Final-Recipient: rfc822; " + failedRecipient + "\r\n" +
154
164
  "Action: failed\r\n" +
155
- "Status: " + (opts.statusCode || "5.0.0") + "\r\n" +
165
+ "Status: " + (statusCode || "5.0.0") + "\r\n" +
156
166
  "Diagnostic-Code: smtp; " + reason + "\r\n" +
157
167
  "\r\n" +
158
168
  "--" + boundary + "\r\n" +
package/lib/mail-srs.js CHANGED
@@ -53,6 +53,7 @@
53
53
 
54
54
  var nodeCrypto = require("node:crypto");
55
55
  var bCrypto = require("./crypto");
56
+ var safeBuffer = require("./safe-buffer");
56
57
  var validateOpts = require("./validate-opts");
57
58
  var { defineClass } = require("./framework-error");
58
59
 
@@ -193,6 +194,9 @@ function create(opts) {
193
194
  function rewrite(originalAddress, nowMs) {
194
195
  validateOpts.requireNonEmptyString(
195
196
  originalAddress, "srs.rewrite.address", SrsError, "srs/bad-address");
197
+ // The local-part is embedded verbatim into the SRS0 envelope address —
198
+ // refuse CR / LF / NUL so it can't be smuggled into a later MAIL FROM.
199
+ safeBuffer.assertHeaderSafe(originalAddress, "srs.rewrite.address", SrsError, "srs/bad-address");
196
200
  var at = originalAddress.lastIndexOf("@");
197
201
  if (at <= 0 || at === originalAddress.length - 1) {
198
202
  throw new SrsError("srs/bad-address",
@@ -221,6 +225,7 @@ function create(opts) {
221
225
  function srs1Rewrite(srsAddress) {
222
226
  validateOpts.requireNonEmptyString(
223
227
  srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
228
+ safeBuffer.assertHeaderSafe(srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
224
229
  var at = srsAddress.lastIndexOf("@");
225
230
  if (at <= 0 || at === srsAddress.length - 1) {
226
231
  throw new SrsError("srs/bad-address",
@@ -264,6 +269,7 @@ function create(opts) {
264
269
  function reverse(srsAddress, nowMs) {
265
270
  validateOpts.requireNonEmptyString(
266
271
  srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
272
+ safeBuffer.assertHeaderSafe(srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
267
273
  var at = srsAddress.lastIndexOf("@");
268
274
  if (at <= 0 || at === srsAddress.length - 1) {
269
275
  throw new SrsError("srs/bad-address",
@@ -45,6 +45,7 @@ var lazyRequire = require("../lazy-require");
45
45
  var numericBounds = require("../numeric-bounds");
46
46
  var validateOpts = require("../validate-opts");
47
47
  var safeBuffer = require("../safe-buffer");
48
+ var requestHelpers = require("../request-helpers");
48
49
  var safeJson = require("../safe-json");
49
50
  var safeSql = require("../safe-sql");
50
51
  var sql = require("../sql");
@@ -537,6 +538,18 @@ function _validateStore(store, where) {
537
538
  where + ": store", IdempotencyError, "idempotency/bad-store", true);
538
539
  }
539
540
 
541
+ // Bind the idempotency slot to the authenticated principal. Without it the
542
+ // slot is keyed on the client Idempotency-Key alone, so principal B is
543
+ // served principal A's cached response (cross-actor disclosure), or A can
544
+ // pre-seed a key to force a 422 on B (cross-actor denial), via a shared or
545
+ // guessed key. Mirrors b.agent.idempotency's (method, actorId, key) binding.
546
+ // Falls back to "anon" only when no principal is resolvable — mount this
547
+ // middleware AFTER authentication so the request carries the principal.
548
+ function _defaultScope(req) {
549
+ var actor = requestHelpers.extractActorContext(req);
550
+ return actor && typeof actor.userId === "string" && actor.userId ? actor.userId : "anon";
551
+ }
552
+
540
553
  function _fingerprintRequest(req, bodyBytes, store) {
541
554
  // Fingerprint preimage = method + path + body. Per the draft §4.3,
542
555
  // a key+body mismatch is a client-side mistake; our preimage covers
@@ -676,6 +689,13 @@ function create(opts) {
676
689
  opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
677
690
  IdempotencyError, "idempotency/bad-body-fingerprint"
678
691
  ) || null;
692
+ // Principal scope for the idempotency slot (see _defaultScope). Binds the
693
+ // cache key to the authenticated actor so one principal cannot read or
694
+ // 422-poison another's slot via a shared / guessed Idempotency-Key.
695
+ // Operators with a non-standard principal shape override via opts.scopeFn.
696
+ var scopeFn = validateOpts.optionalFunction(
697
+ opts.scopeFn, "idempotencyKey.scopeFn",
698
+ IdempotencyError, "idempotency/bad-scope-fn") || _defaultScope;
679
699
  // Default "deny" refuses body-bearing requests that
680
700
  // arrive with neither req._rawBody / req.body NOR an operator-
681
701
  // supplied bodyFingerprint hook. The silent-degrade-to-method+path
@@ -730,6 +750,12 @@ function create(opts) {
730
750
  return problemDetails().respond(res, bad);
731
751
  }
732
752
 
753
+ // Scope the store slot to the authenticated principal (length-prefixed
754
+ // so the scope/key boundary is unambiguous) — a shared or guessed
755
+ // Idempotency-Key cannot cross the actor boundary.
756
+ var scope = String(scopeFn(req) || "anon");
757
+ var scopedKey = scope.length + ":" + scope + ":" + key;
758
+
733
759
  var bodyBytes;
734
760
  if (bodyFingerprintFn) {
735
761
  // Operator-supplied hook — called after body-parser so req.body
@@ -801,7 +827,7 @@ function create(opts) {
801
827
  var fingerprint = _fingerprintRequest(req, bodyBytes, opts.store);
802
828
 
803
829
  var cached = null;
804
- try { cached = opts.store.get(key); }
830
+ try { cached = opts.store.get(scopedKey); }
805
831
  catch (_storeErr) {
806
832
  // Store-read failure — emit audit + treat as miss. Idempotency is
807
833
  // a best-effort optimization; the handler runs anyway.
@@ -887,7 +913,7 @@ function create(opts) {
887
913
  } catch (_e) { /* ignore */ }
888
914
  var combined = collector.result();
889
915
  try {
890
- opts.store.set(key, {
916
+ opts.store.set(scopedKey, {
891
917
  fingerprint: fingerprint,
892
918
  statusCode: status,
893
919
  headers: headerMap,
package/lib/mime-parse.js CHANGED
@@ -37,6 +37,8 @@
37
37
  * mdn: 1 MiB) so this module's hot-path doesn't add its own cap.
38
38
  */
39
39
 
40
+ var pick = require("./pick");
41
+
40
42
  function classifyHeaderBlock(text) {
41
43
  // RFC 5322 §2.2 — every line of a header section is either a header field
42
44
  // (`name: value`), a folding continuation (leading WSP), or the empty line
@@ -153,7 +155,7 @@ function parseContentType(value) {
153
155
  pval = rest.slice(j, endTok).trim();
154
156
  i = endTok;
155
157
  }
156
- params[pname] = pval;
158
+ if (!pick.isPoisonedKey(pname)) params[pname] = pval;
157
159
  }
158
160
  return { type: typePart, params: params };
159
161
  }