@blamejs/blamejs-shop 0.4.128 → 0.5.1
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 +6 -0
- package/SECURITY.md +16 -11
- package/lib/affiliates.js +225 -25
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +10 -8
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +45 -26
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.35.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/metrics-shadow-registry.test.js +58 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.5.x
|
|
10
|
+
|
|
11
|
+
- v0.5.1 (2026-06-27) — **Vendored framework refreshed to 0.15.35 — Prometheus label-injection fix in the metrics shadow registry.** Updates the vendored blamejs framework to 0.15.35. The upstream release fixes a label-injection in the framework's metrics shadow registry: it built a metric's cardinality key by joining label pairs with commas and re-split that string when rendering the Prometheus exposition, so a comma (or `=`) inside a label VALUE forged additional label pairs in the scrape output — which downstream tenant-scoping filters, authorization selectors, recording rules, and alerting trust as a boundary — and two distinct label sets could collide into one cardinality bucket. The key now uses canonical-JSON of the label set and the render emits the structured labels instead of re-splitting, so a value's commas/equals stay inside the value and a label named `constructor` / `prototype` / `__proto__` is preserved. This storefront does not compose the metrics shadow registry, so there is no runtime change for it; the fix ships in the vendored framework regardless and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.35 (Prometheus label-injection fix)* — Refreshes the vendored blamejs framework to 0.15.35. The upstream fix stops a comma or `=` in a metric label value from forging extra Prometheus label pairs in the shadow registry's scrape output — the cardinality key is now canonical-JSON of the label set and the render emits the structured labels rather than splitting a joined string, so distinct label sets stay distinct and reserved-name labels survive. The shipped storefront does not expose the metrics shadow registry, so its runtime, public API, and wire format are unchanged; the hardened framework simply rides along.
|
|
12
|
+
|
|
13
|
+
- v0.5.0 (2026-06-27) — **Affiliate commissions gain a tamper-evident hash chain, joining the gift-card, store-credit, and loyalty ledgers.** The affiliate-commission ledger was the last money ledger without tamper-evidence. Each commission is now linked into a per-affiliate SHA3-512 hash chain: a row names its predecessor, a uniqueness fence allows exactly one child per chain tip (so concurrent commissions for the same affiliate serialize instead of forking), and editing a commission's money fields or deleting a row from the middle breaks the linkage. Unlike the append-only gift-card / store-credit / loyalty ledgers, an affiliate commission walks a pending → paid → voided payout lifecycle in place, so the chain hashes only the immutable money facts — order, affiliate, order total, commission, currency, and time — and leaves the lifecycle columns out, meaning a legitimate payout or void never breaks the chain while an inflated amount or a removed row still does. A new `affiliates.verifyChain(affiliateId)` recomputes an affiliate's chain end to end and reports the first divergence, and accepts a trusted anchor (row count + head hash from an earlier snapshot) to also catch a truncation that deletes the most-recent commissions. Migration 0238 adds the chain columns + fence; commissions recorded before it are tolerated as an unverifiable legacy prefix, and the chain anchors fresh from the first commission recorded afterward. **Added:** *Affiliate commissions are a verifiable, fork-proof per-affiliate hash chain* — Recording a commission now links it into the affiliate's SHA3-512 hash chain — `row_hash = SHA3-512(prev_hash || canonical-json(immutable fields))` — under a `UNIQUE(affiliate_id, prev_hash)` parent fence, so a commission derived from a stale tip collides and retries instead of forking the chain, and concurrent commissions for one affiliate serialize. Because a commission's status, paid-at, payout reference, and void reason are stamped in place over its lifetime, only the immutable money facts are hashed, so a `pending → paid → voided` transition leaves the chain valid while tampering with the order total or commission amount, or deleting a commission, is detected. `affiliates.verifyChain(affiliateId)` walks the chain and reports the first break; pass `{ anchor: { count, head } }` to also rule out a tail truncation. Commissions written before this release are tolerated as an unverifiable legacy prefix and the chain anchors fresh from the next one — no backfill required. Migration 0238 adds the columns and the fence.
|
|
14
|
+
|
|
9
15
|
## v0.4.x
|
|
10
16
|
|
|
11
17
|
- v0.4.128 (2026-06-27) — **Email sign-in links are now rate-limited per recipient, bounding inbox-flood and token-minting abuse.** The email magic-link sign-in — the no-passkey backup on the login screen — minted a new single-use sign-in token and sent an email on every POST for a registered address, bounded only by the per-IP request limiter. An attacker submitting a victim's address from many IP addresses could therefore flood that inbox with sign-in links and mint an unbounded number of valid single-use tokens. The route now applies a per-recipient cooldown: at most one sign-in link (and one minted token) per registered address per minute. The cooldown runs after the unchanged, enumeration-safe confirmation response, so a registered and an unregistered address stay indistinguishable by the reply; a transient lookup failure falls through to sending, keeping availability ahead of the rate cap. **Fixed:** *Email sign-in links are rate-limited per recipient* — Requesting an email sign-in link minted a single-use portal token and sent a message every time it was submitted for a registered address, with only the per-IP limiter in front of it — which a distributed sender (many IPs, one victim address) sails past. The request now checks whether a sign-in link was already minted for that account within the last minute and, if so, sends nothing further. The check runs on the request's background path, after the identical confirmation response is returned, so it does not reintroduce an account-existence oracle (a registered and an unregistered address still get the same reply at the same speed); an address with no account still triggers no mint or send at all. A transient failure of the cooldown lookup falls through to sending, so a degraded datastore can't lock legitimate shoppers out of signing in.
|
package/SECURITY.md
CHANGED
|
@@ -319,17 +319,22 @@ node -e "
|
|
|
319
319
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
320
320
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
321
321
|
its own forensic mark.
|
|
322
|
-
- **The gift-card, store-credit, and
|
|
323
|
-
fork-proof, and verifiable.** Every ledger entry — credits,
|
|
324
|
-
expirations alike — links to its predecessor through a SHA3-512
|
|
325
|
-
keyed per card (gift cards)
|
|
326
|
-
editing a row or deleting one from the middle
|
|
327
|
-
uniqueness fence (one child per chain tip) makes
|
|
328
|
-
serialize rather than fork the chain, and each loyalty /
|
|
329
|
-
store-credit balance change is written in that same single guarded
|
|
330
|
-
so a points or balance mutation is exactly-once even on a backend without
|
|
331
|
-
interactive transactions.
|
|
332
|
-
|
|
322
|
+
- **The gift-card, store-credit, loyalty, and affiliate-commission ledgers
|
|
323
|
+
are hash-chained, fork-proof, and verifiable.** Every ledger entry — credits,
|
|
324
|
+
debits, and expirations alike — links to its predecessor through a SHA3-512
|
|
325
|
+
chain, keyed per card (gift cards), per customer (store credit, loyalty), or
|
|
326
|
+
per affiliate (commissions), so editing a row or deleting one from the middle
|
|
327
|
+
breaks the linkage. A uniqueness fence (one child per chain tip) makes
|
|
328
|
+
concurrent writes serialize rather than fork the chain, and each loyalty /
|
|
329
|
+
gift-card / store-credit balance change is written in that same single guarded
|
|
330
|
+
row, so a points or balance mutation is exactly-once even on a backend without
|
|
331
|
+
interactive transactions. The affiliate-commission chain hashes only the
|
|
332
|
+
immutable money facts (order, affiliate, totals, currency, time), so a
|
|
333
|
+
commission walking its pending → paid → voided payout lifecycle never breaks
|
|
334
|
+
the chain while inflating an amount or deleting a row still does.
|
|
335
|
+
`giftCardLedger.verifyChain(cardId)` /
|
|
336
|
+
`storeCredit.verifyChain(customerId)` / `loyalty.verifyChain(customerId)` /
|
|
337
|
+
`affiliates.verifyChain(affiliateId)`
|
|
333
338
|
recompute a ledger's chain end to end and report the first divergence — run them whenever a balance is
|
|
334
339
|
disputed; pass a trusted anchor (a row count + head hash from an earlier
|
|
335
340
|
snapshot) to also catch a truncation that deletes the most-recent rows,
|
package/lib/affiliates.js
CHANGED
|
@@ -336,6 +336,56 @@ function _normalizeEmail(input) {
|
|
|
336
336
|
|
|
337
337
|
function _now() { return Date.now(); }
|
|
338
338
|
|
|
339
|
+
// ---- commission tamper-evidence chain -----------------------------------
|
|
340
|
+
|
|
341
|
+
// SHA3-512 hex width + the all-zero genesis anchor every affiliate's chain
|
|
342
|
+
// links its first commission off.
|
|
343
|
+
var SHA3_512_HEX_LEN = 128;
|
|
344
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
345
|
+
|
|
346
|
+
// The IMMUTABLE money facts a commission row's hash attests. The lifecycle
|
|
347
|
+
// columns (status / paid_at / voided_at / payout_reference / void_reason) are
|
|
348
|
+
// deliberately EXCLUDED — they are stamped in place as a commission walks
|
|
349
|
+
// pending -> paid -> voided, and hashing them would break the chain on every
|
|
350
|
+
// legitimate payout. Only the facts that must never change after the
|
|
351
|
+
// commission is recorded are bound.
|
|
352
|
+
function _commissionHashFields(row) {
|
|
353
|
+
return {
|
|
354
|
+
id: row.id,
|
|
355
|
+
order_id: row.order_id,
|
|
356
|
+
affiliate_id: row.affiliate_id,
|
|
357
|
+
order_total_minor: Number(row.order_total_minor),
|
|
358
|
+
commission_minor: Number(row.commission_minor),
|
|
359
|
+
currency: row.currency,
|
|
360
|
+
occurred_at: Number(row.occurred_at),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// row_hash = SHA3-512(prev_hash-bytes || canonical-json(immutable fields)),
|
|
365
|
+
// the same construction the gift-card / store-credit / loyalty ledgers use
|
|
366
|
+
// (b.auditChain.canonicalize is the RFC 8785 sorted-key walker).
|
|
367
|
+
function _commissionRowHash(prevHash, fields) {
|
|
368
|
+
var canonical = b.auditChain.canonicalize(fields, ["prev_hash", "row_hash"]);
|
|
369
|
+
var preimage = Buffer.concat([
|
|
370
|
+
Buffer.from(prevHash, "hex"),
|
|
371
|
+
Buffer.from(canonical, "utf8"),
|
|
372
|
+
]);
|
|
373
|
+
return b.crypto.sha3Hash(preimage);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// How many times recordCommissionEvent re-derives the chain tip when the
|
|
377
|
+
// parent fence refuses its INSERT (a competing commission for the SAME
|
|
378
|
+
// affiliate landed first). Each fence round lets exactly one racing writer
|
|
379
|
+
// win, so a burst of N concurrent commissions for one affiliate needs up to N
|
|
380
|
+
// rounds — the cap is the max same-affiliate fan-in that fully succeeds, so it
|
|
381
|
+
// is sized for the PER-AFFILIATE chain: unlike a gift-card chain (one
|
|
382
|
+
// customer's wallet), an affiliate's chain aggregates order completions across
|
|
383
|
+
// every customer they referred, so a large affiliate during a campaign spike
|
|
384
|
+
// can drive many near-simultaneous commissions. 256 covers that with wide
|
|
385
|
+
// margin; AFFILIATE_COMMISSION_CONTENTION (thrown past the cap) is a retryable
|
|
386
|
+
// signal, never silent loss — the caller may re-invoke for that order.
|
|
387
|
+
var COMMISSION_CHAIN_ATTEMPTS = 256;
|
|
388
|
+
|
|
339
389
|
// ---- code generation + canonicalization ---------------------------------
|
|
340
390
|
|
|
341
391
|
function _generateCode() {
|
|
@@ -446,6 +496,28 @@ function create(opts) {
|
|
|
446
496
|
return r.rows[0] || null;
|
|
447
497
|
}
|
|
448
498
|
|
|
499
|
+
// The affiliate's current chain tip — the highest-chain_index commission's
|
|
500
|
+
// row_hash (the next commission links off it as its prev_hash) plus that
|
|
501
|
+
// chain_index. Ordering by chain_index (not occurred_at) keeps the read order
|
|
502
|
+
// equal to the prev_hash linkage WITHOUT touching the operator's timestamp.
|
|
503
|
+
// The `(chain_index IS NULL) ASC` key prefers any chained row over a legacy
|
|
504
|
+
// NULL row, so once the chain has anchored the tip is always a hashed row; an
|
|
505
|
+
// affiliate with only legacy pre-chain rows (NULL chain_index / NULL row_hash)
|
|
506
|
+
// anchors fresh from genesis (ZERO_HASH, index -1 so the first child is 0).
|
|
507
|
+
async function _readCommissionTip(affiliateId) {
|
|
508
|
+
var r = await query(
|
|
509
|
+
"SELECT row_hash, chain_index FROM affiliate_commissions WHERE affiliate_id = ?1 " +
|
|
510
|
+
"ORDER BY (chain_index IS NULL) ASC, chain_index DESC, id DESC LIMIT 1",
|
|
511
|
+
[affiliateId],
|
|
512
|
+
);
|
|
513
|
+
if (!r.rows.length) return { row_hash: ZERO_HASH, chain_index: -1 };
|
|
514
|
+
var row = r.rows[0];
|
|
515
|
+
if (row.chain_index == null || row.row_hash == null) {
|
|
516
|
+
return { row_hash: ZERO_HASH, chain_index: -1 };
|
|
517
|
+
}
|
|
518
|
+
return { row_hash: row.row_hash, chain_index: Number(row.chain_index) };
|
|
519
|
+
}
|
|
520
|
+
|
|
449
521
|
// Inserting an affiliate row. Code generation retries on UNIQUE
|
|
450
522
|
// violation up to a small bound; on the 32^8 space the chance of
|
|
451
523
|
// running out is vanishingly low, but the retry keeps the boot
|
|
@@ -818,32 +890,56 @@ function create(opts) {
|
|
|
818
890
|
aff.commission_kind, Number(aff.commission_value), orderTotal
|
|
819
891
|
);
|
|
820
892
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
893
|
+
// Append to the affiliate's tamper-evidence chain under the parent fence
|
|
894
|
+
// (UNIQUE(affiliate_id, prev_hash), migration 0238): the row links off the
|
|
895
|
+
// affiliate's current tip, and a write derived from a STALE tip collides
|
|
896
|
+
// instead of forking the chain — so we re-read the tip and retry. Two
|
|
897
|
+
// UNIQUE constraints can refuse this INSERT, and the production D1
|
|
898
|
+
// service-binding redacts the SQLite "UNIQUE constraint failed" text to a
|
|
899
|
+
// generic "HTTP 500", so converge MESSAGE-AGNOSTICALLY: (1) re-read by
|
|
900
|
+
// (order_id, affiliate_id) — if a row now exists, this was the idempotency
|
|
901
|
+
// duplicate, return it; (2) else re-read the chain tip — if it advanced
|
|
902
|
+
// past the parent we tried to chain off, a competing commission for this
|
|
903
|
+
// affiliate claimed our slot, so retry; (3) else the insert failed for an
|
|
904
|
+
// unrelated reason, re-throw.
|
|
905
|
+
for (var attempt = 0; attempt < COMMISSION_CHAIN_ATTEMPTS; attempt += 1) {
|
|
906
|
+
var tip = await _readCommissionTip(affiliateId);
|
|
907
|
+
var prevHash = tip.row_hash;
|
|
908
|
+
var chainIndex = tip.chain_index + 1;
|
|
909
|
+
var id = b.uuid.v7();
|
|
910
|
+
var rowHash = _commissionRowHash(prevHash, {
|
|
911
|
+
id: id,
|
|
912
|
+
order_id: orderId,
|
|
913
|
+
affiliate_id: affiliateId,
|
|
914
|
+
order_total_minor: orderTotal,
|
|
915
|
+
commission_minor: commissionMinor,
|
|
916
|
+
currency: currency,
|
|
917
|
+
occurred_at: occurredAt,
|
|
918
|
+
});
|
|
919
|
+
try {
|
|
920
|
+
await query(
|
|
921
|
+
"INSERT INTO affiliate_commissions " +
|
|
922
|
+
"(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
|
|
923
|
+
" currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason, " +
|
|
924
|
+
" prev_hash, row_hash, chain_index) " +
|
|
925
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL, ?8, ?9, ?10)",
|
|
926
|
+
[id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt, prevHash, rowHash, chainIndex],
|
|
927
|
+
);
|
|
928
|
+
return await _getCommissionRaw(id);
|
|
929
|
+
} catch (e) {
|
|
930
|
+
var raced = await query(
|
|
931
|
+
"SELECT * FROM affiliate_commissions WHERE order_id = ?1 AND affiliate_id = ?2",
|
|
932
|
+
[orderId, affiliateId],
|
|
933
|
+
);
|
|
934
|
+
if (raced.rows.length) return raced.rows[0];
|
|
935
|
+
var newTip = await _readCommissionTip(affiliateId);
|
|
936
|
+
if (newTip.row_hash !== prevHash) continue;
|
|
937
|
+
throw e;
|
|
938
|
+
}
|
|
845
939
|
}
|
|
846
|
-
|
|
940
|
+
var contention = new Error("affiliates.recordCommissionEvent: persistent commission-chain contention — retry the write");
|
|
941
|
+
contention.code = "AFFILIATE_COMMISSION_CONTENTION";
|
|
942
|
+
throw contention;
|
|
847
943
|
},
|
|
848
944
|
|
|
849
945
|
commissionsForAffiliate: async function (listOpts) {
|
|
@@ -1004,6 +1100,110 @@ function create(opts) {
|
|
|
1004
1100
|
return await _getCommissionRaw(id);
|
|
1005
1101
|
},
|
|
1006
1102
|
|
|
1103
|
+
// Recompute one affiliate's commission hash chain and flag the first
|
|
1104
|
+
// divergence — the tamper-evidence READ side of the chain recordCommission-
|
|
1105
|
+
// Event maintains. Walks the affiliate's rows in (occurred_at, id) order
|
|
1106
|
+
// over the IMMUTABLE money fields only (lifecycle columns are not hashed, so
|
|
1107
|
+
// a paid/voided transition is not a break). Rows from before the chain
|
|
1108
|
+
// existed (NULL row_hash) are tolerated as an unverifiable legacy PREFIX and
|
|
1109
|
+
// counted, but once a hashed row appears every later row must link — an
|
|
1110
|
+
// unhashed row after the anchor, a prev_hash that doesn't name its parent,
|
|
1111
|
+
// or a row_hash that doesn't recompute is a break. A populated ledger that
|
|
1112
|
+
// never anchors (every row NULL-hashed — what a full rewrite leaves behind)
|
|
1113
|
+
// fails as unanchored; only a genuinely empty ledger passes with no anchor.
|
|
1114
|
+
// An optional trusted anchor { count, head } rules out a tail truncation.
|
|
1115
|
+
// O(n) per affiliate; operator-audit use, not hot-path.
|
|
1116
|
+
verifyChain: async function (affiliateId, opts) {
|
|
1117
|
+
var aff = _uuid(affiliateId, "affiliate_id");
|
|
1118
|
+
opts = opts || {};
|
|
1119
|
+
var anchor = null;
|
|
1120
|
+
if (opts.anchor != null) {
|
|
1121
|
+
if (typeof opts.anchor !== "object"
|
|
1122
|
+
|| typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
|
|
1123
|
+
|| typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
|
|
1124
|
+
throw new TypeError("affiliates.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
|
|
1125
|
+
}
|
|
1126
|
+
anchor = opts.anchor;
|
|
1127
|
+
}
|
|
1128
|
+
// Walk in chain order: the legacy NULL-chain_index prefix first (sorted by
|
|
1129
|
+
// id for a stable count), then the hashed chain by chain_index. This is
|
|
1130
|
+
// the prev_hash linkage order independent of occurred_at, so a backdated
|
|
1131
|
+
// commission verifies in its chain position, not its timestamp position.
|
|
1132
|
+
var rows = (await query(
|
|
1133
|
+
"SELECT * FROM affiliate_commissions WHERE affiliate_id = ?1 " +
|
|
1134
|
+
"ORDER BY (chain_index IS NULL) DESC, chain_index ASC, id ASC",
|
|
1135
|
+
[aff],
|
|
1136
|
+
)).rows;
|
|
1137
|
+
var legacyPrefix = 0;
|
|
1138
|
+
var anchored = false;
|
|
1139
|
+
var prevHash = ZERO_HASH;
|
|
1140
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
1141
|
+
var row = rows[i];
|
|
1142
|
+
if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
|
|
1143
|
+
anchored = true;
|
|
1144
|
+
var breakBase = {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
rows_verified: i - legacyPrefix,
|
|
1147
|
+
legacy_prefix: legacyPrefix,
|
|
1148
|
+
break_at: i,
|
|
1149
|
+
break_row_id: row.id,
|
|
1150
|
+
};
|
|
1151
|
+
if (row.row_hash == null) {
|
|
1152
|
+
return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
|
|
1153
|
+
}
|
|
1154
|
+
if (row.prev_hash !== prevHash) {
|
|
1155
|
+
return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
|
|
1156
|
+
}
|
|
1157
|
+
var computed = _commissionRowHash(prevHash, _commissionHashFields(row));
|
|
1158
|
+
if (computed !== row.row_hash) {
|
|
1159
|
+
return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
|
|
1160
|
+
}
|
|
1161
|
+
prevHash = row.row_hash;
|
|
1162
|
+
}
|
|
1163
|
+
if (!anchored && rows.length > 0) {
|
|
1164
|
+
return {
|
|
1165
|
+
ok: false,
|
|
1166
|
+
rows_verified: 0,
|
|
1167
|
+
legacy_prefix: legacyPrefix,
|
|
1168
|
+
break_at: 0,
|
|
1169
|
+
break_row_id: rows[0].id,
|
|
1170
|
+
reason: "unanchored chain (no hashed row in a populated ledger)",
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (anchor) {
|
|
1174
|
+
if (rows.length < anchor.count) {
|
|
1175
|
+
return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
rows_verified: rows.length - legacyPrefix,
|
|
1178
|
+
legacy_prefix: legacyPrefix,
|
|
1179
|
+
anchor_checked: true,
|
|
1180
|
+
reason: "row count below anchor (possible tail truncation)",
|
|
1181
|
+
expected_count: anchor.count,
|
|
1182
|
+
actual_count: rows.length,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
var anchorRow = rows[anchor.count - 1];
|
|
1186
|
+
if (!anchorRow || anchorRow.row_hash !== anchor.head) {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
rows_verified: rows.length - legacyPrefix,
|
|
1190
|
+
legacy_prefix: legacyPrefix,
|
|
1191
|
+
anchor_checked: true,
|
|
1192
|
+
reason: "anchor row hash mismatch (possible tail truncation or divergence)",
|
|
1193
|
+
expected: anchor.head,
|
|
1194
|
+
actual: anchorRow ? anchorRow.row_hash : null,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
ok: true,
|
|
1200
|
+
rows_verified: rows.length - legacyPrefix,
|
|
1201
|
+
legacy_prefix: legacyPrefix,
|
|
1202
|
+
head: prevHash,
|
|
1203
|
+
anchor_checked: !!anchor,
|
|
1204
|
+
};
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1007
1207
|
// Operator dashboard — which affiliates are owed at least
|
|
1008
1208
|
// `min_payout_minor` as of `as_of`. Returns one row per affiliate
|
|
1009
1209
|
// whose sum-of-pending commissions (occurred_at <= as_of) meets
|
package/lib/asset-manifest.json
CHANGED
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.15.
|
|
7
|
-
"tag": "v0.15.
|
|
6
|
+
"version": "0.15.35",
|
|
7
|
+
"tag": "v0.15.35",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -857,6 +857,7 @@
|
|
|
857
857
|
"release-notes/v0.15.32.json": "lib/vendor/blamejs/release-notes/v0.15.32.json",
|
|
858
858
|
"release-notes/v0.15.33.json": "lib/vendor/blamejs/release-notes/v0.15.33.json",
|
|
859
859
|
"release-notes/v0.15.34.json": "lib/vendor/blamejs/release-notes/v0.15.34.json",
|
|
860
|
+
"release-notes/v0.15.35.json": "lib/vendor/blamejs/release-notes/v0.15.35.json",
|
|
860
861
|
"release-notes/v0.15.4.json": "lib/vendor/blamejs/release-notes/v0.15.4.json",
|
|
861
862
|
"release-notes/v0.15.5.json": "lib/vendor/blamejs/release-notes/v0.15.5.json",
|
|
862
863
|
"release-notes/v0.15.6.json": "lib/vendor/blamejs/release-notes/v0.15.6.json",
|
|
@@ -1534,7 +1535,7 @@
|
|
|
1534
1535
|
"test/smoke.js": "lib/vendor/blamejs/test/smoke.js"
|
|
1535
1536
|
},
|
|
1536
1537
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
1537
|
-
"bundledAt": "2026-06-
|
|
1538
|
+
"bundledAt": "2026-06-27",
|
|
1538
1539
|
"hashes": {
|
|
1539
1540
|
".clusterfuzzlite/Dockerfile": "sha256:6587c80f02f3d622b782380ff1aaa7753b948023ff1d4af8ac27709d1876c218",
|
|
1540
1541
|
".clusterfuzzlite/build.sh": "sha256:b9f5a627272c65f6569fa20dcbb32c383ca3a1183ce2562d481408a2fbaf9881",
|
|
@@ -1565,7 +1566,7 @@
|
|
|
1565
1566
|
".npmrc": "sha256:66f104e7d07c496d2d0409988225e8c0e4ceb8d247dbcac3be75b2128d20ce66",
|
|
1566
1567
|
".pinact.yaml": "sha256:0213ffda55961dc49b64c0a5dfa3c0567419633b1499d57eaf7c8d842d7da6c7",
|
|
1567
1568
|
"ARCHITECTURE.md": "sha256:9b1c8d2b1b7a41838eb348b0a008e4b4369718fd72bfe2974b37155f7536d35b",
|
|
1568
|
-
"CHANGELOG.md": "sha256:
|
|
1569
|
+
"CHANGELOG.md": "sha256:52a0cf964a432750c3cfb566dd024b55286cb1036a0e5c4c3cbaf56c6b7a1da6",
|
|
1569
1570
|
"CODE_OF_CONDUCT.md": "sha256:148a281960fff7c2fe6554dab66da572c72245ddeb00b0d14811558397bff386",
|
|
1570
1571
|
"CONTRIBUTING.md": "sha256:bb4dbdbc8598da31dbce653a8ed322e08ff46560173f2eb67a4d684653948332",
|
|
1571
1572
|
"GOVERNANCE.md": "sha256:906df6afb1f552b27b9acb50f7f96c47b917a2f1021cd4e987dbf4ee0e0a821b",
|
|
@@ -1575,7 +1576,7 @@
|
|
|
1575
1576
|
"NOTICE": "sha256:f487fa47a11aca0f89e2615cdd3c713e9842abf7a30d8d328eeeae1c864aa774",
|
|
1576
1577
|
"README.md": "sha256:3ddcc197b003da0b02db8bdd1aef1e943c94f7eab613c633d6a45bb11d0a80e9",
|
|
1577
1578
|
"SECURITY.md": "sha256:77d8c2bcc04b425a08ef30e51204cebddbd48954b028a259c7a8412dbfeab40a",
|
|
1578
|
-
"api-snapshot.json": "sha256:
|
|
1579
|
+
"api-snapshot.json": "sha256:097e1361a6d64b9b25830f0ba1c0440b39f7efce8770d5b7a9d0545d73cc5439",
|
|
1579
1580
|
"assets/BlameJS_Logo.png": "sha256:3c65699753c771b48ef9ac7f45bb40815ec19a23afcdd0cd30ef4601bbbe293e",
|
|
1580
1581
|
"assets/BlameJS_Logo.svg": "sha256:dda44f3fb1343d5de9db6b1fcdb75fc649c57e7a99a8e8239fcf852e3841e1a8",
|
|
1581
1582
|
"bench/README.md": "sha256:74202f2507fd840bfc1ac6c681975d9273cf36cca6e0f72655f138337304033c",
|
|
@@ -2107,7 +2108,7 @@
|
|
|
2107
2108
|
"lib/mcp-tool-registry.js": "sha256:df3480e6a0d29a9734bdb19d322f83bff5ebfdd756620f947cf593878dea8776",
|
|
2108
2109
|
"lib/mcp.js": "sha256:c65af74fdb3cf30436699aba5051c4d6e8cb972887cbaaecc46250d557599953",
|
|
2109
2110
|
"lib/mdoc.js": "sha256:bc31d38d6fdfac8db8f040dfa4d33ab40405f56340bb4ec978f3ba98e93f6433",
|
|
2110
|
-
"lib/metrics.js": "sha256:
|
|
2111
|
+
"lib/metrics.js": "sha256:55d445b738b2cfc8ceb1bfa7a1e69b306497e8065ea86d61e4f4087873acce1b",
|
|
2111
2112
|
"lib/middleware/age-gate.js": "sha256:dc8c61a4d41f4a781220c9c97eb475fb2867f5ee444eb2756badcbc5b8411795",
|
|
2112
2113
|
"lib/middleware/ai-act-disclosure.js": "sha256:6fc62940d85813019b31c586fc86853570329a939565a6947f81052d50977c42",
|
|
2113
2114
|
"lib/middleware/api-encrypt.js": "sha256:f90210bfc4fe04b50f6d72e14676a140c5759e255ed359567066a1e80ab5fb63",
|
|
@@ -2346,7 +2347,7 @@
|
|
|
2346
2347
|
"oss-fuzz/projects/blamejs/README.md": "sha256:ae13b7bb79ed8d69b1b3276e5562807a0349fb6e6b7d11cf1f683aad1eafdb4b",
|
|
2347
2348
|
"oss-fuzz/projects/blamejs/build.sh": "sha256:0ced1cf21782c97be7f8d74faf5e27a308b60b2f858836fb5ca3b8c4e939a8f7",
|
|
2348
2349
|
"oss-fuzz/projects/blamejs/project.yaml": "sha256:59f2cb83aa622325a175b77416fe155be15b70a9c798bd1a78bba05763b1b03d",
|
|
2349
|
-
"package.json": "sha256:
|
|
2350
|
+
"package.json": "sha256:680de6ef3cb4b04dc38a51d9671f5bc817199fe7724290a05b6404af36318b45",
|
|
2350
2351
|
"release-notes/v0.0.x.json": "sha256:7a49819f30068ee119000cad7010194882bb8bfaa12acbdab4dfc066efb7982f",
|
|
2351
2352
|
"release-notes/v0.1.x.json": "sha256:6742a8c17f947c5cb76f69dead7eea86b942d80621d914b774ba5488e09937e5",
|
|
2352
2353
|
"release-notes/v0.10.x.json": "sha256:fe498045daf88337bd3d987e5964aa42c99a50e1685b6f09e51f698b8687726f",
|
|
@@ -2383,6 +2384,7 @@
|
|
|
2383
2384
|
"release-notes/v0.15.32.json": "sha256:af99a8a782dcc30aff3a135c27179b064d63181fc4c302c31da632c1615b68ce",
|
|
2384
2385
|
"release-notes/v0.15.33.json": "sha256:295ea9939b74498c7f0be6afdc8bc511f97cb2383a436c3040c58b0600598b19",
|
|
2385
2386
|
"release-notes/v0.15.34.json": "sha256:2a1b36d897d8e9f2cece051c098030f08d9c6ee6bef136800bd19453ad65f188",
|
|
2387
|
+
"release-notes/v0.15.35.json": "sha256:e317b2252734aace7a0f734e3ab40c3a0c4ac208d678c0e9834be34eb424d15b",
|
|
2386
2388
|
"release-notes/v0.15.4.json": "sha256:6ac7fa0ef1728c27e71b2050d1b07a810f9b4b1440ccddbf28ad56e2f54d8585",
|
|
2387
2389
|
"release-notes/v0.15.5.json": "sha256:cca1d0edd5d6fc41b512d19d98be224b990dcab41478622c11962f0fcb1bb09a",
|
|
2388
2390
|
"release-notes/v0.15.6.json": "sha256:0e3b9e5e43b70b61dd258c3003d1b8729cd3c26c62a34dedcca81bbec5d31077",
|
|
@@ -2862,7 +2864,7 @@
|
|
|
2862
2864
|
"test/layer-0-primitives/mcp-tool-registry.test.js": "sha256:4d9a9a367489d93afcc8e0bca3442e0aefc683e40ddd29e6e2cddbe3a3b8732f",
|
|
2863
2865
|
"test/layer-0-primitives/mcp.test.js": "sha256:896a9b55fd9d21a1ec2b0d0753b5796eb045911ad28992ba17338285406ac2f9",
|
|
2864
2866
|
"test/layer-0-primitives/mdoc.test.js": "sha256:2fe1086192385e71f858554d676b299b86594d34226e1d774ffefc97b5fa03a4",
|
|
2865
|
-
"test/layer-0-primitives/metrics-shadow-registry.test.js": "sha256:
|
|
2867
|
+
"test/layer-0-primitives/metrics-shadow-registry.test.js": "sha256:a91a9efdb4aa28caed846b969d7c1e5ebfc57067afad85800daa430942d02725",
|
|
2866
2868
|
"test/layer-0-primitives/metrics-snapshot.test.js": "sha256:aa405be22acb6680b062d1de1aa7ce00029e414218a0e4e521e10a14b2254851",
|
|
2867
2869
|
"test/layer-0-primitives/middleware-compose-pipeline.test.js": "sha256:a779468bde043b6de4add5d4405a33973163890ee667b833e3330373ee64f77d",
|
|
2868
2870
|
"test/layer-0-primitives/mime-parse.test.js": "sha256:73ccfea7d08b0c3b5fb899215806d4f430c02bf63e21835024a4cdcfc4254b31",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.35 (2026-06-26) — **`b.metrics` shadow registry no longer lets a comma in a label value forge extra Prometheus label pairs — a label-injection that downstream tenant-scoping or authorization selectors could be tricked by.** The shadow registry serialized a metric's label set into a `name=value,name=value` string key and re-split it on `,` to build the Prometheus exposition. A label VALUE containing a comma therefore split into multiple label pairs, so a single operator-set label could forge additional label pairs in the scrape output (for example turning one `route` label into a `route` plus an attacker-named label), which downstream tenant-scoping filters, authorization selectors, recording rules, and alerting trust as a boundary; two distinct label sets could also collide into one cardinality bucket. The label key now uses canonical-JSON of the string-coerced label set (the same approach the main registry already used) and the render path emits the structured label set kept alongside that key rather than splitting or re-parsing, so a `,` or `=` inside a label value stays inside the value and a label NAMED `constructor`, `prototype`, or `__proto__` (all valid Prometheus label names) is preserved instead of being dropped. Note: the cardinality keys exposed by `shadowRegistry().snapshot()` are now canonical-JSON strings (e.g. `{"route":"/api"}`) rather than `route=/api`. **Changed:** *shadowRegistry().snapshot() cardinality keys are canonical-JSON* — As a consequence of the label-injection fix, the per-series cardinality keys in a shadow-registry snapshot are now canonical-JSON strings (e.g. `{"tenant":"a"}`) instead of the previous `tenant=a` form. Code that reads those keys directly from snapshot() should parse them as JSON; the Prometheus render output is unchanged for conforming label values. **Security:** *Label values can no longer forge extra Prometheus label pairs in the shadow registry* — b.metrics shadowRegistry built a metric's cardinality key by joining `name=value` pairs with commas, then split that string on commas when rendering the Prometheus exposition. A comma (and `=`) in a label value therefore broke one value into several forged label pairs in the scrape output, and two different label sets could collide into the same cardinality bucket. The key is now canonical-JSON of the string-coerced label set and the render emits the structured label set kept alongside that key rather than splitting or re-parsing, so a label value is emitted as a single quoted value with its commas/equals intact, distinct label sets get distinct keys, and a label NAMED `constructor`, `prototype`, or `__proto__` (each a valid Prometheus label name) is preserved rather than dropped. Deterministic regression tests assert a comma-bearing label value produces exactly one label pair and that the reserved-name labels survive render.
|
|
12
|
+
|
|
11
13
|
- v0.15.34 (2026-06-26) — **`b.mail.crypto.pgp.verify` now accepts every valid RSA OpenPGP signature — one whose value happened to have a high zero byte (about 1 in 256) was being rejected.** An OpenPGP RSA signature is an integer modulo the key's modulus, and ~1 in 256 signatures have a value that begins with a zero byte. The MPI encoding strips those leading zero bytes (RFC 9580 §3.2), but b.mail.crypto.pgp.verify passed the stripped value straight to the RSA verification, which requires a signature exactly the modulus byte length — so a perfectly valid signature was intermittently reported invalid. The signature is now left-padded back to the modulus width before verification, exactly as the Ed25519 path already pads its components. Verification of every valid RSA signature is now reliable, including signatures produced by other OpenPGP implementations. Also includes internal test-tooling: two more guard-suite suppression classes were re-verified and their tokens retired. **Fixed:** *RSA OpenPGP signatures with a high zero byte now verify reliably* — b.mail.crypto.pgp.verify read the RSA signature MPI (whose leading zero bytes the OpenPGP wire format strips) and handed it to the RSA verification without restoring the stripped bytes. Node's RSA verify requires the signature to be exactly the modulus byte length, so a signature whose value had one or more high zero bytes — about 1 in 256 of all signatures, for any key — was rejected as invalid even though it was correct. The signature is now left-padded to the modulus width before verification (the same correction the Ed25519 verification path already applied to its R/S components). This affects signatures the framework produces and signatures from other OpenPGP implementations alike. A deterministic regression test searches for a short-MPI signature and asserts it verifies. **Detectors:** *Two more suppression-marker classes re-verified and their tokens retired* — The raw-hash-compare and seal-without-aad guard classes were re-read and confirmed (a data-residency region tag compared with === — not a secret; and two intentional non-AEAD-bound seals — a non-regulated plain-mode column whose AAD is enforced by the posture seal-envelope floor where it matters, and a throwaway vault-readiness probe), then renamed to descriptive tokens with their old names added to the retired-token set. Test-suite tooling only.
|
|
12
14
|
|
|
13
15
|
- v0.15.33 (2026-06-26) — **Internal test-suite hardening only — the published library's runtime behavior and public API are unchanged (source-comment marker text aside).** Three more suppression classes in the codebase-patterns guard suite were re-verified from scratch and renamed to descriptive tokens, with their old names recorded as retired and their in-source marker comments updated to match. No runtime code, public API, or wire format changed. **Detectors:** *Three more suppression-marker classes re-verified and their tokens retired* — Continuing the re-verification pass: each marked site for the math-random-noncrypto, raw-new-url, and dynamic-require guard classes was re-read and confirmed (non-security jitter/sampling; URL parsing for shape/origin inspection or behind the safe wrapper; operator-supplied module loads), then the class was renamed to a descriptive token and its old name added to the retired-token set. This is test-suite tooling; no shipped framework behavior changed.
|
|
@@ -1352,6 +1352,14 @@ function shadowRegistry(opts) {
|
|
|
1352
1352
|
var counters = Object.create(null);
|
|
1353
1353
|
var gauges = Object.create(null);
|
|
1354
1354
|
var info = Object.create(null);
|
|
1355
|
+
// Maps a canonical-JSON cardinality key → the string-coerced, null-prototype
|
|
1356
|
+
// label object that produced it, so the render path reads the structured
|
|
1357
|
+
// labels directly instead of re-parsing the key. Re-parsing would both lose a
|
|
1358
|
+
// label NAMED `constructor` / `prototype` / `__proto__` (all valid Prometheus
|
|
1359
|
+
// label names) to a prototype-pollution-hardened JSON parse, and reintroduce a
|
|
1360
|
+
// serialize-then-split round-trip. Populated only when a value is actually
|
|
1361
|
+
// stored, so it stays bounded by the cardinality cap.
|
|
1362
|
+
var labelSets = Object.create(null);
|
|
1355
1363
|
var lastCardinalityAuditMs = 0;
|
|
1356
1364
|
|
|
1357
1365
|
function _cardinalityHit(metric) {
|
|
@@ -1375,19 +1383,27 @@ function shadowRegistry(opts) {
|
|
|
1375
1383
|
}
|
|
1376
1384
|
}
|
|
1377
1385
|
|
|
1378
|
-
function
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
+
function _coerceLabels(labels) {
|
|
1387
|
+
// → { key, set }: the canonical-JSON cardinality key (collision-proof and
|
|
1388
|
+
// injection-proof, matching the main registry's _labelsKey) plus the
|
|
1389
|
+
// string-coerced, null-prototype label object it came from. The render path
|
|
1390
|
+
// reads `set` directly rather than re-parsing `key`, so a `,` or `=` in a
|
|
1391
|
+
// label VALUE can never forge extra label pairs and a label NAMED
|
|
1392
|
+
// `constructor` / `prototype` / `__proto__` survives verbatim. The
|
|
1393
|
+
// null-prototype object makes a `__proto__` label a normal own property
|
|
1394
|
+
// rather than a prototype assignment.
|
|
1395
|
+
if (!labels || typeof labels !== "object") return { key: "", set: null };
|
|
1396
|
+
var keys = Object.keys(labels);
|
|
1397
|
+
if (keys.length === 0) return { key: "", set: null };
|
|
1398
|
+
var coerced = Object.create(null);
|
|
1399
|
+
for (var i = 0; i < keys.length; i += 1) coerced[keys[i]] = String(labels[keys[i]]);
|
|
1400
|
+
return { key: canonicalJson.stringify(coerced), set: coerced };
|
|
1386
1401
|
}
|
|
1387
1402
|
|
|
1388
1403
|
function inc(name, labels) {
|
|
1389
1404
|
if (!counterSet[name]) return;
|
|
1390
|
-
var
|
|
1405
|
+
var ck = _coerceLabels(labels);
|
|
1406
|
+
var lk = ck.key;
|
|
1391
1407
|
if (!counters[name]) counters[name] = Object.create(null);
|
|
1392
1408
|
var current = counters[name][lk];
|
|
1393
1409
|
if (current === undefined) {
|
|
@@ -1396,6 +1412,7 @@ function shadowRegistry(opts) {
|
|
|
1396
1412
|
return;
|
|
1397
1413
|
}
|
|
1398
1414
|
counters[name][lk] = 1;
|
|
1415
|
+
if (ck.set) labelSets[lk] = ck.set;
|
|
1399
1416
|
} else {
|
|
1400
1417
|
counters[name][lk] = current + 1;
|
|
1401
1418
|
}
|
|
@@ -1407,13 +1424,15 @@ function shadowRegistry(opts) {
|
|
|
1407
1424
|
throw new MetricsError("metrics-shadow/bad-gauge-value",
|
|
1408
1425
|
"shadowRegistry.set: '" + name + "' value must be a finite number");
|
|
1409
1426
|
}
|
|
1410
|
-
var
|
|
1427
|
+
var ck = _coerceLabels(labels);
|
|
1428
|
+
var lk = ck.key;
|
|
1411
1429
|
if (!gauges[name]) gauges[name] = Object.create(null);
|
|
1412
1430
|
if (gauges[name][lk] === undefined && Object.keys(gauges[name]).length >= cap) {
|
|
1413
1431
|
_cardinalityHit(name);
|
|
1414
1432
|
return;
|
|
1415
1433
|
}
|
|
1416
1434
|
gauges[name][lk] = value;
|
|
1435
|
+
if (ck.set) labelSets[lk] = ck.set;
|
|
1417
1436
|
}
|
|
1418
1437
|
|
|
1419
1438
|
function setInfo(name, value) {
|
|
@@ -1449,22 +1468,21 @@ function shadowRegistry(opts) {
|
|
|
1449
1468
|
for (var li = 0; li < lks.length; li += 1) {
|
|
1450
1469
|
var lk = lks[li];
|
|
1451
1470
|
if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
var
|
|
1471
|
+
// Read the structured label set kept alongside the canonical key (see
|
|
1472
|
+
// _coerceLabels) rather than re-parsing the key. No serialize-then-
|
|
1473
|
+
// split round-trip, so a `,` or `=` in a label value stays inside the
|
|
1474
|
+
// value and can never forge extra label pairs; and a label NAMED
|
|
1475
|
+
// `constructor` / `prototype` / `__proto__` survives instead of being
|
|
1476
|
+
// stripped by a prototype-pollution-hardened parse.
|
|
1477
|
+
var labelObj = labelSets[lk];
|
|
1478
|
+
if (!labelObj || typeof labelObj !== "object") { out.push(metric + " " + labelMap[lk]); continue; }
|
|
1479
|
+
var lnames = Object.keys(labelObj).sort(); // allow:bare-canonicalize-walk — deterministic label ordering in the exposition line
|
|
1459
1480
|
var formatted = [];
|
|
1460
|
-
for (var pi = 0; pi <
|
|
1461
|
-
var
|
|
1462
|
-
if (eqIdx === -1) continue;
|
|
1463
|
-
var lname = lpairs[pi].slice(0, eqIdx);
|
|
1464
|
-
var lvalue = lpairs[pi].slice(eqIdx + 1);
|
|
1481
|
+
for (var pi = 0; pi < lnames.length; pi += 1) {
|
|
1482
|
+
var lname = lnames[pi];
|
|
1465
1483
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
|
|
1466
1484
|
// Prometheus exposition: escape `\`, `"`, `\n` in label values.
|
|
1467
|
-
lvalue = String(
|
|
1485
|
+
var lvalue = String(labelObj[lname]).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
|
|
1468
1486
|
formatted.push(lname + '="' + lvalue + '"');
|
|
1469
1487
|
}
|
|
1470
1488
|
out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
|
|
@@ -1516,9 +1534,10 @@ function shadowRegistry(opts) {
|
|
|
1516
1534
|
}
|
|
1517
1535
|
|
|
1518
1536
|
function reset() {
|
|
1519
|
-
counters
|
|
1520
|
-
gauges
|
|
1521
|
-
info
|
|
1537
|
+
counters = Object.create(null);
|
|
1538
|
+
gauges = Object.create(null);
|
|
1539
|
+
info = Object.create(null);
|
|
1540
|
+
labelSets = Object.create(null);
|
|
1522
1541
|
lastCardinalityAuditMs = 0;
|
|
1523
1542
|
}
|
|
1524
1543
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.15.35",
|
|
4
|
+
"date": "2026-06-26",
|
|
5
|
+
"headline": "`b.metrics` shadow registry no longer lets a comma in a label value forge extra Prometheus label pairs — a label-injection that downstream tenant-scoping or authorization selectors could be tricked by",
|
|
6
|
+
"summary": "The shadow registry serialized a metric's label set into a `name=value,name=value` string key and re-split it on `,` to build the Prometheus exposition. A label VALUE containing a comma therefore split into multiple label pairs, so a single operator-set label could forge additional label pairs in the scrape output (for example turning one `route` label into a `route` plus an attacker-named label), which downstream tenant-scoping filters, authorization selectors, recording rules, and alerting trust as a boundary; two distinct label sets could also collide into one cardinality bucket. The label key now uses canonical-JSON of the string-coerced label set (the same approach the main registry already used) and the render path emits the structured label set kept alongside that key rather than splitting or re-parsing, so a `,` or `=` inside a label value stays inside the value and a label NAMED `constructor`, `prototype`, or `__proto__` (all valid Prometheus label names) is preserved instead of being dropped. Note: the cardinality keys exposed by `shadowRegistry().snapshot()` are now canonical-JSON strings (e.g. `{\"route\":\"/api\"}`) rather than `route=/api`.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Security",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Label values can no longer forge extra Prometheus label pairs in the shadow registry",
|
|
13
|
+
"body": "b.metrics shadowRegistry built a metric's cardinality key by joining `name=value` pairs with commas, then split that string on commas when rendering the Prometheus exposition. A comma (and `=`) in a label value therefore broke one value into several forged label pairs in the scrape output, and two different label sets could collide into the same cardinality bucket. The key is now canonical-JSON of the string-coerced label set and the render emits the structured label set kept alongside that key rather than splitting or re-parsing, so a label value is emitted as a single quoted value with its commas/equals intact, distinct label sets get distinct keys, and a label NAMED `constructor`, `prototype`, or `__proto__` (each a valid Prometheus label name) is preserved rather than dropped. Deterministic regression tests assert a comma-bearing label value produces exactly one label pair and that the reserved-name labels survive render."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Changed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "shadowRegistry().snapshot() cardinality keys are canonical-JSON",
|
|
22
|
+
"body": "As a consequence of the label-injection fix, the per-series cardinality keys in a shadow-registry snapshot are now canonical-JSON strings (e.g. `{\"tenant\":\"a\"}`) instead of the previous `tenant=a` form. Code that reads those keys directly from snapshot() should parse them as JSON; the Prometheus render output is unchanged for conforming label values."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -28,7 +28,7 @@ function testInc() {
|
|
|
28
28
|
shadow.inc("hits", { route: "/api" });
|
|
29
29
|
var snap = shadow.snapshot();
|
|
30
30
|
check("counter incremented", snap.counters.hits[""] === 2);
|
|
31
|
-
check("labeled counter tracked", snap.counters.hits["route
|
|
31
|
+
check("labeled counter tracked", snap.counters.hits['{"route":"/api"}'] === 1);
|
|
32
32
|
// Non-mirrored counter silently ignored.
|
|
33
33
|
shadow.inc("not_mirrored");
|
|
34
34
|
check("non-mirrored ignored", snap.counters.not_mirrored === undefined);
|
|
@@ -43,7 +43,7 @@ function testSet() {
|
|
|
43
43
|
shadow.set("queue_depth", 7, { tenant: "a" });
|
|
44
44
|
var snap = shadow.snapshot();
|
|
45
45
|
check("gauge set", snap.gauges.queue_depth[""] === 42);
|
|
46
|
-
check("labeled gauge", snap.gauges.queue_depth["tenant
|
|
46
|
+
check("labeled gauge", snap.gauges.queue_depth['{"tenant":"a"}'] === 7);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function testCardinalityCap() {
|
|
@@ -83,6 +83,60 @@ function testPrometheusPreservesLabels() {
|
|
|
83
83
|
out.indexOf("# TYPE tenant_b_hits_total counter") !== -1);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function testPrometheusLabelValueInjection() {
|
|
87
|
+
// A `,` or `=` in a label VALUE must stay inside the value — it must NOT
|
|
88
|
+
// forge extra label pairs in the exposition output (label injection that
|
|
89
|
+
// downstream tenant-scoping / authz selectors / recording rules would trust
|
|
90
|
+
// as a boundary). The shadow registry previously serialized the label set to
|
|
91
|
+
// a `name=value,...` key and re-split it on `,` for rendering, so a comma in
|
|
92
|
+
// a value split into multiple forged pairs.
|
|
93
|
+
var shadow = b.metrics.snapshot.shadowRegistry({
|
|
94
|
+
namespace: "tenant_x",
|
|
95
|
+
counters: ["hits_total"],
|
|
96
|
+
});
|
|
97
|
+
shadow.inc("hits_total", { route: 'a,evil="bad' });
|
|
98
|
+
var out = shadow.render({ format: "prometheus" });
|
|
99
|
+
// The full value (comma + escaped quote) stays inside route's quotes.
|
|
100
|
+
check("label value with a comma stays inside the value (no forged pair)",
|
|
101
|
+
out.indexOf('route="a,evil=\\"bad"') !== -1);
|
|
102
|
+
// The series line carries exactly ONE label pair (no injected second label).
|
|
103
|
+
var line = out.split("\n").filter(function (l) { return l.indexOf("tenant_x_hits_total{") === 0; })[0] || "";
|
|
104
|
+
var braces = line.slice(line.indexOf("{") + 1, line.lastIndexOf("}"));
|
|
105
|
+
var labelNames = (braces.match(/(^|,)[a-zA-Z_][a-zA-Z0-9_]*="/g) || []).length; // allow:regex-no-length-cap — counts top-level label-name= boundaries in a bounded exposition line
|
|
106
|
+
check("exactly one label pair (no injected label)", labelNames === 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function testPrometheusReservedLabelNames() {
|
|
110
|
+
// A label whose NAME is `constructor`, `prototype`, or `__proto__` is a valid
|
|
111
|
+
// Prometheus label name ([a-zA-Z_][a-zA-Z0-9_]*) and must survive render. The
|
|
112
|
+
// shadow registry must NOT route the label key through a prototype-pollution-
|
|
113
|
+
// hardened JSON parse (which strips those names), nor lose the dimension.
|
|
114
|
+
var shadow = b.metrics.snapshot.shadowRegistry({
|
|
115
|
+
namespace: "tenant_y",
|
|
116
|
+
counters: ["hits_total"],
|
|
117
|
+
});
|
|
118
|
+
shadow.inc("hits_total", { constructor: "a" });
|
|
119
|
+
shadow.inc("hits_total", { prototype: "b" });
|
|
120
|
+
// `{ __proto__: "c" }` as a literal sets the prototype (a no-op for a string);
|
|
121
|
+
// the real vector is a labels object that came from parsed external data, so
|
|
122
|
+
// its `__proto__` is an own enumerable property.
|
|
123
|
+
shadow.inc("hits_total", JSON.parse('{"__proto__":"c"}'));
|
|
124
|
+
var out = shadow.render({ format: "prometheus" });
|
|
125
|
+
check("label named constructor survives render",
|
|
126
|
+
out.indexOf('tenant_y_hits_total{constructor="a"} 1') !== -1);
|
|
127
|
+
check("label named prototype survives render",
|
|
128
|
+
out.indexOf('tenant_y_hits_total{prototype="b"} 1') !== -1);
|
|
129
|
+
check("label named __proto__ survives render",
|
|
130
|
+
out.indexOf('tenant_y_hits_total{__proto__="c"} 1') !== -1);
|
|
131
|
+
// No dimension collapsed into a bare unlabeled series: every value line for
|
|
132
|
+
// this metric carries a label brace.
|
|
133
|
+
var valueLines = out.split("\n").filter(function (l) {
|
|
134
|
+
return l.indexOf("tenant_y_hits_total") === 0 && l.indexOf("# TYPE") !== 0;
|
|
135
|
+
});
|
|
136
|
+
var bare = valueLines.filter(function (l) { return l.indexOf("{") === -1; });
|
|
137
|
+
check("no unlabeled bare series leaked", bare.length === 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
86
140
|
function testRefusalsAtConfigTime() {
|
|
87
141
|
var threw;
|
|
88
142
|
threw = false; try { b.metrics.snapshot.shadowRegistry({}); } catch (_e) { threw = true; }
|
|
@@ -105,6 +159,8 @@ function run() {
|
|
|
105
159
|
testSet();
|
|
106
160
|
testCardinalityCap();
|
|
107
161
|
testPrometheusPreservesLabels();
|
|
162
|
+
testPrometheusLabelValueInjection();
|
|
163
|
+
testPrometheusReservedLabelNames();
|
|
108
164
|
testRefusalsAtConfigTime();
|
|
109
165
|
}
|
|
110
166
|
|
package/package.json
CHANGED