@blamejs/core 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.3 (2026-07-03) — **A batch of fail-open, injection-bypass and error-path fixes across the metrics, guard-sql, MCP, YAML-parser, content-credentials, tus-upload and buffer primitives — including a credential-leak fix on the /metrics exposition surface — alongside a further expansion of the automated test suite.** This patch closes a set of security and robustness defects concentrated in the error-handling and adversarial-input paths of the observability, SQL-guard, MCP, YAML-parsing, content-credentials and resumable-upload primitives, surfaced while continuing to expand the automated test suite. The most consequential: credential-shaped metric label values were redacted only in the internal cardinality key, not in the rendered exposition, so a raw bearer token / API key / JWT placed on a label leaked verbatim to every `/metrics` scrape reader — redaction now applies to the stored labels the exposition renders. The SQL guard's fragment mode (the `whereRaw` escape hatch) refused an embedded single-quoted literal but not a PostgreSQL dollar-quoted string (`$tag$…$tag$`) or a lone top-level `;`, so operator concatenation into a dollar-quoted context bypassed the floor that forces `?`-placeholder binding. MCP tool-input validation was fail-open when a JSON Schema omitted an explicit top-level `type:"object"` (a valid, common shape) — `required`/`properties`/`additionalProperties` were skipped and any input accepted; and its server-initiated sampling token cap was bypassable with a string `maxTokens`. The YAML parser's flow style (`{…}` / `[…]`) accepted duplicate keys and the merge key that block style rejects — letting a later value silently override an earlier one a preceding check had already read — and mis-parsed a root-level JSON object. content-credentials returned a only shallowly-frozen manifest (nested claim fields stayed mutable before signing), and its identity-assertion hash re-check used set membership rather than multiset matching, so duplicate referenced assertions could leave a bound assertion never re-confirmed. Robustness fixes round out the batch: metrics now reject `±Infinity`; the buffer collector preserves a caller error class's code/message (they were swapped for framework error classes, so a caller branching on the error code — e.g. a resumable-upload `413` vs `400` — never matched); resumable uploads return `413` (not a `400` that leaked the internal error code) on an oversized chunk, reject a prototype-key checksum algorithm with `400` (not `500`), and parse a deferred `Upload-Length` as strictly as creation; the MCP guard refuses `/register/` (trailing slash) like `/register`, answers a missing/invalid bearer with `401` and its `WWW-Authenticate` challenge instead of `400`, honors an explicit empty accepted-version list, and accepts a loopback `http` redirect URI for native/CLI clients while still refusing non-loopback `http`. **Fixed:** *Metrics reject non-finite values* — `gauge.set`, `histogram.observe` and `counter.inc` guarded their value with an `isNaN`-only check (counter only checked `< 0`), so `±Infinity` passed and produced `Infinity` / `-Infinity` in the exposition — invalid Prometheus 0.0.4 / OpenMetrics, which a scraper rejects. All three now require a finite number, matching their `must be a finite number` error text. · *Buffer stream collector preserves a caller error class's code and message* — `safeBuffer.collectStream` constructs its size-limit error with `(message, code)`, matching its own error class — but a framework error class (the convention elsewhere) takes `(code, message)`, the opposite order, so a caller that passed its own error class received an error with `code` and `message` swapped. A caller branching on the error code then never matched — for example the resumable-upload handler mapped an oversized chunk to `400` instead of `413`. The collector now sets both fields explicitly so they are correct whichever constructor order the passed class uses. · *Resumable upload: correct status codes and strict deferred-length parse* — An oversized `PATCH`/creation chunk now returns `413 Payload Too Large` instead of a `400` whose body leaked the internal error code; an `Upload-Checksum` naming a prototype key (e.g. `__proto__`) returns `400 unsupported` instead of a `500` from an unguarded lookup; and a deferred-length finalization now parses `Upload-Length` with the same strict check as creation, rejecting trailing junk (`"10abc"`) rather than silently truncating it. · *MCP guard: trailing-slash registration refusal, 401 bearer challenge, empty accepted-version list* — The static-registration refusal matched `/register` exactly or by suffix, so `/register/` — which most routers normalize to the same handler — slipped through; it is now refused too. A missing or invalid bearer now returns `401 Unauthorized` with its `WWW-Authenticate` challenge (RFC 6750 §3) instead of `400`, so clients keyed on `401` re-authenticate. And an explicit empty `accepted` protocol-version list now rejects every version instead of silently widening to the default set. · *MCP accepts a loopback `http` redirect URI* — `redirect_uri` validation parsed the URI with the default HTTPS-only protocol set, so a loopback `http://localhost/…` redirect (RFC 8252 native-app / CLI client, permitted by RFC 9700 §4.1.1) failed as `did not parse` before the local-host allowance could run. The parse now admits `http` so the loopback allowance applies; a non-loopback `http` redirect is still refused. · *YAML: trailing content after a flow collection and root-level JSON objects* — `root: [1, 2] junk` silently dropped the trailing content where the quoted-scalar path rejects it; trailing content after a flow collection is now refused (`yaml/trailing-content`). A root-level flow/JSON object (`{"a": 1, "b": 2}`) was mis-scanned as a block mapping with a garbage key and returned structurally-wrong data; the leading flow bracket is now recognized before the block-mapping scan so a root JSON object parses correctly. · *content-credentials: deep-frozen manifest and typed rejections for bad build/sign inputs* — `build` returned a shallowly-frozen manifest, leaving every nested claim field (`provider`/`system`/`content`) mutable before signing despite the documented pre-sign immutability guarantee; the manifest is now deep-frozen. A non-finite or out-of-Date-range `generatedAt` (`NaN` / `Infinity`) crashed with an untyped `RangeError`, and a non-Buffer `certChain` entry to `signCose` threw an untyped Node `TypeError`; both now reject with a typed `content-credentials/*` error at call time. **Security:** *Credential-shaped metric labels are redacted in the `/metrics` exposition, not just the cardinality key* — The metrics registry rewrote a credential-shaped label value to `[REDACTED-CREDENTIAL]`, but only inside the function that builds the internal Map cardinality key. The stored label object — the one the exposition renderer emits — kept the raw value, so a bearer token, API key or JWT placed on a label (e.g. an `authorization` label) was written verbatim to every `/metrics` scrape reader, contradicting the documented guarantee that the bytes never reach the scrape stream. Redaction now runs on the resolved labels themselves, so the key and the rendered output both carry the marker. · *SQL guard fragment mode refuses PostgreSQL dollar-quoted literals and any top-level semicolon* — In fragment mode (`whereRaw`), the embedded-string-literal floor that forces every value through a `?` placeholder detected a single-quoted literal but not a dollar-quoted one (`$tag$…$tag$` or `$$…$$`), so operator input concatenated into a dollar-quoted context passed the strict floor that the equivalent single-quoted value is refused by. The floor now recognizes dollar-quoted literals. Fragment mode also now refuses any top-level `;` — including a lone or trailing one — rather than relying on the stacked-statement scan, which only fired when a second statement followed the `;`. · *MCP tool-input validation enforces the schema without an explicit `type:"object"`* — `validateToolInput` gated the `required` / `properties` / `additionalProperties` checks on the schema declaring `type:"object"`. A schema that carries `properties`/`required` but omits `type` — a valid and common JSON Schema shape — was treated as untyped, so those constraints were skipped and any argument object was accepted, defeating the primitive's stated purpose (OWASP LLM07). Such a schema is now treated as an object schema and its constraints enforced. · *MCP sampling token cap rejects a non-numeric `maxTokens`* — `sampling.guard` applied the per-request token cap only when `maxTokens` was a number, so a hostile-tool-initiated sampling request carrying `maxTokens` as a string skipped the cap entirely — and a downstream SDK that coerces the string would then exceed the budget. A non-numeric `maxTokens` on this server-initiated surface is now refused with a typed error. · *content-credentials identity-assertion re-check uses multiset matching* — `verifyIdentityAssertion` re-confirmed the signed referenced-assertion hash binding with count-equality plus set membership, never consuming a matched entry. Supplying referenced assertions that hash to `[A, A]` against a signed binding of `[A, B]` passed — the counts matched and each supplied hash was present — even though the bound `hash(B)` was never re-confirmed against a real assertion (a transplant fail-open). The re-check now consumes each bound hash as it is matched, so duplicates can no longer stand in for a distinct bound assertion. · *YAML flow-style mappings refuse duplicate keys and the merge key like block style* — Block mappings rejected a duplicate key (`yaml/duplicate-key`) and the anchor-merge key `<<` (`yaml/merge-key-banned`), but flow mappings (`{ a: 1, a: 2 }`) enforced neither — silently keeping the last value on a repeat and accepting `<<` as an ordinary key. A value an earlier check has already read could thus be overridden through flow style. Flow mappings now apply both guards.
12
+
13
+ - v0.16.2 (2026-07-03) — **Security hardening across the mail, OAuth/SAML, ACME, keychain and router primitives — six fail-open / injection / bypass fixes and seven correctness fixes — alongside a large expansion of the automated test suite.** This patch closes a batch of security and correctness defects surfaced while substantially expanding the test suite across the framework's mail-authentication, federated-identity, ACME, keychain, backup, CLI and router primitives. The security fixes: SPF evaluation no longer throws (and thus can no longer be laundered into a temperror that a permissive inbound policy accepts) on a malformed `ip4:` CIDR mask; the Authentication-Results builder now refuses CR/LF/NUL in the `version` field, closing the last header-injection gap left by the earlier mail-header sweep; `res.redirect` now rejects a horizontal TAB (which user agents strip before URL parsing, turning `/\t/evil.example` into a protocol-relative redirect to an attacker origin); the OAuth refresh-token one-time-use replay gate now treats any truthy `seen()` result as seen (a Redis `1` / SQL `COUNT` no longer slips a stolen refresh token past a `=== true` compare) — with the same normalization applied to its sibling replay checks; SAML Single-Logout over HTTP-POST/SOAP now fails closed (throws) when a verification key is supplied without its algorithm, matching the redirect binding instead of silently skipping signature verification; and the OAuth authorization-URL / PAR builders now refuse operator `extraParams` that collide with framework-managed parameters (`redirect_uri`, `state`, `code_challenge`, …). The correctness fixes: concurrent keychain file-store writes are serialized under the existing file lock (no more lost bindings); TLS-RPT submission reads the real `statusCode` (successful reports are no longer misreported as failures); ACME account key-rollover stops double-encoding its inner JWS payload (rollover was always rejected by the CA); the response-schema validator now also validates JSON bodies sent via `res.end`; keychain `remove` validates a relative fallback path like `store`/`retrieve`; `bundleAdapterStorage` honors the ambient compliance posture like `create`; and a refused `dev --ignore` pattern now returns exit code 2 instead of rejecting the CLI promise. **Fixed:** *Concurrent keychain file-store writes no longer drop bindings* — `store` and the file-remove path performed an unlocked read-modify-write of the fallback file, so two concurrent stores could each read the pre-update document and the later write would clobber the earlier binding. Both read-modify-write sequences are now serialized under the file's existing lock. · *TLS-RPT submission reads the response `statusCode`* — `tlsRpt.submit` read `status` from the HTTP client response, but the client resolves `statusCode`, so a successful (2xx) report POST was reported as `{ ok: false, error: 'HTTP undefined' }`. It now reads `statusCode`, so delivered reports are distinguished from rejected ones. · *ACME account key-rollover no longer double-encodes its inner JWS payload* — `accountKeyRollover` passed an already-stringified payload to a signer that stringifies internally, so the inner keyChange JWS payload encoded a JSON string instead of the JSON object and every rollover was rejected by the CA (RFC 8555 §7.3.5). The payload is now passed once. · *Response-schema validator also validates `res.end` JSON bodies* — The response validator wrapped only `res.json`, so a handler shipping an invalid body via `res.end(JSON.stringify(...))` escaped validation despite the documented contract. JSON-shaped `res.end` bodies are now validated on the same path. · *keychain `remove` validates a relative fallback path like `store`/`retrieve`* — `remove` checked file existence before validating the fallback path, so a relative path silently no-op'd instead of throwing the config error that `store`/`retrieve` raise. It now validates first, consistent with the other file-fallback paths. · *`bundleAdapterStorage` honors the ambient compliance posture* — `bundleAdapterStorage` only refused an unencrypted strategy when an explicit posture was passed, unlike `create`, which reads the ambient `b.compliance` posture and refuses `encrypt:false` under HIPAA. `bundleAdapterStorage` now falls back to the ambient posture, closing the asymmetry. · *`dev --ignore` pattern refusal returns exit code 2* — A `dev --ignore` pattern that exceeded the length cap or was refused by the regex guard threw out of the CLI promise instead of writing to stderr and returning exit code 2 like every other CLI validation failure. It now returns the standard non-zero exit. **Security:** *SPF `ip4:` with a malformed CIDR mask fails closed instead of throwing (fail-open)* — `_ipv4InCidr` lacked the finite-mask guard its IPv6 sibling has, so an `ip4:` mechanism with a non-numeric prefix (e.g. `ip4:192.0.2.0/`) reached `BigInt(32 - NaN)` and threw an uncaught `RangeError` out of `b.mail.spf.verify` / `b.mail.inbound.verify`. Upstream mail handling catches that throw as a temperror, and an inbound policy configured to accept on temperror would then admit a sender that should have been evaluated. SPF now returns a `permerror` result for a malformed mask, honoring the primitive's documented never-throw-on-message-content contract. · *Authentication-Results builder refuses CR/LF/NUL in the `version` field (header injection)* — `b.mail.authResults.emit` validated the `authserv-id` against control characters but not the operator-supplied `version`, which is interpolated onto the `Authentication-Results:` header line — the last field left unguarded by the earlier mail-header CRLF sweep. A `version` containing CRLF could smuggle an arbitrary header. It is now refused with a typed error, like every other field on that line. · *`res.redirect` rejects horizontal TAB (open-redirect / same-origin bypass)* — The redirect target's control-character guard rejected CR/LF/NUL but not TAB (0x09). Node permits a TAB in a header value, but the WHATWG URL parser strips ASCII TAB/LF/CR from a URL before resolving it, so a `Location: /\t/evil.example` collapses to the protocol-relative `//evil.example` and navigates to the attacker origin — bypassing the same-origin/allowlist heuristic. TAB is now rejected alongside CR/LF/NUL. · *OAuth refresh-token replay gate treats any truthy `seen()` result as seen (fail-open)* — The legacy `seen(refreshToken)` replay gate compared the callback's return with `=== true`, but the documented contract is that it returns a truthy value when the token was presented before. A store returning a truthy non-`true` value — a Redis `EXISTS`/`SISMEMBER` `1`, a SQL `COUNT` — slipped a replayed (stolen) refresh token past the one-time-use gate. Any truthy result now counts as seen; the same normalization was applied to the sibling replay checks in the module. · *SAML Single-Logout over HTTP-POST/SOAP fails closed on a key without its algorithm* — The POST and SOAP SLO parsers gated signature verification with an OR condition and the verify helper early-returned when the algorithm was absent, so supplying a verification key without `idpVerifyAlg` silently accepted a forged, unsigned LogoutRequest — while the HTTP-Redirect binding failed closed. All four SLO parse paths now route through one config check that throws when a key is supplied without its algorithm. · *OAuth authorization-URL / PAR builders refuse `extraParams` that collide with managed parameters* — `authorizationUrl` and `pushAuthorizationRequest` merged operator `extraParams` with `URLSearchParams.set`, which replaces — so an `extraParams` entry for `redirect_uri`, `state`, or `code_challenge` overwrote the framework-generated value while the call still returned the framework's `state`/PKCE verifier, diverging the returned session seed from the emitted URL. Reserved parameter keys in `extraParams` are now refused with a typed error at both builders.
14
+
11
15
  - v0.16.1 (2026-07-03) — **Every source file now carries an SPDX license + copyright header; the wiki example emits its full HSTS when it runs behind a TLS-terminating reverse proxy; and a good-first-issue on-ramp lands for new contributors.** This patch stamps a per-file `SPDX-License-Identifier: Apache-2.0` and a copyright line onto every first-party source file, so the license of any single file is unambiguous when it is read, audited, or vendored in isolation. The project stays Apache-2.0 — this is a clarity change, not a license change, and vendored third-party files under lib/vendor/ keep their own upstream headers. The wiki example now passes its trusted-proxy CIDRs to the securityHeaders middleware: a deployment behind a TLS-terminating reverse proxy (Caddy or nginx) that forwards `X-Forwarded-Proto: https` now emits the framework's strong HSTS (two-year max-age, includeSubDomains, preload) instead of suppressing it on the plain-HTTP hop from the proxy — the wiki e2e asserts both the proxied-https and the direct-http cases. CONTRIBUTING gains a good-first-issue on-ramp so new contributors have a clear, small first step. **Added:** *Per-file SPDX license and copyright headers* — Every first-party source file now begins with `// SPDX-License-Identifier: Apache-2.0` and a copyright line, so the license and holder of any individual file are explicit when it is read or extracted on its own. No license change — the project remains Apache-2.0. Vendored files under `lib/vendor/` are untouched and retain their upstream license headers. **Changed:** *Good-first-issue on-ramp for new contributors* — CONTRIBUTING now points new contributors at issues labelled `good first issue` — small, self-contained tasks (a doc or wiki-example fix, a test for an uncovered branch) that are the easiest first step into the codebase. **Security:** *Wiki example emits its full HSTS behind a TLS-terminating reverse proxy* — The wiki example app now hands its trusted-proxy CIDRs to `securityHeaders`, so behind Caddy or nginx forwarding `X-Forwarded-Proto: https` it emits `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` rather than dropping HSTS on the internal HTTP hop from the proxy. Operators modelling their own app on the example inherit the fix by declaring their proxy CIDRs via `WIKI_ADMIN_TRUSTED_PROXIES` (the production compose already defaults to the private ranges). The framework's `securityHeaders` primitive is unchanged; this closes a wiring gap in the example.
