@blamejs/core 0.17.5 → 0.17.6
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 +2 -0
- package/lib/auth/oid4vci.js +20 -17
- package/lib/cluster-provider-db.js +13 -3
- package/lib/guard-pdf.js +9 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.17.x
|
|
10
10
|
|
|
11
|
+
- v0.17.6 (2026-07-17) — **PDF disarm now refuses JavaScript, launch actions, and polyglots even when an operator opt says allow, a renewed cluster lease no longer stretches its own expiry into the future, and a credential-issuance proof is refused when its replay nonce is absent.** b.guardPdf.sanitize is documented to strip a PDF down to inert content and to refuse -- under every profile -- the JavaScript, launch-action, and polyglot classes it cannot safely neutralize. But its forced-reject override pinned only the exfiltration and encryption policies, omitting the JavaScript, launch-action, and polyglot policies; those default to reject, so the gap was invisible until an operator passed an explicit permissive opt, which then let sanitize hand back a live PDF still carrying JavaScript or a launch action. b.clusterProviderDb.renewLease computed a lease's time-to-live as the span between its acquire time and its expiry, but on renewal advanced only the expiry and left the acquire time frozen, so each renewal re-derived an ever-larger TTL and pushed the expiry unboundedly into the future -- a dead leader's lease then never lapsed and no follower could take over. And b.auth.oid4vci's issuer skipped the credential-proof replay/holder-binding nonce check when the expected nonce was null, the miss sentinel that a Redis-, Map-, or SQL-backed nonce store commonly returns, so a forged proof could mint a credential bound to an attacker-chosen key. **Security:** *PDF disarm refuses active content even against a permissive operator opt* — b.guardPdf.sanitize is the disarm-by-refusal primitive: it guarantees it never returns a PDF that still carries JavaScript, a launch/open action, an embedded file, or encryption, and that the JavaScript, launch-action, and polyglot classes are refused under every profile. To hold that guarantee regardless of the operator's configuration, sanitize builds a forced override that pins the relevant policies to reject -- but the override pinned only the embedded-file, open-action, magic, and encryption policies and omitted the JavaScript, launch-action, and polyglot policies. Because those three already default to reject in every shipped profile, the omission was invisible in normal use; an operator who passed an explicit permissive opt (javascriptPolicy, launchActionPolicy, or polyglotPolicy set to allow or audit) turned that opt back on inside sanitize and received a live PDF still carrying JavaScript or a launch action -- exactly the remote-code-execution and polyglot classes the primitive promises to refuse. The forced override now pins all three, so sanitize refuses them unconditionally; the overridable validate and gate entry points, which document these policies as operator-tunable, are unchanged. · *Cluster lease renewal keeps a bounded expiry so a dead leader can be taken over* — b.clusterProviderDb models a leader lease as a sliding window whose span -- expiry minus acquire time -- equals the configured lease TTL. renewLease recovered the TTL from that span but then advanced only the expiry while leaving the acquire time frozen at the original acquisition, so the next renewal measured a span that had grown by one renewal interval and re-derived an ever-larger TTL, pushing the expiry unboundedly into the future. A leader that renewed even a few times and then died left a lease whose expiry was far beyond the configured TTL, so it never lapsed within the takeover window and no follower could steal it -- the cluster could stall with no active leader. renewLease now slides both ends of the window forward on every renewal (acquire time and expiry both move to now and now-plus-TTL), keeping the span at the configured TTL so a lapsed leader's lease expires on schedule and bounded takeover works. · *Credential-issuance proof is refused when its replay nonce is absent* — b.auth.oid4vci's issuer verifies the wallet's key-binding proof against the c_nonce it minted with the access token -- the challenge that binds the proof to this issuance and prevents replay. The verifier treated three states of the expected nonce differently: a string was compared, an undefined value (the miss sentinel b.cache returns) was refused, and a null value was treated as no check required. But a nonce store fronting Redis, a Map, or a SQL row -- all accepted through the documented store option -- commonly signals a miss with null, which fell straight through the comparison and disabled the replay and holder-binding defense entirely: after the short-lived c_nonce expired (while the access token was still valid, in batch issuance), an attacker holding that token could submit a proof signed by an arbitrary key with any nonce and have a credential minted bound to that attacker-controlled key. The verifier now requires the expected nonce to be a non-empty string and fails closed on any other value, so an absent or expired nonce is refused regardless of the store's miss sentinel.
|
|
12
|
+
|
|
11
13
|
- v0.17.5 (2026-07-17) — **A tampered or over-filtered backup can no longer silently wipe the live data directory on restore, DNSSEC-strict resolution stays enforced when a stale answer is served, and a family of guards that matched a name against a lookup table now reject a prototype-member name instead of misclassifying it.** Restoring a backup whose manifest lists zero files -- because a tampered unsigned bundle stripped every entry, or an opts.filter matched nothing -- extracted an empty staging directory and swapped it over the live data directory: a silent, full data-directory wipe reported to the operator as a successful restore. The manifest validator now refuses an empty file list, and the restore path refuses to swap a zero-file extract over a non-empty data directory. Separately, b.network.dns.resolver's validate: true DNSSEC gate was skipped on the serve-stale path, so an upstream outage could downgrade a DNSSEC-strict lookup (including DANE TLSA resolution) to unauthenticated stale data; the gate now holds on the stale path too. And a group of guards resolved a host, scheme, tag, method, or agent name against a plain-object lookup table with a truthiness read, so a name colliding with an inherited object member (constructor) was misclassified -- over-rejected as reserved/forbidden, or, for the mail-server method catalogue, passed the catalogue gate unregistered. All now match against the table's own keys. **Security:** *Restore refuses a zero-file backup instead of wiping the data directory* — b.backupManifest.validate accepted a manifest with an empty files array. The bundle writer never emits one, but the validator is the single guard the restore path parses an untrusted manifest through, so a tampered unsigned bundle that stripped every file entry (while keeping the still-valid wrapped vault key, which needs no passphrase knowledge) parsed cleanly, extracted an empty staging directory, and had the atomic swap move that empty directory over the live data directory -- a silent, destructive wipe of every file, reported to the operator as a successful restore with a file count of zero. The validator now refuses an empty file list (fixing every consumer -- parse, create, serialize -- at once), so a tampered manifest is rejected before extraction. As defense in depth, b.restore.run now also refuses to swap a zero-file extract over a non-empty data directory, which additionally catches an opts.filter that matched no manifest entry; the swap is refused with the data directory left intact rather than destroyed. · *DNSSEC-strict resolution stays enforced on the serve-stale path* — b.network.dns.resolver's validate: true option refuses a response that is not DNSSEC-authenticated (AD=0), but the RFC 8767 serve-stale short-circuit returned a cached stale answer before that gate ran. An attacker who can force an upstream outage (denying the DoH endpoint) could therefore downgrade a DNSSEC-strict lookup -- including b.network.dns.resolver.queryTlsa for DANE, queryDs, or a CNAME chase -- to unauthenticated stale data cached earlier at AD=0, with the caller seeing a success rather than the documented validate-failed refusal. The AD-bit gate now applies on the serve-stale path too (the DNSSEC verdict is per-response), so a stale answer that was not authenticated is refused rather than served to a validating caller. · *Name-lookup guards reject a prototype-member name instead of misclassifying it* — A group of guards tested whether a caller- or peer-supplied name was in a fixed table -- a reserved-HELO-name set, a reserved-local-host set, a dangerous-URL-scheme denylist, a reserved agent-name set, a Tiny-PS forbidden-element set, an HTML void-element set, and the mail-server method catalogue -- with a plain-object truthiness read (TABLE[name]) that walks the prototype chain. A name that collides with an inherited object member -- constructor is the one that survives a lowercased key -- read the inherited value as truthy and was misclassified: over-rejected as reserved or forbidden (b.mail.helo, b.guardListUnsubscribe, b.guardAgentRegistry, b.mailBimi, b.htmlBalance), or, for the mail-server method catalogue, passed the catalogue gate as if it were a registered method (a fail-open at registration). Every site now matches against the table's own keys with an own-property check, so a prototype-member name is treated as any other unknown name; supported names are unaffected.
|
|
12
14
|
|
|
13
15
|
- v0.17.4 (2026-07-17) — **Three fail-open holes closed: a raw cross-border write could evade the data-residency gate by wedging a comment or eliding whitespace, safe decompression returned undecompressed bytes for an unknown algorithm name, and the shared anti-replay helper admitted a replay when its store signalled a duplicate with a non-boolean value.** A raw SQL write submitted through b.db.runSql / b.db.prepare().run() could slip past the cross-border data-residency gate: the gate's write-detection matched tokens on whitespace boundaries, but SQL lets tokens abut with no whitespace when a comment or a quoted identifier sits between them, so INSERT/**/INTO, an INSERT whose quoted table name abuts INTO, and UPDATE/**/table all executed while the gate never engaged -- landing or moving a row across a residency boundary under a regulated posture. b.safeDecompress resolved its algorithm allowlist with a truthiness lookup, so an algorithm name that collides with an inherited object member (constructor, toString) reached the dispatch and returned the input undecompressed instead of refusing the unknown algorithm -- a fail-open for any caller that maps a client Content-Encoding onto the algorithm. And b.nonceStore.enforceReplay -- the anti-replay helper JWT and DPoP verification use -- detected a replay only when its store returned a literal false, so a store signalling a duplicate with another falsy value (a Redis SETNX null, a SQL INSERT rowcount of 0) let the replayed token through. **Added:** *b.safeSql.normalizeForScan* — A parse-only SQL normalizer that produces a copy whose token boundaries are real whitespace, so a scanner built with whitespace-anchored patterns cannot be evaded by a comment or a quoted-identifier boundary that lets two tokens abut. It collapses line and block comments to a single space and inserts a separating space where a quoted string or identifier follows a word character, in the same quote- and comment-aware single pass as the existing b.safeSql placeholder scanners (a comment marker inside a string literal is preserved, never collapsed). The executed SQL is never derived from this copy -- it only feeds a scanner. **Security:** *Data-residency gate catches a raw write that hides behind a comment or elided whitespace* — The cross-border data-residency gate that guards raw SQL writes (b.db.runSql, b.db.prepare().run()) detected the write and extracted its target table with regexes that assume tokens are separated by whitespace. SQL does not require whitespace between tokens when a comment or a quoted-identifier boundary separates them, so three forms that every engine executes as ordinary writes slipped the detection entirely and the gate never engaged: an INSERT whose double-quoted table name abuts INTO with no space, an INSERT with a block comment wedged between INSERT and INTO, and the same for UPDATE and its table. Under a regulated residency posture a row could therefore be inserted or moved across a residency boundary with no tag check (CWE-863; a GDPR Chapter V transfer-restriction bypass). The gate now normalizes a parse-only copy of the statement through the new b.safeSql.normalizeForScan before matching -- collapsing comments to a space and inserting a boundary where a quoted token abuts a word character -- so every write is detected and gated regardless of how its tokens are spaced. The executed SQL is unchanged. · *Safe decompression refuses an unknown algorithm instead of returning the input undecompressed* — b.safeDecompress resolved opts.algorithm against its allowlist of supported codecs (gzip, deflate, deflate-raw, brotli) with a truthiness lookup on a plain object. An algorithm name that names an inherited object member -- constructor, toString, valueOf -- read back a truthy inherited value, slipped the unsupported-algorithm refusal, and reached the dispatch, where invoking the inherited member returned the raw input buffer. The primitive therefore silently returned undecompressed bytes with no error, contrary to its contract that any algorithm outside the allowlist is refused -- a fail-open for a consumer that maps a client-supplied Content-Encoding token onto the algorithm, which also sidesteps the decompression-bomb size and ratio caps. The allowlist is now resolved with an own-property check, so any unsupported name -- however it collides with a built-in member -- is refused. · *Shared anti-replay helper fails closed on a non-boolean store result* — b.nonceStore.enforceReplay is the anti-replay helper that b.auth.jwt and b.auth.dpop route their replay defense through; it calls the store's checkAndInsert and treated the result as a replay only when it was the literal false. The recommended stores signal a duplicate with a different falsy value -- a Redis SET ... NX returns null and a SQL INSERT ... ON CONFLICT returns a rowcount of 0 -- so a store using either would have its duplicate signal missed and the replayed nonce admitted (a replay-protection bypass for a JWT or DPoP proof). enforceReplay now admits a nonce only on a truthy first-seen confirmation and treats every other result as a replay, matching the fail-closed check the framework's inline anti-replay consumers already use.
|
package/lib/auth/oid4vci.js
CHANGED
|
@@ -190,26 +190,29 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
190
190
|
throw new AuthError("auth-oid4vci/wrong-proof-aud",
|
|
191
191
|
"credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
|
|
192
192
|
}
|
|
193
|
-
// c_nonce
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
193
|
+
// The c_nonce is the OID4VCI proof replay + holder-binding challenge
|
|
194
|
+
// (§7.2.1.1). Every access token is minted with a c_nonce in
|
|
195
|
+
// exchangePreAuthorizedCode, so a missing value at /credential means the
|
|
196
|
+
// c_nonce TTL elapsed (shorter than the access token's) — it MUST refuse
|
|
197
|
+
// and force the wallet to fetch a fresh one. There is NO issuance mode
|
|
198
|
+
// that legitimately skips the compare, so `expectedCNonce` is required to
|
|
199
|
+
// be a non-empty string. Fail closed on ANY other shape: a conforming
|
|
200
|
+
// b.cache signals a miss with `undefined`, but a store fronting Redis /
|
|
201
|
+
// a Map / a SQL row commonly signals a miss with `null`; the prior
|
|
202
|
+
// `!== null` guard let that null fall THROUGH the nonce compare entirely,
|
|
203
|
+
// silently disabling the replay defense (a forged proof with any nonce
|
|
204
|
+
// was then accepted). Treating undefined / null / non-string uniformly
|
|
205
|
+
// as "expired" closes that store-shape fail-open.
|
|
206
|
+
if (typeof expectedCNonce !== "string" || expectedCNonce.length === 0) {
|
|
202
207
|
throw new AuthError("auth-oid4vci/c-nonce-expired",
|
|
203
208
|
"credential issuance: c_nonce expected but missing/expired — wallet must request a fresh c_nonce (the /token response's c_nonce TTL elapsed before /credential was called)");
|
|
204
209
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
"credential issuance: proof JWT nonce mismatch (replay defense — wallet must use the c_nonce from the most recent issuer response)");
|
|
212
|
-
}
|
|
210
|
+
// Constant-time c_nonce compare — secret-shaped value vs
|
|
211
|
+
// attacker-controlled wallet payload.
|
|
212
|
+
if (typeof payload.nonce !== "string" ||
|
|
213
|
+
!timingSafeEqual(payload.nonce, expectedCNonce)) {
|
|
214
|
+
throw new AuthError("auth-oid4vci/wrong-proof-nonce",
|
|
215
|
+
"credential issuance: proof JWT nonce mismatch (replay defense — wallet must use the c_nonce from the most recent issuer response)");
|
|
213
216
|
}
|
|
214
217
|
if (typeof payload.iat !== "number") {
|
|
215
218
|
throw new AuthError("auth-oid4vci/no-proof-iat",
|
|
@@ -244,7 +244,17 @@ function create(config) {
|
|
|
244
244
|
async function renewLease(lease, opts) {
|
|
245
245
|
if (!lease || !lease.leaseId) throw _err("INVALID_LEASE", "lease required", true);
|
|
246
246
|
var nowMs = Date.now();
|
|
247
|
-
|
|
247
|
+
// A lease is a sliding window: the span (expiresAt - acquiredAt) equals
|
|
248
|
+
// the configured TTL at acquire time, and every renewal must slide BOTH
|
|
249
|
+
// ends forward so the span stays that TTL. Advancing only expiresAt
|
|
250
|
+
// (leaving acquiredAt frozen at the original acquisition) re-derives an
|
|
251
|
+
// ever-growing TTL on the next renewal, so the expiry drifts unboundedly
|
|
252
|
+
// into the future: a dead leader's row then never lapses within leaseTtl
|
|
253
|
+
// and no follower can steal it, breaking bounded takeover. Refresh
|
|
254
|
+
// acquiredAt alongside expiresAt to keep the span invariant.
|
|
255
|
+
var ttlMs = lease.expiresAt - lease.acquiredAt;
|
|
256
|
+
var newAcquiredAt = nowMs;
|
|
257
|
+
var newExpiresAt = nowMs + ttlMs;
|
|
248
258
|
// opts.endpoint, when provided, refreshes the stored endpoint so
|
|
249
259
|
// operators who hot-update their config see the discovery row catch
|
|
250
260
|
// up. Default = preserve whatever was stored at acquire time.
|
|
@@ -261,7 +271,7 @@ function create(config) {
|
|
|
261
271
|
// MySQL has no RETURNING — UPDATE then read back, with a takeover
|
|
262
272
|
// detected when the read-back row no longer carries our leaseId.
|
|
263
273
|
var rvBuilt = sql().update(LEADER_TABLE, { dialect: dialect })
|
|
264
|
-
.set({ expiresAt: newExpiresAt, endpoint: endpoint })
|
|
274
|
+
.set({ acquiredAt: newAcquiredAt, expiresAt: newExpiresAt, endpoint: endpoint })
|
|
265
275
|
.where("scope", "leader").where("nodeId", lease.nodeId).where("leaseId", lease.leaseId)
|
|
266
276
|
.toSql();
|
|
267
277
|
var rv = await _q(clusterStorage().placeholderize(rvBuilt.sql, dialect), rvBuilt.params);
|
|
@@ -283,7 +293,7 @@ function create(config) {
|
|
|
283
293
|
row = sel.rows[0];
|
|
284
294
|
} else {
|
|
285
295
|
var result = await _run(sql().update(LEADER_TABLE, { dialect: dialect })
|
|
286
|
-
.set({ expiresAt: newExpiresAt, endpoint: endpoint })
|
|
296
|
+
.set({ acquiredAt: newAcquiredAt, expiresAt: newExpiresAt, endpoint: endpoint })
|
|
287
297
|
.where("scope", "leader").where("nodeId", lease.nodeId).where("leaseId", lease.leaseId)
|
|
288
298
|
.returning(renewCols));
|
|
289
299
|
if (!result.rows || result.rows.length === 0) {
|
package/lib/guard-pdf.js
CHANGED
|
@@ -372,13 +372,21 @@ function sanitize(input, opts) {
|
|
|
372
372
|
// Force the active-content / exfil / encryption policies to reject, then
|
|
373
373
|
// refuse anything the validate chain flags. The object graph can't be
|
|
374
374
|
// edited without a parser, so disarm collapses to "refuse if not already
|
|
375
|
-
// inert" — never a silent passthrough of a live action.
|
|
375
|
+
// inert" — never a silent passthrough of a live action. Every active-content
|
|
376
|
+
// policy is pinned to "reject" here so an operator-supplied permissive opt
|
|
377
|
+
// (javascriptPolicy / launchActionPolicy / polyglotPolicy: "allow" | "audit")
|
|
378
|
+
// cannot make disarm hand back a live PDF — the RCE / polyglot classes the
|
|
379
|
+
// guard "refuses to negotiate" on must be pinned exactly as the exfil /
|
|
380
|
+
// encryption ones are, or the override is only half-applied.
|
|
376
381
|
var strict = Object.assign({}, resolved, {
|
|
377
382
|
magicPolicy: "reject",
|
|
383
|
+
javascriptPolicy: "reject",
|
|
384
|
+
launchActionPolicy: "reject",
|
|
378
385
|
openActionPolicy: "reject",
|
|
379
386
|
embeddedFilePolicy: "reject",
|
|
380
387
|
embeddedFileCountPolicy: "reject",
|
|
381
388
|
encryptedPolicy: "reject",
|
|
389
|
+
polyglotPolicy: "reject",
|
|
382
390
|
});
|
|
383
391
|
var issues = _detectIssues(input, strict);
|
|
384
392
|
gateContract.throwOnRefusalSeverity(issues,
|
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:30806748-0a32-42d1-a88d-401da2aee1e8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-17T15:08:18.296Z",
|
|
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.17.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.17.6",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.17.
|
|
25
|
+
"version": "0.17.6",
|
|
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.17.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.17.6",
|
|
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.17.
|
|
57
|
+
"ref": "@blamejs/core@0.17.6",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|