@blamejs/core 0.15.17 → 0.15.19
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 +4 -0
- package/lib/middleware/dpop.js +54 -31
- package/lib/middleware/span-http-server.js +7 -0
- package/lib/network-tls.js +12 -2
- package/lib/request-helpers.js +90 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ 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
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
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.
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -144,33 +144,19 @@ function _nonceManager(rotateSec) {
|
|
|
144
144
|
};
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function _reconstructHtu(req,
|
|
148
|
-
// The proof's htu is the request URI WITHOUT query/fragment. Behind
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// origin
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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,
|
|
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
|
}
|
|
@@ -68,6 +68,11 @@ function _splitUrl(url) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function _scheme(req) {
|
|
71
|
+
// Display-only: the OTel url.scheme span attribute reflects the scheme the
|
|
72
|
+
// client used (forwarded), NOT a Secure/HSTS/origin trust decision. Routing
|
|
73
|
+
// through trustedProtocol would drop the forwarded scheme from spans behind a
|
|
74
|
+
// proxy (less accurate telemetry) for no security gain.
|
|
75
|
+
// allow:raw-xfp — telemetry label, not a trust sink (see above).
|
|
71
76
|
var x = req.headers && (req.headers["x-forwarded-proto"] || "");
|
|
72
77
|
if (typeof x === "string" && x.length > 0) {
|
|
73
78
|
var first = x.split(",")[0].trim().toLowerCase();
|
|
@@ -77,6 +82,8 @@ function _scheme(req) {
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
function _serverAddress(req) {
|
|
85
|
+
// allow:raw-xfp — display-only: server.address span attribute (telemetry),
|
|
86
|
+
// not an authority trust decision. Same rationale as _scheme above.
|
|
80
87
|
var hostHeader = req.headers && (req.headers["x-forwarded-host"] || req.headers.host);
|
|
81
88
|
if (typeof hostHeader === "string" && hostHeader.length > 0) {
|
|
82
89
|
return hostHeader.split(",")[0].trim();
|
package/lib/network-tls.js
CHANGED
|
@@ -1187,8 +1187,18 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
1187
1187
|
var clockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min if absent or invalid
|
|
1188
1188
|
? opts.clockSkewMs : C.TIME.minutes(5);
|
|
1189
1189
|
var now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
1190
|
-
|
|
1191
|
-
|
|
1190
|
+
// thisUpdate / nextUpdate are already unix-ms NUMBERS (parseOcspResponse →
|
|
1191
|
+
// _parseTime returns Date.UTC(...)). Do NOT Date.parse() them: Date.parse
|
|
1192
|
+
// coerces its argument to a string, and a bare-integer string is not a
|
|
1193
|
+
// recognized date format, so Date.parse(<number>) is NaN — which made the
|
|
1194
|
+
// !isFinite guard below reject every response (fresh or stale) with a
|
|
1195
|
+
// misleading "missing thisUpdate", leaving the real staleness window checks
|
|
1196
|
+
// (future thisUpdate, past nextUpdate) as unreachable dead code. Read the
|
|
1197
|
+
// numbers directly; the typeof guard keeps a legitimate epoch-0 thisUpdate
|
|
1198
|
+
// (Date.UTC(1970,0,1)===0, which is falsy) and maps a null nextUpdate to NaN
|
|
1199
|
+
// so the optional-field branch still no-ops.
|
|
1200
|
+
var thisUpdateMs = typeof match.thisUpdate === "number" ? match.thisUpdate : NaN;
|
|
1201
|
+
var nextUpdateMs = typeof match.nextUpdate === "number" ? match.nextUpdate : NaN;
|
|
1192
1202
|
if (!isFinite(thisUpdateMs)) {
|
|
1193
1203
|
return { ok: false, status: parsed.status, signatureValid: true,
|
|
1194
1204
|
certStatus: match.certStatus,
|
package/lib/request-helpers.js
CHANGED
|
@@ -636,6 +636,94 @@ function requestProtocol(req, opts) {
|
|
|
636
636
|
return "http";
|
|
637
637
|
}
|
|
638
638
|
|
|
639
|
+
/**
|
|
640
|
+
* @primitive b.requestHelpers.trustedHost
|
|
641
|
+
* @signature b.requestHelpers.trustedHost(opts?)
|
|
642
|
+
* @since 0.15.18
|
|
643
|
+
* @related b.requestHelpers.requestHost, b.requestHelpers.trustedProtocol
|
|
644
|
+
*
|
|
645
|
+
* Peer-gated companion to trustedProtocol for the request authority (host).
|
|
646
|
+
* Reconstructing the absolute request URL — the DPoP `htu`, an origin/issuer
|
|
647
|
+
* string, a redirect base — depends on the host the client addressed; behind a
|
|
648
|
+
* proxy that comes from X-Forwarded-Host, which is forgeable unless the
|
|
649
|
+
* immediate peer is a trusted proxy. Returns `{ resolve(req)=>string|null,
|
|
650
|
+
* peerGated }`. With `trustedProxies` (CIDRs) X-Forwarded-Host is honored only
|
|
651
|
+
* from a trusted peer; with `hostResolver(req)` the operator owns it; with
|
|
652
|
+
* neither only the request's own Host header is used (forwarded host ignored).
|
|
653
|
+
*
|
|
654
|
+
* @opts
|
|
655
|
+
* trustedProxies: string | string[],
|
|
656
|
+
* hostResolver: function(req): string|null,
|
|
657
|
+
*
|
|
658
|
+
* @example
|
|
659
|
+
* var th = b.requestHelpers.trustedHost({ trustedProxies: ["10.0.0.0/8"] });
|
|
660
|
+
* th.resolve(req); // X-Forwarded-Host only when it came via a trusted peer
|
|
661
|
+
*/
|
|
662
|
+
function trustedHost(opts) {
|
|
663
|
+
opts = opts || {};
|
|
664
|
+
var resolver = opts.hostResolver;
|
|
665
|
+
if (resolver != null && typeof resolver !== "function") {
|
|
666
|
+
throw new TypeError("trustedHost: hostResolver must be a function(req) => string|null");
|
|
667
|
+
}
|
|
668
|
+
var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedHost");
|
|
669
|
+
return {
|
|
670
|
+
peerGated: !!(resolver || predicate),
|
|
671
|
+
resolve: function (req) {
|
|
672
|
+
if (resolver) return resolver(req);
|
|
673
|
+
if (predicate) return requestHost(req, { trustProxy: predicate });
|
|
674
|
+
return requestHost(req, { trustProxy: false });
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* @primitive b.requestHelpers.requestHost
|
|
681
|
+
* @signature b.requestHelpers.requestHost(req, opts?)
|
|
682
|
+
* @since 0.15.18
|
|
683
|
+
* @related b.requestHelpers.requestProtocol, b.requestHelpers.trustedHost
|
|
684
|
+
*
|
|
685
|
+
* Resolve the inbound authority (host[:port]). Default returns the request's
|
|
686
|
+
* own `Host` header. Behind a trusted reverse proxy that rewrites the host,
|
|
687
|
+
* pass `trustProxy` as a PREDICATE `function(addr)=>boolean` (build it via
|
|
688
|
+
* `b.requestHelpers.trustedHost`): `X-Forwarded-Host` is then honored only when
|
|
689
|
+
* the immediate peer is a trusted proxy, so a direct caller can't forge it. The
|
|
690
|
+
* legacy `trustProxy: true` reads the leftmost forwarded hop without checking
|
|
691
|
+
* the peer — forgeable. Returns the host string, or `null` when absent.
|
|
692
|
+
*
|
|
693
|
+
* @opts
|
|
694
|
+
* trustProxy: boolean | function // false (default) | predicate (peer-gated) | legacy true
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* b.requestHelpers.requestHost({ headers: { host: "app.example.com" } });
|
|
698
|
+
* // → "app.example.com"
|
|
699
|
+
*/
|
|
700
|
+
function requestHost(req, opts) {
|
|
701
|
+
if (!req || !req.headers) return null;
|
|
702
|
+
var trust = opts && opts.trustProxy;
|
|
703
|
+
if (trust) {
|
|
704
|
+
var fwd = req.headers["x-forwarded-host"];
|
|
705
|
+
if (typeof fwd === "string" && fwd.length > 0) {
|
|
706
|
+
var hops = parseListHeader(fwd);
|
|
707
|
+
if (hops.length > 0) {
|
|
708
|
+
if (typeof trust === "function") {
|
|
709
|
+
// Peer-gated: honor X-Forwarded-Host only when the immediate TCP peer
|
|
710
|
+
// is a trusted proxy. A direct caller's forged header is ignored —
|
|
711
|
+
// fall through to the request's own Host header.
|
|
712
|
+
var peer =
|
|
713
|
+
(req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
714
|
+
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
715
|
+
: null;
|
|
716
|
+
if (peer && trust(peer)) return hops[0];
|
|
717
|
+
// peer not a trusted proxy → ignore forgeable header, fall through
|
|
718
|
+
} else {
|
|
719
|
+
return hops[0]; // legacy true — spoofable, see docstring
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return typeof req.headers.host === "string" ? req.headers.host : null;
|
|
725
|
+
}
|
|
726
|
+
|
|
639
727
|
// RFC 9110 §5.6.2 token grammar — letters, digits, and the
|
|
640
728
|
// punctuation set `!#$%&'*+-.^_`|~`. Used by header-list parsers
|
|
641
729
|
// that consume protocol tokens (Connection, Sec-WebSocket-Protocol,
|
|
@@ -1221,6 +1309,8 @@ module.exports = {
|
|
|
1221
1309
|
ipPrefix: ipPrefix,
|
|
1222
1310
|
requestProtocol: requestProtocol,
|
|
1223
1311
|
trustedProtocol: trustedProtocol,
|
|
1312
|
+
requestHost: requestHost,
|
|
1313
|
+
trustedHost: trustedHost,
|
|
1224
1314
|
appendVary: appendVary,
|
|
1225
1315
|
// CVE-2026-21710 wrap — safe alternative to req.headersDistinct
|
|
1226
1316
|
safeHeadersDistinct: safeHeadersDistinct,
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:0f15f1bc-20bf-42b0-81c9-af07a955a721",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-23T06:24:33.061Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.19",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.19",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.19",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.19",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|