@blamejs/core 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.5 (2026-07-03) — **Correctness fixes across the ReDoS guard, Base32 codec, HTTP multipart builder, SAML encrypted-assertion decryption, and the OAuth device grant — several restore advertised functionality that was silently non-working, plus a multipart header-injection fix.** This patch fixes a batch of correctness and security defects, several of which restore advertised behaviour that never actually worked. The ReDoS guard (b.guardRegex) no longer false-rejects LINEAR patterns that use a quantified non-capturing group (`(?:…)?`, `(?:…)*`) or an optional quantified group (`(X+)?`, `(?:X+)?`) — those repeat the group at most once and are not catastrophic; the detector now correctly requires the OUTER quantifier to be unbounded, so operator regex screening and b.selfUpdate asset patterns that use an optional SemVer suffix work again while genuine `(a+)+` shapes are still refused. The Base32 decoder now rejects NON-CANONICAL input (a final symbol whose unused low bits are non-zero — previously two distinct strings decoded to the same bytes, a malleability problem for the TOTP secrets and identifiers Base32 backs) and impossible symbol counts (1/3/6 mod 8 that silently produced a truncated buffer). The HTTP multipart/form-data builder now refuses CR/LF/NUL in a field name, filename, or content-type, closing a part-header-injection / form-part-forgery path. Two SAML EncryptedAssertion decryption paths that were completely dead — ML-KEM-1024 key transport and XChaCha20-Poly1305 content — are fixed (they referenced crypto entry points that were never exported, so the framework's advertised PQC-first / AEAD SAML encryption always failed and misreported the cause as a wrong-key or tag-mismatch error). The OAuth device grant now works against spec-compliant identity providers (its poll aborted on the first `authorization_pending`, which RFC 8628 delivers as an HTTP 400 the client was rejecting before reading), and a static (non-discovery) OAuth client can now use introspection, dynamic registration, and device authorization by configuring those endpoints. Smaller fixes round it out: the HTTP client's default error carries statusCode/permanent; the CLI dev server's repeatable flags accumulate; DANE and MTA-STS failures are scoped correctly per RFC; and typed errors replace raw TypeErrors on a couple of malformed-input paths. **Fixed:** *HTTP client default error carries statusCode and permanent* — request() fell back to the base FrameworkError when no explicit opts.errorClass was passed, so error.statusCode and error.permanent were undefined on the default path despite the documented default of HttpClientError. The default is now HttpClientError, so those fields are populated for every consumer. · *Static OAuth clients can configure introspection / registration / device-authorization endpoints* — create() never read opts.introspectionEndpoint / registrationEndpoint / deviceAuthorizationEndpoint into its static-endpoint set, so introspectToken / registerClient / deviceAuthorization resolved those endpoints only via OIDC discovery — a static (non-discovery) client could not use them, even though introspectToken's own error told operators to set the endpoint on create(). Those three endpoints are now honored from static config. · *CLI dev server repeatable flags accumulate* — `blamejs dev --arg / --watch / --ignore` are documented repeatable, but the argument parser overwrote each on repeat, so only the last occurrence survived — only the last --watch directory was monitored, only the last --ignore applied, and the child received only the last --arg. Repeated occurrences of these flags now accumulate as documented. · *DANE per-recipient failure isolation and MTA-STS testing-mode handling* — A DANE-enforce TLSA-lookup failure threw out of the entire deliver() batch instead of failing just the one recipient; it now fails that recipient and lets the rest proceed. And an MTA-STS policy published in `testing` mode was hard-bounced under the default enforce posture, violating RFC 8461 §5.2 (testing mode is report-only); a testing-mode policy no longer blocks delivery. · *external-db validates defaultBackend and enforces the pool min floor* — init() did not validate that opts.defaultBackend named a registered backend, so a typo surfaced as an opaque TypeError at the first query instead of a typed config-time error; it is now validated at init. The pool `min` (documented as a floor on idle clients) was never enforced; the reaper now respects it. · *mail-bounce returns a typed error for a null SES SNS message* — An SES SNS notification whose Message field is JSON literal `null` dereferenced null and threw a raw TypeError (also risking an internal-message leak); it now throws the typed MailBounceError like the other malformed-input paths. **Security:** *b.guardRegex no longer false-rejects linear quantified-group patterns (#432, #429)* — The nested-quantifier ReDoS detector treated the `?` in a `(?:` group prefix as an inner quantifier and treated a bounded outer `?` / `{0,1}` as a dangerous outer quantifier, so it wrongly refused LINEAR patterns like `^(?:/page/\d+)?$`, `^foo(?:bar)*$`, `^(a+)?$`, and `(?:[-+][0-9A-Za-z.-]+)?`. The catastrophic class requires the OUTER quantifier to be unbounded (`*`/`+`/`{n,}`). The detector now relies solely on the paren-aware structural scanner, which requires an unbounded outer quantifier and does not miscount a group prefix — so genuine `(a+)+` / `((a)+)+` shapes stay refused while these linear ones are accepted. Consumers screening operator-supplied regexes (route rules, and b.selfUpdate asset patterns using an optional SemVer prerelease/build group) are no longer forced to rewrite valid input. · *Base32 decoder rejects non-canonical encodings and impossible lengths* — b.base32.decode discarded the final symbol's unused low bits without checking they were zero (RFC 4648 §3.5), so two distinct strings — e.g. `MY======` and `MZ======` — decoded to the same bytes (decoder malleability), and it silently accepted impossible symbol counts (1/3/6 mod 8) that can't represent whole bytes, returning a truncated buffer. Both are now refused (`base32/non-canonical`, `base32/bad-length`), giving a one-to-one mapping between a byte sequence and its Base32 string — important where the string is a key / secret / dedup handle (TOTP, identifiers). Valid input, including unpadded and loose-mode input, is unaffected. · *HTTP multipart/form-data builder refuses CR/LF/NUL in part-header values* — The multipart body builder interpolated the field name, file field, filename, and content-type onto `Content-Disposition:` / `Content-Type:` part-header lines without a control-character check, so an attacker controlling one of those values could smuggle a CRLF and inject additional part headers or forge form parts. CR, LF, and NUL in any of those values are now refused, matching the header-safety the mail-header sweep already applies to RFC 822 lines. · *SAML EncryptedAssertion ML-KEM-1024 and XChaCha20-Poly1305 decryption now work* — verifyResponse's post-quantum key-transport branch and its XChaCha20-Poly1305 content branch called crypto entry points that the crypto module never exported, so every ML-KEM-1024-wrapped or XChaCha20-Poly1305-encrypted SAML assertion failed with an internal TypeError that was re-reported as a key-unwrap or tag-mismatch error — the advertised PQC-first / AEAD SAML encryption was dead. Both paths now route through the exported envelope-open and packed-AEAD primitives, verified with a full encrypt→decrypt round trip. Fails closed (no auth bypass) either way. · *OAuth device grant polls correctly against spec-compliant providers* — pollDeviceCode issued its token request in the default buffering mode, so the HTTP client rejected the HTTP 400 that RFC 8628 §3.5 / RFC 6749 §5.2 use to carry `authorization_pending` / `slow_down` before the poll loop could read the OAuth error body — the grant aborted on the first poll (which is almost always `authorization_pending`, since the user hasn't approved yet). The request now resolves 4xx OAuth error responses so the pending / slow-down / terminal handling runs and the device grant completes.
12
+
13
+ - v0.16.4 (2026-07-03) — **A CalDAV/CardDAV path-traversal fix, an SMTP BDAT body-corruption fix, and a database-stream off-by-one — plus five smaller robustness fixes — surfaced by a large expansion of command-handler and error-branch test coverage across the mail servers, DNS, TLS and database primitives.** This patch fixes a batch of defects concentrated in the error and adversarial-input paths of the mail-server, DNS, TLS and database primitives, found while substantially expanding the automated test suite over their command handlers and failure branches. The most serious is a CalDAV/CardDAV path-traversal: the request-path guard rejected a literal `..` or `%2e%2e` before decoding, but each path segment was then percent-decoded and handed to the storage backend — so a MIXED encoding such as `.%2e` (neither form the guard checks) slipped through and decoded to `..`, reaching the operator's storage backend with a traversal segment. Path segments are now validated AFTER decoding. On the SMTP submission path, the message body was dot-stuffed (SMTP DATA transparency) inside the message builder and that same body was reused for RFC 3030 BDAT/CHUNKING chunks — which are length-framed and are NOT un-stuffed by the receiver — so any body line beginning with `.` was delivered with a spurious doubled dot to every peer advertising CHUNKING (Gmail/Outlook/Exchange/Postfix all do); dot-stuffing now applies only on the DATA path. The database row-streamer had an off-by-one: a result set of exactly `streamLimit` rows errored with `db/stream-limit-exceeded` instead of completing, because the cap was checked before the iterator could report done — so a valid, complete export surfaced as a stream error. Five smaller fixes round it out: CalDAV/CardDAV now return 400 (not 500) for a malformed/empty client request body; `dns.resolve` rejects a non-string record type with a typed error instead of an untyped TypeError; the DNS negative cache is honored when an explicit negative TTL is set even if the positive TTL is 0; and the Resend mail transport handles a literal JSON `null` response body without taking a mislabeled error path. **Fixed:** *SMTP BDAT/CHUNKING no longer corrupts body lines beginning with a dot* — The RFC 822 message builder applied SMTP DATA-transparency dot-stuffing (a leading `.` on a line is doubled) to the body, and the submission transport reused that same body for RFC 3030 BDAT chunks. BDAT framing is purely length-based and receivers do NOT un-stuff it, so a body line starting with `.` was delivered with an extra leading dot to any peer advertising CHUNKING (which Gmail, Outlook, Exchange and Postfix all do — CHUNKING is default-on). Dot-stuffing is now applied only when framing via DATA; the message the builder produces (and that DKIM signs) is the un-stuffed body the receiver verifies, and the BDAT path sends it verbatim. · *db.stream no longer errors on a result set of exactly streamLimit rows* — The stream's per-read cap was checked at the top of the read callback, before the row iterator could report completion, so after emitting exactly `streamLimit` rows the next read destroyed the stream with `db/stream-limit-exceeded` even though the count never exceeded the cap — turning a valid, complete export into an error event. The cap is now enforced after pulling the next row and checking for end-of-results, so exactly-`streamLimit` rows complete cleanly and only a row BEYOND the cap trips the guard. · *CalDAV/CardDAV return 400 for a malformed or empty client request body* — A malformed PROPFIND/REPORT XML body (or an empty REPORT body) threw from the body parser into the generic dispatch catch, which unconditionally returned 500 — inconsistent with MKCALENDAR/MKCOL, which already return 400 for the same input. Client-fault body-parse errors now return 400 (Bad Request); a genuine server-side backend throw still returns 500. · *dns.resolve rejects a non-string record type with a typed error* — `dns.resolve(host, type)` called `.toUpperCase()` on whatever `type` was passed, so a non-string (e.g. a number) threw a raw `TypeError` with no code and no `.permanent` flag — unlike an unknown STRING type, which throws a typed `dns/unsupported-type`. A caller keying on the documented `err.permanent` retry contract would have read undefined (falsy) and treated an unfixable input as retryable. A non-string type is now rejected with the typed error. · *DNS negative cache honors an explicit negative TTL when the positive TTL is 0* — The negative-response cache early-returned whenever the positive `cacheTtlMs` was 0, so `setCacheTtlMs(0, 60000)` (positive caching off, negative caching 60s) silently never populated the negative cache — the accepted negative TTL was not applied. The negative cache now uses the explicit negative TTL even when the positive TTL is 0. · *Resend transport handles a literal JSON null response body* — The Resend mail transport parsed the response body then dereferenced `data.id`; a literal JSON `null` body parses to `null`, so the dereference threw a raw TypeError that was reported as an interpret failure rather than the intended bad-response verdict. A null/absent parsed body is now treated as a bad response directly. **Security:** *CalDAV/CardDAV path-traversal via mixed percent-encoding is refused* — `_parsePath` rejected a literal `..`, `%2e%2e`, `%00` or NUL in the RAW request path, but then percent-decoded each segment and passed the decoded value (principalId / collection / component id) straight to the storage backend (getComponent / putComponent / listComponents / …). A mixed encoding such as `.%2e` or `%2e.` contains neither a literal `..` nor `%2e%2e`, so it passed the pre-decode guard and decoded to `..`, reaching the backend with a traversal segment. Each segment is now validated AFTER decoding — any decoded `.` / `..` / NUL / embedded path separator is refused — matching the decode-once-then-validate discipline the router already applies.
14
+
11
15
  - v0.16.3 (2026-07-03) — **A batch of fail-open, injection-bypass and error-path fixes across the metrics, guard-sql, MCP, YAML-parser, content-credentials, tus-upload and buffer primitives — including a credential-leak fix on the /metrics exposition surface — alongside a further expansion of the automated test suite.** This patch closes a set of security and robustness defects concentrated in the error-handling and adversarial-input paths of the observability, SQL-guard, MCP, YAML-parsing, content-credentials and resumable-upload primitives, surfaced while continuing to expand the automated test suite. The most consequential: credential-shaped metric label values were redacted only in the internal cardinality key, not in the rendered exposition, so a raw bearer token / API key / JWT placed on a label leaked verbatim to every `/metrics` scrape reader — redaction now applies to the stored labels the exposition renders. The SQL guard's fragment mode (the `whereRaw` escape hatch) refused an embedded single-quoted literal but not a PostgreSQL dollar-quoted string (`$tag$…$tag$`) or a lone top-level `;`, so operator concatenation into a dollar-quoted context bypassed the floor that forces `?`-placeholder binding. MCP tool-input validation was fail-open when a JSON Schema omitted an explicit top-level `type:"object"` (a valid, common shape) — `required`/`properties`/`additionalProperties` were skipped and any input accepted; and its server-initiated sampling token cap was bypassable with a string `maxTokens`. The YAML parser's flow style (`{…}` / `[…]`) accepted duplicate keys and the merge key that block style rejects — letting a later value silently override an earlier one a preceding check had already read — and mis-parsed a root-level JSON object. content-credentials returned a only shallowly-frozen manifest (nested claim fields stayed mutable before signing), and its identity-assertion hash re-check used set membership rather than multiset matching, so duplicate referenced assertions could leave a bound assertion never re-confirmed. Robustness fixes round out the batch: metrics now reject `±Infinity`; the buffer collector preserves a caller error class's code/message (they were swapped for framework error classes, so a caller branching on the error code — e.g. a resumable-upload `413` vs `400` — never matched); resumable uploads return `413` (not a `400` that leaked the internal error code) on an oversized chunk, reject a prototype-key checksum algorithm with `400` (not `500`), and parse a deferred `Upload-Length` as strictly as creation; the MCP guard refuses `/register/` (trailing slash) like `/register`, answers a missing/invalid bearer with `401` and its `WWW-Authenticate` challenge instead of `400`, honors an explicit empty accepted-version list, and accepts a loopback `http` redirect URI for native/CLI clients while still refusing non-loopback `http`. **Fixed:** *Metrics reject non-finite values* — `gauge.set`, `histogram.observe` and `counter.inc` guarded their value with an `isNaN`-only check (counter only checked `< 0`), so `±Infinity` passed and produced `Infinity` / `-Infinity` in the exposition — invalid Prometheus 0.0.4 / OpenMetrics, which a scraper rejects. All three now require a finite number, matching their `must be a finite number` error text. · *Buffer stream collector preserves a caller error class's code and message* — `safeBuffer.collectStream` constructs its size-limit error with `(message, code)`, matching its own error class — but a framework error class (the convention elsewhere) takes `(code, message)`, the opposite order, so a caller that passed its own error class received an error with `code` and `message` swapped. A caller branching on the error code then never matched — for example the resumable-upload handler mapped an oversized chunk to `400` instead of `413`. The collector now sets both fields explicitly so they are correct whichever constructor order the passed class uses. · *Resumable upload: correct status codes and strict deferred-length parse* — An oversized `PATCH`/creation chunk now returns `413 Payload Too Large` instead of a `400` whose body leaked the internal error code; an `Upload-Checksum` naming a prototype key (e.g. `__proto__`) returns `400 unsupported` instead of a `500` from an unguarded lookup; and a deferred-length finalization now parses `Upload-Length` with the same strict check as creation, rejecting trailing junk (`"10abc"`) rather than silently truncating it. · *MCP guard: trailing-slash registration refusal, 401 bearer challenge, empty accepted-version list* — The static-registration refusal matched `/register` exactly or by suffix, so `/register/` — which most routers normalize to the same handler — slipped through; it is now refused too. A missing or invalid bearer now returns `401 Unauthorized` with its `WWW-Authenticate` challenge (RFC 6750 §3) instead of `400`, so clients keyed on `401` re-authenticate. And an explicit empty `accepted` protocol-version list now rejects every version instead of silently widening to the default set. · *MCP accepts a loopback `http` redirect URI* — `redirect_uri` validation parsed the URI with the default HTTPS-only protocol set, so a loopback `http://localhost/…` redirect (RFC 8252 native-app / CLI client, permitted by RFC 9700 §4.1.1) failed as `did not parse` before the local-host allowance could run. The parse now admits `http` so the loopback allowance applies; a non-loopback `http` redirect is still refused. · *YAML: trailing content after a flow collection and root-level JSON objects* — `root: [1, 2] junk` silently dropped the trailing content where the quoted-scalar path rejects it; trailing content after a flow collection is now refused (`yaml/trailing-content`). A root-level flow/JSON object (`{"a": 1, "b": 2}`) was mis-scanned as a block mapping with a garbage key and returned structurally-wrong data; the leading flow bracket is now recognized before the block-mapping scan so a root JSON object parses correctly. · *content-credentials: deep-frozen manifest and typed rejections for bad build/sign inputs* — `build` returned a shallowly-frozen manifest, leaving every nested claim field (`provider`/`system`/`content`) mutable before signing despite the documented pre-sign immutability guarantee; the manifest is now deep-frozen. A non-finite or out-of-Date-range `generatedAt` (`NaN` / `Infinity`) crashed with an untyped `RangeError`, and a non-Buffer `certChain` entry to `signCose` threw an untyped Node `TypeError`; both now reject with a typed `content-credentials/*` error at call time. **Security:** *Credential-shaped metric labels are redacted in the `/metrics` exposition, not just the cardinality key* — The metrics registry rewrote a credential-shaped label value to `[REDACTED-CREDENTIAL]`, but only inside the function that builds the internal Map cardinality key. The stored label object — the one the exposition renderer emits — kept the raw value, so a bearer token, API key or JWT placed on a label (e.g. an `authorization` label) was written verbatim to every `/metrics` scrape reader, contradicting the documented guarantee that the bytes never reach the scrape stream. Redaction now runs on the resolved labels themselves, so the key and the rendered output both carry the marker. · *SQL guard fragment mode refuses PostgreSQL dollar-quoted literals and any top-level semicolon* — In fragment mode (`whereRaw`), the embedded-string-literal floor that forces every value through a `?` placeholder detected a single-quoted literal but not a dollar-quoted one (`$tag$…$tag$` or `$$…$$`), so operator input concatenated into a dollar-quoted context passed the strict floor that the equivalent single-quoted value is refused by. The floor now recognizes dollar-quoted literals. Fragment mode also now refuses any top-level `;` — including a lone or trailing one — rather than relying on the stacked-statement scan, which only fired when a second statement followed the `;`. · *MCP tool-input validation enforces the schema without an explicit `type:"object"`* — `validateToolInput` gated the `required` / `properties` / `additionalProperties` checks on the schema declaring `type:"object"`. A schema that carries `properties`/`required` but omits `type` — a valid and common JSON Schema shape — was treated as untyped, so those constraints were skipped and any argument object was accepted, defeating the primitive's stated purpose (OWASP LLM07). Such a schema is now treated as an object schema and its constraints enforced. · *MCP sampling token cap rejects a non-numeric `maxTokens`* — `sampling.guard` applied the per-request token cap only when `maxTokens` was a number, so a hostile-tool-initiated sampling request carrying `maxTokens` as a string skipped the cap entirely — and a downstream SDK that coerces the string would then exceed the budget. A non-numeric `maxTokens` on this server-initiated surface is now refused with a typed error. · *content-credentials identity-assertion re-check uses multiset matching* — `verifyIdentityAssertion` re-confirmed the signed referenced-assertion hash binding with count-equality plus set membership, never consuming a matched entry. Supplying referenced assertions that hash to `[A, A]` against a signed binding of `[A, B]` passed — the counts matched and each supplied hash was present — even though the bound `hash(B)` was never re-confirmed against a real assertion (a transplant fail-open). The re-check now consumes each bound hash as it is matched, so duplicates can no longer stand in for a distinct bound assertion. · *YAML flow-style mappings refuse duplicate keys and the merge key like block style* — Block mappings rejected a duplicate key (`yaml/duplicate-key`) and the anchor-merge key `<<` (`yaml/merge-key-banned`), but flow mappings (`{ a: 1, a: 2 }`) enforced neither — silently keeping the last value on a repeat and accepting `<<` as an ordinary key. A value an earlier check has already read could thus be overridden through flow style. Flow mappings now apply both guards.
12
16
 
13
17
  - v0.16.2 (2026-07-03) — **Security hardening across the mail, OAuth/SAML, ACME, keychain and router primitives — six fail-open / injection / bypass fixes and seven correctness fixes — alongside a large expansion of the automated test suite.** This patch closes a batch of security and correctness defects surfaced while substantially expanding the test suite across the framework's mail-authentication, federated-identity, ACME, keychain, backup, CLI and router primitives. The security fixes: SPF evaluation no longer throws (and thus can no longer be laundered into a temperror that a permissive inbound policy accepts) on a malformed `ip4:` CIDR mask; the Authentication-Results builder now refuses CR/LF/NUL in the `version` field, closing the last header-injection gap left by the earlier mail-header sweep; `res.redirect` now rejects a horizontal TAB (which user agents strip before URL parsing, turning `/\t/evil.example` into a protocol-relative redirect to an attacker origin); the OAuth refresh-token one-time-use replay gate now treats any truthy `seen()` result as seen (a Redis `1` / SQL `COUNT` no longer slips a stolen refresh token past a `=== true` compare) — with the same normalization applied to its sibling replay checks; SAML Single-Logout over HTTP-POST/SOAP now fails closed (throws) when a verification key is supplied without its algorithm, matching the redirect binding instead of silently skipping signature verification; and the OAuth authorization-URL / PAR builders now refuse operator `extraParams` that collide with framework-managed parameters (`redirect_uri`, `state`, `code_challenge`, …). The correctness fixes: concurrent keychain file-store writes are serialized under the existing file lock (no more lost bindings); TLS-RPT submission reads the real `statusCode` (successful reports are no longer misreported as failures); ACME account key-rollover stops double-encoding its inner JWS payload (rollover was always rejected by the CA); the response-schema validator now also validates JSON bodies sent via `res.end`; keychain `remove` validates a relative fallback path like `store`/`retrieve`; `bundleAdapterStorage` honors the ambient compliance posture like `create`; and a refused `dev --ignore` pattern now returns exit code 2 instead of rejecting the CLI promise. **Fixed:** *Concurrent keychain file-store writes no longer drop bindings* — `store` and the file-remove path performed an unlocked read-modify-write of the fallback file, so two concurrent stores could each read the pre-update document and the later write would clobber the earlier binding. Both read-modify-write sequences are now serialized under the file's existing lock. · *TLS-RPT submission reads the response `statusCode`* — `tlsRpt.submit` read `status` from the HTTP client response, but the client resolves `statusCode`, so a successful (2xx) report POST was reported as `{ ok: false, error: 'HTTP undefined' }`. It now reads `statusCode`, so delivered reports are distinguished from rejected ones. · *ACME account key-rollover no longer double-encodes its inner JWS payload* — `accountKeyRollover` passed an already-stringified payload to a signer that stringifies internally, so the inner keyChange JWS payload encoded a JSON string instead of the JSON object and every rollover was rejected by the CA (RFC 8555 §7.3.5). The payload is now passed once. · *Response-schema validator also validates `res.end` JSON bodies* — The response validator wrapped only `res.json`, so a handler shipping an invalid body via `res.end(JSON.stringify(...))` escaped validation despite the documented contract. JSON-shaped `res.end` bodies are now validated on the same path. · *keychain `remove` validates a relative fallback path like `store`/`retrieve`* — `remove` checked file existence before validating the fallback path, so a relative path silently no-op'd instead of throwing the config error that `store`/`retrieve` raise. It now validates first, consistent with the other file-fallback paths. · *`bundleAdapterStorage` honors the ambient compliance posture* — `bundleAdapterStorage` only refused an unencrypted strategy when an explicit posture was passed, unlike `create`, which reads the ambient `b.compliance` posture and refuses `encrypt:false` under HIPAA. `bundleAdapterStorage` now falls back to the ambient posture, closing the asymmetry. · *`dev --ignore` pattern refusal returns exit code 2* — A `dev --ignore` pattern that exceeded the length cap or was refused by the regex guard threw out of the CLI promise instead of writing to stderr and returning exit code 2 like every other CLI validation failure. It now returns the standard non-zero exit. **Security:** *SPF `ip4:` with a malformed CIDR mask fails closed instead of throwing (fail-open)* — `_ipv4InCidr` lacked the finite-mask guard its IPv6 sibling has, so an `ip4:` mechanism with a non-numeric prefix (e.g. `ip4:192.0.2.0/`) reached `BigInt(32 - NaN)` and threw an uncaught `RangeError` out of `b.mail.spf.verify` / `b.mail.inbound.verify`. Upstream mail handling catches that throw as a temperror, and an inbound policy configured to accept on temperror would then admit a sender that should have been evaluated. SPF now returns a `permerror` result for a malformed mask, honoring the primitive's documented never-throw-on-message-content contract. · *Authentication-Results builder refuses CR/LF/NUL in the `version` field (header injection)* — `b.mail.authResults.emit` validated the `authserv-id` against control characters but not the operator-supplied `version`, which is interpolated onto the `Authentication-Results:` header line — the last field left unguarded by the earlier mail-header CRLF sweep. A `version` containing CRLF could smuggle an arbitrary header. It is now refused with a typed error, like every other field on that line. · *`res.redirect` rejects horizontal TAB (open-redirect / same-origin bypass)* — The redirect target's control-character guard rejected CR/LF/NUL but not TAB (0x09). Node permits a TAB in a header value, but the WHATWG URL parser strips ASCII TAB/LF/CR from a URL before resolving it, so a `Location: /\t/evil.example` collapses to the protocol-relative `//evil.example` and navigates to the attacker origin — bypassing the same-origin/allowlist heuristic. TAB is now rejected alongside CR/LF/NUL. · *OAuth refresh-token replay gate treats any truthy `seen()` result as seen (fail-open)* — The legacy `seen(refreshToken)` replay gate compared the callback's return with `=== true`, but the documented contract is that it returns a truthy value when the token was presented before. A store returning a truthy non-`true` value — a Redis `EXISTS`/`SISMEMBER` `1`, a SQL `COUNT` — slipped a replayed (stolen) refresh token past the one-time-use gate. Any truthy result now counts as seen; the same normalization was applied to the sibling replay checks in the module. · *SAML Single-Logout over HTTP-POST/SOAP fails closed on a key without its algorithm* — The POST and SOAP SLO parsers gated signature verification with an OR condition and the verify helper early-returned when the algorithm was absent, so supplying a verification key without `idpVerifyAlg` silently accepted a forged, unsigned LogoutRequest — while the HTTP-Redirect binding failed closed. All four SLO parse paths now route through one config check that throws when a key is supplied without its algorithm. · *OAuth authorization-URL / PAR builders refuse `extraParams` that collide with managed parameters* — `authorizationUrl` and `pushAuthorizationRequest` merged operator `extraParams` with `URLSearchParams.set`, which replaces — so an `extraParams` entry for `redirect_uri`, `state`, or `code_challenge` overwrote the framework-generated value while the call still returned the framework's `state`/PKCE verifier, diverging the returned session seed from the emitted URL. Reserved parameter keys in `extraParams` are now refused with a typed error at both builders.
package/lib/arg-parser.js CHANGED
@@ -638,6 +638,12 @@ function create(opts) {
638
638
  * value`, `--key=value`, and bare `--bool`. `--` terminates flag
639
639
  * parsing.
640
640
  *
641
+ * A flag repeated on the command line accumulates every occurrence into
642
+ * an array, in order — `--watch a --watch b` yields `["a", "b"]`, not
643
+ * just the last value. A flag seen once stays a scalar. This keeps
644
+ * repeatable flags (the `dev` command's `--arg` / `--watch` / `--ignore`)
645
+ * from silently dropping all but the final occurrence.
646
+ *
641
647
  * @example
642
648
  * var r = b.argParser.parseRaw(
643
649
  * ["build", "--target=node", "-v", "--out", "dist", "--", "extra"]);
@@ -646,6 +652,24 @@ function create(opts) {
646
652
  * r.flags.v; // → true
647
653
  * r.flags.out; // → "dist"
648
654
  */
655
+ // Record a parsed flag, accumulating repeats. First occurrence stores the
656
+ // scalar as-is (so single-value flags keep their string/boolean shape);
657
+ // the second and later occurrences promote the entry to an array holding
658
+ // every value in argv order. Without this a repeated flag would overwrite
659
+ // the earlier value and a repeatable flag could never yield more than one.
660
+ function _assignFlag(flags, name, val) {
661
+ if (Object.prototype.hasOwnProperty.call(flags, name)) {
662
+ var prev = flags[name];
663
+ if (Array.isArray(prev)) {
664
+ prev.push(val);
665
+ } else {
666
+ flags[name] = [prev, val];
667
+ }
668
+ } else {
669
+ flags[name] = val;
670
+ }
671
+ }
672
+
649
673
  function parseRaw(argv) {
650
674
  if (!Array.isArray(argv)) {
651
675
  throw new ArgParserError("arg-parser/argv-not-array",
@@ -679,14 +703,14 @@ function parseRaw(argv) {
679
703
  throw new ArgParserError("arg-parser/argv-forbidden-name",
680
704
  "flag '--" + name + "' is reserved");
681
705
  }
682
- flags[name] = val;
706
+ _assignFlag(flags, name, val);
683
707
  } else if (tok.indexOf("-") === 0 && tok.length === 2) {
684
708
  var s = tok.slice(1);
685
709
  if (pick.isPoisonedKey(s)) {
686
710
  throw new ArgParserError("arg-parser/argv-forbidden-name",
687
711
  "flag '-" + s + "' is reserved");
688
712
  }
689
- flags[s] = true;
713
+ _assignFlag(flags, s, true);
690
714
  } else {
691
715
  pos.push(tok);
692
716
  }
package/lib/auth/oauth.js CHANGED
@@ -1123,6 +1123,16 @@ function create(opts) {
1123
1123
  backchannelAuthenticationEndpoint:
1124
1124
  opts.backchannelAuthenticationEndpoint ||
1125
1125
  (preset && preset.backchannelAuthenticationEndpoint) || null,
1126
+ // _resolveEndpoint maps these three snake-case discovery keys, and the
1127
+ // introspect / register / device-grant primitives resolve through it. A
1128
+ // static (non-discovery) client must be able to supply them as opts —
1129
+ // introspectToken's own no-endpoint refusal tells operators to set
1130
+ // opts.introspectionEndpoint, so create() has to actually read it.
1131
+ introspectionEndpoint: opts.introspectionEndpoint || (preset && preset.introspectionEndpoint) || null,
1132
+ registrationEndpoint: opts.registrationEndpoint || (preset && preset.registrationEndpoint) || null,
1133
+ deviceAuthorizationEndpoint:
1134
+ opts.deviceAuthorizationEndpoint ||
1135
+ (preset && preset.deviceAuthorizationEndpoint) || null,
1126
1136
  };
1127
1137
 
1128
1138
  // Discovery + JWKS caches use b.cache.create + .wrap so concurrent
@@ -2859,6 +2869,16 @@ function create(opts) {
2859
2869
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
2860
2870
  if (allowInternal !== null) req.allowInternal = allowInternal;
2861
2871
  Object.assign(req, httpClientOpts);
2872
+ // RFC 8628 §3.5 / RFC 6749 §5.2 return the device-grant errors
2873
+ // (authorization_pending / slow_down and the terminal codes) as an
2874
+ // HTTP 400 whose body carries `error`. The loop below reads that body,
2875
+ // so the token request MUST NOT let b.httpClient reject the 4xx first —
2876
+ // the default buffer mode would throw before the pending/slow_down/
2877
+ // terminal handling runs, aborting the grant on the first poll (which
2878
+ // is almost always authorization_pending). Force always-resolve AFTER
2879
+ // merging httpClientOpts so an operator override cannot silently
2880
+ // reinstate buffer mode.
2881
+ req.responseMode = "always-resolve";
2862
2882
  var res = await hc.request(req);
2863
2883
  var text = res.body ? res.body.toString("utf8") : "";
2864
2884
  var parsed;
package/lib/auth/saml.js CHANGED
@@ -54,6 +54,7 @@ var zlib = require("node:zlib");
54
54
  var nodeCrypto = require("node:crypto");
55
55
  var pqcSoftware = require("../pqc-software");
56
56
  var bCrypto = require("../crypto");
57
+ var C = require("../constants");
57
58
  var { generateToken, timingSafeEqual } = bCrypto;
58
59
  var { AuthError } = require("../framework-error");
59
60
 
@@ -1681,16 +1682,16 @@ function _decryptEncryptedAssertion(encAssertion, spPrivateKeyPem) {
1681
1682
  "OAEP unwrap failed: " + ((eR && eR.message) || String(eR)));
1682
1683
  }
1683
1684
  } else if (keyAlg === "urn:blamejs:experimental:xmlenc:ml-kem-1024") {
1684
- // Framework PQC envelopewrappedKey carries the ML-KEM
1685
- // ciphertext concatenated with the AEAD-wrapped CEK. We invoke
1686
- // b.pqcSoftware.ml_kem_1024.decapsulate to recover the shared
1687
- // secret, then ChaCha20-Poly1305 unwrap. The exact wire shape is
1688
- // the framework's `b.crypto.envelope` format.
1685
+ // Framework PQC key transport the EncryptedKey CipherValue carries
1686
+ // a b.crypto envelope (ML-KEM-1024 KEM-only suite) whose plaintext IS
1687
+ // the content-encryption key. b.crypto.decrypt parses the envelope
1688
+ // magic + suite header, decapsulates with the SP's ML-KEM-1024 private
1689
+ // key, and returns the CEK bytes. `raw: true` keeps the CEK as a
1690
+ // Buffer (it is binary key material, not utf8 text); the SP private
1691
+ // key travels as the PEM the envelope opener expects.
1689
1692
  try {
1690
- cek = bCrypto.decryptEnvelope({
1691
- envelope: wrappedKey,
1692
- privateKey: nodeCrypto.createPrivateKey({ key: spPrivateKeyPem, format: "pem" }),
1693
- });
1693
+ cek = bCrypto.decrypt(wrappedKey.toString("base64"),
1694
+ { privateKey: spPrivateKeyPem }, { raw: true });
1694
1695
  } catch (eM) {
1695
1696
  throw new AuthError("auth-saml/encrypted-key-unwrap-failed",
1696
1697
  "ML-KEM-1024 unwrap failed: " + ((eM && eM.message) || String(eM)));
@@ -1744,17 +1745,16 @@ function _decryptEncryptedAssertion(encAssertion, spPrivateKeyPem) {
1744
1745
  throw new AuthError("auth-saml/encrypted-content-too-short",
1745
1746
  "XChaCha20-Poly1305 CipherValue too short");
1746
1747
  }
1747
- var xnonce = contentBlob.subarray(0, 24); // XChaCha20 nonce size
1748
- var xtag = contentBlob.subarray(contentBlob.length - 16); // Poly1305 tag size
1749
- var xct = contentBlob.subarray(24, contentBlob.length - 16);
1748
+ // XMLEnc content wire is nonce(24) || ciphertext || Poly1305-tag(16).
1749
+ // b.crypto.decryptPacked reads a 1-byte format tag followed by that
1750
+ // exact 24-byte-nonce + (ciphertext||tag) tail, so prepending the
1751
+ // XChaCha20-Poly1305 format byte routes the content through the
1752
+ // framework's own AEAD (which verifies the Poly1305 tag) instead of a
1753
+ // hand-rolled cipher call.
1754
+ var packedContent = Buffer.concat([
1755
+ Buffer.from([C.FORMAT.XCHACHA20_POLY1305]), contentBlob]);
1750
1756
  try {
1751
- clearBytes = bCrypto.aeadDecrypt({
1752
- alg: "xchacha20-poly1305",
1753
- key: cek,
1754
- nonce: xnonce,
1755
- ct: xct,
1756
- tag: xtag,
1757
- });
1757
+ clearBytes = bCrypto.decryptPacked(packedContent, cek);
1758
1758
  } catch (eX) {
1759
1759
  throw new AuthError("auth-saml/encrypted-content-tag-mismatch",
1760
1760
  "XChaCha20-Poly1305 tag mismatch: " + ((eX && eX.message) || String(eX)));
package/lib/base32.js CHANGED
@@ -128,6 +128,7 @@ function decode(str, opts) {
128
128
  var bytes = [];
129
129
  var value = 0, bits = 0;
130
130
  var inPad = false; // once "=" padding starts, only more "=" may follow
131
+ var dataCount = 0; // count of data symbols (excludes padding + skipped separators)
131
132
  for (var i = 0; i < str.length; i++) {
132
133
  var ch = str.charAt(i);
133
134
  if (ch === "=") { inPad = true; continue; } // trailing padding
@@ -140,11 +141,31 @@ function decode(str, opts) {
140
141
  if (idx === undefined) throw new Base32Error("base32/bad-char", "base32.decode: invalid Base32 character '" + str.charAt(i) + "' at index " + i);
141
142
  value = (value << BITS) | idx;
142
143
  bits += BITS;
144
+ dataCount += 1;
143
145
  if (bits >= 8) { // emit a full output byte
144
146
  bytes.push((value >>> (bits - 8)) & 0xff); // eight-bit output byte mask
145
147
  bits -= 8; // consumed eight bits
146
148
  }
147
149
  }
150
+ // A group of 5 input bytes encodes to 8 symbols; the only valid partial-group
151
+ // symbol counts are 2, 4, 5, 7 (for 1, 2, 3, 4 trailing bytes). A count of
152
+ // 1/3/6 (mod 8) cannot represent any whole-byte input, so the OLD decoder
153
+ // silently returned a truncated/garbage buffer — refuse it.
154
+ var rem = dataCount % GROUP;
155
+ if (rem === 1 || rem === 3 || rem === 6) {
156
+ throw new Base32Error("base32/bad-length",
157
+ "base32.decode: " + dataCount + " data symbol(s) is not a valid Base32 length " +
158
+ "(a partial group is 2, 4, 5 or 7 symbols)");
159
+ }
160
+ // Non-canonical trailing bits (RFC 4648 §3.5): a conforming encoder zeroes the
161
+ // final symbol's unused low bits. Non-zero leftover bits mean two distinct
162
+ // strings decode to the same bytes — decoder malleability. Refuse it so every
163
+ // byte sequence has exactly one Base32 form (matters where the string is a
164
+ // key / secret / dedup handle, e.g. TOTP, identifiers).
165
+ if (bits > 0 && (value & ((1 << bits) - 1)) !== 0) {
166
+ throw new Base32Error("base32/non-canonical",
167
+ "base32.decode: non-canonical encoding — the final symbol's unused low bits must be zero");
168
+ }
148
169
  return Buffer.from(bytes);
149
170
  }
150
171
 
package/lib/db.js CHANGED
@@ -1877,15 +1877,19 @@ function stream(sql) {
1877
1877
  objectMode: true,
1878
1878
  read: function () {
1879
1879
  try {
1880
+ var step = iter.next();
1881
+ if (step.done) { this.push(null); return; }
1882
+ // Enforce the cap AFTER pulling the row + the done-check: a result set of
1883
+ // EXACTLY streamLimit rows completes cleanly; only a row BEYOND the cap
1884
+ // (the streamLimit+1'th) trips the guard (the top-of-read `>=` form errored
1885
+ // at == limit, turning a valid complete export into a stream error).
1880
1886
  if (emitted >= perCallLimit) {
1881
1887
  this.destroy(new DbError("db/stream-limit-exceeded",
1882
- "db.stream: emitted " + emitted + " rows, exceeding streamLimit " +
1883
- perCallLimit + ". Pass opts.streamLimit higher OR raise via " +
1888
+ "db.stream: result set exceeds streamLimit " + perCallLimit +
1889
+ " (reached row " + (emitted + 1) + "). Pass opts.streamLimit higher OR raise via " +
1884
1890
  "db.init({ streamLimit }) after auditing the export path."));
1885
1891
  return;
1886
1892
  }
1887
- var step = iter.next();
1888
- if (step.done) { this.push(null); return; }
1889
1893
  emitted += 1;
1890
1894
  var row = step.value;
1891
1895
  this.push(unseal ? unseal.unsealRow(table, row) : row);
@@ -637,11 +637,24 @@ class Pool {
637
637
  }
638
638
 
639
639
  _reapIdle() {
640
+ // Reap idle clients past idleTimeoutMs, but honor `min` as a floor on
641
+ // idle clients: retain at least `min` warm connections even when they
642
+ // have all gone idle past the timeout, so a quiet period does not drain
643
+ // the pool below its configured warm floor. The freshest entries sit at
644
+ // the end of the idle stack (release() pushes, acquire() pops), so the
645
+ // stalest expired clients are reaped first and the floor keeps the most
646
+ // recently used. min:0 opts out of the floor (reap every expired idle).
647
+ var min = (typeof this.config.min === "number" && isFinite(this.config.min) && this.config.min > 0)
648
+ ? this.config.min : 0;
649
+ var reapable = this.idle.length - min;
650
+ if (reapable <= 0) return; // at or below the floor — keep every idle client warm
640
651
  var now = Date.now();
641
652
  var keep = [];
653
+ var reaped = 0;
642
654
  var self = this;
643
655
  this.idle.forEach(function (entry) {
644
- if ((now - entry.lastUsedAt) >= self.config.idleTimeoutMs) {
656
+ if (reaped < reapable && (now - entry.lastUsedAt) >= self.config.idleTimeoutMs) {
657
+ reaped += 1;
645
658
  Promise.resolve().then(function () { return self.close(entry.client); }).catch(function () {});
646
659
  } else {
647
660
  keep.push(entry);
@@ -907,7 +920,21 @@ function init(opts) {
907
920
  };
908
921
  }
909
922
 
910
- defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
923
+ // defaultBackend when supplied it must name a REGISTERED backend, so a
924
+ // typo surfaces as a typed config-time error at init() rather than as an
925
+ // opaque TypeError when the first query dereferences a missing pool.
926
+ // Omitted → the first declared backend (declaration order).
927
+ if (opts.defaultBackend !== undefined && opts.defaultBackend !== null) {
928
+ validateOpts.requireNonEmptyString(opts.defaultBackend, "defaultBackend", ExternalDbError, "INVALID_CONFIG");
929
+ if (!Object.prototype.hasOwnProperty.call(backends, opts.defaultBackend)) {
930
+ throw _err("INVALID_CONFIG",
931
+ "defaultBackend: no backend named '" + opts.defaultBackend + "' " +
932
+ "(declared backends: " + Object.keys(backends).join(", ") + ")", true);
933
+ }
934
+ defaultBackend = opts.defaultBackend;
935
+ } else {
936
+ defaultBackend = Object.keys(backends)[0];
937
+ }
911
938
 
912
939
  // dbRoleBackends — request-time role → backend mapping. Each role name
913
940
  // validates as a SQL identifier at init (matches the dbRoleFor
@@ -59,7 +59,11 @@ var _err = GuardRegexError.factory;
59
59
 
60
60
  // Nested-quantifier detector: `(group)+`-style followed by another
61
61
  // quantifier or repetition that operates on the grouped match.
62
- var NESTED_QUANT_RE = /\([^()]*[*+?][^()]*\)\s*[*+?{]/;
62
+ // (The flat single-group nested-quantifier case is handled by the paren-aware
63
+ // structural scanner _hasNestedQuantifier, which — unlike a flat regex — requires
64
+ // the OUTER quantifier to be UNBOUNDED (`*`/`+`/`{n,}`, not `?`/`{0,1}`/`{n,m}`)
65
+ // and does not miscount a `(?:` group prefix as an inner quantifier, so it does
66
+ // not false-positive on linear shapes like `(?:X+)?` / `(X+)?` / `(?:bar)*`.)
63
67
 
64
68
  // Alternation-with-quantifier — `(a|b|...)+`, `(a|b)*`.
65
69
  var ALTERNATION_QUANT_RE = /\([^()]*\|[^()]*\)\s*[*+]/;
@@ -184,8 +188,7 @@ function _detectIssues(input, opts) {
184
188
  if (pre.done) return pre.issues;
185
189
  var issues = pre.issues;
186
190
 
187
- if (opts.nestedQuantPolicy !== "allow" &&
188
- (NESTED_QUANT_RE.test(input) || _hasNestedQuantifier(input))) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
191
+ if (opts.nestedQuantPolicy !== "allow" && _hasNestedQuantifier(input)) {
189
192
  issues.push({
190
193
  kind: "nested-quantifier", severity: "critical",
191
194
  ruleId: "regex.nested-quantifier",
@@ -57,7 +57,7 @@ var ssrfGuard = require("./ssrf-guard");
57
57
  var networkProxy = require("./network-proxy");
58
58
  var numericBounds = require("./numeric-bounds");
59
59
  var validateOpts = require("./validate-opts");
60
- var { FrameworkError, HttpClientError } = require("./framework-error");
60
+ var { HttpClientError } = require("./framework-error");
61
61
 
62
62
  // Per-origin transport cache. Entry is either the resolved transport
63
63
  // object or a pending Promise that resolves to one. The Promise form
@@ -370,8 +370,11 @@ function _getTransport(u, opts, ips) {
370
370
  }
371
371
 
372
372
  function _makeError(errorClass, code, message, permanent, statusCode) {
373
- if (!errorClass) return new FrameworkError(message, code);
374
- return new errorClass(code, message, permanent, statusCode);
373
+ // Default to HttpClientError (the documented default errorClass) rather than
374
+ // the base FrameworkError, so error.statusCode and error.permanent are
375
+ // populated even when the caller passed no explicit opts.errorClass.
376
+ var Cls = errorClass || HttpClientError;
377
+ return new Cls(code, message, permanent, statusCode);
375
378
  }
376
379
 
377
380
  // RFC 9110 §15.5 4xx codes that are NOT permanent (request-timeout,
@@ -553,10 +556,25 @@ function _buildMultipartBody(spec) {
553
556
  totalSize += CRLF.length;
554
557
  }
555
558
 
559
+ // Reject CR/LF/NUL in any value interpolated onto a part-header line
560
+ // (Content-Disposition name/filename, Content-Type). Without this an attacker
561
+ // who controls a field name / filename / content-type can smuggle a CRLF and
562
+ // inject extra part headers or forge additional form parts (multipart header
563
+ // injection) — the same class the mail-header sweep closed for RFC 822 lines.
564
+ function _assertHeaderSafe(v, label) {
565
+ for (var i = 0; i < v.length; i++) {
566
+ var c = v.charCodeAt(i);
567
+ if (c === 13 || c === 10 || c === 0) { // CR / LF / NUL
568
+ throw new Error("multipart: " + label + " must not contain CR, LF, or NUL (header injection)");
569
+ }
570
+ }
571
+ }
572
+
556
573
  function _pushField(name, value) {
557
574
  if (typeof name !== "string" || name.length === 0) {
558
575
  throw new Error("multipart: field name must be a non-empty string");
559
576
  }
577
+ _assertHeaderSafe(name, "field name");
560
578
  var disposition = 'Content-Disposition: form-data; name="' + name + '"';
561
579
  var head = _entryHeaderBytes(disposition, null);
562
580
  var bodyBuf = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
@@ -588,6 +606,9 @@ function _buildMultipartBody(spec) {
588
606
  filename = "blob";
589
607
  }
590
608
  var mimeType = file.contentType || file.mimeType || "application/octet-stream";
609
+ _assertHeaderSafe(file.field, "file.field");
610
+ _assertHeaderSafe(filename, "filename");
611
+ _assertHeaderSafe(mimeType, "contentType");
591
612
  var disposition = 'Content-Disposition: form-data; name="' + file.field + '"' +
592
613
  '; filename="' + filename.replace(/"/g, "%22") + '"';
593
614
  var head = _entryHeaderBytes(disposition, mimeType);
@@ -183,6 +183,14 @@ function _parseSes(p) {
183
183
  throw _err("ses/bad-message-json",
184
184
  "SES SNS Message field is not valid JSON: " + (e && e.message));
185
185
  }
186
+ // A syntactically valid Message can still decode to a non-object —
187
+ // JSON literal null / number / string / boolean / array. Reject it
188
+ // with a typed error rather than dereferencing null downstream, and
189
+ // do not echo the decoded value (avoid leaking payload internals).
190
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
191
+ throw _err("ses/bad-message-json",
192
+ "SES SNS Message field must decode to a JSON object");
193
+ }
186
194
  }
187
195
  var notificationType = msg.notificationType || msg.eventType;
188
196
  if (!notificationType) {
package/lib/mail-dav.js CHANGED
@@ -247,8 +247,19 @@ function create(opts) {
247
247
  var segs = path.split("/").filter(function (s) { return s.length > 0; });
248
248
  var decoded = [];
249
249
  for (var i = 0; i < segs.length; i++) {
250
- try { decoded.push(decodeURIComponent(segs[i])); }
250
+ var seg;
251
+ try { seg = decodeURIComponent(segs[i]); }
251
252
  catch (_e) { return { principalId: null, parts: [], rejected: "malformed-uri" }; }
253
+ // Validate the DECODED segment. The pre-decode guard above only catches a
254
+ // literal ".."/"%2e%2e", so a MIXED encoding (".%2e", "%2e.") passes it and
255
+ // then decodes to "..". Reject any decoded segment that is "." / ".." or
256
+ // carries a NUL or an embedded path separator — the traversal the storage
257
+ // backend (getComponent/putComponent/...) would otherwise receive.
258
+ if (seg === "." || seg === ".." || seg.indexOf("\0") >= 0 ||
259
+ seg.indexOf("/") >= 0 || seg.indexOf("\\") >= 0) {
260
+ return { principalId: null, parts: [], rejected: "traversal" };
261
+ }
262
+ decoded.push(seg);
252
263
  }
253
264
  return {
254
265
  principalId: decoded[0] || null,
@@ -551,7 +562,11 @@ function create(opts) {
551
562
  _emit("mail.dav.handler_threw",
552
563
  { method: method, kind: "caldav",
553
564
  error: (e && e.message) || String(e) }, "failure");
554
- return _refuseStatus(res, 500, "Server error");
565
+ // A malformed / empty client request BODY (bad PROPFIND/REPORT XML) is a
566
+ // client fault → 400, mirroring the 400 that MKCALENDAR/MKCOL already return
567
+ // for the same input; only a genuine server-side throw is 500.
568
+ var isClientFault = e && /mail-dav\/bad-(propfind|report)-body/.test(e.code || "");
569
+ return _refuseStatus(res, isClientFault ? 400 : 500, isClientFault ? "Bad request" : "Server error");
555
570
  }
556
571
  }
557
572
 
@@ -889,7 +904,11 @@ function create(opts) {
889
904
  _emit("mail.dav.handler_threw",
890
905
  { method: method, kind: "carddav",
891
906
  error: (e && e.message) || String(e) }, "failure");
892
- return _refuseStatus(res, 500, "Server error");
907
+ // A malformed / empty client request BODY (bad PROPFIND/REPORT XML) is a
908
+ // client fault → 400, mirroring the 400 that MKCALENDAR/MKCOL already return
909
+ // for the same input; only a genuine server-side throw is 500.
910
+ var isClientFault = e && /mail-dav\/bad-(propfind|report)-body/.test(e.code || "");
911
+ return _refuseStatus(res, isClientFault ? 400 : 500, isClientFault ? "Bad request" : "Server error");
893
912
  }
894
913
  }
895
914
 
@@ -244,22 +244,32 @@ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
244
244
  { domain: domain, mode: policyMode });
245
245
  return mxs;
246
246
  }
247
- if (sts.mode === "testing" && policyMode === "enforce") {
248
- // Testing-mode STS doesn't refuse delivery but does record the
249
- // mismatch via TLS-RPT. Honor the STS allowlist as an information
250
- // signal; don't refuse.
247
+ if (sts.mode === "testing") {
248
+ // RFC 8461 §5.2 a policy published in "testing" mode records
249
+ // validation failures via TLS-RPT but MUST NOT block delivery. The
250
+ // domain has explicitly opted out of enforcement, so the local
251
+ // posture (even the default mtaSts:"enforce") cannot promote a
252
+ // testing policy to a hard bounce. Deliver against the full MX set
253
+ // and surface the match result as a report-only signal.
254
+ var testingMatched = mxs.filter(function (m) {
255
+ return smtpPolicy().mtaSts.matchMx(m.exchange, sts.mx || []);
256
+ });
251
257
  auditEmit("mail.send.deliver.mtaSts.testing", "info",
252
- { domain: domain, mxPatterns: sts.mx });
258
+ { domain: domain, mxPatterns: sts.mx,
259
+ matched: testingMatched.length, total: mxs.length });
260
+ return mxs;
253
261
  }
254
262
  var filtered = mxs.filter(function (m) {
255
263
  return smtpPolicy().mtaSts.matchMx(m.exchange, sts.mx || []);
256
264
  });
257
- if (filtered.length === 0 && (sts.mode === "enforce" || policyMode === "enforce")) {
265
+ if (filtered.length === 0 && sts.mode === "enforce") {
258
266
  throw new DeliverError("deliver/mta-sts-mx-mismatch",
259
267
  "no MX for " + domain + " matches the published MTA-STS policy (mode=" + sts.mode + ")");
260
268
  }
261
269
  if (filtered.length === 0) {
262
- // testing or off mode log and continue with original list
270
+ // Any remaining published mode that is neither enforce nor testing
271
+ // with no match — log and continue with the original list rather
272
+ // than block delivery.
263
273
  auditEmit("mail.send.deliver.mtaSts.no-match", "warn",
264
274
  { domain: domain, mode: sts.mode });
265
275
  return mxs;
@@ -352,7 +362,23 @@ async function _deliverOne(envelope, recipient, ctx) {
352
362
  // composes directly into smtpTransport.dane); this branch carries
353
363
  // the discovery so the audit chain records the policy posture
354
364
  // applied to each delivery attempt.
355
- await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane, ctx.auditEmit);
365
+ try {
366
+ await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane, ctx.auditEmit);
367
+ } catch (daneErr) {
368
+ // DANE "enforce": a TLSA lookup failure means this MX host cannot
369
+ // be used for authenticated delivery (RFC 7672 §2.2). Fail this
370
+ // single MX over to the next candidate; if every MX for this
371
+ // recipient fails DANE the recipient is deferred (and eventually
372
+ // bounced once the operator's retry budget is spent). A per-
373
+ // recipient DANE failure must never throw out of the whole
374
+ // deliver() batch and discard the sibling recipients' outcomes —
375
+ // it is contained here exactly like the MTA-STS enforce path.
376
+ lastErr = daneErr;
377
+ ctx.auditEmit("mail.send.deliver.dane-failover", "warn", {
378
+ recipient: recipient, mxHost: mx.exchange, reason: daneErr.message,
379
+ });
380
+ continue;
381
+ }
356
382
  try {
357
383
  var rv = await _tryHost({
358
384
  from: envelope.from,
package/lib/mail.js CHANGED
@@ -786,13 +786,23 @@ function _buildRfc822(message) {
786
786
  body = parts.join("\r\n");
787
787
  }
788
788
 
789
- // Normalize line endings then dot-stuff per SMTP transparency.
789
+ // Normalize line endings only. Dot-stuffing (SMTP DATA transparency, RFC 5321
790
+ // §4.5.2) is applied at DATA send time — NOT here — because it must NOT touch
791
+ // the RFC 3030 BDAT/CHUNKING path (length-framed; receivers do not un-stuff, so
792
+ // a baked-in doubled dot corrupts the body) and must NOT be inside the
793
+ // DKIM-signed message (the receiver verifies the un-stuffed body).
790
794
  body = body.replace(/\r?\n/g, "\r\n");
791
- body = body.split("\r\n").map(function (l) { return l.charAt(0) === "." ? "." + l : l; }).join("\r\n");
792
795
 
793
796
  return headers.join("\r\n") + "\r\n\r\n" + body;
794
797
  }
795
798
 
799
+ // Dot-stuff a message for the SMTP DATA path (RFC 5321 §4.5.2): any line whose
800
+ // first byte is "." gets a doubled dot so the receiver's `.`-on-its-own-line
801
+ // terminator can't be forged by body content. Applied ONLY on DATA, never BDAT.
802
+ function _dotStuffForData(msg) {
803
+ return msg.split("\r\n").map(function (l) { return l.charAt(0) === "." ? "." + l : l; }).join("\r\n");
804
+ }
805
+
796
806
  function smtpTransport(opts) {
797
807
  opts = opts || {};
798
808
  if (!opts.host) {
@@ -1371,7 +1381,7 @@ function _smtpSend(message, cfg) {
1371
1381
  }
1372
1382
  else if (step === SMTP_STEP_DATA) {
1373
1383
  if (code !== 354) { fail("data-rejected (code " + code + ")"); return; }
1374
- send(dataMessage + "\r\n.");
1384
+ send(_dotStuffForData(dataMessage) + "\r\n.");
1375
1385
  step = SMTP_STEP_BODY;
1376
1386
  }
1377
1387
  else if (step === SMTP_STEP_BDAT) {
@@ -1609,10 +1619,10 @@ function resendTransport(opts) {
1609
1619
  throw new MailError("mail/resend-bad-response",
1610
1620
  "resend response was not JSON: " + text.slice(0, DIAG_SNIPPET_LEN), false);
1611
1621
  }
1612
- if (!data.id) {
1622
+ if (!data || !data.id) {
1613
1623
  return {
1614
1624
  ok: false,
1615
- reason: data.message || JSON.stringify(data).slice(0, DIAG_SNIPPET_LEN),
1625
+ reason: (data && data.message) || JSON.stringify(data).slice(0, DIAG_SNIPPET_LEN),
1616
1626
  };
1617
1627
  }
1618
1628
  return { ok: true, id: data.id };
@@ -158,8 +158,12 @@ function _cachePutPositive(host, family, value) {
158
158
  }
159
159
 
160
160
  function _cachePutNegative(host, family, error) {
161
- if (STATE.cacheTtlMs <= 0) return;
162
- var ttl = STATE.cacheNegativeTtlMs > 0 ? STATE.cacheNegativeTtlMs : Math.min(STATE.cacheTtlMs, C.TIME.seconds(30));
161
+ // Effective negative TTL: an explicit cacheNegativeTtlMs applies even when the
162
+ // positive TTL is 0 (setCacheTtlMs(0, 60000) = positives off, negatives 60s);
163
+ // only fall back to the positive TTL when no explicit negative TTL is set.
164
+ var ttl = STATE.cacheNegativeTtlMs > 0 ? STATE.cacheNegativeTtlMs
165
+ : (STATE.cacheTtlMs > 0 ? Math.min(STATE.cacheTtlMs, C.TIME.seconds(30)) : 0);
166
+ if (ttl <= 0) return;
163
167
  NEGATIVE_CACHE.set(host + "/" + family, {
164
168
  error: error,
165
169
  expiresAt: _now() + ttl,
@@ -237,6 +241,14 @@ function setCacheTtlMs(ms, negativeMs) {
237
241
  "dns.setCacheTtlMs negativeMs: expected non-negative finite number, got " + JSON.stringify(negativeMs));
238
242
  }
239
243
  STATE.cacheNegativeTtlMs = negativeMs;
244
+ } else {
245
+ // No explicit negative TTL this call — clear any stale explicit value so it
246
+ // can't outlive the setCacheTtlMs call that set it. Otherwise
247
+ // setCacheTtlMs(0) after an earlier setCacheTtlMs(ms, negMs) would leave
248
+ // negative caching alive (sticky transient failures) even though caching was
249
+ // just disabled; with this cleared, negative TTL derives from the positive
250
+ // TTL, so ms=0 disables both positive and negative caching.
251
+ STATE.cacheNegativeTtlMs = 0;
240
252
  }
241
253
  if (ms === 0) _clearCache();
242
254
  }
@@ -1696,6 +1708,13 @@ async function resolveAaaa(host, opts) { return _resolveProtocol(host, 6, opts);
1696
1708
  // `type` defaults to "A"; "AAAA" routes through resolve6; SVCB / HTTPS
1697
1709
  // types route through the new querySvcb / queryHttps primitives.
1698
1710
  async function resolve(host, type, opts) {
1711
+ // A non-string `type` would throw a raw TypeError from .toUpperCase() (code
1712
+ // undefined, no .permanent) — reject it as a typed DnsError like every other
1713
+ // bad-type path, so a caller keying on err.permanent gets the right verdict.
1714
+ if (type !== undefined && type !== null && typeof type !== "string") {
1715
+ throw new DnsError("dns/unsupported-type",
1716
+ "dns.resolve: type must be a string (got " + typeof type + ")");
1717
+ }
1699
1718
  type = (type || "A").toUpperCase();
1700
1719
  if (type === "A") return _resolveProtocol(host, 4, opts);
1701
1720
  if (type === "AAAA") return _resolveProtocol(host, 6, opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:3e7515ff-90ec-4d95-be2f-ec3082f1f6d8",
5
+ "serialNumber": "urn:uuid:f9a1389c-b576-4d1a-8d0e-33d85c815fb6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-03T23:06:39.610Z",
8
+ "timestamp": "2026-07-04T07:19:07.771Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.16.3",
22
+ "bom-ref": "@blamejs/core@0.16.5",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.3",
25
+ "version": "0.16.5",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.16.3",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.5",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.16.3",
57
+ "ref": "@blamejs/core@0.16.5",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]