@blamejs/core 0.15.14 → 0.15.15

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.15 (2026-06-21) — **A focused correctness and hardening release: more authentication and signature verifiers fail closed (JWT issuer, SAML recipient binding, OAuth nonce, CIBA token binding, FIDO certification level), the WebSocket client bounds a fragmented message before it completes, DMARC resolves the From domain before the policy lookup, and the CSP builder refuses a directive-injecting source; fixes a middleware pipeline promise that never settled on a write-and-halt, and exposes the X.509 CA-bit issuer test, an IP-prefix helper, and a reverse-proxy-aware session fingerprint option.** This release continues to tighten existing protections without adding opt-ins on the request path. A further set of authentication and signature verifiers now refuse malformed or unbound credentials: JWT verify rejects an array-valued issuer claim (an any-match bypass, CVE-2025-30144 class), SAML requires the mandatory Recipient on a Bearer or holder-of-key confirmation and enforces every AudienceRestriction, OAuth no longer skips the ID-token nonce check on an empty nonce, CIBA binds the returned ID token to its auth_req_id, the FIDO MDS3 certification level reflects the authenticator's current status rather than its historical maximum, and a JOSE header that decodes to a non-object is a typed error rather than an uncaught throw. The WebSocket client now bounds a fragmented incoming message by its running total instead of only at the final frame, DMARC validates the From-header domain before the policy lookup, the data-residency write gate compares the residency column case-insensitively, and the SQL builder refuses an equality against NULL and validates every IN-list element. The CSP builder refuses a source token containing whitespace or a semicolon (directive injection). A middleware pipeline that a request handler halted by writing the response without calling next() left its promise pending forever, retaining the request closure — it now settles. New surface is additive and backward compatible: the basicConstraints-enforcing X.509 issuer test is exposed publicly, the session device fingerprint can resolve the client IP through a trusted-proxy allowlist, and the /24+/64 IP-prefix masking it uses is exposed for reuse. **Added:** *b.x509Chain — the CA-bit-enforcing X.509 issuer test* — isCaCert(cert) and issuerValidlyIssued(issuer, subject) are now public. Node's X509Certificate.checkIssued() does not enforce the basicConstraints cA bit (CVE-2002-0862 class), so a non-CA leaf can be accepted as an issuer; these helpers require cA:TRUE and verify the signature, fail closed on any malformed input, and are the same test the framework's own chain walkers use. A consumer validating a chain outside a TLS handshake (an operator-uploaded CA bundle, a non-handshake PQ-signed certificate) can now use the hardened path instead of raw checkIssued(). · *Reverse-proxy-aware session device fingerprint* — b.session.create / verify / rotate accept { trustedProxies } (CIDRs) or { clientIpResolver } so the built-in clientIp / clientIpPrefix fingerprint fields resolve the real client IP behind a trusted proxy, consistent with b.requestHelpers.trustedClientIp. Without the option the fingerprint still binds to the bare socket peer (unchanged default), so existing fingerprints keep matching; pass the same option to create, verify, and rotate. The /24 (IPv4) + /64 (IPv6) subnet masking is exposed as b.requestHelpers.ipPrefix for an operator using a function-form fingerprint field. **Changed:** *DKIM l= is counted over the canonicalized body* — On verify, the l= body-length tag was applied to the raw body before canonicalization, so a legitimate relaxed/relaxed signature whose l= matched the canonicalized length was rejected with a body-hash mismatch whenever relaxed canonicalization changed the byte count within the first l= octets. The body is now canonicalized first and the canonicalized octet stream truncated to l= (RFC 6376 §3.4.5). · *PIPL cross-border security-assessment threshold documentation corrected* — The b.pipl.sccFilingAssessment contract stated the CAC security assessment becomes mandatory above 100,000 cumulative PI subjects (the superseded 2022 figure). The builder enforces the current CAC Provisions on Promoting and Regulating Cross-Border Data Flows — a security assessment above 1,000,000 non-sensitive PI subjects or 10,000 sensitive, with the 100,000–1,000,000 band in the standard-contract / certification tier. The documentation now matches the enforced thresholds. **Fixed:** *composePipeline settles its promise when a middleware halts* — A regular middleware that wrote the response and returned without calling next() — the intended way to halt the chain from an auth / rate-limit / bot block — left the promise returned by b.middleware.composePipeline pending forever, retaining its request/response closure (an unbounded leak under sustained blocked traffic). The halt path now settles the promise without advancing to the route handler; the same applies to an error handler that consumes the error without calling next(). · *Quality-list header parsing is quote-aware* — parseQualityList mis-read a q= substring that appeared inside a quoted media-range or Accept-* parameter, mis-weighting content negotiation. It now parses the q parameter with quote-aware splitting. **Security:** *More authentication and signature verifiers fail closed* — JWT verify rejects an array-valued iss claim (it had passed through a generic any-match built for the legitimately-array aud claim, so a multi-issuer array satisfied a single-issuer expectation — CVE-2025-30144 class), matching the external JWT and OIDC verifiers. SAML verifyResponse requires the Recipient attribute on a Bearer or holder-of-key SubjectConfirmation and confirms it equals the ACS URL (an absent Recipient was silently skipped), and enforces every AudienceRestriction (AND-combined per SAML core). OAuth no longer fails open and skips the ID-token nonce replay check when the supplied nonce is empty. CIBA binds the polled / notified ID token to its auth_req_id so a token minted for another request can't be accepted. The FIDO MDS3 certification level reflects the authenticator's current status, not the highest level it ever held, so a later decertification or downgrade is honored by a step-up policy. A JOSE header or payload that decodes to JSON null or a non-object now raises a typed authentication error instead of an uncaught TypeError. · *WebSocket fragmented-message reassembly is bounded before completion* — The client enforced maxMessageBytes only when a fragmented message's final frame arrived, so a peer could stream unbounded continuation frames and exhaust memory before the limit was ever checked. The running reassembly total is now enforced per frame, and a frame arriving after close is ignored. · *DMARC resolves the From domain before the policy lookup* — The From-header bare addr-spec domain extraction did not reject RFC 5322 group syntax or a trailing semicolon, so a crafted From header could yield a corrupted domain that defeated the DMARC alignment / policy lookup. The domain is now validated (length-bounded, rejecting the group and punctuation forms) before it is used. · *Data-residency and SQL builder correctness* — The per-row data-residency write gate compares the residency column name case-insensitively, so a raw UPDATE that spells the column in a different letter case can no longer slip the cross-border transfer check. The SQL builder refuses an equality against NULL (col = NULL is always false; use IS NULL) and validates every element of an IN-list on the Postgres = ANY(?) path, matching the sqlite / MySQL path. · *CSP builder refuses a directive-injecting source* — A CSP source is a single non-whitespace token. build() and mergeDirectives() now reject a source containing whitespace or a semicolon — because the emitter space-joins sources and semicolon-joins directives, a value like "https://x; script-src https://evil" injected a live directive.
12
+
11
13
  - v0.15.14 (2026-06-20) — **A broad security-hardening release: every request-IP trust decision is peer-gated against the immediate connection (closing an X-Forwarded-For spoofing class — one behavior change, see Migration), a wide set of authentication and signature verifiers now fail closed, pre-auth parsers are hardened against algorithmic-complexity and frame-desync attacks, and untrusted-key allowlist lookups, at-rest encryption binding, and on-disk writes are tightened framework-wide; adds streaming and exclusive atomic-write primitives, a script-context JSON serializer, and HTTP-client bandwidth throttling.** This release tightens existing protections across the framework rather than adding opt-ins. The headline behavior change is how the client IP is trusted: the IP gates that make security decisions (network allowlist, rate limiter, break-glass grant pin) no longer trust a bare leftmost X-Forwarded-For value when trustProxy is set without an explicit proxy allowlist — they resolve the client IP by a peer-gated right-to-left walk anchored at the connecting socket, so a direct caller can no longer forge an allowed address. Operators who terminate behind a proxy now declare its address range in trustedProxies (see Migration). Alongside that, a large set of verifiers that could accept a malformed or unsigned credential now fail closed (SAML audience restriction, FDA electronic-signature, SD-JWT key-binding replay, DPoP, CIBA, mdoc trust anchor, COSE algorithm/curve binding, agent-snapshot plaintext under a regulated posture); pre-authentication parsers that an attacker reaches before any credential check are hardened against quadratic-time and frame-desync denial of service (CBOR, JSONPath, tar/PAX, gzip, YAML, ASN.1 DER, the scheduler's timer math); allowlist and posture lookups keyed by untrusted input can no longer reach Object.prototype; the framework's own file writes are now symlink-refusing and atomic; at-rest and egress paths bind ciphertext to its location and redact nested secrets; and several lost-update races are serialized. New surface is additive: streaming and exclusive atomic-write primitives, a serializer safe to embed in an inline <script>, and download/upload bandwidth throttling on the HTTP client. **Added:** *b.atomicFile.writeStream(filepath, source, opts?) and b.atomicFile.writeExclSync(filepath, data, opts?)* — Two atomic-write primitives that complete the read-side fdSafeReadSync family. writeStream writes a stream to a fresh O_EXCL|O_NOFOLLOW temporary file with an output-byte cap, then renames into place; writeExclSync stages a synchronous write through a verify-then-rename (clearing any stale temp under O_EXCL|O_NOFOLLOW first) for the small sealed-state round-trips (vault seal / unseal / rotate). Both refuse to follow a symlink at the destination or temp path, so an attacker who pre-creates a link can't redirect the write. The framework's own predictable-temp and symlink-following write sites now route through them; fdSafeReadSync also gains a withStat option that returns the bound fd's stat alongside the bytes. · *b.safeJson.stringifyForScript(value, opts?)* — A JSON serializer safe to embed directly in an inline <script> block: it escapes <, >, and & and the U+2028 / U+2029 line separators so a value containing </script> or a JS line terminator cannot break out of the script context (DOM-XSS). The import-map helper gains scriptTag(importMap, { nonce }) built on it, and the speculation-rules inline emitter uses it. · *HTTP-client bandwidth throttling* — b.httpClient requests accept a bandwidth limit that interposes a throttling transform on both the download and upload paths, so a large transfer can be rate-shaped without buffering the whole body. Useful for bounding the egress a single outbound call can consume. · *Incremental audit-chain verification* — The audit verifiers accept a from/to range so a long chain can be verified in increments rather than head-to-tail every time, report an accurate rowsVerified count, and keep the newest review when reconciling. b.vault also exposes getDefaultStore() for callers that need the process's default seal/unseal store handle. **Changed:** *DORA major-incident reporting clock is four hours* — The Digital Operational Resilience Act initial-notification deadline is corrected to four hours from classification (it had used the generic 24-hour default), incident classification weighs the affected-client-base percentage, and the deadline anchors to the prior filing where one exists. · *Range and conditional-request correctness* — Compression skips a 206 Partial Content or any Content-Range response (RFC 7233 §4.1), so it can't drop Content-Length over a now-compressed byte interval; static-file conditional handling gives If-None-Match precedence over If-Modified-Since and If-Match over If-Unmodified-Since (RFC 7232); and the object store honors the [start, end] array range contract that previously fell through to a full read. · *Declared request bodies are always validated* — A route that declares a body schema now validates it even when no body was parsed, so an omitted required body is rejected at the router instead of reaching the handler — mirroring the always-run params and query checks. **Fixed:** *Object store getStream works on remote backends* — getStream() returned a stream wrapping an unresolved promise on every remote backend, so it never delivered bytes; it now resolves the object before streaming. · *Queue and body-parser correctness* — The local queue's fail()/complete() paths guard on the current row status so a stale lease can't resurrect or double-process a job, and the body parser's raw() mode no longer matches every content type by default (it had answered 415 to nothing or everything depending on the wildcard). · *Sanctions screening and GraphQL SDL probing no longer under-match* — Sanctions screening no longer skips a candidate whose type doesn't match the query's type filter (a sanctioned entity of an unspecified type was missed) and reports a typeMatch flag; the GraphQL federation guard probes for the _service / _entities introspection fields by word boundary (an aliased field bypassed the prefix check) and over batched-array request bodies. The per-tenant query budget is a true sliding window, so a burst straddling the window boundary can't briefly double the rate. **Security:** *Request-IP trust is peer-gated end to end* — The client-IP resolution that every IP gate depends on is extracted into one trust model (a trustedProxies-aware, peer-gated right-to-left walk) so the network allowlist, rate limiter, break-glass pin, and bot-guard audit attribution all decide identity the same way. X-Forwarded-Proto is peer-gated across the security middleware on the same model, and a WebSocket reconnect to a swapped target is re-validated against the SSRF / blocked-host guard before the connection is reused. See Migration for the one behavior change. · *Authentication and signature verifiers fail closed* — A set of verifiers that could accept a malformed or unsigned credential now refuse it: SAML verifyResponse fails closed on a missing AudienceRestriction or unparseable Conditions; the FDA 21 CFR 11 electronic-signature verify refuses a record with no signature (recordHash alone is self-consistency, not authentication); the SD-JWT key-binding JWT requires an audience and nonce (replay binding) and an expiry when configured; DPoP cross-checks the proof's alg against its embedded JWK key type before importing it; CIBA verifies the ID token on poll and notification; the mdoc trust anchor is enforced (no fail-open); COSE binds the signature algorithm to the key curve; and agent-snapshot refuses allowPlaintext under a regulated compliance posture and gates sealing on vault initialization. · *Pre-auth parsers hardened against complexity and frame-desync denial of service* — Parsers an attacker reaches before any credential check are bounded. CBOR rejects the quadratic-time duplicate-map-key construction (it ran O(n^2) on every COSE / CWT / EAT / mdoc verify) and rejects non-minimal integer/key encodings in canonical mode; JSONPath caps the node-list cross-product that could exhaust memory and rejects the filter expression that could overflow the parser stack; the tar reader caps a random-access entry read and resynchronizes a PAX header whose size field is non-numeric; the gzip reader caps a random-access output size; the YAML scanner replaces a backtracking pattern with a linear scan; the ASN.1 DER reader caps high-tag-number and OID sub-identifier decoding and rejects non-minimal forms; and the scheduler clamps far-future timers so a 32-bit overflow can't make a timer fire immediately or an interval tight-loop. · *Allowlist and posture lookups keyed by untrusted input can't reach the prototype* — Lookups of the form map[untrustedKey] across the framework's allowlist and compliance-posture tables are now own-property checks, so a key like __proto__ or constructor resolves as absent instead of returning an inherited value and walking the gate (CWE-1321). The shared posture-accessor that several guards built by hand is centralized, and an inverse detector flags any re-introduced unguarded indexed lookup. · *Framework file writes are symlink-refusing and atomic* — The predictable-temp and symlink-following write sites (cookie jar, config-drift snapshot, archive extraction, local object store, backup bundle, and the vault passphrase seal/unseal/rotate) now write through the atomic-write primitives above, so a pre-planted symlink at the destination or temp path can no longer redirect a privileged write, and a partial write can't leave a torn file. A re-introduced raw write without the exclusive/no-follow flags is flagged by a detector. · *X.509 issuer chains enforce the CA basic constraint* — Node's checkIssued() does not verify that the issuer certificate is actually a CA (CVE-2002-0862 class), so a leaf signed by a non-CA certificate could be accepted as an issuer. Certificate-chain walking is centralized in one helper that requires the issuer's basicConstraints cA bit and verifies the signature, and every chain consumer (TSA, BIMI, S/MIME, mdoc, content-credentials, the FIDO MDS3 anchor match) routes through it. · *Header, value, and markup injection closed* — Mail rejects CRLF in Reply-To and in custom header keys/values (SMTP header injection); the GDPR data-export CSV writer neutralizes formula-injection prefixes; the content guards decode no-semicolon numeric HTML entities when revealing a dangerous URL scheme (so &#106;avascript: is caught); the early-hints emitter rejects CRLF in pushed link values; the HTML/SVG/BIMI comment scanners honor the WHATWG abrupt-comment-close forms (--!>, <!-->) so a mutation-XSS comment can't smuggle markup; and the CSP builder no longer emits a host source alongside 'none'. · *At-rest and egress data is bound to its context* — Backup restore binds each encrypted file blob to its manifest path as additional authenticated data, so a blob remapped to another entry fails the AEAD tag on restore (signed and unsigned bundles alike), and threads the signature-verification options through extraction; the data-subject-request lookup dual-reads the keyed and legacy subject hashes so a subject is still found across the hashing change (GDPR Art. 17); the log redactor collapses a secret nested as an array or object value, not only as a scalar; and the OTLP exporter redacts the span name and status message, not just attributes, before they leave the process. · *Network-protocol verification hardened* — OCSP responses are bound to the requested certificate serial; the WebSocket reader rejects reserved opcodes and reserved bits; the DNS parser refuses a record that reads past the message buffer; the NTP client rejects a reply whose origin timestamp does not echo the request (and the SNTP cookie path was reworked to bind the exchange); DANE TLSA matching is constant-time; and MTA-STS policy fetch fails closed rather than degrading to no-policy on an ambiguous response. · *Byte caps are measured in bytes, and middleware enforcement is per-instance* — Size limits that compared a byte budget against a string's character length (so multibyte input slipped a ~2-4x looser bound) now measure actual bytes — guard-json per-string cap, the per-tenant quota header budget, the JMAP request cap. The CSRF and fetch-metadata middleware track their gate per instance, so mounting a second, stricter instance still enforces (a shared request flag previously let the first, lenient mount disable it); the CSRF cookie is still issued once per response. The WebAssembly global is stripped from the sandbox worker so sandboxed code cannot JIT around the isolation. · *Lost-update races serialized* — Concurrent failure recording in the login lockout and bot-challenge gates is serialized per key, so parallel failed attempts can no longer each read the same pre-write counter and stay under the lockout / challenge threshold. The inline webhook delivery is claimed before the POST so a concurrent retry pass can't double-send it, and a transient transport failure now backs off and retries instead of being dead-lettered. **Migration:** *X-Forwarded-For trust now requires an explicit proxy allowlist* — The IP gates that enforce a security decision — network allowlist, rate limiter, and the break-glass grant IP pin — previously resolved the client IP from the leftmost X-Forwarded-For value whenever trustProxy was truthy. A direct caller could set that header to any address and walk an IP allowlist or evade a rate-limit key (CWE-290 / CWE-348). These gates now fail closed: with trustProxy set but no trustedProxies, they ignore X-Forwarded-For and use the connecting socket's peer address. To keep trusting a forwarded client IP, set trustedProxies to the CIDR(s) of your front proxy/load balancer — the client IP is then resolved by a peer-gated right-to-left walk that stops at the first hop not in the allowlist. For an allowlist / rate-limit / break-glass gate the new default is stricter (it trusts less), so an operator who has not migrated sees tighter enforcement, never looser. Display-only consumers (error pages, request logs) keep the lenient resolution and carry a docstring warning. The wiki example's WIKI_TRUST_PROXY flag is replaced by WIKI_ADMIN_TRUSTED_PROXIES (a CIDR list).
12
14
 
13
15
  - v0.15.13 (2026-06-19) — **Consolidates a large set of duplicated, security-sensitive code paths — injected-dependency validation, positive-integer option caps, TOCTOU-safe file reads, markup escaping, audit emission, structured-field parsing, and untrusted-JWK import — onto shared hardened primitives, so a fix or guard now applies everywhere at once; adds a TOCTOU-safe file reader; and makes several configuration errors throw typed framework errors instead of a bare Error.** Across the framework, logic that had been hand-rolled in many places (and had quietly drifted between copies) is now single-sourced through shared primitives. The practical effect for operators is consistency: the strongest available guard is applied everywhere a given operation happens, rather than in whichever copy happened to have it. Injected dependencies (stores, backends, vaults, db handles, query objects) are validated through one contract; operator-tunable positive-integer options (pool sizes, byte caps, timeouts, stream limits) validate through one bounds primitive that forwards the caller's non-retryable / HTTP-status flags; the open-fd → fstat → read-fully pattern that several call sites used to defend file reads against a swap-after-stat race (CWE-367) is now the b.atomicFile.fdSafeReadSync primitive, with symlink refusal, inode-equality, a byte cap, and an integrity hash available as options and each caller's exact typed error preserved; and the XML/HTML markup escapers (including the b.guardHtml / b.guardSvg sanitizer output) route through one b.markupEscape so the base & < > " chain can't drift between them. A few configuration-validation paths that threw a bare Error (httpClient.configurePool, b.db stream-limit validation) now throw the module's typed framework error. No security defaults change; this release tightens and unifies existing protections rather than adding opt-ins. **Added:** *b.atomicFile.fdSafeReadSync(filepath, opts?)* — A TOCTOU-safe synchronous file read (CWE-367 / js/file-system-race): it opens the path read-only, then binds the size, content, and integrity measurement to the inode the fd holds open, so a swap between stat and read can't change which bytes are returned. Optional guards layer on top — maxBytes (refuse an over-cap file), refuseSymlink + inodeCheck (refuse a symlink source and any inode swap, the strongest posture for operator-writable paths), expectedHash (SHA3-512), encoding (return a string), allowShortRead — and an errorFor(kind, detail) callback lets each caller keep its own typed error. The framework's own file reads (atomic-file, the TLS certificate-path loader, the sealed-PEM source reader, the backup bundle reader) all route through it. · *Shared substrate primitives for previously-duplicated logic* — A set of shared primitives now back call sites that had each re-implemented the same logic: validateOpts.requireMethods (gains a `permanent` flag) for injected-dependency contract checks; numericBounds positive-finite-int validators (gain an errorOpts { permanent, statusCode }) for tunable integer options; b.markupEscape for the & < > " markup chain; structured-field parsing helpers (parseTagList, forEachKeyValue, splitUnquoted, stripDoubleQuotes, unfoldHeaderContinuations); crypto.importPublicJwk and crypto.makeBase64UrlDecoder; safeSql.toPositional; safeBuffer.makeByteCoercer; nonceStore.enforceReplay; and the audit/observability namespaced emitters. These are mostly invisible substrate — the operator-visible benefit is that a guard or fix added to one of them now covers every caller. **Changed:** *Configuration errors throw typed framework errors* — Two configuration-validation paths that threw a bare `Error` now throw the module's typed framework error: httpClient.configurePool (bad maxSockets / maxFreeSockets / keepAliveMsecs → HttpClientError, code httpclient/bad-opts) and the b.db stream-limit validation (Query.stream → DbQueryError). The thrown error remains an Error subclass, so existing `instanceof Error` / try-catch handling is unaffected; code that matched the old free-text message should match the typed error's `code` instead. Several routed validators also report a slightly more precise message ("positive finite integer" instead of "positive integer"). **Fixed:** *Webhook deliveries retry transient transport failures instead of dead-lettering them* — A delivery that failed with a transient transport error (connection reset, timeout, 5xx) was recorded as permanently failed and dead-lettered rather than retried. The destination-safety check (SSRF / blocked-host guard) now runs in its own step and is the only permanent failure; a transport failure backs off and retries up to the configured limit. · *Webhook deliveries table creates on MySQL* — The pending-delivery index used a partial-index (WHERE) clause that MySQL does not support, so the deliveries table failed to create on a MySQL backend. The index is now dialect-aware — partial on PostgreSQL / SQLite, plain on MySQL. · *Inline webhook delivery can't be double-sent by concurrent retry processing* — An inline delivery is now claimed (status set to in-flight with a claim timestamp) before the POST, so a concurrent retry pass can't pick up and re-send the same delivery; a row left in-flight by a crash is reclaimed after the stale-claim window. · *Audit checkpoint can never be anchored against a replaced database* — close() fires a best-effort final checkpoint as the database shuts down. In a narrow close-then-reopen window that checkpoint's write could resume after a fresh database had opened and anchor the prior database's chain tip into it. checkpoint() now binds to the database it read the tip from and refuses to write (returns null, fail-closed) if that handle has since been closed or replaced — so a checkpoint is only ever written against the database it measured. **Security:** *TOCTOU-safe file reads applied uniformly* — The open-fd → fstat → read pattern that anchors a file read to one inode against a swap-after-stat race is now centralized in b.atomicFile.fdSafeReadSync, and every framework file read routes through it. The sealed-PEM source reader keeps the strongest posture (symlink refusal + inode-equality + byte cap), and those guards are now available as options to any caller — a re-introduced hand-rolled read-loop is flagged by a codebase-pattern detector. · *Injected-dependency and integer-option validation can't silently drift* — Injected stores / backends / vaults / db handles / query objects are validated through one contract (validateOpts.requireMethods), and operator-tunable positive-integer options through one bounds primitive (numericBounds), both forwarding the caller's non-retryable / HTTP-status error flags so a config error stays permanent / carries its 4xx. Inverse detectors flag any re-introduced inline check, so the validation can no longer be partially copied or weakened in one place. · *Single markup-escape chain for the HTML/SVG sanitizers* — The b.guardHtml and b.guardSvg sanitizer output escapers, the AI-Act transparency and mail HTML escapers, and the XML report escapers now share one b.markupEscape for the base & < > " chain (apostrophe form as a parameter; each caller keeps its own input coercion and any extra escapes such as guardHtml.escapeAttr's backtick / = IE-attribute-injection hardening). Byte-for-byte parity with the prior escapers was verified across an XSS-vector corpus, so the consolidation cannot have introduced an escaping gap; a re-inlined & < > " chain anywhere else is flagged by a detector. · *One hardening point for untrusted public-JWK import* — The createPublicKey-from-JWK call that imports attacker-controlled key material (DID publicKeyJwk, DNSKEY, COSE_Key, DPoP / OAuth / OIDC / DBSC proof JWKs) is centralized in b.crypto.importPublicJwk, so the untrusted-key import has a single audited home; each caller keeps its own kty/crv pre-validation and typed error. · *Linear-time Content-Security-Policy header parsing* — The CSP response-header parser split directives on a `/\s*,\s*/` regex that ran in O(n^2) on a long comma-less run of whitespace, letting a crafted header stall a worker (js/polynomial-redos). It now splits on the literal comma and trims each item, which is linear and byte-for-byte equivalent. · *Parsed INI and OpenAPI path maps can't reach Object.prototype* — The INI parser already refuses a `__proto__` / `constructor` / `prototype` section or key (it throws before any write); as defense-in-depth, every parsed node — and the OpenAPI paths map keyed by URL pattern — is now a null-prototype object, so even a future gap could only ever set an own property, never mutate Object.prototype (CWE-1321).
package/index.js CHANGED
@@ -400,6 +400,7 @@ var authBotChallenge = require("./lib/auth-bot-challenge");
400
400
  var sessionDeviceBinding = require("./lib/session-device-binding");
401
401
  var acme = require("./lib/acme");
402
402
  var cert = require("./lib/cert");
403
+ var x509Chain = require("./lib/x509-chain");
403
404
  var watcher = require("./lib/watcher");
404
405
  var localDbThin = require("./lib/local-db-thin");
405
406
  var daemon = require("./lib/daemon");
@@ -750,6 +751,7 @@ module.exports = {
750
751
  sessionDeviceBinding: sessionDeviceBinding,
751
752
  acme: acme,
752
753
  cert: cert,
754
+ x509Chain: x509Chain,
753
755
  ntpCheck: ntpCheck,
754
756
  tlsExporter: tlsExporter,
755
757
  watcher: watcher,
package/lib/auth/ciba.js CHANGED
@@ -162,6 +162,10 @@ function create(opts) {
162
162
  tokenEndpoint: opts.tokenEndpoint,
163
163
  httpClientOpts: opts.httpClientOpts,
164
164
  allowHttp: opts.allowHttp === true,
165
+ // Thread the SSRF opt-in through to the inner client's discovery / JWKS /
166
+ // token fetches — an operator with an internal-network IdP needs it, and
167
+ // dropping it silently made allowInternal an accepted-but-ignored option.
168
+ allowInternal: opts.allowInternal,
165
169
  isOidc: true,
166
170
  });
167
171
 
@@ -456,10 +460,11 @@ function create(opts) {
456
460
  // returning its verified claims, or null when no id_token was supplied. A
457
461
  // present-but-invalid id_token throws (fail-closed) so a forged token's
458
462
  // sub/acr/amr can never be returned as trusted.
459
- async function _verifyIdTokenIfPresent(idToken, auditKey, op) {
463
+ async function _verifyIdTokenIfPresent(idToken, auditKey, op, expectedAuthReqId) {
460
464
  if (!idToken) return null;
465
+ var verified;
461
466
  try {
462
- return await inner.verifyIdToken(idToken);
467
+ verified = await inner.verifyIdToken(idToken);
463
468
  } catch (e) {
464
469
  _emitAudit("id_token_verify_fail", "failure",
465
470
  auditKey ? { authReqIdHash: sha3Hash(auditKey) } : {});
@@ -467,6 +472,27 @@ function create(opts) {
467
472
  "ciba." + op + ": id_token failed verification: " +
468
473
  ((e && e.code) || (e && e.message) || String(e)));
469
474
  }
475
+ // OpenID CIBA Core §7.3 / §10.1: the ID Token MUST carry the
476
+ // urn:openid:params:jwt:claim:auth_req_id claim and the RP MUST verify it
477
+ // equals the auth_req_id this flow used. Without it, an id_token minted for
478
+ // a DIFFERENT auth_req_id (another user's CIBA flow at the same RP) could be
479
+ // substituted — cross-user token substitution. verifyIdToken returns
480
+ // { header, claims }, so the claim lives on `.claims`. Constant-time compare.
481
+ // idToken is present (we returned early if not), so the binding is
482
+ // MANDATORY and ALWAYS checked — never gated behind a truthiness test that
483
+ // an empty string could slip. Both call sites pass a non-empty auth_req_id
484
+ // (validated at the parseNotification / pollToken entry), so a falsy value
485
+ // can't reach here to skip the comparison.
486
+ var payload = verified && verified.claims;
487
+ var boundId = payload && payload["urn:openid:params:jwt:claim:auth_req_id"];
488
+ if (typeof boundId !== "string" || !timingSafeEqual(boundId, expectedAuthReqId)) {
489
+ _emitAudit("id_token_authreqid_mismatch", "failure",
490
+ auditKey ? { authReqIdHash: sha3Hash(auditKey) } : {});
491
+ throw new AuthError("auth-ciba/id-token-authreqid-mismatch",
492
+ "ciba." + op + ": id_token urn:openid:params:jwt:claim:auth_req_id does not " +
493
+ "match the expected auth_req_id (cross-user token-substitution defense)");
494
+ }
495
+ return verified;
470
496
  }
471
497
 
472
498
  async function pollToken(popts) {
@@ -536,7 +562,7 @@ function create(opts) {
536
562
  // accepted-algorithms and enforces signature + iss/aud/exp. Returning an
537
563
  // unverified id_token let a forged token's sub/acr/amr be trusted.
538
564
  var pollClaims = await _verifyIdTokenIfPresent(rv.id_token,
539
- "auth-ciba:" + popts.authReqId, "pollToken");
565
+ "auth-ciba:" + popts.authReqId, "pollToken", popts.authReqId);
540
566
  _emitAudit("token_received", "success", {
541
567
  authReqIdHash: sha3Hash("auth-ciba:" + popts.authReqId),
542
568
  });
@@ -631,10 +657,8 @@ function create(opts) {
631
657
  throw new AuthError("auth-ciba/no-notification-body",
632
658
  "ciba.parseNotification: body required (Buffer/string parsed by middleware)");
633
659
  }
634
- if (typeof body.auth_req_id !== "string") {
635
- throw new AuthError("auth-ciba/no-auth-req-id-in-body",
636
- "ciba.parseNotification: body missing auth_req_id");
637
- }
660
+ validateOpts.requireNonEmptyString(body.auth_req_id,
661
+ "ciba.parseNotification: auth_req_id", AuthError, "auth-ciba/no-auth-req-id-in-body");
638
662
  // Verify the pushed ID token (CIBA §10.2 / OIDC Core MUST) via the
639
663
  // composed inner OAuth client before returning it as trusted. The
640
664
  // notification-token bearer authenticates the CALLER, not the token; a
@@ -642,7 +666,7 @@ function create(opts) {
642
666
  // arbitrary id_token claims the RP would trust on the documented
643
667
  // "no follow-up call" push path.
644
668
  var pushClaims = await _verifyIdTokenIfPresent(body.id_token,
645
- "auth-ciba:" + body.auth_req_id, "parseNotification");
669
+ "auth-ciba:" + body.auth_req_id, "parseNotification", body.auth_req_id);
646
670
  _emitAudit("notification_received", "success", {
647
671
  authReqIdHash: sha3Hash("auth-ciba:" + body.auth_req_id),
648
672
  mode: deliveryMode,
package/lib/auth/dpop.js CHANGED
@@ -317,6 +317,15 @@ async function verify(proof, opts) {
317
317
  catch (_e) { throw new AuthError("auth-dpop/malformed", "header is not valid base64url-JSON"); }
318
318
  try { payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8")); }
319
319
  catch (_e) { throw new AuthError("auth-dpop/malformed", "payload is not valid base64url-JSON"); }
320
+ // safeJson.parse accepts the literal `null` / scalars, so a header segment of
321
+ // base64url("null") would otherwise reach `header.typ` and throw a raw
322
+ // TypeError rather than the typed malformed error. Require JSON objects.
323
+ if (!safeJson.isJsonObject(header)) {
324
+ throw new AuthError("auth-dpop/malformed", "header is not a JSON object");
325
+ }
326
+ if (!safeJson.isJsonObject(payload)) {
327
+ throw new AuthError("auth-dpop/malformed", "payload is not a JSON object");
328
+ }
320
329
 
321
330
  // Header checks
322
331
  if (header.typ !== "dpop+jwt") {
@@ -550,24 +550,37 @@ function lookupAaguid(blob, aaguid) {
550
550
  return null;
551
551
  }
552
552
 
553
- // Pull the certified-level token out of a list of status reports. The
554
- // most recent FIDO_CERTIFIED_L{N}[_PLUS] report wins; if none exist,
555
- // the authenticator is uncertified (level 0).
553
+ // Resolve the CURRENT certified level from the status-report history. FIDO MDS3
554
+ // status reports are chronological; the level is whatever the MOST RECENT
555
+ // certification-status report says a FIDO_CERTIFIED_L{N}[_PLUS], or 0 when the
556
+ // latest such report is NOT_FIDO_CERTIFIED (a decertification). Taking the
557
+ // highest-ever level instead let a step-up / risk policy that requires L{N}
558
+ // accept an authenticator that was later decertified or downgraded (the
559
+ // certifiedLevel is the policy input, so a stale max is an authenticator-
560
+ // assurance bypass). Ordering is by effectiveDate (ISO YYYY-MM-DD, so lexical ==
561
+ // chronological); ties and missing dates fall back to array order (append
562
+ // order, which the spec defines as chronological).
556
563
  function _certifiedLevel(statusReports) {
557
564
  if (!Array.isArray(statusReports)) return { level: 0, plus: false };
558
- var best = { level: 0, plus: false };
565
+ var latest = null;
566
+ var latestDate = null;
559
567
  for (var i = 0; i < statusReports.length; i++) {
560
568
  var sr = statusReports[i];
561
- if (!sr || typeof sr.status !== "string") continue;
562
- var m = CERT_LEVEL_RE.exec(sr.status);
563
- if (!m) continue;
564
- var level = parseInt(m[1], 10);
565
- var plus = !!m[2];
566
- if (level > best.level || (level === best.level && plus && !best.plus)) {
567
- best = { level: level, plus: plus };
569
+ // Only certification-status reports move the level: a level grant, or its
570
+ // explicit revocation (NOT_FIDO_CERTIFIED). Other statuses (UPDATE_AVAILABLE,
571
+ // REVOKED, …) are handled elsewhere and must not be read as a level. The
572
+ // status length is bounded before the regex test below — FIDO status tokens
573
+ // are short enums; bounding input before any .test() is the convention.
574
+ if (!sr || typeof sr.status !== "string" || sr.status.length > 64) continue;
575
+ if (!CERT_LEVEL_RE.test(sr.status) && sr.status !== "NOT_FIDO_CERTIFIED") continue;
576
+ var d = typeof sr.effectiveDate === "string" ? sr.effectiveDate : "";
577
+ if (latest === null || d >= latestDate) {
578
+ latest = sr; latestDate = d;
568
579
  }
569
580
  }
570
- return best;
581
+ if (!latest || latest.status === "NOT_FIDO_CERTIFIED") return { level: 0, plus: false };
582
+ var m = CERT_LEVEL_RE.exec(latest.status);
583
+ return { level: parseInt(m[1], 10), plus: !!m[2] };
571
584
  }
572
585
 
573
586
  /**
package/lib/auth/jwt.js CHANGED
@@ -193,6 +193,16 @@ function decode(token) {
193
193
  catch (_e) { throw new AuthError("auth-jwt/malformed", "header is not valid base64url-JSON"); }
194
194
  try { payload = safeJson.parse(_b64urlDecode(parts[1])); }
195
195
  catch (_e) { throw new AuthError("auth-jwt/malformed", "payload is not valid base64url-JSON"); }
196
+ // The JOSE header and the claims set MUST each be a JSON object. safeJson.parse
197
+ // accepts the literal `null` (a valid JSON document) and scalars/arrays, so a
198
+ // header segment of base64url("null") would otherwise survive to a `.crit` /
199
+ // `.kid` dereference and throw a raw TypeError instead of a typed AuthError.
200
+ if (!safeJson.isJsonObject(header)) {
201
+ throw new AuthError("auth-jwt/malformed", "header is not a JSON object");
202
+ }
203
+ if (!safeJson.isJsonObject(payload)) {
204
+ throw new AuthError("auth-jwt/malformed", "payload is not a JSON object");
205
+ }
196
206
  var signature;
197
207
  try { signature = _b64urlDecode(parts[2]); }
198
208
  catch (_e) { throw new AuthError("auth-jwt/malformed", "signature is not valid base64url"); }
@@ -351,10 +361,16 @@ async function verify(token, opts) {
351
361
  "token not yet valid: nbf=" + p.nbf + " (now=" + nowSec + ", tolerance=" + tol + "s)");
352
362
  }
353
363
 
354
- // String-claim assertions
355
- if (opts.issuer !== undefined && !_matchClaim(p.iss, opts.issuer, "iss")) {
364
+ // String-claim assertions. `iss` is StringOrURI (RFC 7519 §4.1.1) — a single
365
+ // value, not a list: reject a non-string iss before matching. Routing it
366
+ // through the aud-style any-of _matchClaim let an attacker-influenced
367
+ // multi-issuer array (iss:["evil","trusted"]) satisfy a single-issuer
368
+ // expectation (CVE-2025-30144 / fast-jwt iss-array class) — the same defense
369
+ // jwtExternal.verifyExternal and oauth.verifyIdToken already apply.
370
+ if (opts.issuer !== undefined &&
371
+ (typeof p.iss !== "string" || !_matchClaim(p.iss, opts.issuer, "iss"))) {
356
372
  throw new AuthError("auth-jwt/iss-mismatch",
357
- "iss='" + p.iss + "' does not match expected " + JSON.stringify(opts.issuer));
373
+ "iss=" + JSON.stringify(p.iss) + " does not match expected " + JSON.stringify(opts.issuer));
358
374
  }
359
375
  if (opts.audience !== undefined && !_matchClaim(p.aud, opts.audience, "aud")) {
360
376
  throw new AuthError("auth-jwt/aud-mismatch",
package/lib/auth/oauth.js CHANGED
@@ -1276,9 +1276,15 @@ function create(opts) {
1276
1276
  // browser session could be replayed without detection. Throw
1277
1277
  // loudly so the operator sees the bug at config time, not at
1278
1278
  // first-replay-attempt time.
1279
- if (isOidc && eopts.nonce === undefined && eopts.skipNonceCheck !== true) {
1279
+ // Require a NON-EMPTY STRING nonce, not merely a defined one: a falsy
1280
+ // nonce (null / "" — e.g. a session field that was never set) would slip a
1281
+ // strict `=== undefined` guard, and the downstream verifier only checks the
1282
+ // nonce when vopts.nonce is truthy, so the ID-token nonce check would be
1283
+ // silently skipped and a token captured from another session replayed.
1284
+ if (isOidc && eopts.skipNonceCheck !== true &&
1285
+ (typeof eopts.nonce !== "string" || eopts.nonce.length === 0)) {
1280
1286
  throw new OAuthError("auth-oauth/no-nonce",
1281
- "exchangeCode: nonce is required on OIDC flows. Pass the " +
1287
+ "exchangeCode: a non-empty nonce is required on OIDC flows. Pass the " +
1282
1288
  "value returned from authorizationUrl() through to exchangeCode " +
1283
1289
  "({ code, state, verifier, nonce }). Operators with a deliberate " +
1284
1290
  "no-nonce flow must pass `skipNonceCheck: true` (audited reason).");
package/lib/auth/saml.js CHANGED
@@ -695,7 +695,7 @@ function create(opts) {
695
695
  if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // ms→s
696
696
  Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // ms→s
697
697
  var recipHok = _attr(scdHok, "Recipient");
698
- if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
698
+ if (!recipHok || recipHok !== opts.assertionConsumerServiceUrl) continue; // §3.1→§4.1.4.2 — Recipient is mandatory; absent fails the endpoint binding
699
699
  hokOk = true;
700
700
  break;
701
701
  }
@@ -711,8 +711,11 @@ function create(opts) {
711
711
  var nb = Date.parse(notBefore) / 1000; // ms→s
712
712
  if (isFinite(nb) && nb > nowSec + clockSkewSec) continue;
713
713
  }
714
+ // §4.1.4.2 — a Bearer SubjectConfirmationData delivered to an ACS MUST
715
+ // carry a Recipient equal to this SP's ACS URL. Absent Recipient fails
716
+ // the endpoint binding (treated as a mismatch), not silently skipped.
714
717
  var recipient = _attr(scd, "Recipient");
715
- if (recipient && recipient !== opts.assertionConsumerServiceUrl) {
718
+ if (!recipient || recipient !== opts.assertionConsumerServiceUrl) {
716
719
  continue;
717
720
  }
718
721
  var inResponseTo = _attr(scd, "InResponseTo");
@@ -774,17 +777,24 @@ function create(opts) {
774
777
  // SP). Fail closed when an audience is configured; opt out only via
775
778
  // vopts.requireAudienceRestriction === false.
776
779
  if (audience && vopts.requireAudienceRestriction !== false) {
777
- var ar = conditions && _findChild(conditions, "AudienceRestriction", SAML_NS.assertion);
778
- if (!ar) {
780
+ var ars = conditions
781
+ ? _findAllChildren(conditions, "AudienceRestriction", SAML_NS.assertion) : [];
782
+ if (ars.length === 0) {
779
783
  throw new AuthError("auth-saml/no-audience-restriction",
780
784
  "verifyResponse: assertion has no AudienceRestriction binding it to \"" +
781
785
  audience + "\" (audience-confusion defense; set requireAudienceRestriction:false to opt out)");
782
786
  }
783
- var audiences = _findAllChildren(ar, "Audience", SAML_NS.assertion).map(_textContent);
784
- if (audiences.indexOf(audience) === -1) {
785
- throw new AuthError("auth-saml/wrong-audience",
786
- "Audience \"" + audience + "\" not in assertion's AudienceRestriction (got " +
787
- JSON.stringify(audiences) + ")");
787
+ // SAML core §2.5.1.4: multiple <AudienceRestriction> elements are
788
+ // AND-combined the SP must be a member of the Audience set of EVERY
789
+ // one. Checking only the first let an IdP that narrowed the assertion to
790
+ // a DIFFERENT audience in a later restriction be accepted here.
791
+ for (var ari = 0; ari < ars.length; ari += 1) {
792
+ var audiences = _findAllChildren(ars[ari], "Audience", SAML_NS.assertion).map(_textContent);
793
+ if (audiences.indexOf(audience) === -1) {
794
+ throw new AuthError("auth-saml/wrong-audience",
795
+ "Audience \"" + audience + "\" not in AudienceRestriction #" + (ari + 1) +
796
+ " of " + ars.length + " (got " + JSON.stringify(audiences) + ")");
797
+ }
788
798
  }
789
799
  }
790
800
 
@@ -1747,9 +1747,27 @@ function assertColumnResidency(table, row, args) {
1747
1747
  var entry = columnResidency[table];
1748
1748
  if (!entry || !row || !args) return null;
1749
1749
  var backendTag = args.backendTag || "unrestricted";
1750
+ // SQL unquoted identifiers are case-insensitive; a raw-SQL-parsed row keeps
1751
+ // the column token's case, so resolve each mapped column case-insensitively
1752
+ // (a case-sensitive `row[col]` let a differently-cased column skip the gate —
1753
+ // CWE-178). Lazily index the row's keys by lowercase; exact match wins first.
1754
+ var rowLcIndex = null;
1755
+ function _rowVal(c) {
1756
+ if (Object.prototype.hasOwnProperty.call(row, c)) return row[c];
1757
+ if (rowLcIndex === null) {
1758
+ rowLcIndex = {};
1759
+ var ks = Object.keys(row);
1760
+ for (var i = 0; i < ks.length; i++) {
1761
+ var lk = ks[i].toLowerCase();
1762
+ if (!Object.prototype.hasOwnProperty.call(rowLcIndex, lk)) rowLcIndex[lk] = row[ks[i]];
1763
+ }
1764
+ }
1765
+ return rowLcIndex[String(c).toLowerCase()];
1766
+ }
1750
1767
  for (var col in entry) {
1751
1768
  var want = entry[col];
1752
- if (row[col] === undefined || row[col] === null) continue;
1769
+ var cellVal = _rowVal(col);
1770
+ if (cellVal === undefined || cellVal === null) continue;
1753
1771
  if (want === "global" || want === "unrestricted") continue;
1754
1772
  if (backendTag === "unrestricted") continue;
1755
1773
  if (backendTag !== want) {
package/lib/csp.js CHANGED
@@ -210,6 +210,15 @@ function build(directives, opts) {
210
210
  throw new CspError("csp/header-injection",
211
211
  "csp.build: source '" + src + "' contains CR/LF/NUL");
212
212
  }
213
+ // A CSP source-expression is a single non-whitespace token. The emitter
214
+ // space-joins sources within a directive and ';'-joins directives, so a
215
+ // source containing ';' or ASCII whitespace would inject a brand-new live
216
+ // directive (e.g. "https://x; script-src https://evil") — refuse both.
217
+ if (/[\s;]/.test(src)) {
218
+ throw new CspError("csp/bad-source",
219
+ "csp.build: source '" + src + "' contains whitespace or ';' — a CSP source " +
220
+ "must be a single token (directive-injection defense)");
221
+ }
213
222
  if (!acknowledgeUnsafe && SCRIPT_DIRECTIVES.indexOf(name) !== -1 &&
214
223
  UNSAFE_KEYWORDS.indexOf(src) !== -1) {
215
224
  throw new CspError("csp/unsafe-keyword",
package/lib/db-query.js CHANGED
@@ -89,6 +89,19 @@ function _postureState() {
89
89
  //
90
90
  // Unregulated postures audit (drop-silent) and pass; tables with no
91
91
  // declaration are untouched.
92
+ // Resolve a column in a raw-SQL-parsed row case-insensitively (SQL unquoted
93
+ // identifiers fold case; the parser preserves the token). Exact match wins
94
+ // first so a structured-builder row (keys already canonical) is unaffected.
95
+ function _ciColumn(row, col) {
96
+ if (Object.prototype.hasOwnProperty.call(row, col)) return { present: true, value: row[col] };
97
+ var lc = String(col).toLowerCase();
98
+ var keys = Object.keys(row);
99
+ for (var i = 0; i < keys.length; i++) {
100
+ if (keys[i].toLowerCase() === lc) return { present: true, value: row[keys[i]] };
101
+ }
102
+ return { present: false, value: undefined };
103
+ }
104
+
92
105
  function _assertLocalResidency(table, plaintextRow, op) {
93
106
  var spec = cryptoField.getPerRowResidency(table);
94
107
  var colMap = cryptoField.getColumnResidency(table);
@@ -106,9 +119,16 @@ function _assertLocalResidency(table, plaintextRow, op) {
106
119
  var regulated = state.regulated;
107
120
 
108
121
  if (spec) {
109
- var tag = plaintextRow[spec.residencyColumn];
122
+ // SQL unquoted identifiers are case-insensitive, and the raw-SQL parser
123
+ // preserves the column token's case — so resolve the residency column
124
+ // case-insensitively. A case-sensitive lookup let `UPDATE t SET REGION=...`
125
+ // miss a `residencyColumn: "region"` declaration, skipping the gate and
126
+ // admitting a cross-border write (CWE-178 / CWE-863). Fail-safe: any
127
+ // spelling that could be the residency column engages the gate.
128
+ var resolved = _ciColumn(plaintextRow, spec.residencyColumn);
129
+ var tag = resolved.value;
110
130
  var tagPresent = tag !== undefined && tag !== null;
111
- var colInChangeSet = Object.prototype.hasOwnProperty.call(plaintextRow, spec.residencyColumn);
131
+ var colInChangeSet = resolved.present;
112
132
  if (op === "insert" && !tagPresent) {
113
133
  throw new DbQueryError("db-query/row-residency-tag-missing",
114
134
  op + ": table '" + table + "' declares per-row residency on column '" +
@@ -368,6 +388,17 @@ class Query {
368
388
  return this.where(field, "IN", values);
369
389
  }
370
390
 
391
+ // whereNull / whereNotNull — explicit NULL predicates (IS NULL / IS NOT
392
+ // NULL). `where({ field: null })` / `where(field, "=", null)` is refused
393
+ // because `col = NULL` is UNKNOWN in SQL (never true); these are the
394
+ // intended way to test a column for NULL.
395
+ whereNull(field) {
396
+ return this.where(field, "IS", null);
397
+ }
398
+ whereNotNull(field) {
399
+ return this.where(field, "IS NOT", null);
400
+ }
401
+
371
402
  // Resolve a (field, op, value) predicate through the framework gates
372
403
  // (JSONB value guard, sealed-field → derived-hash rewrite, column
373
404
  // membership) and return the post-rewrite { field, op, value } that
package/lib/mail-auth.js CHANGED
@@ -2173,15 +2173,38 @@ function _extractFromHeaders(headerBlock) {
2173
2173
  // one of several authors (the §6.6.1 forbidden move), so the
2174
2174
  // field is treated as unparsable instead.
2175
2175
  address = value.trim();
2176
- if (/[\s,]/.test(address)) address = null;
2176
+ // An RFC 5322 addr-spec cannot contain whitespace, commas, or the
2177
+ // group / route markers ; : < > — their presence means a list, a
2178
+ // display-name, or RFC 5322 group syntax (`display-name: mailbox ;`),
2179
+ // not a single bare addr-spec.
2180
+ if (/[\s,;:<>]/.test(address)) address = null;
2177
2181
  }
2178
2182
  var at = address ? address.lastIndexOf("@") : -1;
2179
2183
  var domain = (at > 0 && address && at < address.length - 1)
2180
2184
  ? address.slice(at + 1).toLowerCase()
2181
2185
  : null;
2186
+ // The domain drives the DMARC policy lookup + alignment check. A value that
2187
+ // is not a syntactically valid DNS hostname (a trailing ';', group-syntax
2188
+ // soup, or other bytes that slipped the filters) would silently miss the
2189
+ // policy record and defeat DMARC (CWE-290). Require a real hostname;
2190
+ // otherwise treat From as unparsable — the caller fails closed (permerror →
2191
+ // reject).
2192
+ if (domain && !_isValidFromHostname(domain)) domain = null;
2182
2193
  return { count: count, address: address || null, domain: domain };
2183
2194
  }
2184
2195
 
2196
+ // Strict, bounded DNS-hostname check (ASCII / A-label form, as DMARC lookups
2197
+ // use). Anchored with a length lookahead and tempered labels — linear time, no
2198
+ // catastrophic backtracking. Requires at least two labels (a dot).
2199
+ var _FROM_HOSTNAME_RE =
2200
+ /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
2201
+ function _isValidFromHostname(host) {
2202
+ // Bound the length before the regex test (max DNS name is 253 octets) — the
2203
+ // pattern is already anchored + tempered, but bounding untrusted input before
2204
+ // any .test() is the framework convention.
2205
+ return typeof host === "string" && host.length <= 253 && _FROM_HOSTNAME_RE.test(host);
2206
+ }
2207
+
2185
2208
  async function inboundVerify(opts) {
2186
2209
  validateOpts.requireObject(opts, "inbound.verify", MailAuthError, "mail-auth/inbound-bad-input");
2187
2210
  validateOpts(opts, ["ip", "helo", "mailFrom", "message", "dnsLookup", "domainExists",
package/lib/mail-dkim.js CHANGED
@@ -167,13 +167,24 @@ function _parseHeaders(rawHeaders) {
167
167
 
168
168
  // ---- Hashing + signing ----
169
169
 
170
- function _bodyHashB64(body, algorithm, canonBody) {
170
+ function _bodyHashB64(body, algorithm, canonBody, lcap) {
171
171
  var canonicalized = canonBody === "simple"
172
172
  ? _canonBodySimple(body)
173
173
  : _canonBodyRelaxed(body);
174
174
  var hashName = "sha256"; // both rsa-sha256 and ed25519-sha256 hash with sha256
175
- return nodeCrypto.createHash(hashName)
176
- .update(canonicalized).digest("base64");
175
+ var hash = nodeCrypto.createHash(hashName);
176
+ // RFC 6376 §3.4.5 — the l= body-length count is octets of the body AFTER
177
+ // canonicalization. Canonicalize first, then truncate the canonicalized
178
+ // octet stream to lcap (slicing the raw body before canonicalizing diverges
179
+ // whenever relaxed canon changes the byte count within the first lcap
180
+ // octets — WSP-run collapse, trailing-WSP strip, CRLF normalization).
181
+ if (typeof lcap === "number" && isFinite(lcap) && lcap >= 0) {
182
+ var buf = Buffer.from(canonicalized, "utf8");
183
+ hash.update(lcap < buf.length ? buf.subarray(0, lcap) : buf);
184
+ } else {
185
+ hash.update(canonicalized);
186
+ }
187
+ return hash.digest("base64");
177
188
  }
178
189
 
179
190
  function _signString(strToSign, privateKey, algorithm) {
@@ -674,15 +685,17 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
674
685
 
675
686
  var split = _splitHeadersBody(rfc822);
676
687
  var body = split.body;
688
+ var lcap;
677
689
  if (sigTags.l !== undefined) {
678
690
  // The framework refuses l= at SIGN-time per the M3AAWG / Gmail /
679
691
  // Microsoft 365 guidance (v0.7.18). On VERIFY, an `l=` tag on an
680
692
  // inbound signature signals append-after-signature exposure —
681
693
  // operators decide acceptance. Honor the cap for the body hash so
682
694
  // the signature still validates against legitimate senders that
683
- // use l=, but flag in the result.
684
- var lcap = parseInt(sigTags.l, 10);
685
- if (isFinite(lcap) && lcap >= 0) body = body.slice(0, lcap);
695
+ // use l=, but flag in the result. The cap is octets of the
696
+ // CANONICALIZED body (RFC 6376 §3.4.5), applied inside _bodyHashB64.
697
+ var parsedL = parseInt(sigTags.l, 10);
698
+ if (isFinite(parsedL) && parsedL >= 0) lcap = parsedL;
686
699
  }
687
700
 
688
701
  // 1. Body-hash check.
@@ -690,7 +703,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
690
703
  if (typeof expectedBh !== "string") {
691
704
  return { result: "permerror", errors: ["DKIM-Signature missing bh="] };
692
705
  }
693
- var actualBh = _bodyHashB64(body, algorithm, canonBody);
706
+ var actualBh = _bodyHashB64(body, algorithm, canonBody, lcap);
694
707
  if (actualBh !== expectedBh) {
695
708
  return { result: "fail", errors: ["body hash mismatch"] };
696
709
  }