@blamejs/core 0.16.37 → 0.16.38
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/agent-idempotency.js +18 -2
- package/lib/break-glass.js +19 -10
- package/lib/dsr.js +22 -2
- 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.16.x
|
|
10
10
|
|
|
11
|
+
- v0.16.38 (2026-07-16) — **Three fixes to data-subject scoping, break-glass grant limits, and idempotent-retry replay: a data-subject filter no longer matches every subject when it has no indexable key, a single-row break-glass grant can no longer be spent twice concurrently, and an idempotent retry returns its cached result under a vault.** b.dsr's subject-scoped ticket filter failed open: a subject carrying none of the indexable keys (email / subjectId) -- a phone-only, alias-only, or empty subject -- matched EVERY ticket instead of none, so listBySubject returned other subjects' tickets and the erasure-completion purge deleted them. Both ticket stores now fail closed. b.breakGlass's per-row grant limit could be exceeded under concurrency: two simultaneous unseals of a one-row grant against different rows both succeeded, because the claim was decided from a re-read of the shared counter that already reflected the other caller's increment; the claim is now decided from the atomic update's affected-row count. And b.agent.idempotency's putIfAbsent replay parsed the sealed result blob without unsealing it, so under a vault (the production default) every idempotent retry that landed on a completed key threw instead of returning the cached result -- and get() on a pending claim threw on a null result blob rather than reporting no cached result. **Fixed:** *Data-subject request filter fails closed when a subject has no indexable key* — b.dsr's subject-scoped ticket filter matched on the indexable keys email and subjectId. When the supplied subject carried neither -- a phone-only subject (a legitimate SMS-first identity), an alias-only subject, or an empty object -- the filter added no predicate and returned every ticket in the store instead of none. Through the exported API this meant listBySubject(subject) disclosed every subject's tickets, and the erasure-completion purge (which lists a subject's other tickets and deletes them) deleted every other subject's tickets. Both the in-memory and database ticket stores now fail closed: a subject filter that produces no usable predicate matches nothing, so an unindexable subject can neither read nor delete another subject's data. Filters that supply an indexable key are unchanged. · *A single-row break-glass grant can no longer be spent twice under concurrency* — b.breakGlass.unsealRow enforces a per-grant row limit with an atomic compare-and-increment (update the consumed counter where it is still below the cap). It then decided whether the caller won the slot by re-reading the counter and comparing it to the caller's own stale pre-value -- but a concurrent winner's increment is visible to the loser's re-read, so both callers saw a change and both proceeded, unsealing two rows under a one-row grant. The claim is now decided from the atomic update's affected-row count: exactly one caller's compare-and-increment modifies the row, and the loser (zero rows modified) is refused with grant-exhausted. The re-read is retained only for the audit's remaining-rows hint. · *Idempotent retries return their cached result under a vault, and a pending-claim read reports absent* — b.agent.idempotency seals the cached result at rest via b.cryptoField when a vault is configured (the production default). putIfAbsent's replay branch parsed the stored result blob as JSON without unsealing it first, so a retry that landed on an already-completed key threw a corrupt-result error instead of returning the cached result -- breaking the primitive's exactly-once replay guarantee exactly where operators run it. It now unseals before parsing, mirroring get(). Separately, get() on a pending claim (whose result blob is null because no result has been written yet) fed null to the JSON parser and threw the same corrupt-result error; it now reports no cached result, so a concurrent status check during another worker's in-flight claim no longer throws.
|
|
12
|
+
|
|
11
13
|
- v0.16.37 (2026-07-16) — **Three fail-open / injection fixes: the age gate no longer admits a user whose age fails to compute, the query builder's OFFSET-without-LIMIT runs on every backend, and metrics exemplars can no longer carry an unsanitized value into the scrape stream.** Three defects, each in a class the framework treats as security-relevant. b.middleware.ageGate classified a non-finite age (a NaN or Infinity returned by getAge when a birth field fails to parse) as an adult -- admitting the request with none of the child-safety privacy defaults -- instead of treating an uncomputable age as unknown. b.sql (and b.db.from / b.db.collection over it) emitted a bare OFFSET with no LIMIT, which is valid only on Postgres and is a syntax error on SQLite -- the framework's own backend -- and MySQL, so a valid builder chain failed to run on two of three dialects. And b.metrics exemplars rendered their labels, value, and timestamp into the OpenMetrics scrape surface without the credential-scrub and numeric-coercion regular labels receive, so an operator-supplied exemplar could leak a credential-shaped label or inject a forged metric line through exemplar.value / exemplar.timestamp. **Fixed:** *Age gate treats a non-finite age as unknown, not as an adult* — b.middleware.ageGate now classifies a non-finite age (NaN or +/-Infinity, the shape getAge returns when a birth field fails to parse or date math goes wrong) as "unknown" rather than letting it fall through to "above-threshold". Because typeof NaN === "number" and every comparison against NaN is false, an uncomputable age previously bypassed the below-threshold branch and was admitted as a confirmed adult -- with none of the child-safety privacy defaults (Cache-Control: private, no-store; Referrer-Policy: no-referrer; the privacy-posture header) the unknown path applies. When the birth value is request-derived this is attacker-influenced. A non-finite age is now handled exactly like a null return: the request is classified unknown and the privacy defaults are applied. · *Query builder OFFSET without LIMIT runs on SQLite and MySQL, not only Postgres* — b.sql SELECT (and the b.db.from / b.db.collection consumers built on it) emitted a bare "OFFSET n" when .offset() was set without .limit(). A bare OFFSET is valid only on Postgres; SQLite (the framework's own node:sqlite backend) and MySQL both reject it as a syntax error, so a valid builder chain produced SQL that failed to prepare on two of the three supported dialects, including the default one. The builder now emits the dialect's unbounded-limit sentinel before the OFFSET -- SQLite LIMIT -1, MySQL the maximum unsigned BIGINT, Postgres LIMIT ALL -- so one query text runs unchanged across all three. Statements that set an explicit LIMIT are byte-for-byte unchanged. · *Metrics exemplars are sanitized on the scrape surface the same way regular labels are* — b.metrics histogram exemplars rendered their labels, value, and timestamp into the OpenMetrics /metrics exposition -- the same broadly-readable scrape surface regular labels reach -- without the sanitization regular labels receive. Exemplar label values bypassed the credential scrubber, so a credential-shaped value an operator attached to an exemplar (e.g. tapping a raw header alongside trace context) egressed in cleartext (CWE-532); and exemplar.value / exemplar.timestamp were appended to the exposition line raw, so a non-numeric operator-supplied value such as "1.0\n# forged 999" could inject a forged metric line. Exemplar labels now flow through the same credential scrubber as regular labels, and exemplar value and timestamp are coerced to a finite number (value falling back to the observed value, timestamp to none) at store time, so only sanitized labels and bare numbers ever reach the wire. Trace context (trace_id / span_id) and numeric values pass through unchanged.
|
|
12
14
|
|
|
13
15
|
- v0.16.36 (2026-07-16) — **DKIM simple header canonicalization now signs and verifies the DKIM-Signature header verbatim, so simple-canon signatures round-trip and interoperate.** b.mail.dkim's simple header canonicalization (c=simple/... in a signature) signs and verifies each header exactly as it appears on the wire, per RFC 6376 §3.4.1. Two defects broke that for the DKIM-Signature header itself: the signer canonicalized the header UNFOLDED while emitting it FOLDED on the wire, and the verifier prefixed a spurious extra space to the parsed header value. Together they meant a simple-header-canonicalization signature never matched the bytes that were signed -- neither the framework's own signatures nor an RFC-compliant peer's would verify under simple canon. The signer now canonicalizes and emits the same folded header (appending the signature to the folded, b-emptied form), and the verifier canonicalizes the parsed header verbatim, so a simple-canon signature round-trips and is byte-compatible with other implementations. Relaxed canonicalization, the common default, is unchanged. Separately, a supply-chain gate now fails the build if a vendored component in lib/vendor/MANIFEST.json has no attribution entry in NOTICE. **Added:** *b.safeBuffer.byteLengthOfIfMeasurable — measure a value's byte length, or null when it is not a byte carrier* — byteLengthOfIfMeasurable(value) returns the byte length of a string, Buffer, or Uint8Array, and null for anything else (a plain Array, an array-like object, a number, null). It is the safe way to cap the size of an untrusted metadata bag whose byte field may be any shape: measure the cap only when the value is measurable, rather than gating byteLengthOf (which throws on a non-byte-carrier) on a hand-rolled length check that admits array-likes and crashes. The image and PDF content guards now compose it for their byte caps instead of each vetting the type inline. **Fixed:** *DKIM simple header canonicalization signs and verifies the DKIM-Signature header verbatim* — Under simple header canonicalization (RFC 6376 §3.4.1) the DKIM-Signature header is signed and verified byte-for-byte as it appears on the wire, including its folding. b.mail.dkim canonicalized the header UNFOLDED when computing the signature but emitted it FOLDED on the wire, and the verifier prepended an extra space to the parsed header value before canonicalizing -- so the bytes signed never matched the bytes verified. A simple-header-canonicalization signature therefore never verified, whether produced by the framework or by an RFC-compliant peer. The signer now canonicalizes the folded, b-emptied header and builds the wire header by appending the signature to it, and the verifier canonicalizes the parsed (folded) header verbatim -- including the DKIM-Signature field name exactly as it appears on the wire, so a peer that signs a lowercase dkim-signature: field name also verifies -- so a simple-canon signature round-trips and interoperates. This was fail-closed -- a broken simple-canon signature was reported as a verification failure, never a false pass -- and relaxed canonicalization (the common default, which normalizes folding and whitespace on both sides) was and remains correct. **Detectors:** *Every vendored component in the manifest must be attributed in NOTICE* — A gate fails the build when a component recorded in lib/vendor/MANIFEST.json has no attribution entry in the NOTICE file. Third-party components ship with their license and attribution obligations; this catches a vendored library or data file added to the manifest without its NOTICE entry before the package is published, rather than after a downstream scanner flags the omission.
|
package/lib/agent-idempotency.js
CHANGED
|
@@ -249,9 +249,18 @@ async function _putIfAbsent(store, method, actorId, key, putOpts, ttlMs, maxResu
|
|
|
249
249
|
if (existing.status === "pending") {
|
|
250
250
|
return { alreadyClaimed: true, pending: true, firstAt: existing.firstAt };
|
|
251
251
|
}
|
|
252
|
-
// Completed cached result.
|
|
252
|
+
// Completed cached result. Unseal the result blob when a vault is
|
|
253
|
+
// configured — put() sealed it at rest via b.cryptoField, so parsing the
|
|
254
|
+
// raw sealed cell as JSON would throw. Mirrors _get's unseal path (same
|
|
255
|
+
// seal-table, same unseal call-site shape); vault-less / pre-sealing rows
|
|
256
|
+
// carry plain JSON and pass through unchanged.
|
|
257
|
+
var unsealed = existing;
|
|
258
|
+
if (vault().isInitialized()) {
|
|
259
|
+
_ensureSealTable();
|
|
260
|
+
unsealed = cryptoField().unsealRow(SEAL_TABLE, existing);
|
|
261
|
+
}
|
|
253
262
|
var result;
|
|
254
|
-
try { result = safeJson.parse(
|
|
263
|
+
try { result = safeJson.parse(unsealed.resultBlob, { maxBytes: maxResultBytes }); }
|
|
255
264
|
catch (e) {
|
|
256
265
|
throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
|
|
257
266
|
"putIfAbsent: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
|
|
@@ -280,6 +289,13 @@ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultByte
|
|
|
280
289
|
{ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)) });
|
|
281
290
|
return null;
|
|
282
291
|
}
|
|
292
|
+
// A pending row (claimed via putIfAbsent, no result written yet) carries a
|
|
293
|
+
// null resultBlob — there is no cached result to return. Report absent
|
|
294
|
+
// rather than feeding null to safeJson.parse, which throws corrupt-result.
|
|
295
|
+
// get() is a public status-check path and putIfAbsent produces pending rows,
|
|
296
|
+
// so a concurrent claim must not turn a peer's get() into a spurious throw;
|
|
297
|
+
// a later put() replaces the pending row with the completed result.
|
|
298
|
+
if (row.resultBlob === null || row.resultBlob === undefined) return null;
|
|
283
299
|
// Unseal the result blob into a copy (when a vault is configured;
|
|
284
300
|
// vault-less or pre-sealing rows are plain JSON and used as-is). The
|
|
285
301
|
// original sealed `row` is preserved so the replay-count re-put below
|
package/lib/break-glass.js
CHANGED
|
@@ -1431,8 +1431,25 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1431
1431
|
.whereOp("expiresAt", ">", Date.now())
|
|
1432
1432
|
.toSql();
|
|
1433
1433
|
var updateRes = await clusterStorage.execute(incBuilt.sql, incBuilt.params);
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1434
|
+
// The atomic compare-and-increment IS the claim, and the count of rows it
|
|
1435
|
+
// CHANGED is the only per-caller signal for whether THIS caller won the
|
|
1436
|
+
// slot. `execute` returns rowCount = the rows the UPDATE modified
|
|
1437
|
+
// (info.changes on local sqlite; the driver's affected-row count in cluster
|
|
1438
|
+
// mode), so rowCount >= 1 means this caller's compare-and-increment landed
|
|
1439
|
+
// and rowCount === 0 means it lost — the grant was exhausted / revoked /
|
|
1440
|
+
// expired concurrently between the runtime checks above and this UPDATE.
|
|
1441
|
+
//
|
|
1442
|
+
// Do NOT infer the claim from a re-read of rowsConsumed against this
|
|
1443
|
+
// caller's stale pre-value: a concurrent WINNER's increment is visible to
|
|
1444
|
+
// the LOSER's re-read, so `post !== pre` is true for the loser too and both
|
|
1445
|
+
// proceed — a double-claim that reads two rows under a maxRowsPerGrant:1
|
|
1446
|
+
// grant, defeating row-by-row auth. The affected-row count is unambiguous.
|
|
1447
|
+
if (!updateRes || Number(updateRes.rowCount) < 1) {
|
|
1448
|
+
throw new BreakGlassError("breakglass/grant-exhausted",
|
|
1449
|
+
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
1450
|
+
}
|
|
1451
|
+
// Re-query for the post-increment counter — used ONLY for the audit's
|
|
1452
|
+
// rowsRemaining hint below, no longer for the claim decision.
|
|
1436
1453
|
var postReadBuilt = sql.select("_blamejs_break_glass_grants", _sqlOpts()) // allow:hand-rolled-sql
|
|
1437
1454
|
.columns(["rowsConsumed", "revokedAt", "expiresAt"])
|
|
1438
1455
|
.where("_id", grantHandle.id)
|
|
@@ -1443,14 +1460,6 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1443
1460
|
"unsealRow: grant " + grantHandle.id + " disappeared during unseal", true);
|
|
1444
1461
|
}
|
|
1445
1462
|
var postRowsConsumed = Number(postRows[0].rowsConsumed);
|
|
1446
|
-
// If the UPDATE didn't actually increment (race lost — another unseal
|
|
1447
|
-
// exhausted the grant or it was revoked / expired between our check
|
|
1448
|
-
// and the UPDATE), refuse this read.
|
|
1449
|
-
if (postRowsConsumed === Number(grantRow.rowsConsumed)) {
|
|
1450
|
-
throw new BreakGlassError("breakglass/grant-exhausted",
|
|
1451
|
-
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
1452
|
-
}
|
|
1453
|
-
void updateRes;
|
|
1454
1463
|
// policy was fetched above for the pin enforcement; reuse it for the
|
|
1455
1464
|
// Model-A vs Model-B (cryptographic) unseal dispatch.
|
|
1456
1465
|
var unsealedRow;
|
package/lib/dsr.js
CHANGED
|
@@ -920,8 +920,18 @@ function memoryTicketStore() {
|
|
|
920
920
|
var t = entry[1];
|
|
921
921
|
if (filter.status && t.status !== filter.status) continue;
|
|
922
922
|
if (filter.subject) {
|
|
923
|
-
|
|
924
|
-
|
|
923
|
+
// Fail closed: a subject filter must match on at least one of the
|
|
924
|
+
// store's indexable keys (email / subjectId). A subject carrying
|
|
925
|
+
// none of them — phone-only, alias-only, or an empty object —
|
|
926
|
+
// matches NOTHING, never every ticket. Fail-open here would make
|
|
927
|
+
// listBySubject leak other subjects' tickets and the erasure
|
|
928
|
+
// purge (list-by-subject → delete) destroy them.
|
|
929
|
+
var subj = filter.subject;
|
|
930
|
+
var tSubj = t.subject || {};
|
|
931
|
+
var applied = false;
|
|
932
|
+
if (subj.email) { applied = true; if (tSubj.email !== subj.email) continue; }
|
|
933
|
+
if (subj.subjectId) { applied = true; if (tSubj.subjectId !== subj.subjectId) continue; }
|
|
934
|
+
if (!applied) continue;
|
|
925
935
|
}
|
|
926
936
|
out.push(Object.assign({}, t));
|
|
927
937
|
}
|
|
@@ -1257,11 +1267,13 @@ function dbTicketStore(opts) {
|
|
|
1257
1267
|
if (!filter.subject) return;
|
|
1258
1268
|
var vaulted = vault().isInitialized();
|
|
1259
1269
|
if (vaulted) _ensureDsrSealTable();
|
|
1270
|
+
var appliedAny = false;
|
|
1260
1271
|
SUBJECT_FILTER_SPEC.forEach(function (spec) {
|
|
1261
1272
|
var supplied = filter.subject[spec.key];
|
|
1262
1273
|
if (!supplied) return;
|
|
1263
1274
|
if (!vaulted) {
|
|
1264
1275
|
qb.where(spec.plainCol, "=", supplied);
|
|
1276
|
+
appliedAny = true;
|
|
1265
1277
|
return;
|
|
1266
1278
|
}
|
|
1267
1279
|
// Vaulted: match BOTH the active keyed-MAC digest AND the legacy
|
|
@@ -1274,7 +1286,15 @@ function dbTicketStore(opts) {
|
|
|
1274
1286
|
var values = cand && cand.values ? cand.values : [];
|
|
1275
1287
|
if (values.length === 0) return;
|
|
1276
1288
|
qb.whereIn(spec.hashCol, values);
|
|
1289
|
+
appliedAny = true;
|
|
1277
1290
|
});
|
|
1291
|
+
// Fail closed: a subject filter that produced no usable predicate — a
|
|
1292
|
+
// phone-only / alias-only / empty subject, or a vaulted key with no hash
|
|
1293
|
+
// candidates — must match NOTHING, not every row. Without this, list({
|
|
1294
|
+
// subject }) degrades to a full-table scan and both listBySubject (leak)
|
|
1295
|
+
// and the erasure-completion purge (cross-subject delete) touch every
|
|
1296
|
+
// subject's tickets.
|
|
1297
|
+
if (!appliedAny) qb.whereRaw("1 = 0");
|
|
1278
1298
|
}
|
|
1279
1299
|
|
|
1280
1300
|
return {
|
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:d9b1b08c-4823-449d-bc92-12a4eb6cae3a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-16T20:35:21.934Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.38",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.38",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.16.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|