@blamejs/blamejs-shop 0.4.87 → 0.4.88

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/SECURITY.md +8 -0
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +53 -31
  5. package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +7 -2
  6. package/lib/vendor/blamejs/.github/dependabot.yml +12 -0
  7. package/lib/vendor/blamejs/.github/workflows/ci.yml +16 -12
  8. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +3 -1
  9. package/lib/vendor/blamejs/.github/workflows/release-container.yml +23 -0
  10. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  12. package/lib/vendor/blamejs/examples/wiki/Dockerfile +19 -2
  13. package/lib/vendor/blamejs/lib/atomic-file.js +32 -9
  14. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +105 -40
  15. package/lib/vendor/blamejs/lib/middleware/dpop.js +54 -31
  16. package/lib/vendor/blamejs/lib/middleware/span-http-server.js +7 -0
  17. package/lib/vendor/blamejs/lib/network-tls.js +12 -2
  18. package/lib/vendor/blamejs/lib/queue-local.js +123 -46
  19. package/lib/vendor/blamejs/lib/queue.js +13 -9
  20. package/lib/vendor/blamejs/lib/request-helpers.js +90 -0
  21. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +69 -7
  22. package/lib/vendor/blamejs/lib/self-update.js +11 -2
  23. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
  24. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +6 -2
  25. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +689 -688
  26. package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +5 -0
  27. package/lib/vendor/blamejs/package.json +1 -1
  28. package/lib/vendor/blamejs/release-notes/v0.15.17.json +52 -0
  29. package/lib/vendor/blamejs/release-notes/v0.15.18.json +49 -0
  30. package/lib/vendor/blamejs/release-notes/v0.15.19.json +18 -0
  31. package/lib/vendor/blamejs/scripts/check-vendor-currency.js +24 -0
  32. package/lib/vendor/blamejs/test/integration/queue-cluster-mysql.test.js +419 -0
  33. package/lib/vendor/blamejs/test/integration/queue-cluster-pg.test.js +471 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt-rejection-envelope.test.js +309 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -8
  36. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js +138 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +26 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-htu-peergating.test.js +227 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +83 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-poll-asset-digest.test.js +90 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-standalone-verifier-ecdsa-encoding.test.js +274 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/tls-ocsp-freshness.test.js +192 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/vendor-currency-classify.test.js +36 -0
  44. package/package.json +1 -1
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.19 (2026-06-22) — **Restores the wiki container build by keeping its base image on the continuously-patched rolling tag.** A follow-up to 0.15.18. The framework code is unchanged from 0.15.18 — this patch reverts one part of that release's supply-chain pass: the example wiki container's Chainguard base images had been pinned to specific digests, but Chainguard rebuilds those images continuously to ship CVE fixes, so the frozen digest fell behind an upstream fix within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build (a fixed npm/undici denial-of-service the rolling tag already carried). For a deployed, Trivy-gated image, tracking the rolling, always-patched tag is the correct posture; the wiki Dockerfile is back on it. The ClusterFuzzLite fuzz base (not deployed, not release-gated) stays digest-pinned with Dependabot keeping it current. **Fixed:** *Wiki container builds again on a continuously-patched base* — 0.15.18 digest-pinned the example wiki container's Chainguard base images (runtime + builder). Because Chainguard rebuilds those images continuously to ship CVE fixes, the pinned digest went stale within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build over an already-fixed npm/undici DoS (CVE-2026-12151) the rolling tag carried. The wiki Dockerfile tracks the rolling tag again, so the deployed image is always CVE-current and the build passes the release gate. To keep the Trivy-scanned image identical to the multi-arch image that is published (they are built separately), the release workflow resolves the rolling tag to a digest once at build time and feeds it to both builds via build-args — scan-equals-publish without a committed pin that goes stale. (This trades the OSSF Scorecard PinnedDependencies signal for CVE currency on a deployed, gate-scanned image — the right call here; the non-deployed ClusterFuzzLite fuzz base remains digest-pinned with Dependabot bumping it.) The framework package itself is identical to 0.15.18.
12
+
13
+ - v0.15.18 (2026-06-22) — **OCSP response-freshness enforcement is restored, DPoP request-URI reconstruction peer-gates forwarded headers, and container/tool supply-chain pinning is tightened.** Three hardening fixes. The stapled-OCSP evaluator parsed each response's thisUpdate / nextUpdate with Date.parse, but those fields are already numeric (unix-ms), so Date.parse returned NaN — the freshness guard then rejected every signature-valid response, fresh or stale, with a misleading "missing thisUpdate", and the real future-dated / past-nextUpdate window checks (RFC 6960 §4.2.2.1) were unreachable dead code. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale or future-dated response is refused and a fresh "good" response is accepted, so OCSP stapling validation works again. b.middleware.dpop reconstructed the absolute request URI — the cryptographically-bound htu — trusting X-Forwarded-Proto / X-Forwarded-Host from any caller whenever trustForwardedHeaders was set, so a direct attacker could forge the scheme or authority and make a proof signed for one origin validate against another; both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost (honored only from a declared trusted-proxy peer), matching csrf-protect / security-headers / cors, and the bare trustForwardedHeaders boolean is refused. The supply-chain pass pins the wiki container and ClusterFuzzLite fuzz base images to digests (Dependabot keeps them current) and the npm-publish bundle tools to exact versions. **Added:** *b.requestHelpers.requestHost and b.requestHelpers.trustedHost* — Peer-gated request-authority resolvers — the host companions to requestProtocol / trustedProtocol. trustedHost(opts) returns { resolve(req) => string|null, peerGated }: with trustedProxies (CIDRs) X-Forwarded-Host is honored only from a trusted-proxy peer; with hostResolver(req) the operator owns it; with neither, only the request's own Host header is used and a forged X-Forwarded-Host is ignored. requestHost(req, opts?) is the low-level resolver (default Host-only; a peer predicate gates the forwarded header). For reconstructing an absolute request URL (a DPoP htu, an origin/issuer string, a redirect base) behind a proxy without trusting a forgeable header. **Changed:** *Container and bundle-tool supply-chain pinning tightened* — The wiki container's base images (cgr.dev/chainguard/node runtime + builder stages) and the ClusterFuzzLite fuzz base (gcr.io/oss-fuzz-base/base-builder-javascript) are pinned to image digests; Dependabot's docker ecosystem keeps both current, so the pin is reproducible without freezing CVE patches. The npm-publish workflow's bundle tools (esbuild, postject) are pinned to exact versions, matching CI. The OSS-Fuzz project-submission Dockerfile is intentionally left tracking the upstream base-builder, per OSS-Fuzz convention. **Security:** *OCSP response-freshness enforcement restored (RFC 6960 §4.2.2.1)* — The stapled-OCSP evaluator computed thisUpdate / nextUpdate via Date.parse(), but the parser hands those fields back as unix-ms NUMBERS (Date.UTC(...)). Date.parse() coerces its argument to a string and a bare-integer string is not a recognized date, so the result was always NaN: the !isFinite guard then rejected every signature-valid response — fresh OR stale — with a misleading "missing thisUpdate", and the genuine staleness checks (future-dated thisUpdate, past nextUpdate) sat behind it as unreachable dead code, with the stale-rejection branch latently fail-open. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale (past-nextUpdate) or future-dated response is refused, and a fresh "good" response is accepted — OCSP stapling validation, and its replay defense, work again. · *DPoP htu reconstruction peer-gates X-Forwarded-Proto and X-Forwarded-Host* — b.middleware.dpop rebuilds the absolute request URI (scheme + authority + path) that the proof's cryptographically-bound htu claim (RFC 9449 §4.3) is verified against. When the legacy trustForwardedHeaders: true was set it derived the scheme and host from X-Forwarded-Proto / X-Forwarded-Host trusted from ANY caller, with no immediate-peer check — a direct attacker could forge X-Forwarded-Proto: https or a victim X-Forwarded-Host and make a proof signed for one origin validate against another (htu confusion). Both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost, which honor the forwarded headers only when the immediate connection is a declared trusted proxy — the same fail-closed model csrf-protect (Secure cookie), security-headers (HSTS), cors (same-origin), and bot-guard (secure context) already use. A codebase-patterns detector now flags any further middleware that reads X-Forwarded-Proto / -Host directly for a scheme/authority decision. **Migration:** *DPoP: trustForwardedHeaders is replaced by trustedProxies* — b.middleware.dpop no longer honors the bare trustForwardedHeaders: true boolean — it trusted forgeable X-Forwarded-Proto / X-Forwarded-Host from any caller. Behind a reverse proxy, declare your proxy CIDRs via trustedProxies: ["10.0.0.0/8", …] (peer-gates both headers for the htu reconstruction), or own the reconstruction via protocolResolver(req) / hostResolver(req) / getHtu(req). A bare trustForwardedHeaders: true now throws at create() with that guidance. Apps not behind a proxy need no change — the default already derives the scheme from the TLS socket and the host from the request's Host header.
14
+
15
+ - v0.15.17 (2026-06-22) — **The job queue runs on a Postgres or MySQL cluster backend, and a set of correctness and confidentiality fixes land for encrypted-API rejections, flow-job ordering, the self-update signature verifier, and bounded file reads.** The local job queue's lease path is now dialect-aware, so the queue works when its store is a Postgres or MySQL cluster backend rather than only single-node SQLite. The previous lease was a single SQLite-only statement: it double-quoted identifiers (which MySQL reads as string literals), updated a table named in its own subquery (MySQL error 1093), and used RETURNING (which MySQL rejects), so the first enqueue or lease against a cluster backend failed outright. The lease now resolves the active backend dialect and composes per-dialect SQL — a transactional SELECT ... FOR UPDATE SKIP LOCKED claim over the head of the queue followed by a guarded UPDATE against a literal id list, returning the claimed rows via RETURNING on Postgres and a re-select on MySQL — with backtick (MySQL) or double-quote (Postgres) identifier quoting, mirroring the framework's outbox claim. enqueue, lease, complete, fail, sweep, and dead-letter all run against the cluster backend, verified end-to-end against live MySQL and Postgres. Alongside the queue work: a request rejected on an established encrypted-API session is now returned inside the session envelope instead of leaking the rejection reason in cleartext; a flow child that declares dependencies can no longer be leased before those dependencies run during the brief window of the two-pass flow enqueue; the standalone self-update verifier classifies an ECDSA signature's encoding by its ASN.1 structure rather than its byte length and fails closed on an indeterminate shape; the self-update poller surfaces each asset's content digest; and the bounded synchronous file reader routes every failure branch through its typed error mapper. **Changed:** *Bundled Public Suffix List refreshed to current upstream* — The vendored Mozilla Public Suffix List — which b.publicSuffix uses to derive organizational domains for DMARC (psd= / np=), BIMI, cookie-scope checks, and same-site policy — is refreshed to the latest upstream revision. Domain classification reflects recent additions and removals to the list. **Fixed:** *The job queue's lease runs dialect-correct SQL on a Postgres or MySQL cluster backend* — When the queue's store is a cluster backend, the lease previously failed at the first enqueue or claim: it was composed as one SQLite-only statement that double-quoted column identifiers (a syntax error on MySQL, which treats double quotes as string literals), updated the jobs table from a subquery that named the same table (MySQL error 1093), and relied on RETURNING (which MySQL does not support). The lease now resolves the active backend dialect and builds per-dialect SQL — a transactional SELECT ... FOR UPDATE SKIP LOCKED over the head of the queue, then a guarded UPDATE ... WHERE status = 'pending' AND id IN (...) with a literal id list (no self-referencing subquery), reading the claimed rows back via RETURNING on Postgres and a re-select by id on MySQL — and quotes identifiers with backticks on MySQL and double quotes on Postgres. enqueue, lease, complete, fail, sweep, and the dead-letter operations now work against a cluster backend, with sealed-at-rest job payloads round-tripping correctly; single-node SQLite continues to use the original single-statement claim. The behavior is verified end-to-end against live MySQL and Postgres. · *Encrypted-API rejections that would disclose session state ride the session envelope* — On a per-session encrypted API channel, a rejection that reveals session state — an expired or rotated session, or a request admitted past the replay gate whose ciphertext then failed authentication — returned its reason as cleartext JSON, disclosing which check failed to a network observer of an otherwise-encrypted channel. Those rejections are now encrypted inside the session envelope under the response counter the server persists for them. The generic 'rejected' refusals that carry no session-lifecycle detail (a stale or duplicate request counter) stay plaintext: they reveal nothing a 400 status does not, and encrypting them would consume a response counter the server does not persist on those paths — desyncing the session's monotonic counter and bricking it for the next genuine request. Pre-session handshake failures (a malformed bootstrap, a stale timestamp, an unknown session) also remain plaintext, since no key exists yet to encrypt under. · *A flow child with dependencies can no longer be leased before its dependencies* — b.queue.enqueueFlow inserts a flow's children in two passes (the second resolves dependency names to the sibling job ids assigned in the first). The first pass previously enqueued every child as immediately leaseable and relied on the second pass to park the dependency-bearing children, leaving a window in which a consumer polling between the two passes could lease a child before the jobs it depends on had run. A dependency-bearing child is now parked at enqueue time on the first pass, so it is never leaseable in that window; the second pass only rewrites its dependency names to the resolved job ids. Children with no dependencies remain immediately leaseable. · *The self-update poller carries each asset's content digest* — b.selfUpdate.poll() now includes the content digest for each asset and its detached signature in the result it returns, so a caller can verify a downloaded asset against the digest advertised by the release feed. · *Bounded file reads always return the typed error shape* — The bounded synchronous file reader could surface an unmapped error on a few failure branches — a refused symlink whose target had since been removed, a short read, an integrity mismatch — rather than the typed error its callers expect. Every failure branch now passes through the reader's error mapper, so a caller always receives the documented error kind. **Security:** *The standalone self-update verifier detects ECDSA signature encoding by structure* — The standalone update-signature verifier inferred whether an ECDSA signature was DER- or IEEE-P1363-encoded from its byte length — a heuristic two distinct encodings can collide on, which could lead a valid-but-misclassified signature to be parsed under the wrong scheme. It now parses the signature's ASN.1 structure to classify the encoding and fails closed on an indeterminate shape rather than guessing.
16
+
11
17
  - v0.15.16 (2026-06-22) — **A correctness and hardening release: a broad set of credential and certificate verifiers now fail closed on a present-but-unparseable timestamp instead of silently skipping the check, outbound network clients gain an overall wall-clock timeout and a bounded framing buffer, DMARC refuses a record with no usable policy, append-only log sinks refuse a symlinked path, and the interactive-transaction and replay-store contracts refuse a backend that cannot honor them.** This release closes a family of fail-open and resource-exhaustion gaps without adding opt-ins to the request path. A recurring pattern across the verifiers — a timestamp parsed with a lenient parser, then gated by isFinite() so an unparseable value disables the check — is converted to fail-closed everywhere it appears: DKIM x=/t=/l=, ARC AMS/AS t=/x=, SAML Bearer and holder-of-key NotBefore, and the FIDO MDS3 / BIMI / S/MIME certificate validity windows now refuse a malformed value rather than accepting the credential. DMARC now validates and requires a usable p= policy and never recommends delivery for a failing message with an absent or unrecognized policy. Outbound clients that exposed a single timeout but applied it only as an idle (zero-progress) timer now also enforce it as an overall wall-clock cap, so a peer that trickles bytes within the idle window can no longer hold a request open indefinitely: the encrypted HTTP client, the object-store and SQS request paths, the CloudWatch / OTLP / webhook log sinks, and the password breach check. The DNS resolver and the lower-level DNS client gain a per-query wall-clock deadline that also tears the socket down, the SMTP client and the proxy CONNECT tunnel bound their framing buffers and add an overall deadline, and the NTS key-establishment handshake bounds its accumulator. The append-only local log sink and the daemon log open with O_NOFOLLOW so a symlink planted at the path is refused rather than followed. b.externalDb.transaction and b.outbox now refuse a backend that declares it cannot provide an interactive transaction instead of silently running a non-atomic block, and the GraphQL-federation replay store adopts the framework's atomic check-and-insert contract. **Added:** *b.atomicFile.openAppendNoFollowSync — symlink-refusing append open* — Opens a long-lived append target (an active log file kept open across appends and reopened on rotation) with O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW: the file is created or appended normally, but a symlink at the final path component fails the open closed (ELOOP) rather than redirecting writes. The append-sink counterpart to openNoFollowSync (read) and the exclusive-create temp open. · *b.externalDb.supportsTransactions() and stateless-adapter declaration* — A backend adapter may declare supportsTransactions: false (a stateless / autocommit-per-statement adapter) and/or provide a batch(client, statements) hook for an atomic multi-statement path. b.externalDb.supportsTransactions() reports whether the picked backend can provide an interactive transaction, so a consumer built on the dual-write guarantee can refuse a non-atomic backend up front. · *Store-free device fingerprint* — b.sessionDeviceBinding.fingerprint(req, opts) is now a static entry point returning the request-shape digest with no store, and b.sessionDeviceBinding.create() with neither a bindingStore nor storeInSession returns an instance whose stateless fingerprint() works while bind()/verify() throw a clear "no store configured". Soft device binding for self-validating tokens (a sealed cookie or JWT carrying the fingerprint) no longer needs a fabricated no-op store. **Changed:** *Device fingerprint IP masking is canonical* — b.sessionDeviceBinding masked the client IP into its prefix bucket by textual group slicing with no IPv6 normalization, so equivalent forms of one address (a ::-compressed form vs its fully-expanded form) hashed to different fingerprints and a roaming client could be logged out on a false drift. It now masks through the same canonical prefix helper b.session uses, preserving the configured prefix width (the IPv6 default stays /48, and ipPrefixBits is honored). b.requestHelpers.ipPrefix accepts { v4Bits, v6Bits } to override the /24 + /64 default for a function-form fingerprint or other reuse. **Fixed:** *b.session.destroyAllForUser works for pluggable-store consumers* — A consumer configuring sessions through b.session.useStore without calling b.db.init() got a db/not-initialized error from destroyAllForUser — every "log out everywhere" / suspend / delete path rejected with a 500 after the store rows had already been deleted, because the stateless valid-from boundary write was routed only to the framework database. The boundary now prefers the framework database when one is initialized and otherwise falls back to the configured session store (provisioning its table on demand), so store-backed deployments raise the stateless boundary successfully; it still fails closed when neither a framework database nor a store is available. **Security:** *Certificate and credential verifiers fail closed on an unparseable timestamp* — A present-but-unparseable date no longer silently disables a validity check. DKIM x= / t= (signature expiry, future-date, ordering) and l= (body-length cap), ARC AMS/AS t= / x=, SAML Bearer and holder-of-key SubjectConfirmationData NotBefore, and the FIDO MDS3, BIMI, and S/MIME certificate validity windows previously parsed the value, then guarded the comparison with isFinite() — so a value that did not parse (NaN) skipped the check and the credential was accepted. Each now refuses a present-but-unparseable value (a permerror / not-valid result) and only an absent optional field is ignored, matching the existing fail-closed handling of the SAML Conditions block. · *DMARC refuses a record with no usable policy* — The DMARC policy tag p= (and the subdomain sp=) were taken verbatim with no validation and p= was never required, so the disposition mapped a null or typo'd policy to the catch-all "deliver". A failing, unaligned message against a domain whose record omitted p= or carried an unrecognized value was therefore delivered. p= and sp= are now validated against none|quarantine|reject, p= is required for a record to carry a policy at all, an invalid record is a permerror (not a transient temperror), and the disposition is an explicit fail-closed mapping that never delivers a failing message on an absent or unrecognized policy. · *Outbound clients enforce an overall wall-clock timeout, not only an idle timeout* — Several clients exposed a single timeout but forwarded it to the underlying request only as idleTimeoutMs (a zero-progress cap that resets on every received byte), leaving no overall bound — a peer that trickles one byte inside each idle window holds the call open indefinitely. b.httpClient.encrypted (which dropped timeoutMs entirely, and now also forwards the caller's AbortSignal), the object-store request path and Azure/GCS bucket operations, the SQS queue client, the CloudWatch / OTLP / webhook log sinks, and the password HIBP breach check now set the overall wall-clock timeout alongside the idle timeout. · *DNS lookups gain a default wall-clock deadline and tear the socket down* — The DNS resolver's DoH transport had no time bound of any kind and the lower-level DNS client defaulted its lookup timeout to zero (no deadline). A non-responsive or slow-trickle upstream — reachable while resolving DKIM/SPF/BIMI records for inbound mail — held the lookup pending forever. The resolver now takes a per-query timeout (default 10s) wrapping every query and CNAME hop, the DNS client defaults its lookup timeout to 10s (operators opt out with a zero timeout), and the raw DoH/DoT request paths arm a socket timeout that destroys the connection on a stall rather than leaking the descriptor. · *SMTP, proxy-CONNECT, and NTS clients bound their read buffers and add deadlines* — The SMTP client accumulated the server's reply without a byte cap and relied on an idle timer alone; a hostile or broken MX that never sent a line terminator could exhaust memory, and a slow trickle held the transaction open. It now caps the accumulated reply (refusing once it exceeds the response ceiling) and enforces an overall transaction deadline. The proxy CONNECT-tunnel client, which accumulated the proxy's reply unbounded with no socket timeout, now caps the header buffer and times out the connect. The NTS key-establishment handshake reader caps its record accumulator so a server streaming non-terminating records cannot exhaust memory before the handshake timer fires. · *Append-only log sinks refuse a symlinked path* — The local log sink and the daemon log opened the active file in append mode without O_NOFOLLOW, so a symlink planted at the log path (including one re-planted in the race window after a caller's pre-check) was followed, redirecting log writes to an attacker-chosen file. Both now open with O_NOFOLLOW via the new b.atomicFile.openAppendNoFollowSync, refusing a symlinked final path component. · *Transaction and replay-store contracts refuse a backend that cannot honor them* — b.externalDb.transaction and b.outbox.create now refuse a backend that declares it cannot provide an interactive transaction (a stateless / autocommit-per-statement adapter) instead of silently running BEGIN, the body, and COMMIT on different sessions — no isolation, no rollback — while reporting success. b.graphqlFederation.guardSdl now consults its replay nonce store through the framework's atomic checkAndInsert(nonce, expireAt) contract (the same one b.webhook and b.nonceStore use) rather than a non-atomic has()-then-remember() check that raced concurrent redeliveries; a store lacking checkAndInsert is refused at construction. **Migration:** *DNS lookups now time out by default* — The lower-level DNS client's lookup timeout previously defaulted to zero (no deadline); it now defaults to 10 seconds. A deployment that intentionally ran DNS lookups with no wall-clock bound must set the lookup timeout to zero explicitly to restore the old behavior. The b.network.dns.resolver default timeout is also 10 seconds and is configurable via its timeoutMs option. · *b.graphqlFederation.guardSdl nonceStore contract* — guardSdl's optional replay nonceStore now requires the atomic checkAndInsert(nonce, expireAt) contract (b.nonceStore.create is the reference store) and refuses the previous { has, remember } shape at construction. Operators using the old shape should pass b.nonceStore.create(...) or an adapter exposing checkAndInsert. · *Interactive transactions on a stateless backend are refused* — If an externalDb backend adapter declares supportsTransactions: false, b.externalDb.transaction and b.outbox.create now throw rather than running a silently non-atomic block. Existing stateful adapters (Postgres / MySQL / SQLite, and any adapter that supplies beginTx/commit/rollback or omits the flag) are unaffected. A stateless / autocommit-per-statement adapter should supply interactive transaction hooks or a batch adapter.
12
18
 
13
19
  - 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.15.16",
4
- "createdAt": "2026-06-22T12:04:10.257Z",
3
+ "frameworkVersion": "0.15.19",
4
+ "createdAt": "2026-06-23T04:32:56.651Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -49239,6 +49239,10 @@
49239
49239
  "type": "function",
49240
49240
  "arity": 2
49241
49241
  },
49242
+ "requestHost": {
49243
+ "type": "function",
49244
+ "arity": 2
49245
+ },
49242
49246
  "requestProtocol": {
49243
49247
  "type": "function",
49244
49248
  "arity": 2
@@ -49259,6 +49263,10 @@
49259
49263
  "type": "function",
49260
49264
  "arity": 1
49261
49265
  },
49266
+ "trustedHost": {
49267
+ "type": "function",
49268
+ "arity": 1
49269
+ },
49262
49270
  "trustedProtocol": {
49263
49271
  "type": "function",
49264
49272
  "arity": 1
@@ -22,7 +22,22 @@
22
22
  # The :latest runtime image runs as `nonroot` (different user, lower
23
23
  # privilege); --chown=nonroot:nonroot on the COPY --from=deps rewrites
24
24
  # ownership at copy time into the runtime image.
25
- FROM cgr.dev/chainguard/node:latest-dev AS deps
25
+ # Base images are NOT digest-pinned in source: Chainguard rebuilds :latest
26
+ # continuously to ship CVE fixes, so a committed digest goes stale within hours
27
+ # and the Trivy CRITICAL/HIGH release gate then rejects the build (e.g. a fixed
28
+ # npm/undici DoS the rolling tag already carries). For a deployed, Trivy-gated
29
+ # image, rolling + always-patched beats reproducible-but-frozen. (Scorecard
30
+ # PinnedDependencies flags this by design — accepted.)
31
+ #
32
+ # To make the Trivy-scanned image identical to the multi-arch image that gets
33
+ # published (release-container.yml builds those separately), the workflow
34
+ # resolves the rolling tag to a digest ONCE at build time and passes it via
35
+ # these build-args, so scan == publish without a stale committed pin. A local
36
+ # `docker build` with no build-args falls back to the rolling tag.
37
+ ARG BASE_DEPS=cgr.dev/chainguard/node:latest-dev
38
+ ARG BASE_RUNTIME=cgr.dev/chainguard/node:latest
39
+
40
+ FROM ${BASE_DEPS} AS deps
26
41
  WORKDIR /home/node
27
42
  COPY --chown=node:node package.json index.js NOTICE LICENSE README.md ./
28
43
  COPY --chown=node:node bin ./bin
@@ -41,7 +56,9 @@ RUN rm -rf node_modules data data-e2e public/dist \
41
56
  && node -e "var b = require('@blamejs/core'); var path = require('node:path'); (async function () { var bundler = b.bundler.create({ entries: { wiki: path.join(process.cwd(), 'src', 'wiki.js'), editor: path.join(process.cwd(), 'src', 'editor.js') }, outdir: path.join(process.cwd(), 'public', 'dist'), manifest: 'manifest.json', hashLen: 16 }); await bundler.build(); console.log('[Dockerfile] bundle pre-built into public/dist'); })().catch(function (e) { console.error('bundle build failed:', e.stack || e); process.exit(1); });" \
42
57
  && mkdir -p /home/node/data-skeleton
43
58
 
44
- FROM cgr.dev/chainguard/node:latest AS runtime
59
+ # See the deps stage above: rolling base, resolved to a digest at build time by
60
+ # release-container.yml (build-arg) so the scanned and published images match.
61
+ FROM ${BASE_RUNTIME} AS runtime
45
62
  # Explicit USER directive — Chainguard's :latest image already runs as
46
63
  # `nonroot` (UID 65532) by default, but Trivy's static Dockerfile
47
64
  # checker (DS-0002) flags any image without an explicit USER line
@@ -895,6 +895,19 @@ function fdSafeReadSync(filepath, opts) {
895
895
  var errorFor = opts.errorFor || function (kind, detail) {
896
896
  return new AtomicFileError((detail && detail.message) || ("atomic-file: " + kind), "atomic-file/" + kind);
897
897
  };
898
+ // Every failure KIND routes through this one mapper-then-fallback gate: throw
899
+ // the caller's typed error when errorFor returns truthy, else the fallback —
900
+ // the raw OS error for enoent (preserving the rethrow-raw posture network-tls
901
+ // / vault / backup rely on), or a synthetic AtomicFileError for the kinds with
902
+ // no underlying OS error to rethrow. An errorFor that returns undefined for a
903
+ // kind must never make us throw the literal `undefined` (the openSync/enoent
904
+ // branch already guarded this; symlink / too-large / toctou / short-read /
905
+ // integrity now do too — #358).
906
+ function _raise(kind, detail, fallbackErr) {
907
+ var typed = errorFor(kind, detail);
908
+ throw typed || fallbackErr ||
909
+ new AtomicFileError((detail && detail.message) || ("atomic-file: " + kind), "atomic-file/" + kind);
910
+ }
898
911
  if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
899
912
  var mode = opts.mode === undefined ? 0o600 : opts.mode;
900
913
  // refuseSymlink: lstat the path first and refuse a symlink source —
@@ -902,10 +915,21 @@ function fdSafeReadSync(filepath, opts) {
902
915
  // fd's inode is re-checked against this lstat's inode below.
903
916
  var lstat = null;
904
917
  if (opts.refuseSymlink) {
905
- lstat = nodeFs.lstatSync(filepath);
906
- if (lstat.isSymbolicLink()) throw errorFor("symlink", { path: filepath });
918
+ try {
919
+ lstat = nodeFs.lstatSync(filepath);
920
+ } catch (lstatErr) {
921
+ // A missing file makes lstat throw raw ENOENT BEFORE the openSync branch
922
+ // that consults errorFor("enoent"). Route it through the same mapping so
923
+ // the missing-file error class is identical with or without refuseSymlink
924
+ // (#358); any other lstat error rethrows raw.
925
+ if (lstatErr && lstatErr.code === "ENOENT") {
926
+ _raise("enoent", { path: filepath, cause: lstatErr }, lstatErr);
927
+ }
928
+ throw lstatErr;
929
+ }
930
+ if (lstat.isSymbolicLink()) _raise("symlink", { path: filepath });
907
931
  if (opts.maxBytes !== undefined && lstat.size > opts.maxBytes) {
908
- throw errorFor("too-large", { size: lstat.size, max: opts.maxBytes });
932
+ _raise("too-large", { size: lstat.size, max: opts.maxBytes });
909
933
  }
910
934
  }
911
935
  // The third argument pins an owner-only mode (0o600 default). The flag
@@ -918,8 +942,7 @@ function fdSafeReadSync(filepath, opts) {
918
942
  fd = nodeFs.openSync(filepath, "r", mode);
919
943
  } catch (openErr) {
920
944
  if (openErr && openErr.code === "ENOENT") {
921
- var typed = errorFor("enoent", { path: filepath, cause: openErr });
922
- if (typed) throw typed;
945
+ _raise("enoent", { path: filepath, cause: openErr }, openErr);
923
946
  }
924
947
  throw openErr;
925
948
  }
@@ -933,10 +956,10 @@ function fdSafeReadSync(filepath, opts) {
933
956
  // plain (no-inodeCheck) reader reports an over-cap as too-large.
934
957
  if (lstat && opts.inodeCheck) {
935
958
  if (fstat.ino !== lstat.ino || (opts.maxBytes !== undefined && fstat.size > opts.maxBytes)) {
936
- throw errorFor("toctou", { path: filepath });
959
+ _raise("toctou", { path: filepath });
937
960
  }
938
961
  } else if (opts.maxBytes !== undefined && fstat.size > opts.maxBytes) {
939
- throw errorFor("too-large", { size: fstat.size, max: opts.maxBytes });
962
+ _raise("too-large", { size: fstat.size, max: opts.maxBytes });
940
963
  }
941
964
  buf = Buffer.alloc(fstat.size);
942
965
  var read = 0;
@@ -947,7 +970,7 @@ function fdSafeReadSync(filepath, opts) {
947
970
  }
948
971
  if (read !== fstat.size) {
949
972
  if (opts.allowShortRead) { buf = buf.slice(0, read); }
950
- else { throw errorFor("short-read", { read: read, size: fstat.size }); }
973
+ else { _raise("short-read", { read: read, size: fstat.size }); }
951
974
  }
952
975
  } finally {
953
976
  try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
@@ -955,7 +978,7 @@ function fdSafeReadSync(filepath, opts) {
955
978
  if (opts.expectedHash) {
956
979
  var actual = sha3Hash(buf);
957
980
  if (actual !== opts.expectedHash) {
958
- throw errorFor("integrity", { expected: opts.expectedHash, actual: actual });
981
+ _raise("integrity", { expected: opts.expectedHash, actual: actual });
959
982
  }
960
983
  }
961
984
  var content = opts.encoding ? buf.toString(opts.encoding) : buf;
@@ -223,12 +223,39 @@ function _validSid(sid) {
223
223
  SID_RE.test(sid);
224
224
  }
225
225
 
226
- function _writeRejection(res, code, body) {
226
+ // _writeRejection emit a protocol-level rejection body.
227
+ //
228
+ // On an ESTABLISHED per-session encrypted channel the rejection MUST
229
+ // travel inside the session envelope, exactly like a successful
230
+ // response: a client on a keyed channel that sends a stale / replayed /
231
+ // malformed request would otherwise learn, in cleartext, which check
232
+ // failed over an otherwise-encrypted channel. The middleware stamps
233
+ // `req.apiEncryptRejectEncode` the moment a session is resolved with a
234
+ // valid session key; when present, the body is wrapped through the same
235
+ // response-encryption path successful responses use. Absent it (a
236
+ // pre-session handshake error, where no session context exists yet, or
237
+ // per-request mode) the body falls back to plaintext.
238
+ function _writeRejection(req, res, code, body, opts) {
227
239
  if (res.headersSent || res.writableEnded) return;
228
- if (typeof res.writeHead === "function") {
229
- res.writeHead(code, { "Content-Type": "application/json" });
230
- res.end(JSON.stringify(body));
240
+ if (typeof res.writeHead !== "function") return;
241
+ var out = body;
242
+ // opts.plaintext forces a cleartext body even on an established channel.
243
+ // Used for the generic "encrypted-payload-rejected" refusals that DO NOT
244
+ // delete the session (the monotonic-counter replay and the atomic-claim
245
+ // loss): riding those on the session envelope would emit a response _ctr
246
+ // the client tracks as consumed, but those paths return before the
247
+ // server persists responsesEmitted — so the next genuine response reuses
248
+ // that _ctr and the client refuses it as a replay, bricking the session.
249
+ // The body is already generic (no session-lifecycle reason leaks the way
250
+ // session-expired / rotation-required would), so plaintext here costs no
251
+ // meaningful confidentiality.
252
+ var encode = (!opts || !opts.plaintext) && req && req.apiEncryptRejectEncode;
253
+ if (typeof encode === "function") {
254
+ try { out = encode(body); }
255
+ catch (_e) { out = body; } // encryption failed → fall back to plaintext rather than hang
231
256
  }
257
+ res.writeHead(code, { "Content-Type": "application/json" });
258
+ res.end(JSON.stringify(out));
232
259
  }
233
260
 
234
261
  // ---- Server-side middleware ----
@@ -458,6 +485,26 @@ function create(opts) {
458
485
  return encrypted;
459
486
  }
460
487
 
488
+ // _installRejectEncoder — stamp req with a function that wraps a
489
+ // protocol-level rejection body in the SAME session envelope a
490
+ // successful response uses, so an error emitted on an established
491
+ // per-session encrypted channel does not leak (in cleartext) which
492
+ // check the request tripped. Called the moment a session is resolved
493
+ // with a valid session key, BEFORE the expiry / rotation / replay
494
+ // gates that fire on a keyed channel. The rejection rides a fresh
495
+ // response counter (sid bound, strictly above the session's last
496
+ // emitted response) so the client's monotonic _ctr check still holds.
497
+ // The session-deleting rejections (expired / rotation) and the
498
+ // post-claim tag-mismatch ride this encoder; the two generic surviving
499
+ // rejections (monotonic-counter replay, atomic-claim loss) opt out via
500
+ // _writeRejection({ plaintext: true }) because they return before the
501
+ // consumed counter is persisted — see _writeRejection.
502
+ function _installRejectEncoder(req, sessionKey, sid, responseCtr) {
503
+ req.apiEncryptRejectEncode = function (body) {
504
+ return _encodeEnvelope(body, sessionKey, { sid: sid, responseCtr: responseCtr });
505
+ };
506
+ }
507
+
461
508
  // _wrapResJson — install res.json that encrypts the response with the
462
509
  // session key. In per-request mode the response is `{ _ct }`; in
463
510
  // per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
@@ -507,18 +554,18 @@ function create(opts) {
507
554
  var body = req.body;
508
555
  if (!body || typeof body !== "object") {
509
556
  _emitFailure(req, "shape");
510
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
557
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
511
558
  }
512
559
 
513
560
  var now = Date.now();
514
561
  var ct = body._ct, ts = body._ts;
515
562
  if (typeof ct !== "string" || typeof ts !== "number") {
516
563
  _emitFailure(req, "shape");
517
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
564
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
518
565
  }
519
566
  if (Math.abs(now - ts) > replayWindowMs) {
520
567
  _emitFailure(req, "stale");
521
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
568
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
522
569
  }
523
570
 
524
571
  // Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
@@ -540,25 +587,25 @@ function create(opts) {
540
587
  try { freshNonce = await store.checkAndInsert(nonceHash, expireAt); }
541
588
  catch (_e) {
542
589
  _emitFailure(req, "nonce-store-error");
543
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
590
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
544
591
  }
545
592
  if (!freshNonce) {
546
593
  _emitFailure(req, "replay");
547
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
594
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
548
595
  }
549
596
  sessionKey = _decryptEkToSessionKey(ek);
550
597
  if (!sessionKey) {
551
598
  _emitFailure(req, "tag");
552
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
599
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
553
600
  }
554
601
  if (keying === "per-session") {
555
602
  if (!_validSid(sid)) {
556
603
  _emitFailure(req, "shape");
557
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
604
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
558
605
  }
559
606
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
560
607
  _emitFailure(req, "shape");
561
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
608
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
562
609
  }
563
610
  // Bootstrap a new session row keyed by sid. responsesEmitted is
564
611
  // set to 1 (this bootstrap emits one response) BEFORE the store
@@ -579,7 +626,7 @@ function create(opts) {
579
626
  try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
580
627
  catch (_e) {
581
628
  _emitFailure(req, "session-store-error");
582
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
629
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
583
630
  }
584
631
  _emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
585
632
  _emitSessionAudit("apiEncrypt.session.created", {
@@ -588,28 +635,56 @@ function create(opts) {
588
635
  requestId: req.requestId || null,
589
636
  });
590
637
  sessionCtx = { sid: sid, responseCtr: 1 };
638
+ // Session now established — a post-bootstrap rejection (e.g. the
639
+ // final decrypt below) rides the session envelope under the same
640
+ // counter the success response would have used.
641
+ _installRejectEncoder(req, sessionKey, sid, 1);
591
642
  }
592
643
  } else if (keying === "per-session" &&
593
644
  typeof sid === "string" && typeof ctr === "number") {
594
645
  // ---- Per-session subsequent-request path ----
595
646
  if (!_validSid(sid)) {
596
647
  _emitFailure(req, "shape");
597
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
648
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
598
649
  }
599
650
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
600
651
  _emitFailure(req, "shape");
601
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
652
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
602
653
  }
603
654
  try { session = await sessionStore.get(sid); }
604
655
  catch (_e) {
605
656
  _emitFailure(req, "session-store-error");
606
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
657
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
607
658
  }
608
659
  if (!session) {
609
660
  _emitObs("apiEncrypt.session.unknown", 1, {});
610
661
  _emitFailure(req, "session-unknown");
611
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
662
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
663
+ }
664
+ // Recover + coerce the session key BEFORE the expiry / rotation /
665
+ // replay gates so a rejection on this established channel can ride
666
+ // the session envelope (the channel is keyed the moment the sid
667
+ // resolves to a stored session, even if THIS request is then
668
+ // refused). An operator store may have JSON-serialised the buffer.
669
+ sessionKey = session.sessionKey;
670
+ if (Buffer.isBuffer(sessionKey) === false) {
671
+ if (typeof sessionKey === "string") {
672
+ sessionKey = Buffer.from(sessionKey, "base64");
673
+ } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
674
+ sessionKey = Buffer.from(sessionKey.data);
675
+ } else if (sessionKey instanceof Uint8Array) {
676
+ sessionKey = Buffer.from(sessionKey);
677
+ }
678
+ }
679
+ if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
680
+ sessionKey = null;
681
+ _emitFailure(req, "session-store-error");
682
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
612
683
  }
684
+ // From here the channel is established: error bodies encrypt. The
685
+ // rejection counter sits one above the session's last emitted
686
+ // response so the client's strictly-increasing _ctr check holds.
687
+ _installRejectEncoder(req, sessionKey, sid, session.responsesEmitted + 1);
613
688
  if (now > session.expiresAt) {
614
689
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
615
690
  _emitObs("apiEncrypt.session.expired", 1, {});
@@ -620,7 +695,7 @@ function create(opts) {
620
695
  requestId: req.requestId || null,
621
696
  });
622
697
  _emitFailure(req, "session-expired");
623
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
698
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
624
699
  }
625
700
  if (session.responsesEmitted >= sessionMaxResponses) {
626
701
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
@@ -632,7 +707,7 @@ function create(opts) {
632
707
  requestId: req.requestId || null,
633
708
  });
634
709
  _emitFailure(req, "session-rotation-required");
635
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
710
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
636
711
  }
637
712
  // Replay defense: counter MUST strictly increase.
638
713
  if (ctr <= session.lastReqCtr) {
@@ -644,7 +719,10 @@ function create(opts) {
644
719
  requestId: req.requestId || null,
645
720
  });
646
721
  _emitFailure(req, "counter-replay");
647
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
722
+ // Plaintext: this path does not persist a consumed response counter
723
+ // (see _writeRejection). Keeping it cleartext avoids desyncing the
724
+ // session's response-counter sequence.
725
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
648
726
  }
649
727
  // Atomic replay gate (CWE-367). The monotonic counter check above is
650
728
  // an ordering fast-path only: on a clustered session store, get(sid)
@@ -674,7 +752,7 @@ function create(opts) {
674
752
  try { ctrFresh = await store.checkAndInsert(ctrKey, session.expiresAt); }
675
753
  catch (_e) {
676
754
  _emitFailure(req, "nonce-store-error");
677
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
755
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
678
756
  }
679
757
  if (!ctrFresh) {
680
758
  _emitObs("apiEncrypt.session.replay_rejected", 1, { lane: "atomic" });
@@ -685,23 +763,10 @@ function create(opts) {
685
763
  requestId: req.requestId || null,
686
764
  });
687
765
  _emitFailure(req, "counter-replay");
688
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
689
- }
690
- sessionKey = session.sessionKey;
691
- if (Buffer.isBuffer(sessionKey) === false) {
692
- // Operator-supplied store may have JSON-serialised the buffer.
693
- // Accept hex / base64 / Uint8Array and coerce.
694
- if (typeof sessionKey === "string") {
695
- sessionKey = Buffer.from(sessionKey, "base64");
696
- } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
697
- sessionKey = Buffer.from(sessionKey.data);
698
- } else if (sessionKey instanceof Uint8Array) {
699
- sessionKey = Buffer.from(sessionKey);
700
- }
701
- }
702
- if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
703
- _emitFailure(req, "session-store-error");
704
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
766
+ // Plaintext for the same reason as the monotonic-replay path above:
767
+ // the atomic-claim loser returns before responsesEmitted is persisted,
768
+ // so encrypting it would consume a response _ctr the server never records.
769
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
705
770
  }
706
771
  session.lastReqCtr = ctr;
707
772
  session.lastUsedAt = now;
@@ -711,7 +776,7 @@ function create(opts) {
711
776
  sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
712
777
  } else {
713
778
  _emitFailure(req, "shape");
714
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
779
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
715
780
  }
716
781
 
717
782
  // Decrypt _ct → cleartext payload bytes → JSON object. The request
@@ -726,7 +791,7 @@ function create(opts) {
726
791
  clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
727
792
  } catch (_e) {
728
793
  _emitFailure(req, "tag");
729
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
794
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
730
795
  }
731
796
 
732
797
  // Replace req.body with cleartext, stash session key for any
@@ -144,33 +144,19 @@ function _nonceManager(rotateSec) {
144
144
  };
145
145
  }
146
146
 
147
- function _reconstructHtu(req, mopts) {
148
- // The proof's htu is the request URI WITHOUT query/fragment. Behind
149
- // a reverse proxy the operator may need to override via opts.htu /
150
- // opts.getHtu. X-Forwarded-* headers are ATTACKER-CONTROLLED when
151
- // the origin is reachable directly; an attacker who can hit the
152
- // origin while spoofing X-Forwarded-Proto: https can trick this
153
- // function into building an `https` htu that the DPoP proof was
154
- // signed for when the origin is actually serving HTTP. RFC 9449
155
- // §4.3 says htu MUST be the absolute URL the request was sent to.
156
- //
157
- // Default: ignore X-Forwarded-* and derive proto/host from the
158
- // socket. Operators with a confirmed-trusted front proxy opt in
159
- // via opts.trustForwardedHeaders: true.
160
- mopts = mopts || {};
161
- var trustForwarded = mopts.trustForwardedHeaders === true;
162
- var proto;
163
- if (trustForwarded && req.headers["x-forwarded-proto"]) {
164
- proto = String(req.headers["x-forwarded-proto"]).split(",")[0].trim();
165
- } else {
166
- proto = req.socket && req.socket.encrypted ? "https" : "http";
167
- }
168
- var host;
169
- if (trustForwarded && req.headers["x-forwarded-host"]) {
170
- host = String(req.headers["x-forwarded-host"]).split(",")[0].trim();
171
- } else {
172
- host = req.headers.host;
173
- }
147
+ function _reconstructHtu(req, protoResolver, hostResolver) {
148
+ // The proof's htu is the request URI WITHOUT query/fragment. Behind a
149
+ // reverse proxy the operator may override via opts.getHtu. RFC 9449 §4.3
150
+ // says htu MUST be the absolute URL the request was sent to — and it is
151
+ // cryptographically bound in the proof, so a forged scheme/authority lets a
152
+ // proof signed for one origin validate against another. proto + host are
153
+ // resolved through the peer-gated requestHelpers resolvers built in create():
154
+ // X-Forwarded-Proto / -Host are honored only from a declared trusted-proxy
155
+ // peer; otherwise the real TLS socket scheme + the request's own Host are
156
+ // used and forged forwarded headers are ignored.
157
+ if (!req || !req.headers) return null;
158
+ var proto = protoResolver.resolve(req);
159
+ var host = hostResolver.resolve(req);
174
160
  if (!host) return null;
175
161
  var path = req.url || "/";
176
162
  var qIdx = path.indexOf("?");
@@ -205,6 +191,9 @@ function _reconstructHtu(req, mopts) {
205
191
  * getAccessToken: function(req): string|null,
206
192
  * getNonce: async function(req): string|null,
207
193
  * getHtu: function(req): string,
194
+ * trustedProxies: string|string[], // CIDRs of your reverse proxies — peer-gates X-Forwarded-Proto + X-Forwarded-Host for htu reconstruction
195
+ * protocolResolver: function(req): "http"|"https", // own the scheme decision
196
+ * hostResolver: function(req): string|null, // own the authority decision
208
197
  * nonceStore: object,
209
198
  * nonceWindowSec: number,
210
199
  * nonceRotateSec: number,
@@ -228,9 +217,11 @@ function create(opts) {
228
217
  "replayStore", "algorithms", "iatWindowSec",
229
218
  "getAccessToken", "getNonce", "getHtu", "audit",
230
219
  "nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
231
- // v0.9.4 opt-in trust gate for X-Forwarded-Proto/Host when
232
- // reconstructing htu. Default off; operators
233
- // with a confirmed-trusted front proxy set this to `true`.
220
+ // htu reconstruction trust. trustedProxies (CIDRs) peer-gates
221
+ // X-Forwarded-Proto + X-Forwarded-Host; protocolResolver/hostResolver let
222
+ // the operator own each. trustForwardedHeaders (legacy boolean) is refused
223
+ // on its own — see the peer-gating block below.
224
+ "trustedProxies", "protocolResolver", "hostResolver",
234
225
  "trustForwardedHeaders", "onDeny", "problemDetails",
235
226
  ], "middleware.dpop");
236
227
 
@@ -282,6 +273,38 @@ function create(opts) {
282
273
  validateOpts.optionalFunction(opts.getHtu,
283
274
  "middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
284
275
 
276
+ // htu reconstruction (RFC 9449 §4.3) builds the absolute request URL —
277
+ // proto + host — that the proof's cryptographically-bound `htu` claim is
278
+ // verified against. Behind a proxy both come from forgeable X-Forwarded-*
279
+ // headers, so resolve them through the peer-gated requestHelpers primitives
280
+ // (the same fail-closed model csrf-protect / security-headers / cors use):
281
+ // X-Forwarded-Proto / -Host are honored ONLY when the immediate peer is a
282
+ // declared trusted proxy. The legacy trustForwardedHeaders:true trusted the
283
+ // headers from ANY caller — a direct attacker could forge XFP:https / a
284
+ // victim XFH to make a proof signed for one origin validate against another
285
+ // (htu confusion). It is refused on its own; migrate to trustedProxies.
286
+ var _proto = requestHelpers.trustedProtocol({
287
+ trustedProxies: opts.trustedProxies,
288
+ protocolResolver: opts.protocolResolver,
289
+ });
290
+ var _host = requestHelpers.trustedHost({
291
+ trustedProxies: opts.trustedProxies,
292
+ hostResolver: opts.hostResolver,
293
+ });
294
+ // Only refuse the spoofable legacy flag when the htu is actually
295
+ // reconstructed from the request. When the operator supplies getHtu they own
296
+ // the entire URI, _reconstructHtu (and the forwarded headers) is never
297
+ // consulted, so a leftover trustForwardedHeaders is moot — don't fail
298
+ // construction on it (the error text even offers getHtu as a migration path).
299
+ if (typeof opts.getHtu !== "function" && opts.trustForwardedHeaders === true && !_proto.peerGated) {
300
+ throw new AuthError("auth-dpop/bad-opt",
301
+ "middleware.dpop: trustForwardedHeaders is spoofable for the htu reconstruction " +
302
+ "(a direct caller can forge X-Forwarded-Proto / X-Forwarded-Host) and is no longer " +
303
+ "honored on its own. Declare your reverse proxies via trustedProxies: [\"10.0.0.0/8\", …] " +
304
+ "(peer-gates X-Forwarded-Proto + X-Forwarded-Host), or own the decision via " +
305
+ "protocolResolver(req) / hostResolver(req) / getHtu(req).");
306
+ }
307
+
285
308
  function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
286
309
 
287
310
  var middleware = async function dpopMiddleware(req, res, next) {
@@ -309,7 +332,7 @@ function create(opts) {
309
332
  "multiple DPoP proofs in one header value are not allowed", null, onDeny, problemMode);
310
333
  }
311
334
 
312
- var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
335
+ var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, _proto, _host));
313
336
  if (!htu) {
314
337
  return _writeUnauthorized(req, res, "invalid_dpop_proof", "could not reconstruct htu", null, onDeny, problemMode);
315
338
  }