12
16
 
13
17
  - v0.16.0 (2026-07-03) — **Raises the minimum Node.js engine to 24.18.0; the status-list verifier now binds the token type against a typ-confusion replay; and CI supply-chain pinning is consolidated behind committed dev/example lockfiles, `npm ci`, and a single aggregating pin tool that closes the OpenSSF Scorecard pinned-dependency gaps.** This minor raises the supported Node.js floor from 24.16.0 to 24.18.0 — operators must run Node 24.18.0 or newer (24.17.0 was a security release; 24.18.0 bundles SQLite 3.53.1 and a backup-path fix, and guarantees the native OpenSSL 3.5 SLH-DSA surface). b.auth.statusList.fromJwt now enforces RFC 8725 §3.11 by binding the JWS header typ to the "statuslist+jwt" that toJwt stamps, so a token minted for another purpose can't be replayed as a status list. The build's supply-chain pinning is reworked so OpenSSF Scorecard sees pinned installs everywhere it can: the dev-tool, example, and fuzz manifests now ship committed lockfiles and CI installs them with `npm ci` (Scorecard keys pinning on the `ci` verb, not on an `@version` specifier), the vendored fuzz toolchain and the oss-fuzz base image are pinned, and a new scripts/pin-all.js aggregates every pin — regenerating all lockfiles, pinning the GitHub Action SHAs, and syncing the Docker base-image digests in one `--fix`, with a `--check` gate wired into CI and the release flow so no pin can silently drift. The committed lockfiles are dev/example-only and excluded from the published tarball; the zero-npm-RUNTIME-deps rule is unchanged. **Added:** *scripts/pin-all.js — aggregated supply-chain pin tool* — One command re-pins every supply-chain segment: `--fix` regenerates the committed lockfiles (root / examples/wiki / fuzz), pins the GitHub Action SHAs, and syncs the Docker base-image digests; `--check` (wired into CI and the release flow) verifies the lockfile and digest segments — including each lockfile's package version — are in sync and fails closed on drift. Contributor tooling — it does not ship in the tarball. · *Project-governance and assurance documentation; OpenSSF Best Practices silver badge* — Adds a ROADMAP.md (documented direction plus an explicit out-of-scope list), a Developer Certificate of Origin policy in CONTRIBUTING.md (contributions carry a Signed-off-by; releases sign off through the release tooling), and an explicit security assurance case in SECURITY.md (a top security claim decomposed into evidence-backed sub-claims, with a residual-risk statement). The project earned the OpenSSF Best Practices silver badge, linked from the README. **Changed:** *Minimum Node.js engine raised to 24.18.0* — engines.node is now >=24.18.0 (was >=24.16.0). Operators must run Node 24.18.0 or newer. 24.17.0 was a security release and 24.18.0 bundles SQLite 3.53.1 plus a backup-keepalive fix; the floor also guarantees the native OpenSSL 3.5 SLH-DSA sign/verify surface. CI, the release container, and the docs are updated to the new floor. · *Supply-chain pinning consolidated behind committed lockfiles + `npm ci` + one aggregating pin tool* — The root dev-tool (esbuild, postject), examples/wiki, and fuzz (jazzer.js) manifests now ship committed package-lock.json files, and CI installs them with `npm ci` instead of `npm install pkg@version` — OpenSSF Scorecard's Pinned-Dependencies check treats only the `npm ci` verb as pinned, so this is what actually clears those alerts (an exact `@version` specifier does not). The vendored fuzz toolchain is pinned to an exact jazzer version and the oss-fuzz base image is digest-pinned (mirroring the Dependabot-tracked .clusterfuzzlite digest). A new scripts/pin-all.js aggregates every pinning segment — it regenerates all committed lockfiles, pins the GitHub Action SHAs (via check-actions-currency), and syncs the Docker base-image digests in one `--fix`; a `--check` mode runs in CI and scripts/release.js so a stale lockfile or drifted digest fails closed. The committed lockfiles are dev/example-only, excluded from the published tarball's files allowlist; the zero-npm-RUNTIME-deps rule is unchanged. **Security:** *Status-list verification binds the token type (RFC 8725 §3.11 typ-confusion)* — b.auth.statusList.fromJwt now passes expectedTyp "statuslist+jwt" to the JWT verifier, matching the typ that b.auth.statusList.toJwt stamps and every sibling verifier enforces. A token minted for another purpose — even one that happens to carry a status_list.lst claim — is now refused on the type binding before the claim is read, closing the typ-confusion class where such a token could be replayed as a status list.
