@blamejs/core 0.13.34 → 0.13.35
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 +15 -3
- package/lib/bounded-map.js +102 -0
- package/lib/i18n.js +10 -1
- package/lib/network-dns.js +10 -2
- package/lib/nonce-store.js +39 -12
- 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.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.35 (2026-05-29) — **In-memory replay, idempotency, DNS, and i18n stores gain entry-count ceilings.** Several framework caches keyed on request-influenced input grew without an upper bound between their periodic sweeps, so a flood of unique keys could exhaust process memory faster than the sweep reclaimed it. Each now enforces a hard entry ceiling. The replay-protection nonce store is the security-sensitive one: rather than evict a live nonce to admit a new one — which would reopen a replay window for the evicted nonce — it purges expired entries and then fails closed at capacity, refusing the unrecordable request instead of admitting it unprotected. The idempotency, DNS, and i18n caches hold re-derivable values, so they evict the oldest entry instead (the worst case is a recomputed value or a single re-executed retry under flood). Ceilings are generous defaults that normal traffic never reaches; the nonce store and the agent idempotency in-memory backend expose options to tune them. **Fixed:** *Agent idempotency in-memory backend no longer grows without bound* — The default in-memory backend for `b.agent.idempotency` is keyed on the request-supplied idempotency key, and its garbage collector only reclaims expired rows when an operator wires a scheduler to call it — so a flood of distinct keys could grow it until the process ran out of memory. It now caps its entry count and evicts oldest-first; a dropped record just means that one key re-executes on a later retry, never a crash. A new `maxInMemoryEntries` option tunes the ceiling (default 100,000); deployments needing a hard guarantee at scale still supply a durable `store`. · *DNS resolver cache is bounded* — The positive and negative resolver caches in `b.network.dns` reclaimed an expired entry only when the same hostname was looked up again, so entries for never-requeried hostnames persisted — and hostnames reaching the resolver are request-influenced (outbound request targets, mail MX lookups). Both caches now cap their entry count and evict oldest-first; DNS simply re-resolves on the next miss. · *i18n formatter cache is bounded* — Per-instance `Intl` formatter caches in `b.i18n` are keyed on the locale plus a hash of the format options. The format-options shape is open-ended and caller-supplied, so the key space was request-influenced and uncapped. The cache now enforces an entry ceiling and evicts oldest-first — a formatter is pure-derived and re-created on the next miss. **Security:** *Replay-nonce store bounds memory and fails closed under a nonce flood* — The in-memory `b.nonceStore` backend recorded every request-supplied nonce until a periodic sweep ran, so a stream of unique nonces could exhaust memory between sweeps (a memory-amplification denial of service). It now caps its entry count. Because a replay-protection store must never evict a live nonce to make room — doing so would reopen a replay window for the evicted nonce — it instead purges expired entries inline and, if still at capacity with live nonces, fails closed: the new request is refused rather than admitted without replay protection. A new `maxEntries` option tunes the ceiling (default 1,000,000).
|
|
12
|
+
|
|
11
13
|
- v0.13.34 (2026-05-29) — **Corrupt TLS certs self-heal at boot, and graceful shutdown no longer loses the final DB flush.** Two failure-mode fixes in the same family as the encrypted-DB recovery in 0.13.33. The cert manager treated a corrupt sealed cert or key worse than a missing one: a missing file re-issues via ACME, but a corrupt one let a raw decrypt error escape out of start(), so the same bad file was read on every boot — an unrecoverable crash loop. A corrupt sealed cert/key is now treated like an absent one and re-issued, and a corrupt derived meta.json is re-derived rather than fatal; the ACME account key (which binds order history) instead fails with an actionable error rather than a raw throw. On the shutdown side, an encrypted database that failed its final re-encrypt used to delete its plaintext working copy anyway, discarding every write since the last periodic flush; it now keeps the working copy so the next boot recovers it. The shutdown orchestrator also gains a hard-deadline watchdog: when the operator delegates signal handling to it, a phase that never settles can no longer hold the process open until the supervisor SIGKILLs it (which would skip the final DB re-encrypt) — the watchdog forces a clean exit, so exit handlers still flush. The wiki production and base compose files set a stop_grace_period above that budget so a docker stop or rolling redeploy lets the re-encrypt finish. **Changed:** *Shutdown watchdog forces a clean, DB-flushing exit if a phase hangs* — The graceful-shutdown orchestrator uses soft per-phase timeouts — on expiry the underlying work keeps running — so a phase that never settles could hold the event loop open past the grace window, after which a container supervisor SIGKILLs the process and skips the final DB re-encrypt. When the operator opts into signal handling, a watchdog now forces `process.exit` `graceMs + forceExitMarginMs` after the signal; exit runs the registered handlers (the DB re-encrypt), so the last flush still happens. A new `forceExitMarginMs` option (default 5000) tunes the headroom; set the container stop grace above `graceMs + forceExitMarginMs`. · *Wiki compose sets stop_grace_period above the shutdown budget* — `examples/wiki/docker-compose.yml` and `docker-compose.prod.yml` now set `stop_grace_period: 40s`. Docker's 10s default would SIGKILL the container before the 30s shutdown budget reaches the DB re-encrypt phase, losing the final flush on a `docker stop` or rolling redeploy. The production note also reminds PaaS platforms that regenerate the compose (Coolify, Dokku, CapRover) to set the stop grace via the platform UI alongside the persistent-storage mount and `--shm-size`. **Fixed:** *A corrupt sealed TLS cert or key re-issues instead of crash-looping at boot* — `b.cert`'s start path read the sealed `cert.pem`/`key.pem` and let a raw unseal/decrypt error escape if the file was truncated or corrupt, so a managed restart read the same bad file on every boot — a crash loop, and worse handling than an absent file (which already re-issues). A corrupt sealed cert/key is now treated like a missing one: it is logged, an audit event is emitted, and the certificate is re-issued via ACME. A corrupt derived `meta.json` is likewise re-derived rather than throwing `cert/bad-meta`. · *Unreadable ACME account key fails with an actionable error, not a raw decrypt throw* — Unlike a re-issuable certificate, the ACME account key binds existing order and authorization history, so it is not silently regenerated on corruption. An unreadable `account/jwk.json.sealed` now raises `cert/account-key-unreadable` naming the file and the recovery (restore from backup, or delete to register a fresh account) instead of letting a raw decrypt/parse error escape out of start(). · *Encrypted DB keeps its working copy when the final shutdown re-encrypt fails* — `db.close()` re-encrypts the tmpfs working copy to `db.enc`, then deletes the working copy. If that final re-encrypt failed (a full `/dev/shm`, a full disk), the delete still ran, discarding every write since the last periodic flush and leaving only the older `db.enc`. The working copy is now kept whenever the re-encrypt fails, so the next boot's integrity-probed recovery picks up the latest writes (and still falls back to `db.enc` if the working copy is itself corrupt). `db.enc` is never modified by this path. **Detectors:** *Cross-artifact guard that stop_grace_period covers the shutdown budget* — A new codebase check fails if either wiki compose file omits `stop_grace_period` or sets it below the orchestrator's `graceMs` plus the watchdog margin read from `lib/app-shutdown.js`, so raising the shutdown budget without bumping the compose — or dropping the setting — cannot silently reopen the SIGKILL-before-re-encrypt data-loss window.
|
|
12
14
|
|
|
13
15
|
- v0.13.33 (2026-05-28) — **Encrypted-mode DB recovers from a corrupt tmpfs working copy instead of crash-looping.** In encrypted-at-rest mode the live SQLite copy is decrypted into a tmpfs working file and re-encrypted to db.enc periodically. If that working copy was corrupted (an unclean shutdown, or a full tmpfs — Docker's /dev/shm defaults to 64 MiB), the boot path trusted it because its mtime was newer than db.enc, so db.init failed its integrity gate with "database disk image is malformed" identically on every boot — an unrecoverable crash loop. db now integrity-probes the newer working copy before trusting it: if it is unreadable, the working copy is discarded and db.enc (the last-good encrypted snapshot) is re-decrypted, so the next boot self-heals. db.enc is never modified by this path, and a genuinely-corrupt db.enc still fails loudly rather than wiping data. The boot error on an unreadable database is now actionable (it names the tmpfs-size cause and the recovery). The wiki production compose also gains the storage settings encrypted mode needs. **Fixed:** *Corrupt tmpfs working copy no longer causes a boot crash loop (encrypted-at-rest mode)* — `db.init`'s crash-recovery path preferred a newer tmpfs working copy over `db.enc` unconditionally. When that copy was corrupt (truncated by an unclean shutdown or a full `/dev/shm`), every boot trusted it and failed the integrity gate the same way — an unrecoverable loop. The newer working copy is now integrity-probed (`PRAGMA quick_check`); if it is unreadable it is discarded and `db.enc` — the last-good encrypted snapshot — is re-decrypted, so boot self-heals. `db.enc` is never modified, so this only ever rolls back to the persistent copy; if `db.enc` is also corrupt, boot still fails loudly (no silent data loss). A regression test pins the recovery. · *Actionable boot error when the database is unreadable* — When SQLite reports a database too corrupt to even run an integrity check, the boot error now names the likely cause and recovery instead of surfacing the raw "database disk image is malformed": in encrypted mode it points at the tmpfs working copy and the most common operational cause (Docker's 64 MiB `/dev/shm` default — raise it via `shm_size` / `--shm-size`), or restoring `db.enc` / the DB file from backup. · *Wiki production compose ships the storage encrypted mode needs* — `examples/wiki/docker-compose.prod.yml` now sets `shm_size: '512m'` (so the encrypted-mode tmpfs working copy has headroom above Docker's 64 MiB default) and mounts a persistent `wiki-data` volume at `/data` (so `db.enc` + sealed keys survive container recreate, host reboot, and image redeploys, and give a restore point). A note flags that PaaS platforms which regenerate the compose on deploy (Coolify, Dokku, CapRover, …) must set both via the platform UI — a persistent-storage mount for `/data` and a `--shm-size 512m` custom option.
|
package/lib/agent-idempotency.js
CHANGED
|
@@ -68,6 +68,16 @@ var bCrypto = require("./crypto");
|
|
|
68
68
|
var safeJson = require("./safe-json");
|
|
69
69
|
var guardIdempotencyKey = require("./guard-idempotency-key");
|
|
70
70
|
var agentAudit = require("./agent-audit");
|
|
71
|
+
var { boundedMap } = require("./bounded-map");
|
|
72
|
+
|
|
73
|
+
// The default in-memory backend is keyed on (method, actorId, keyHash) —
|
|
74
|
+
// the key hash comes from request-supplied idempotency keys, so a flood of
|
|
75
|
+
// distinct keys would grow the Map without bound (gc only reclaims EXPIRED
|
|
76
|
+
// rows, and only if the operator wires a scheduler to call it). Cap it.
|
|
77
|
+
// Evict-oldest degrades gracefully under flood: the worst case is a dropped
|
|
78
|
+
// dedup record, so a retry of that one key re-executes — never an OOM.
|
|
79
|
+
// Operators who need a hard guarantee at scale supply a durable `opts.store`.
|
|
80
|
+
var DEFAULT_IN_MEMORY_MAX_ENTRIES = 100000;
|
|
71
81
|
|
|
72
82
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
73
83
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
@@ -130,7 +140,7 @@ function _ensureSealTable() {
|
|
|
130
140
|
*/
|
|
131
141
|
function create(opts) {
|
|
132
142
|
opts = opts || {};
|
|
133
|
-
var store = opts.store || _inMemoryBackend();
|
|
143
|
+
var store = opts.store || _inMemoryBackend(opts.maxInMemoryEntries);
|
|
134
144
|
if (typeof store.get !== "function" || typeof store.put !== "function" ||
|
|
135
145
|
typeof store.delete !== "function") {
|
|
136
146
|
throw new AgentIdempotencyError("agent-idempotency/bad-store",
|
|
@@ -470,8 +480,10 @@ function _checkArgs(method, actorId, key) {
|
|
|
470
480
|
// key is validated separately via guardIdempotencyKey.validate.
|
|
471
481
|
}
|
|
472
482
|
|
|
473
|
-
function _inMemoryBackend() {
|
|
474
|
-
|
|
483
|
+
function _inMemoryBackend(maxEntries) {
|
|
484
|
+
// boundedMap validates maxEntries (throws bounded-map/bad-max-entries on a
|
|
485
|
+
// non-positive-int); undefined falls back to the default ceiling.
|
|
486
|
+
var map = boundedMap({ maxEntries: maxEntries || DEFAULT_IN_MEMORY_MAX_ENTRIES, policy: "evict-oldest" });
|
|
475
487
|
function _k(method, actorId, hash) { return method + "\0" + actorId + "\0" + hash; }
|
|
476
488
|
return {
|
|
477
489
|
get: function (method, actorId, hash) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* bounded-map — a Map facade that caps its entry count.
|
|
4
|
+
*
|
|
5
|
+
* Defends the unbounded-in-bounded resource-exhaustion class: an in-memory
|
|
6
|
+
* store keyed on request-derived input (a locale, a hostname, an
|
|
7
|
+
* idempotency key, a replay nonce) grows without limit between sweeps
|
|
8
|
+
* unless something enforces a ceiling. A periodic TTL sweep alone does not
|
|
9
|
+
* bound peak memory — a flood of unique keys arrives faster than the sweep
|
|
10
|
+
* interval. This adds the missing ceiling.
|
|
11
|
+
*
|
|
12
|
+
* Two policies for what happens on `set` when already at `maxEntries`:
|
|
13
|
+
*
|
|
14
|
+
* "evict-oldest" (default) — drop the oldest entry (insertion order)
|
|
15
|
+
* to make room, then store the new one. For caches whose entries are
|
|
16
|
+
* re-derivable on demand (Intl formatters, DNS results, idempotency
|
|
17
|
+
* records) eviction is cheap — the worst case is a recomputed value or
|
|
18
|
+
* a missed dedup under active flood, never a correctness or security
|
|
19
|
+
* hole. `set` always stores and returns true.
|
|
20
|
+
*
|
|
21
|
+
* "reject" — refuse the new entry (do NOT evict a live one) and return
|
|
22
|
+
* false. For stores where evicting an unexpired entry would be unsafe:
|
|
23
|
+
* a replay-protection nonce store must not drop a live nonce to admit a
|
|
24
|
+
* new one, because that reopens a replay window for the dropped nonce.
|
|
25
|
+
* The caller fails closed on a false return (treats the request as
|
|
26
|
+
* un-recordable → reject it). Callers should purge expired entries
|
|
27
|
+
* before relying on this so the ceiling is hit only under genuine flood.
|
|
28
|
+
*
|
|
29
|
+
* This is deliberately NOT `b.cache` — that is an operator-facing primitive
|
|
30
|
+
* with TTL, LRU-touch, observability, and pluggable backends. This is the
|
|
31
|
+
* minimal internal ceiling the framework's own request-keyed Maps need, and
|
|
32
|
+
* leaves TTL/expiry semantics to the caller (which already owns them).
|
|
33
|
+
*
|
|
34
|
+
* `onEvict(key, value)` (optional) fires when an entry is dropped to make
|
|
35
|
+
* room under "evict-oldest" — for an observability counter, say. It never
|
|
36
|
+
* fires under "reject" (nothing is evicted; the new entry is dropped).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var numericBounds = require("./numeric-bounds");
|
|
40
|
+
var { defineClass } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var BoundedMapError = defineClass("BoundedMapError");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {number} opts.maxEntries - hard ceiling; throws if not a positive finite int
|
|
47
|
+
* @param {string} [opts.policy] - "evict-oldest" (default) | "reject"
|
|
48
|
+
* @param {function} [opts.onEvict] - (key, value) called on eviction under "evict-oldest"
|
|
49
|
+
* @returns Map-like facade: get/has/set/delete/clear, size getter, keys/values/entries/forEach, [Symbol.iterator]
|
|
50
|
+
*/
|
|
51
|
+
function boundedMap(opts) {
|
|
52
|
+
opts = opts || {};
|
|
53
|
+
if (!numericBounds.isPositiveFiniteInt(opts.maxEntries)) {
|
|
54
|
+
throw new BoundedMapError("bounded-map/bad-max-entries",
|
|
55
|
+
"boundedMap: opts.maxEntries must be a positive finite integer, got " + JSON.stringify(opts.maxEntries));
|
|
56
|
+
}
|
|
57
|
+
var maxEntries = opts.maxEntries;
|
|
58
|
+
var policy = opts.policy || "evict-oldest";
|
|
59
|
+
if (policy !== "evict-oldest" && policy !== "reject") {
|
|
60
|
+
throw new BoundedMapError("bounded-map/bad-policy",
|
|
61
|
+
"boundedMap: opts.policy must be 'evict-oldest' | 'reject', got " + JSON.stringify(policy));
|
|
62
|
+
}
|
|
63
|
+
var onEvict = typeof opts.onEvict === "function" ? opts.onEvict : null;
|
|
64
|
+
var inner = new Map();
|
|
65
|
+
|
|
66
|
+
function set(key, value) {
|
|
67
|
+
// Updating an existing key never grows the map — always allowed.
|
|
68
|
+
if (inner.has(key)) { inner.set(key, value); return true; }
|
|
69
|
+
if (inner.size >= maxEntries) {
|
|
70
|
+
if (policy === "reject") return false;
|
|
71
|
+
// evict-oldest: the first key in insertion order is the oldest.
|
|
72
|
+
var oldest = inner.keys().next().value;
|
|
73
|
+
if (oldest !== undefined || inner.has(oldest)) {
|
|
74
|
+
var evictedVal = inner.get(oldest);
|
|
75
|
+
inner.delete(oldest);
|
|
76
|
+
if (onEvict) { try { onEvict(oldest, evictedVal); } catch (_e) { /* obs hook — drop-silent */ } }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
inner.set(key, value);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
get: function (k) { return inner.get(k); },
|
|
85
|
+
has: function (k) { return inner.has(k); },
|
|
86
|
+
set: set,
|
|
87
|
+
delete: function (k) { return inner.delete(k); },
|
|
88
|
+
clear: function () { inner.clear(); },
|
|
89
|
+
keys: function () { return inner.keys(); },
|
|
90
|
+
values: function () { return inner.values(); },
|
|
91
|
+
entries: function () { return inner.entries(); },
|
|
92
|
+
forEach: function (fn, thisArg) { return inner.forEach(fn, thisArg); },
|
|
93
|
+
get size() { return inner.size; },
|
|
94
|
+
get maxEntries() { return maxEntries; },
|
|
95
|
+
get policy() { return policy; },
|
|
96
|
+
// Iterable like a Map, so `for (var e of bmap)` yields [key, value]
|
|
97
|
+
// entries — callers that iterate a plain Map keep working unchanged.
|
|
98
|
+
[Symbol.iterator]: function () { return inner[Symbol.iterator](); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { boundedMap: boundedMap, BoundedMapError: BoundedMapError };
|
package/lib/i18n.js
CHANGED
|
@@ -64,6 +64,15 @@ var requestHelpers = require("./request-helpers");
|
|
|
64
64
|
var safeJson = require("./safe-json");
|
|
65
65
|
var validateOpts = require("./validate-opts");
|
|
66
66
|
var { I18nError } = require("./framework-error");
|
|
67
|
+
var { boundedMap } = require("./bounded-map");
|
|
68
|
+
|
|
69
|
+
// Per-instance formatter caches are keyed on (locale, JSON.stringify
|
|
70
|
+
// formatOpts). A fixed `locales` set bounds the locale axis, but operators
|
|
71
|
+
// can pass fresh formatOpts shapes per call (and `Intl` options are open-
|
|
72
|
+
// ended), so the key space is request-influenced — cap it so it can't grow
|
|
73
|
+
// without limit. Evict-oldest is free here: a formatter is pure-derived and
|
|
74
|
+
// re-created on the next miss.
|
|
75
|
+
var FORMATTER_CACHE_MAX_ENTRIES = 512;
|
|
67
76
|
|
|
68
77
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
69
78
|
|
|
@@ -353,7 +362,7 @@ function _interpolate(template, vars, interpolation) {
|
|
|
353
362
|
// only with operators handing in fresh literals every call (they
|
|
354
363
|
// usually pass the same shape).
|
|
355
364
|
function _makeFormatterCache(make, kind, emitObs) {
|
|
356
|
-
var cache =
|
|
365
|
+
var cache = boundedMap({ maxEntries: FORMATTER_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
|
|
357
366
|
return function getFormatter(locale, formatOpts) {
|
|
358
367
|
var optsKey = formatOpts ? JSON.stringify(formatOpts) : "";
|
|
359
368
|
var cacheKey = locale + "\x1f" + optsKey;
|
package/lib/network-dns.js
CHANGED
|
@@ -13,6 +13,7 @@ var safeBuffer = require("./safe-buffer");
|
|
|
13
13
|
var safeUrl = require("./safe-url");
|
|
14
14
|
var validateOpts = require("./validate-opts");
|
|
15
15
|
var { defineClass } = require("./framework-error");
|
|
16
|
+
var { boundedMap } = require("./bounded-map");
|
|
16
17
|
|
|
17
18
|
var DnsError = defineClass("DnsError", { alwaysPermanent: false });
|
|
18
19
|
|
|
@@ -89,8 +90,15 @@ function _ensureSecureDefault() {
|
|
|
89
90
|
STATE.doh = { url: DEFAULT_DOH_URL, method: null, ca: null };
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
// Resolver caches are keyed on (hostname, family). Hostnames reaching the
|
|
94
|
+
// resolver are request-influenced (outbound HTTP targets, mail MX lookups,
|
|
95
|
+
// operator-supplied URLs), and expired entries are only reclaimed lazily on
|
|
96
|
+
// a re-query of the SAME key — so a stream of unique hostnames would grow
|
|
97
|
+
// these without bound. Cap them; evict-oldest is free (DNS re-resolves on
|
|
98
|
+
// the next miss). The cap bounds peak memory even with no periodic sweep.
|
|
99
|
+
var DNS_CACHE_MAX_ENTRIES = 4096;
|
|
100
|
+
var POSITIVE_CACHE = boundedMap({ maxEntries: DNS_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
|
|
101
|
+
var NEGATIVE_CACHE = boundedMap({ maxEntries: DNS_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
|
|
94
102
|
|
|
95
103
|
function _now() { return Date.now(); }
|
|
96
104
|
|
package/lib/nonce-store.js
CHANGED
|
@@ -45,10 +45,18 @@ var clusterStorage = require("./cluster-storage");
|
|
|
45
45
|
var C = require("./constants");
|
|
46
46
|
var safeAsync = require("./safe-async");
|
|
47
47
|
var { defineClass } = require("./framework-error");
|
|
48
|
+
var { boundedMap } = require("./bounded-map");
|
|
48
49
|
|
|
49
50
|
var NonceStoreError = defineClass("NonceStoreError");
|
|
50
51
|
|
|
51
52
|
var DEFAULT_SWEEP_INTERVAL_MS = C.TIME.minutes(5);
|
|
53
|
+
// Memory-backend ceiling. Each request carries an attacker-choosable unique
|
|
54
|
+
// nonce, so between sweeps the store would otherwise grow without bound (a
|
|
55
|
+
// memory-amplification DoS). Capped — but a replay-protection store must
|
|
56
|
+
// NOT evict a live nonce to admit a new one (that reopens a replay window
|
|
57
|
+
// for the evicted nonce), so the cap uses the "reject" policy and the
|
|
58
|
+
// backend fails CLOSED at capacity (see checkAndInsert).
|
|
59
|
+
var DEFAULT_MAX_ENTRIES = 1000000;
|
|
52
60
|
|
|
53
61
|
function _err(code, message) {
|
|
54
62
|
return new NonceStoreError(code, message, true);
|
|
@@ -58,14 +66,22 @@ function _err(code, message) {
|
|
|
58
66
|
|
|
59
67
|
function _memoryBackend(opts) {
|
|
60
68
|
var sweepIntervalMs = opts.sweepIntervalMs || DEFAULT_SWEEP_INTERVAL_MS;
|
|
61
|
-
var
|
|
69
|
+
var maxEntries = opts.maxEntries || DEFAULT_MAX_ENTRIES;
|
|
70
|
+
// policy "reject" — never evict a live nonce (that would reopen a replay
|
|
71
|
+
// window for the dropped one). At capacity the backend fails closed.
|
|
72
|
+
var seen = boundedMap({ maxEntries: maxEntries, policy: "reject" });
|
|
73
|
+
var capacityRejects = 0;
|
|
62
74
|
|
|
63
|
-
|
|
75
|
+
function _purgeExpiredSync() {
|
|
64
76
|
var now = Date.now();
|
|
77
|
+
var removed = 0;
|
|
65
78
|
for (var entry of seen) {
|
|
66
|
-
if (entry[1] <= now) seen.delete(entry[0]);
|
|
79
|
+
if (entry[1] <= now) { seen.delete(entry[0]); removed++; }
|
|
67
80
|
}
|
|
68
|
-
|
|
81
|
+
return removed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var sweepTimer = safeAsync.repeating(_purgeExpiredSync, sweepIntervalMs, { name: "nonce-sweep" });
|
|
69
85
|
|
|
70
86
|
function checkAndInsert(nonce, expireAt) {
|
|
71
87
|
if (typeof nonce !== "string" || nonce.length === 0) {
|
|
@@ -78,17 +94,26 @@ function _memoryBackend(opts) {
|
|
|
78
94
|
if (existing !== undefined && existing > Date.now()) {
|
|
79
95
|
return Promise.resolve(false); // replay
|
|
80
96
|
}
|
|
81
|
-
seen.set(nonce, expireAt);
|
|
97
|
+
var stored = seen.set(nonce, expireAt);
|
|
98
|
+
if (!stored) {
|
|
99
|
+
// At capacity. Reclaim expired entries inline, then retry once.
|
|
100
|
+
_purgeExpiredSync();
|
|
101
|
+
stored = seen.set(nonce, expireAt);
|
|
102
|
+
}
|
|
103
|
+
if (!stored) {
|
|
104
|
+
// Still full of LIVE nonces — a genuine flood. FAIL CLOSED: we
|
|
105
|
+
// cannot record this nonce, so we cannot prove it is first-seen.
|
|
106
|
+
// Refuse it (report as "seen") rather than admit an unprotected
|
|
107
|
+
// request. Evicting a live nonce to make room would reopen a replay
|
|
108
|
+
// window, so we never evict — the request is rejected instead.
|
|
109
|
+
capacityRejects += 1;
|
|
110
|
+
return Promise.resolve(false);
|
|
111
|
+
}
|
|
82
112
|
return Promise.resolve(true);
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
function purgeExpired() {
|
|
86
|
-
|
|
87
|
-
var removed = 0;
|
|
88
|
-
for (var entry of seen) {
|
|
89
|
-
if (entry[1] <= now) { seen.delete(entry[0]); removed++; }
|
|
90
|
-
}
|
|
91
|
-
return Promise.resolve(removed);
|
|
116
|
+
return Promise.resolve(_purgeExpiredSync());
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
function close() {
|
|
@@ -101,8 +126,10 @@ function _memoryBackend(opts) {
|
|
|
101
126
|
checkAndInsert: checkAndInsert,
|
|
102
127
|
purgeExpired: purgeExpired,
|
|
103
128
|
close: close,
|
|
104
|
-
// Test
|
|
129
|
+
// Test hooks — underlying entry count + count of capacity fail-closed
|
|
130
|
+
// rejections (a nonce flood that hit the ceiling).
|
|
105
131
|
_size: function () { return seen.size; },
|
|
132
|
+
_capacityRejects: function () { return capacityRejects; },
|
|
106
133
|
};
|
|
107
134
|
}
|
|
108
135
|
|
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:6028bee9-9cbe-4913-ac29-c3b458c70b55",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-29T13:13:34.221Z",
|
|
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.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.35",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.35",
|
|
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.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.35",
|
|
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.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.35",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|