package/lib/acme.js CHANGED
@@ -1177,7 +1177,11 @@ function create(opts) {
1177
1177
  url: state.directory.keyChange,
1178
1178
  };
1179
1179
  var innerPayload = { account: state.accountUrl, oldKey: publicJwk };
1180
- var innerJws = _signJws(newPrivateKey, innerProtected, _stringify(innerPayload));
1180
+ // _signJws base64url-encodes _stringify(payload) itself (like every
1181
+ // other signer, e.g. _signedPost) — pass the raw object, never a
1182
+ // pre-stringified string, or the inner JWS payload double-encodes to
1183
+ // a JSON *string* and every RFC 8555 §7.3.5 server rejects it.
1184
+ var innerJws = _signJws(newPrivateKey, innerProtected, innerPayload);
1181
1185
  var rsp = await _signedPost(state.directory.keyChange, innerJws);
1182
1186
  if (rsp.statusCode !== 200) {
1183
1187
  _emitAudit(audit, "acme.account.key_rotated", "failure",
package/lib/auth/oauth.js CHANGED
@@ -961,7 +961,11 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
961
961
  throw new OAuthError("auth-oauth/attestation-pop-seen-callback-failed",
962
962
  "client-attestation-pop: seenJti() callback threw: " + ((e && e.message) || String(e)));
963
963
  }
964
- if (unseen === false) {
964
+ // Fail closed on ANY non-truthy result. The contract is "returns truthy
965
+ // when UNSEEN"; an operator store fronting Redis EXISTS / SISMEMBER or a
966
+ // SQL COUNT returns 0 (falsy, not `false`) on a replay, so an
967
+ // `=== false` comparison would miss it and accept the replayed PoP.
968
+ if (!unseen) {
965
969
  throw new OAuthError("auth-oauth/attestation-pop-replay",
966
970
  "client-attestation-pop: jti already seen (replay refused, draft §12.1)");
967
971
  }
@@ -982,6 +986,47 @@ function _constantTimeStrEq(a, b) {
982
986
  return cryptoTimingSafeEqual(a, b);
983
987
  }
984
988
 
989
+ // Framework-managed authorization-request parameters. Operator-supplied
990
+ // extraParams may not carry any of these — the builder generates them and
991
+ // RETURNS the security-critical ones (state, PKCE verifier/challenge) to the
992
+ // caller, so an extraParams override would silently diverge the returned
993
+ // values from what the URL/PAR body actually carries (a broken CSRF / PKCE
994
+ // binding). Covers redirect target, client identity, requested response,
995
+ // scope, state, nonce, PKCE challenge, response mode, RAR details, and the
996
+ // JAR request container.
997
+ var RESERVED_AUTHZ_PARAMS = {
998
+ "response_type": 1,
999
+ "client_id": 1,
1000
+ "redirect_uri": 1,
1001
+ "scope": 1,
1002
+ "state": 1,
1003
+ "nonce": 1,
1004
+ "code_challenge": 1,
1005
+ "code_challenge_method": 1,
1006
+ "response_mode": 1,
1007
+ "authorization_details": 1,
1008
+ "request": 1,
1009
+ "request_uri": 1,
1010
+ };
1011
+
1012
+ // Refuse operator-supplied extraParams keys that collide with a framework-
1013
+ // managed parameter. extraParams is operator-controlled, so a collision is a
1014
+ // config-time bug (a library merge / copy-paste) — refuse it loudly rather
1015
+ // than let it shadow a value the framework generated and returned. Fail
1016
+ // closed; shared by authorizationUrl, pushAuthorizationRequest, and
1017
+ // endSessionUrl so every URL/PAR builder guards the same way.
1018
+ function _assertNoReservedExtraParams(extraParams, reserved, errCode, ctx) {
1019
+ if (!extraParams || typeof extraParams !== "object") return;
1020
+ var ek = Object.keys(extraParams);
1021
+ for (var i = 0; i < ek.length; i++) {
1022
+ if (Object.prototype.hasOwnProperty.call(reserved, ek[i])) {
1023
+ throw new OAuthError(errCode,
1024
+ ctx + ": extraParams key '" + ek[i] + "' collides with a " +
1025
+ "framework-managed parameter — pass it through the named option instead");
1026
+ }
1027
+ }
1028
+ }
1029
+
985
1030
  // ---- core ----
986
1031
 
987
1032
  function create(opts) {
@@ -1261,7 +1306,14 @@ function create(opts) {
1261
1306
  params.set("authorization_details", JSON.stringify(requestedAuthzDetails));
1262
1307
  }
1263
1308
  // Operator-supplied additional params (audience, resource, etc.).
1309
+ // Refuse keys that collide with a framework-managed parameter so an
1310
+ // operator typo / library-merge can't shadow the redirect_uri / state /
1311
+ // code_challenge the builder generated and returned — which would
1312
+ // silently diverge the returned {state, verifier} from the URL and
1313
+ // break the CSRF / PKCE binding.
1264
1314
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
1315
+ _assertNoReservedExtraParams(uopts.extraParams, RESERVED_AUTHZ_PARAMS,
1316
+ "auth-oauth/reserved-extra-param", "authorizationUrl");
1265
1317
  var ek = Object.keys(uopts.extraParams);
1266
1318
  for (var i = 0; i < ek.length; i++) params.set(ek[i], String(uopts.extraParams[ek[i]]));
1267
1319
  }
@@ -1373,23 +1425,30 @@ function create(opts) {
1373
1425
  throw new OAuthError("auth-oauth/seen-callback-failed",
1374
1426
  "refreshAccessToken: checkAndInsert() callback threw: " + ((e && e.message) || String(e)));
1375
1427
  }
1376
- // Spec contract: inserted===true → first sighting (OK);
1377
- // inserted===false → replay. v0.9.3 had this inverted, which
1378
- // broke every first refresh attempt for operators reusing an
1379
- // existing b.nonceStore-style backend.
1380
- alreadySeen = inserted === false;
1428
+ // Spec contract: a TRUTHY result → first sighting (the value was
1429
+ // inserted, OK); a FALSY result → replay. v0.9.3 had this inverted,
1430
+ // which broke every first refresh attempt for operators reusing an
1431
+ // existing b.nonceStore-style backend. Test truthiness, not an
1432
+ // `=== false` literal: a store fronting Redis SETNX / SQL INSERT
1433
+ // returns 0 (falsy, not `false`) when the row already existed, so
1434
+ // an exact-literal compare would miss the replay and fail OPEN.
1435
+ alreadySeen = !inserted;
1381
1436
  } else if (typeof ropts.seen === "function") {
1382
1437
  // Legacy non-atomic path. Documented as a check-then-act race;
1383
1438
  // operators sharing a single-writer store (Redis SETNX, DB
1384
1439
  // INSERT ON CONFLICT) MUST migrate to checkAndInsert. Stays
1385
1440
  // here for backwards-compat with existing operator code.
1441
+ // Legacy contract: truthy → the token was presented before (replay).
1442
+ // The value is used by truthiness at the gate below, so a store
1443
+ // returning 1 from Redis EXISTS / a SQL COUNT is honored as "seen"
1444
+ // instead of being missed by an `=== true` literal compare.
1386
1445
  try { alreadySeen = await ropts.seen(refreshToken); }
1387
1446
  catch (e) {
1388
1447
  throw new OAuthError("auth-oauth/seen-callback-failed",
1389
1448
  "refreshAccessToken: seen() callback threw: " + ((e && e.message) || String(e)));
1390
1449
  }
1391
1450
  }
1392
- if (alreadySeen === true) {
1451
+ if (alreadySeen) {
1393
1452
  throw new OAuthError("auth-oauth/refresh-token-replay",
1394
1453
  "refreshAccessToken: refresh token has been presented before — refused " +
1395
1454
  "(OAuth 2.1 §6.1 / RFC 9700 §4.13 one-time-use defense). The operator MUST " +
@@ -2012,15 +2071,10 @@ function create(opts) {
2012
2071
  "ui_locales": 1,
2013
2072
  "client_id": 1,
2014
2073
  };
2074
+ _assertNoReservedExtraParams(uopts.extraParams, RESERVED_END_SESSION_PARAMS,
2075
+ "auth-oauth/end-session-reserved-extra-param", "endSessionUrl");
2015
2076
  var ek = Object.keys(uopts.extraParams);
2016
- for (var i = 0; i < ek.length; i++) {
2017
- if (RESERVED_END_SESSION_PARAMS[ek[i]]) {
2018
- throw new OAuthError("auth-oauth/end-session-reserved-extra-param",
2019
- "endSessionUrl: extraParams key '" + ek[i] + "' collides with a first-class " +
2020
- "RP-Init Logout parameter — pass it through the named field instead");
2021
- }
2022
- params.set(ek[i], String(uopts.extraParams[ek[i]]));
2023
- }
2077
+ for (var i = 0; i < ek.length; i++) params.set(ek[i], String(uopts.extraParams[ek[i]]));
2024
2078
  }
2025
2079
  var qs = params.toString();
2026
2080
  if (qs.length === 0) return endpoint;
@@ -2108,6 +2162,11 @@ function create(opts) {
2108
2162
  : JSON.stringify(requestedAuthzDetails); // form param — JSON string
2109
2163
  }
2110
2164
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
2165
+ // Same reserved-key guard as authorizationUrl — a PAR request pushes
2166
+ // the identical security-critical parameter set, so extraParams may
2167
+ // not shadow redirect_uri / state / code_challenge here either.
2168
+ _assertNoReservedExtraParams(uopts.extraParams, RESERVED_AUTHZ_PARAMS,
2169
+ "auth-oauth/reserved-extra-param", "pushAuthorizationRequest");
2111
2170
  var ek = Object.keys(uopts.extraParams);
2112
2171
  for (var i = 0; i < ek.length; i++) authzParams[ek[i]] = String(uopts.extraParams[ek[i]]);
2113
2172
  }
@@ -2354,7 +2413,10 @@ function create(opts) {
2354
2413
  "verifyBackchannelLogoutToken: atomicReplayStore.checkAndInsert threw: " +
2355
2414
  ((e && e.message) || String(e)));
2356
2415
  }
2357
- if (inserted === false) {
2416
+ // Fail closed on ANY non-truthy result. A store fronting SETNX / an
2417
+ // ON-CONFLICT INSERT returns 0 (falsy, not `false`) on a duplicate,
2418
+ // so an `=== false` compare would miss the replay.
2419
+ if (!inserted) {
2358
2420
  throw new OAuthError("auth-oauth/logout-token-replay",
2359
2421
  "verifyBackchannelLogoutToken: jti '" + claims.jti +
2360
2422
  "' already seen — replay refused (atomic)");
@@ -2370,7 +2432,10 @@ function create(opts) {
2370
2432
  throw new OAuthError("auth-oauth/seen-callback-failed",
2371
2433
  "verifyBackchannelLogoutToken: seen() callback threw: " + ((e && e.message) || String(e)));
2372
2434
  }
2373
- if (first === false) {
2435
+ // Fail closed on ANY non-truthy result — `seen()` returns truthy the
2436
+ // first time it sees the (jti, iss) pair; a store returning 0 for a
2437
+ // duplicate must still refuse, which an `=== false` compare would miss.
2438
+ if (!first) {
2374
2439
  throw new OAuthError("auth-oauth/logout-token-replay",
2375
2440
  "verifyBackchannelLogoutToken: jti already seen — replay refused");
2376
2441
  }
package/lib/auth/saml.js CHANGED
@@ -1052,7 +1052,7 @@ function create(opts) {
1052
1052
  "parseLogoutRequest: inflate failed: " + ((e && e.message) || String(e)));
1053
1053
  }
1054
1054
  // Verify the redirect-binding signature when an IdP key is supplied.
1055
- if (vopts.idpVerifyKey) {
1055
+ if (_verifyConfig(vopts.idpVerifyKey, vopts.idpVerifyAlg, "parseLogoutRequest")) {
1056
1056
  if (typeof vopts.queryString !== "string") {
1057
1057
  throw new AuthError("auth-saml/no-query-string",
1058
1058
  "parseLogoutRequest: idpVerifyKey requires queryString (raw URL query)");
@@ -1240,7 +1240,7 @@ function create(opts) {
1240
1240
  throw new AuthError("auth-saml/bad-saml-response",
1241
1241
  "parseLogoutResponse: inflate failed: " + ((e && e.message) || String(e)));
1242
1242
  }
1243
- if (vopts.idpVerifyKey) {
1243
+ if (_verifyConfig(vopts.idpVerifyKey, vopts.idpVerifyAlg, "parseLogoutResponse")) {
1244
1244
  if (typeof vopts.queryString !== "string") {
1245
1245
  throw new AuthError("auth-saml/no-query-string",
1246
1246
  "parseLogoutResponse: idpVerifyKey requires queryString");
@@ -1412,7 +1412,7 @@ function create(opts) {
1412
1412
  "parseLogoutRequestPost: samlRequestB64 must be a non-empty string");
1413
1413
  }
1414
1414
  var xml = Buffer.from(samlRequestB64, "base64").toString("utf8");
1415
- if (vopts.idpVerifyKey || vopts.idpVerifyAlg) {
1415
+ if (_verifyConfig(vopts.idpVerifyKey, vopts.idpVerifyAlg, "parseLogoutRequestPost")) {
1416
1416
  _verifyEmbeddedXmlDsig(xml, vopts.idpVerifyKey, vopts.idpVerifyAlg, "LogoutRequest");
1417
1417
  }
1418
1418
  var c14n = xmlC14n();
@@ -1534,7 +1534,7 @@ function create(opts) {
1534
1534
  "parseLogoutResponseSoap: soap:Body is empty");
1535
1535
  }
1536
1536
  var innerXml = Buffer.from(c14n.canonicalize(inner)).toString("utf8");
1537
- if (vopts.idpVerifyKey || vopts.idpVerifyAlg) {
1537
+ if (_verifyConfig(vopts.idpVerifyKey, vopts.idpVerifyAlg, "parseLogoutResponseSoap")) {
1538
1538
  _verifyEmbeddedXmlDsig(innerXml, vopts.idpVerifyKey, vopts.idpVerifyAlg, "LogoutResponse");
1539
1539
  }
1540
1540
  var innerLocal = inner.name.split(":").pop();
@@ -1866,8 +1866,31 @@ function _embedXmlDsig(bodyXml, refId, signingKey, signingAlg) {
1866
1866
  return bodyXml.substring(0, splitAt) + sigEl + bodyXml.substring(splitAt);
1867
1867
  }
1868
1868
 
1869
+ // SLO verification-config gate. Presence of EITHER idpVerifyKey or
1870
+ // idpVerifyAlg signals the operator intends to verify the inbound
1871
+ // message; a half-supplied pair is a configuration error, never a
1872
+ // license to skip the signature check. Returns true when both are
1873
+ // present (verify), false when neither is (verification not requested),
1874
+ // and throws — fail closed — when exactly one is present, so a
1875
+ // fat-fingered verifier can never silently accept a forged, unsigned
1876
+ // LogoutRequest / LogoutResponse. Every SLO parse path (HTTP-Redirect,
1877
+ // HTTP-POST, SOAP) routes its verify decision through this one gate so
1878
+ // the redirect and back-channel bindings agree on fail-closed behavior.
1879
+ function _verifyConfig(idpVerifyKey, idpVerifyAlg, ctx) {
1880
+ if (!idpVerifyKey && !idpVerifyAlg) return false;
1881
+ if (!idpVerifyKey) {
1882
+ throw new AuthError("auth-saml/no-verify-key",
1883
+ ctx + ": idpVerifyAlg supplied without idpVerifyKey — verification cannot proceed");
1884
+ }
1885
+ if (!idpVerifyAlg) {
1886
+ throw new AuthError("auth-saml/no-verify-alg",
1887
+ ctx + ": idpVerifyKey supplied without idpVerifyAlg (alg is required when key is supplied)");
1888
+ }
1889
+ return true;
1890
+ }
1891
+
1869
1892
  function _verifyEmbeddedXmlDsig(xml, idpVerifyKey, idpVerifyAlg, expectedRootLocal) {
1870
- if (!idpVerifyKey || !idpVerifyAlg) return;
1893
+ if (!_verifyConfig(idpVerifyKey, idpVerifyAlg, "_verifyEmbeddedXmlDsig")) return;
1871
1894
  var sigAlgUrn = _sigAlgUrn(idpVerifyAlg);
1872
1895
  if (!sigAlgUrn) {
1873
1896
  throw new AuthError("auth-saml/bad-verify-alg",
@@ -1178,7 +1178,19 @@ function bundleAdapterStorage(opts) {
1178
1178
  "the adapter — the wrap layer composes only with tar / tar.gz bundles. Per-file " +
1179
1179
  "encryption for directory format is a future patch alongside the _crypto-base.js refactor.");
1180
1180
  }
1181
+ // Mirror create()'s ambient-posture read (see the create() encryption
1182
+ // gate above): an explicit opts.posture wins, but when it is unset the
1183
+ // globally-pinned compliance posture (b.compliance.set(...)) still drives
1184
+ // the encryption-required gate. Without this fallback a deployment that
1185
+ // pins HIPAA / PCI-DSS once and constructs the store with the documented
1186
+ // default ({ adapter }, cryptoStrategy defaulting to "none") slips a
1187
+ // plaintext bundle store past a gate create() enforces on encrypt:false —
1188
+ // an asymmetric fail-open under the same regulated posture.
1181
1189
  var posture = opts.posture;
1190
+ if (posture === undefined || posture === null) {
1191
+ try { posture = compliance().current(); }
1192
+ catch (_e) { posture = null; } // compliance optional at construction time
1193
+ }
1182
1194
  if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1) {
1183
1195
  if (cryptoStrategy === "none") {
1184
1196
  throw new BackupError("backup/posture-requires-encryption",
package/lib/cli.js CHANGED
@@ -348,6 +348,44 @@ function _coerceList(val) {
348
348
  return Array.isArray(val) ? val.slice() : [val];
349
349
  }
350
350
 
351
+ // Compile one operator-supplied `dev --ignore` pattern to a RegExp,
352
+ // refusing (with a CliError the caller converts to exit 2) any pattern
353
+ // that is over-length, a ReDoS catastrophic-backtracking shape, or one
354
+ // RegExp() itself can't compile. Every refusal shape raises the same
355
+ // CliError type so a single caller-side catch takes the standard
356
+ // exit-2 + stderr path.
357
+ function _compileIgnorePattern(s) {
358
+ var str = String(s);
359
+ if (str.length > MAX_IGNORE_PATTERN_LENGTH) {
360
+ throw new CliError("cli/bad-ignore-pattern",
361
+ "blamejs dev: --ignore pattern exceeds max length " +
362
+ MAX_IGNORE_PATTERN_LENGTH + " (got " + str.length + ")");
363
+ }
364
+ // ReDoS / catastrophic-backtracking defense — refuses nested-quant
365
+ // (CVE-2024-21538 class), consecutive-* (CVE-2026-26996), nested
366
+ // extglob (CVE-2026-33671), and lookaround-quant shapes before the
367
+ // pattern reaches RegExp(). Operator typo / hostile-input identical
368
+ // shape from here on — both want the same refusal.
369
+ try {
370
+ guardRegex.sanitize(str, { profile: "strict" });
371
+ } catch (e) {
372
+ throw new CliError("cli/bad-ignore-pattern",
373
+ "blamejs dev: --ignore pattern refused by guardRegex: " +
374
+ ((e && e.message) || String(e)));
375
+ }
376
+ // A pattern that clears the ReDoS screen can still be un-compilable
377
+ // (unbalanced group, dangling quantifier). RegExp() throws a
378
+ // SyntaxError here — route it through the same refusal, not up to a
379
+ // main() rejection.
380
+ try {
381
+ return RegExp(str);
382
+ } catch (e) {
383
+ throw new CliError("cli/bad-ignore-pattern",
384
+ "blamejs dev: --ignore pattern is not a valid regular expression: " +
385
+ ((e && e.message) || String(e)));
386
+ }
387
+ }
388
+
351
389
  async function _runDev(args, ctx) {
352
390
  if (args.flags.help || args.flags.h) {
353
391
  _writeLine(ctx.stdout, DEV_USAGE);
@@ -361,27 +399,24 @@ async function _runDev(args, ctx) {
361
399
  }
362
400
  var argList = _coerceList(args.flags.arg).map(String);
363
401
  var watchList = _coerceList(args.flags.watch).map(String);
364
- var ignoreList = _coerceList(args.flags.ignore).map(function (s) {
365
- var str = String(s);
366
- if (str.length > MAX_IGNORE_PATTERN_LENGTH) {
367
- throw new CliError("cli/bad-ignore-pattern",
368
- "blamejs dev: --ignore pattern exceeds max length " +
369
- MAX_IGNORE_PATTERN_LENGTH + " (got " + str.length + ")");
370
- }
371
- // ReDoS / catastrophic-backtracking defense refuses nested-quant
372
- // (CVE-2024-21538 class), consecutive-* (CVE-2026-26996), nested
373
- // extglob (CVE-2026-33671), and lookaround-quant shapes before the
374
- // pattern reaches RegExp(). Operator typo / hostile-input identical
375
- // shape from here on — both want the same refusal.
376
- try {
377
- guardRegex.sanitize(str, { profile: "strict" });
378
- } catch (e) {
379
- throw new CliError("cli/bad-ignore-pattern",
380
- "blamejs dev: --ignore pattern refused by guardRegex: " +
381
- ((e && e.message) || String(e)));
402
+ // --ignore validation is operator-input validation, not an internal
403
+ // fault: a refused pattern must take the same exit-2 + stderr path
404
+ // every other dev flag uses, never reject main()'s awaited promise
405
+ // (the bin shim would surface a rejection as a stack trace + exit 1).
406
+ // The per-pattern helper raises a CliError for the three refusal
407
+ // shapes over-length, guardRegex-refused, and an un-compilable
408
+ // RegExp — and the catch below converts any of them to the standard
409
+ // refusal path; a non-CliError (a genuine internal fault) re-throws.
410
+ var ignoreList;
411
+ try {
412
+ ignoreList = _coerceList(args.flags.ignore).map(_compileIgnorePattern);
413
+ } catch (e) {
414
+ if (e && e.isCliError) {
415
+ _writeLine(ctx.stderr, e.message || String(e));
416
+ return 2;
382
417
  }
383
- return RegExp(str);
384
- });
418
+ throw e;
419
+ }
385
420
  var graceMs = args.flags["grace-ms"] !== undefined ? Number(args.flags["grace-ms"]) : undefined;
386
421
  if (graceMs !== undefined && (!Number.isFinite(graceMs) || graceMs < 0)) {
387
422
  _writeLine(ctx.stderr, "blamejs dev: --grace-ms must be a non-negative number");
@@ -137,6 +137,15 @@ function _validateBuildOpts(opts) {
137
137
  */
138
138
  function build(opts) {
139
139
  _validateBuildOpts(opts);
140
+ // A `typeof === "number"` gate alone lets NaN / Infinity / out-of-Date-range
141
+ // values through, which then crash `new Date(x).toISOString()` with an untyped
142
+ // RangeError. Config-time inputs reject with a typed error instead.
143
+ if (opts.generatedAt !== undefined &&
144
+ (typeof opts.generatedAt !== "number" || !isFinite(opts.generatedAt) ||
145
+ Math.abs(opts.generatedAt) > 8.64e15)) {
146
+ throw ContentCredentialsError.factory("content-credentials/bad-generated-at",
147
+ "contentCredentials.build: generatedAt must be a finite epoch-millis number within the valid Date range");
148
+ }
140
149
  var generatedAt = typeof opts.generatedAt === "number" ? opts.generatedAt : Date.now();
141
150
  var manifest = {
142
151
  "@context": "https://c2pa.org/specifications/specifications/2.1/",
@@ -161,7 +170,24 @@ function build(opts) {
161
170
  // Optional operator-supplied display assertion (SB-942 §22757(b))
162
171
  visibleDisclosure: opts.visibleDisclosure || null,
163
172
  };
164
- return Object.freeze(manifest);
173
+ // Deep-freeze — every claim field lives in a nested object (provider/system/
174
+ // content), so a top-level Object.freeze alone left content.id etc. mutable,
175
+ // contradicting the documented pre-sign immutability guarantee.
176
+ return _deepFreeze(manifest);
177
+ }
178
+
179
+ // Recursively freeze an object graph so no nested claim can be mutated before
180
+ // signing. Cyclic input isn't produced here (manifest is a plain literal tree).
181
+ function _deepFreeze(obj) {
182
+ if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
183
+ var keys = Object.keys(obj);
184
+ for (var i = 0; i < keys.length; i += 1) {
185
+ var v = obj[keys[i]];
186
+ if (v && typeof v === "object") _deepFreeze(v);
187
+ }
188
+ Object.freeze(obj);
189
+ }
190
+ return obj;
165
191
  }
166
192
 
167
193
  /**
@@ -672,6 +698,15 @@ function signCose(manifest, opts) {
672
698
  // 35) when a token was attached.
673
699
  var unprotEntries = [];
674
700
  if (Array.isArray(opts.certChain) && opts.certChain.length > 0) {
701
+ // Validate each entry is raw DER bytes — a non-Buffer element would reach
702
+ // Buffer.concat / _cborBytes and throw an untyped Node ERR_INVALID_ARG_TYPE
703
+ // instead of an operator-actionable typed rejection.
704
+ opts.certChain.forEach(function (der, idx) {
705
+ if (!Buffer.isBuffer(der) && !(der instanceof Uint8Array)) {
706
+ throw ContentCredentialsError.factory("content-credentials/bad-cert-chain",
707
+ "contentCredentials.signCose: certChain[" + idx + "] must be a Buffer/Uint8Array of DER bytes");
708
+ }
709
+ });
675
710
  var chainArray;
676
711
  if (opts.certChain.length === 1) {
677
712
  // Single-cert form: header value is the DER bytes directly.
@@ -1252,14 +1287,20 @@ function verifyIdentityAssertion(assertion, publicKeyPem, opts) {
1252
1287
  if (supplied.length !== bound.length) {
1253
1288
  return _fail("referenced-assertions-count-mismatch");
1254
1289
  }
1290
+ // Multiset match — consume each bound hash as it is matched so duplicate
1291
+ // supplied hashes ([A, A]) can't satisfy a binding of distinct hashes
1292
+ // ([A, B]) while leaving hash(B) never re-confirmed (a transplant fail-open).
1293
+ var boundRemaining = bound.slice();
1255
1294
  for (var i = 0; i < supplied.length; i += 1) {
1256
- if (bound.indexOf(supplied[i]) === -1) {
1295
+ var mIdx = boundRemaining.indexOf(supplied[i]);
1296
+ if (mIdx === -1) {
1257
1297
  if (auditOn) {
1258
1298
  audit.safeEmit({ action: "contentcredentials.identity_verified", outcome: "denied",
1259
1299
  metadata: { binding: sp.binding, reason: "assertion-hash-mismatch" } });
1260
1300
  }
1261
1301
  return _fail("assertion-hash-mismatch");
1262
1302
  }
1303
+ boundRemaining.splice(mIdx, 1);
1263
1304
  }
1264
1305
 
1265
1306
  // (3) trust resolution. verified:true ONLY for x509 with a supplied
package/lib/guard-sql.js CHANGED
@@ -1045,6 +1045,18 @@ function _inspectFragment(rawText, normalized, opts, issues) {
1045
1045
  "with a ? placeholder, or pass allowLiterals:true for a static literal",
1046
1046
  });
1047
1047
  }
1048
+ // A value-expression fragment contains no statement separator at all — any
1049
+ // top-level ';' violates the fragment contract, including a LONE or TRAILING
1050
+ // one ("x = 1;") that the stacked-statement scan misses (it only fires on a
1051
+ // second statement after the ';'). Literals/comments are masked in
1052
+ // `normalized`, so a ';' here is genuinely top-level.
1053
+ if (normalized.indexOf(";") !== -1) {
1054
+ issues.push({
1055
+ code: "sql.refuse", severity: "critical", kind: "semicolon-in-fragment",
1056
+ ruleId: "sql.semicolon-in-fragment",
1057
+ snippet: "top-level ';' in a value-expression fragment — a fragment must be a single expression",
1058
+ });
1059
+ }
1048
1060
  }
1049
1061
 
1050
1062
  // operator-sql mode — exactly one statement (stacked scan already
@@ -1122,6 +1134,23 @@ function _hasEmbeddedStringLiteral(sql) {
1122
1134
  continue;
1123
1135
  }
1124
1136
  if (ch === SQUOTE) return true;
1137
+ // Postgres dollar-quoted string literal opener ($tag$ or $$, tag = identifier
1138
+ // chars or empty) — an embedded dollar-quote is a string literal just like
1139
+ // '...', and the fragment floor must refuse it the same way (the single-quote
1140
+ // check alone let `x = $tag$secret$tag$` bypass the strict embedded-literal gate).
1141
+ if (ch === "$") {
1142
+ // The tag between the opening and closing '$' is identifier chars only
1143
+ // (no '$', which terminates it) — char compares, not a regex literal, to
1144
+ // keep the pattern out of the shared-regex-duplication detector.
1145
+ var j = i + 1;
1146
+ while (j < len) {
1147
+ var tch = sql.charAt(j);
1148
+ if (!((tch >= "a" && tch <= "z") || (tch >= "A" && tch <= "Z") ||
1149
+ (tch >= "0" && tch <= "9") || tch === "_")) break;
1150
+ j += 1;
1151
+ }
1152
+ if (j < len && sql.charAt(j) === "$") return true;
1153
+ }
1125
1154
  i += 1;
1126
1155
  }
1127
1156
  return false;
package/lib/keychain.js CHANGED
@@ -649,9 +649,20 @@ async function store(opts) {
649
649
  }
650
650
 
651
651
  _validateFallbackFile(opts.fallbackFile, "keychain.store");
652
- var doc = await _readFile(opts.fallbackFile, opts.passphrase);
653
- doc.entries[_bindingKey(opts.service, opts.account)] = String(opts.password);
654
- await _writeFile(opts.fallbackFile, doc, opts.passphrase);
652
+ // The lock sentinel lives beside fallbackFile, so its parent directory must
653
+ // exist before the FIRST writer can lock — otherwise the lock (added to
654
+ // serialize the RMW below) fails where the pre-lock atomicFile.write used to
655
+ // create the directory lazily on first store.
656
+ atomicFile.ensureDir(nodePath.dirname(opts.fallbackFile), 0o700);
657
+ // Serialize the read-modify-write so concurrent stores to the same
658
+ // fallbackFile can't each read the pre-update document and clobber one
659
+ // another's binding on write. atomicFile.lock is a cross-process file
660
+ // mutex; _removeFromFile serializes its RMW through the same lock.
661
+ await atomicFile.lock(opts.fallbackFile, async function () {
662
+ var doc = await _readFile(opts.fallbackFile, opts.passphrase);
663
+ doc.entries[_bindingKey(opts.service, opts.account)] = String(opts.password);
664
+ await _writeFile(opts.fallbackFile, doc, opts.passphrase);
665
+ });
655
666
  _emit("keychain.stored", "success", {
656
667
  service: opts.service, account: opts.account, backend: "file",
657
668
  }, auditOn);
@@ -818,13 +829,18 @@ async function remove(opts) {
818
829
  }
819
830
  }
820
831
 
832
+ // Validate a supplied fallbackFile (reject a relative path) BEFORE the
833
+ // exists check, so a relative path throws keychain/relative-fallback-file
834
+ // consistently with store/retrieve instead of silently no-op'ing.
835
+ if (opts.fallbackFile) {
836
+ _validateFallbackFile(opts.fallbackFile, "keychain.remove");
837
+ }
821
838
  if (!opts.fallbackFile || !atomicFile.exists(opts.fallbackFile)) {
822
839
  _emit("keychain.removed", "no-op", {
823
840
  service: opts.service, account: opts.account, backend: "file",
824
841
  }, auditOn);
825
842
  return false;
826
843
  }
827
- _validateFallbackFile(opts.fallbackFile, "keychain.remove");
828
844
  var existed = await _removeFromFile(opts.fallbackFile, opts.service, opts.account, opts.passphrase);
829
845
  _emit("keychain.removed", existed ? "success" : "no-op", {
830
846
  service: opts.service, account: opts.account, backend: "file",
@@ -833,12 +849,16 @@ async function remove(opts) {
833
849
  }
834
850
 
835
851
  async function _removeFromFile(fallbackFile, service, account, passphrase) {
836
- var doc = await _readFile(fallbackFile, passphrase);
837
- var bindingKey = _bindingKey(service, account);
838
- if (!Object.prototype.hasOwnProperty.call(doc.entries, bindingKey)) return false;
839
- delete doc.entries[bindingKey];
840
- await _writeFile(fallbackFile, doc, passphrase);
841
- return true;
852
+ // Same serialized read-modify-write as store: hold the file mutex across
853
+ // read -> delete -> write so a concurrent store/remove can't lose the edit.
854
+ return await atomicFile.lock(fallbackFile, async function () {
855
+ var doc = await _readFile(fallbackFile, passphrase);
856
+ var bindingKey = _bindingKey(service, account);
857
+ if (!Object.prototype.hasOwnProperty.call(doc.entries, bindingKey)) return false;
858
+ delete doc.entries[bindingKey];
859
+ await _writeFile(fallbackFile, doc, passphrase);
860
+ return true;
861
+ });
842
862
  }
843
863
 
844
864
  // ---- Test seam -------------------------------------------------------------
package/lib/mail-auth.js CHANGED
@@ -294,7 +294,7 @@ function _ipv4InCidr(ip, cidr) {
294
294
  var slash = cidr.indexOf("/");
295
295
  var net = slash === -1 ? cidr : cidr.slice(0, slash);
296
296
  var mask = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10); // IPv4 max prefix
297
- if (mask < 0 || mask > 32) return false; // IPv4 max prefix
297
+ if (!isFinite(mask) || mask < 0 || mask > 32) return false; // IPv4 max prefix
298
298
  var ipInt = _ipv4ToInt(ip);
299
299
  var netInt = _ipv4ToInt(net);
300
300
  if (ipInt === null || netInt === null) return false;
@@ -2123,6 +2123,10 @@ function authResultsEmit(opts) {
2123
2123
 
2124
2124
  var version = (opts.version === undefined || opts.version === null)
2125
2125
  ? "1" : String(opts.version);
2126
+ if (/[\r\n\0]/.test(version)) {
2127
+ throw new MailAuthError("mail-auth/ar-bad-version",
2128
+ "authResults.emit: version contains forbidden control characters");
2129
+ }
2126
2130
  var head = opts.authservId + (version === "1" ? "" : " " + version);
2127
2131
 
2128
2132
  if (opts.results.length === 0) {
package/lib/mcp.js CHANGED
@@ -149,6 +149,7 @@ function refuse(res, code, message, id) {
149
149
  // HTTP status mapping for the JSON-RPC error code we reply with.
150
150
  res.statusCode = code === JSONRPC_PARSE_ERROR || code === JSONRPC_INVALID_REQUEST ? 400 : // HTTP status code (RFC 9110)
151
151
  code === JSONRPC_METHOD_NOT_FOUND ? 404 : // HTTP status code (RFC 9110)
152
+ code === JSONRPC_AUTH_REQUIRED ? 401 : // RFC 6750 §3 — bearer challenge is 401
152
153
  code === JSONRPC_INTERNAL_ERROR ? 500 : 400; // HTTP status code (RFC 9110)
153
154
  res.end(body);
154
155
  }
@@ -190,7 +191,11 @@ function _checkRedirectUri(uri, allowlist, errorClass) {
190
191
  "mcp: redirect_uri not in allowlist (OAuth 2.1 / RFC 9700 sec 4.1.1)");
191
192
  }
192
193
  var parsed;
193
- try { parsed = safeUrl.parse(uri); }
194
+ // Allow http: through the PARSE (default is https-only) so a loopback
195
+ // http://localhost redirect (RFC 8252 native-app / CLI MCP client) reaches
196
+ // the isHttps||isLocal gate below instead of failing here as "did not parse".
197
+ // Non-loopback http is still refused by that gate (RFC 9700 §4.1.1).
198
+ try { parsed = safeUrl.parse(uri, { allowedProtocols: ["https:", "http:"] }); }
194
199
  catch (_e) {
195
200
  throw errorClass.factory("mcp/bad-redirect-uri",
196
201
  "mcp: redirect_uri did not parse");
@@ -321,7 +326,11 @@ function serverGuard(opts) {
321
326
  req.mcpClaims = claims || null;
322
327
 
323
328
  var path = String(req.url || "").split("?")[0];
324
- if (path === "/register" || path.endsWith("/register")) {
329
+ // Strip trailing slashes for the match — most routers normalize
330
+ // `/register/` to the same handler, so the guard must too (an exact/
331
+ // suffix match on the raw path let `/register/` slip past the refusal).
332
+ var normPath = path.replace(/\/+$/, "");
333
+ if (normPath === "/register" || normPath.endsWith("/register")) {
325
334
  if (!allowDynamicRegister) {
326
335
  _emitDenied(req, "mcp.register.refused-static", "dynamic registration disabled", { path: path });
327
336
  return refuse(res, JSONRPC_METHOD_NOT_FOUND, "dynamic client registration is not permitted");
@@ -663,7 +672,22 @@ function _validateValueAgainstSchema(value, schema, path) {
663
672
  if (typeof schema.minimum === "number" && value < schema.minimum) return path + ": " + value + " < minimum " + schema.minimum;
664
673
  if (typeof schema.maximum === "number" && value > schema.maximum) return path + ": " + value + " > maximum " + schema.maximum;
665
674
  }
666
- if (t === "object" && value && typeof value === "object" && !Array.isArray(value)) {
675
+ // A schema with `properties`/`required`/`additionalProperties` but no explicit
676
+ // `type: "object"` is valid + common JSON Schema — treat it as an object schema
677
+ // so those constraints are ENFORCED, not silently skipped (fail-open on the
678
+ // whole validation gate, OWASP LLM07).
679
+ var describesObject = t === "object" || (t === undefined &&
680
+ (schema.properties !== undefined || schema.required !== undefined ||
681
+ schema.additionalProperties !== undefined));
682
+ if (describesObject) {
683
+ // An inferred object schema (no explicit `type`) skips the top type-match,
684
+ // so a scalar / null / array would otherwise pass by simply skipping the
685
+ // checks below — the exact fail-open this branch exists to close. Reject a
686
+ // non-object value explicitly.
687
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
688
+ return path + ": expected object, got " +
689
+ (Array.isArray(value) ? "array" : (value === null ? "null" : typeof value));
690
+ }
667
691
  if (Array.isArray(schema.required)) {
668
692
  for (var ri = 0; ri < schema.required.length; ri++) {
669
693
  if (!Object.prototype.hasOwnProperty.call(value, schema.required[ri])) {
@@ -766,7 +790,9 @@ var MCP_PROTOCOL_VERSIONS_ACCEPTED = ["2024-11-05", "2025-03-26", "2025-06-18",
766
790
  */
767
791
  function _assertProtocolVersion(req, opts) {
768
792
  opts = opts || {};
769
- var accepted = Array.isArray(opts.accepted) && opts.accepted.length > 0
793
+ // An explicit empty `accepted: []` means "accept no version" (reject all), not
794
+ // "fall back to the default set" — only an absent/non-array value defaults.
795
+ var accepted = Array.isArray(opts.accepted)
770
796
  ? opts.accepted : MCP_PROTOCOL_VERSIONS_ACCEPTED;
771
797
  var hdr = req && req.headers && req.headers["mcp-protocol-version"];
772
798
  if (typeof hdr !== "string" || hdr.length === 0) {
@@ -854,6 +880,13 @@ function _samplingGuard(opts) {
854
880
  throw new McpError("mcp/sampling-too-many-messages",
855
881
  "sampling.guard: " + messages.length + " messages > maxMessagesPerRequest=" + maxMsg);
856
882
  }
883
+ // Reject a non-number maxTokens on this untrusted, server-initiated surface
884
+ // — a string "999999" would otherwise skip the numeric cap entirely and a
885
+ // downstream SDK that coerces it would blow past the budget (type-confusion).
886
+ if (samplingRequest.maxTokens !== undefined && typeof samplingRequest.maxTokens !== "number") {
887
+ throw new McpError("mcp/sampling-bad-max-tokens",
888
+ "sampling.guard: maxTokens must be a number");
889
+ }
857
890
  if (typeof samplingRequest.maxTokens === "number" && samplingRequest.maxTokens > maxTokens) {
858
891
  throw new McpError("mcp/sampling-too-many-tokens",
859
892
  "sampling.guard: requested maxTokens " + samplingRequest.maxTokens +
package/lib/metrics.js CHANGED
@@ -282,7 +282,15 @@ function _resolveLabels(defaultLabels, declaredNames, callLabels) {
282
282
  "label '" + declaredNames[n] + "' is required (declared in labelNames)");
283
283
  }
284
284
  }
285
- return out;
285
+ // Redact credential-shaped values on the resolved labels themselves — the
286
+ // stored entry.labels are rendered verbatim into the exposition stream, so
287
+ // redacting only inside _labelsKey (the Map cardinality key) left raw bearer
288
+ // tokens / API keys / JWTs reaching /metrics. _labelsKey re-runs the same
289
+ // coercion, so this stays idempotent.
290
+ var redacted = {};
291
+ var ok = Object.keys(out);
292
+ for (var r = 0; r < ok.length; r++) redacted[ok[r]] = _validateLabelValue(out[ok[r]]);
293
+ return redacted;
286
294
  }
287
295
 
288
296
  // ---- registry factory ----
@@ -384,6 +392,10 @@ function create(opts) {
384
392
  values: values,
385
393
  inc: function (callLabels, n) {
386
394
  var arg = _normalizeLabelArg(callLabels, n, 1);
395
+ if (typeof arg.value !== "number" || !isFinite(arg.value)) {
396
+ throw new MetricsError("metrics/counter-bad-value",
397
+ "counter.inc value must be a finite number");
398
+ }
387
399
  if (arg.value < 0) {
388
400
  throw new MetricsError("metrics/counter-decrement",
389
401
  "counter.inc value must be >= 0 (got " + arg.value + ") — counters never decrease");
@@ -454,7 +466,7 @@ function create(opts) {
454
466
  values: values,
455
467
  set: function (callLabels, v) {
456
468
  var arg = _normalizeLabelArg(callLabels, v, NaN);
457
- if (typeof arg.value !== "number" || isNaN(arg.value)) {
469
+ if (typeof arg.value !== "number" || !isFinite(arg.value)) {
458
470
  throw new MetricsError("metrics/gauge-bad-value",
459
471
  "gauge.set value must be a finite number");
460
472
  }
@@ -520,7 +532,7 @@ function create(opts) {
520
532
  values: values,
521
533
  observe: function (callLabels, v, exemplar) {
522
534
  var arg = _normalizeLabelArg(callLabels, v, NaN);
523
- if (typeof arg.value !== "number" || isNaN(arg.value)) {
535
+ if (typeof arg.value !== "number" || !isFinite(arg.value)) {
524
536
  throw new MetricsError("metrics/histogram-bad-value",
525
537
  "histogram.observe value must be a finite number");
526
538
  }
@@ -157,8 +157,12 @@ function _parseChecksumHeader(headerValue, allowedSet) {
157
157
  var algo = kvp.key;
158
158
  var digestB64 = kvp.value.trim();
159
159
  if (algo.length === 0 || digestB64.length === 0) return { error: "malformed" };
160
- if (!allowedSet[algo]) return { error: "algo-unsupported" };
160
+ // hasOwnProperty-guarded lookups a bare `allowedSet[algo]` resolves
161
+ // Object.prototype for algo="__proto__"/"constructor" (truthy), bypassing the
162
+ // unsupported-algo guard and handing createHash a non-string → HTTP 500.
163
+ if (!Object.prototype.hasOwnProperty.call(allowedSet, algo)) return { error: "algo-unsupported" };
161
164
  if (!/^[A-Za-z0-9+/=]+$/.test(digestB64)) return { error: "malformed" };
165
+ if (!Object.prototype.hasOwnProperty.call(KNOWN_CHECKSUM_ALGORITHMS, algo)) return { error: "algo-unsupported" };
162
166
  var nodeAlgo = KNOWN_CHECKSUM_ALGORITHMS[algo];
163
167
  if (!nodeAlgo) return { error: "algo-unsupported" };
164
168
  return { algo: algo, nodeAlgo: nodeAlgo, digestB64: digestB64 };
@@ -570,7 +574,10 @@ function create(opts) {
570
574
  if (rec.length === null && req.headers["upload-length"] !== undefined) {
571
575
  // Upload-Defer-Length finalization (§4.3) — declare length on first PATCH
572
576
  var declared = parseInt(req.headers["upload-length"], 10);
573
- if (!isFinite(declared) || declared < 0) {
577
+ // Same strict parse the POST creation path uses — reject trailing junk
578
+ // ("10abc" → 10, "0x10" → 0) rather than parseInt-ing leniently.
579
+ if (!isFinite(declared) || declared < 0 ||
580
+ String(declared) !== String(req.headers["upload-length"]).trim()) {
574
581
  return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
575
582
  }
576
583
  if (maxSize !== undefined && declared > maxSize) {
@@ -675,7 +675,10 @@ async function tlsRptSubmit(report, opts) {
675
675
  body: gzipped,
676
676
  timeoutMs: timeoutMs,
677
677
  });
678
- entry.status = rv && rv.status;
678
+ // The framework httpClient resolves { statusCode, headers, body }
679
+ // (same shape mtaSts.fetch reads above) — not { status }. Reading
680
+ // the wrong field marked every real 2xx submission as a failure.
681
+ entry.status = rv && rv.statusCode;
679
682
  entry.ok = entry.status >= 200 && entry.status < 300; // HTTP 2xx range
680
683
  if (!entry.ok) entry.error = "HTTP " + entry.status;
681
684
  } else if (/^mailto:/i.test(uri)) {
@@ -262,6 +262,14 @@ function parse(input, opts) {
262
262
  return _parseBlockSequence(k, indent, depth);
263
263
  }
264
264
 
265
+ // Root-level flow collection ({...} / [...]) — recognize the flow bracket
266
+ // BEFORE the block-mapping key scan. Otherwise a root JSON/flow object like
267
+ // '{a: 1, b: 2}' matches the block-mapping key scan (the first ': ' makes
268
+ // '{a' look like a bare key) and silently returns structurally-wrong data.
269
+ if (content.charAt(0) === "{" || content.charAt(0) === "[") {
270
+ return { value: _parseInlineValue(content, firstLine.lineNumber, indent), nextLine: k + 1 };
271
+ }
272
+
265
273
  // Block mapping starts with a key (bare or quoted) followed by `:`.
266
274
  var keyRange = _scanKeyRange(content, firstLine.lineNumber, indent);
267
275
  if (keyRange) {
@@ -493,8 +501,19 @@ function parse(input, opts) {
493
501
  // Strip trailing whitespace/comment already done by caller (mostly).
494
502
  // Handle: flow [...] / {...}, quoted strings, plain scalars.
495
503
  var t = text;
496
- if (t.charAt(0) === "[") return _parseFlowSequence(t, lineNumber, col, 0);
497
- if (t.charAt(0) === "{") return _parseFlowMapping(t, lineNumber, col, 0);
504
+ if (t.charAt(0) === "[" || t.charAt(0) === "{") {
505
+ // Reject trailing junk after a flow collection (matching the quoted-scalar
506
+ // branch below) using _parseFlowValue's end position, then return the value
507
+ // via the direct parser so the shape is identical to the pre-existing path.
508
+ var fend = _parseFlowValue(t, 0, lineNumber, col, 0).nextPos;
509
+ var afterFlow = t.slice(fend).replace(/^\s+/, "");
510
+ if (afterFlow.length > 0 && afterFlow.charAt(0) !== "#") {
511
+ throw new SafeYamlError("unexpected content after flow collection",
512
+ "yaml/trailing-content", lineNumber, col);
513
+ }
514
+ return t.charAt(0) === "[" ? _parseFlowSequence(t, lineNumber, col, 0)
515
+ : _parseFlowMapping(t, lineNumber, col, 0);
516
+ }
498
517
  if (t.charAt(0) === '"') {
499
518
  var dq = _decodeDoubleQuoted(t, lineNumber, col);
500
519
  var afterDq = _trailingAfterQuoted(t, '"');
@@ -570,6 +589,18 @@ function parse(input, opts) {
570
589
  throw new SafeYamlError("forbidden key '" + key + "'",
571
590
  "yaml/poisoned-key", lineNumber, col + p);
572
591
  }
592
+ // Mirror the block-mapping guards so flow style can't bypass them:
593
+ // the merge key '<<' is banned, and a duplicate key is refused (block
594
+ // rejects both; flow previously accepted '<<' as a literal key and
595
+ // silently kept the LAST value on a repeat — a config-override risk).
596
+ if (key === "<<") {
597
+ throw new SafeYamlError("merge key '<<' not supported (anchor-using feature)",
598
+ "yaml/merge-key-banned", lineNumber, col + p);
599
+ }
600
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
601
+ throw new SafeYamlError("duplicate mapping key '" + key + "'",
602
+ "yaml/duplicate-key", lineNumber, col + p);
603
+ }
573
604
  _bumpKeys(lineNumber);
574
605
  p = keyVal.nextPos;
575
606
  p = _flowSkipWsIndex(text, p);
package/lib/router.js CHANGED
@@ -44,6 +44,7 @@ var requestHelpers = require("./request-helpers");
44
44
  var lazyRequire = require("./lazy-require");
45
45
  var safeAsync = require("./safe-async");
46
46
  var safeEnv = require("./parsers/safe-env");
47
+ var safeJson = require("./safe-json");
47
48
  var safeUrl = require("./safe-url");
48
49
  var validateOpts = require("./validate-opts");
49
50
  var websocket = require("./websocket");
@@ -163,9 +164,25 @@ function _makeSchemaValidator(spec) {
163
164
  };
164
165
  }
165
166
 
167
+ // True when a res.end body structurally looks like a JSON document — the
168
+ // first non-whitespace byte is an object/array opener. Narrow on purpose:
169
+ // a plain-text / HTML body on a response-schema route (leading "<", a bare
170
+ // word, etc.) is skipped so the validator never throws on a non-JSON
171
+ // response, and a JSON scalar (a bare number / quoted string) is not
172
+ // mistaken for a schema-shaped document.
173
+ function _bodyLooksLikeJson(text) {
174
+ for (var i = 0; i < text.length; i += 1) {
175
+ var ch = text.charCodeAt(i);
176
+ if (ch === 0x20 || ch === 0x09 || ch === 0x0A || ch === 0x0D) continue; // skip leading whitespace
177
+ return ch === 0x7B || ch === 0x5B; // "{" or "["
178
+ }
179
+ return false;
180
+ }
181
+
166
182
  function _makeResponseValidator(spec) {
167
- // Wraps res.json (and res.end when called with a JSON-shaped buffer)
168
- // to validate the response body against spec.response. Mode:
183
+ // Validate the response body against spec.response no matter which exit
184
+ // the handler used res.json OR a raw res.end(JSON.stringify(...)) that
185
+ // ships a JSON-shaped buffer. Mode:
169
186
  // - BLAMEJS_VALIDATE_RESPONSES=throw (or per-route validateResponse: "throw")
170
187
  // → throw a SafeSchemaError-shaped error; route handler's caller sees a 500.
171
188
  // - BLAMEJS_VALIDATE_RESPONSES=warn (or per-route validateResponse: "warn")
@@ -177,21 +194,59 @@ function _makeResponseValidator(spec) {
177
194
  if (!mode) return function passthrough(_req, _res, next) { next(); };
178
195
 
179
196
  return function responseValidator(req, res, next) {
197
+ // One request-scoped check shared by both the res.json and res.end
198
+ // wrappers so a single failure shape covers every exit.
199
+ var validated = false;
200
+ function runCheck(value, headersAlreadySent) {
201
+ var rr = spec.response.safeParse(value);
202
+ if (rr.ok) return;
203
+ if (mode === "throw") {
204
+ // A raw res.end after writeHead cannot be un-sent — throwing there
205
+ // would abort mid-flush and can't reach a clean 500. Degrade to a
206
+ // logged error in that case; res.json (and a pre-writeHead res.end)
207
+ // still throw before any header is committed.
208
+ if (!headersAlreadySent) {
209
+ throw new Error("router response-validation failed for " +
210
+ (req.method + " " + req.routePattern) + ": " +
211
+ JSON.stringify(rr.errors));
212
+ }
213
+ log.error("response-validation failed after headers sent on " +
214
+ req.method + " " + req.routePattern + ": " +
215
+ JSON.stringify(rr.errors).slice(0, 500));
216
+ return;
217
+ }
218
+ // warn mode
219
+ log.warn("response-validation drift on " + req.method + " " + req.routePattern +
220
+ ": " + JSON.stringify(rr.errors).slice(0, 500));
221
+ }
222
+
180
223
  var origJson = typeof res.json === "function" ? res.json.bind(res) : null;
181
224
  if (origJson) {
182
225
  res.json = function (value) {
183
- var rr = spec.response.safeParse(value);
184
- if (!rr.ok) {
185
- if (mode === "throw") {
186
- throw new Error("router response-validation failed for " +
187
- (req.method + " " + req.routePattern) + ": " +
188
- JSON.stringify(rr.errors));
226
+ runCheck(value, false); // res.json validates before it writes headers
227
+ validated = true; // res.json delegates to res.end internally — don't re-check
228
+ return origJson(value);
229
+ };
230
+ }
231
+
232
+ var origEnd = typeof res.end === "function" ? res.end.bind(res) : null;
233
+ if (origEnd) {
234
+ res.end = function (chunk) {
235
+ if (!validated) {
236
+ var text = null;
237
+ if (typeof chunk === "string") text = chunk;
238
+ else if (Buffer.isBuffer(chunk)) text = chunk.toString("utf8");
239
+ if (text !== null && _bodyLooksLikeJson(text)) {
240
+ var parsed;
241
+ var parsedOk = true;
242
+ try { parsed = safeJson.parse(text); } catch (_e) { parsedOk = false; }
243
+ if (parsedOk) {
244
+ validated = true;
245
+ runCheck(parsed, res.headersSent === true);
246
+ }
189
247
  }
190
- // warn mode
191
- log.warn("response-validation drift on " + req.method + " " + req.routePattern +
192
- ": " + JSON.stringify(rr.errors).slice(0, 500));
193
248
  }
194
- return origJson(value);
249
+ return origEnd.apply(res, arguments);
195
250
  };
196
251
  }
197
252
  next();
@@ -958,16 +1013,24 @@ class Router {
958
1013
  "res.redirect: target must be a non-empty string"
959
1014
  );
960
1015
  }
961
- // Reject embedded CR / LF / NUL early header injection class.
962
- // Node's writeHead would refuse these too, but the explicit
963
- // refusal here gives operators a router-shaped error rather than
964
- // a generic ERR_INVALID_CHAR.
1016
+ // Reject every byte a header line or a URL-parsing user agent
1017
+ // treats specially. CR / LF / NUL are the header-injection class
1018
+ // (Node's writeHead would refuse them too the explicit refusal
1019
+ // gives operators a router-shaped error over ERR_INVALID_CHAR).
1020
+ // TAB (0x09) is the open-redirect class: Node PERMITS a horizontal
1021
+ // tab in a header value, but WHATWG URL removes ASCII TAB / LF / CR
1022
+ // from a URL before parsing it, so "/<TAB>/evil.example" — which
1023
+ // the same-origin heuristic below reads as a rooted path — collapses
1024
+ // to the protocol-relative "//evil.example" in the browser and
1025
+ // bounces to an attacker origin. Refuse the whole browser-stripped
1026
+ // set here so the byte the heuristic inspects is the byte the user
1027
+ // agent parses.
965
1028
  for (var ci = 0; ci < url.length; ci += 1) {
966
1029
  var cc = url.charCodeAt(ci);
967
- if (cc === 0x00 || cc === 0x0A || cc === 0x0D) {
1030
+ if (cc === 0x00 || cc === 0x09 || cc === 0x0A || cc === 0x0D) {
968
1031
  throw new RouterError(
969
1032
  "router/redirect-target-has-control-chars",
970
- "res.redirect: target must not contain CR / LF / NUL bytes"
1033
+ "res.redirect: target must not contain CR / LF / TAB / NUL bytes"
971
1034
  );
972
1035
  }
973
1036
  }
@@ -69,7 +69,17 @@ class SafeBufferError extends FrameworkError {
69
69
 
70
70
  function _throw(errorClass, message, code) {
71
71
  var Cls = errorClass || SafeBufferError;
72
- throw new Cls(message, code);
72
+ var err = new Cls(message, code);
73
+ // SafeBufferError takes (message, code) but a defineClass errorClass (the
74
+ // convention for every framework-error, e.g. a caller's TusError) takes
75
+ // (code, message) — the opposite order — so `new Cls(message, code)` would
76
+ // SWAP .code/.message for such a class (a caller branching on e.code then
77
+ // never matches). Set both fields explicitly so they are correct whichever
78
+ // constructor order the passed class uses; the class-derived flags are set
79
+ // from the class options, not the code value, so this is safe.
80
+ err.code = code;
81
+ err.message = message;
82
+ throw err;
73
83
  }
74
84
 
75
85
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:c7d7addc-92d7-4060-8b48-b51d9726a093",
5
+ "serialNumber": "urn:uuid:3e7515ff-90ec-4d95-be2f-ec3082f1f6d8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-03T05:24:29.849Z",
8
+ "timestamp": "2026-07-03T23:06:39.610Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.16.1",
22
+ "bom-ref": "@blamejs/core@0.16.3",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.1",
25
+ "version": "0.16.3",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.16.1",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.3",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.16.1",
57
+ "ref": "@blamejs/core@0.16.3",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]