@blamejs/core 0.15.42 → 0.15.44
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 +4 -0
- package/lib/auth/lockout.js +95 -85
- package/lib/cache.js +11 -5
- package/lib/file-upload.js +7 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.44 (2026-06-28) — **`b.fileUpload` now rejects the bare path tokens "." and ".." as upload IDs, closing a path traversal where a ".." id resolved to the staging directory's parent and a cancel or cleanup could recursively delete it.** b.fileUpload validated the uploadId with a character-class regex that permits the dot character, so the bare path tokens "." and ".." passed validation even though they are not ordinary identifiers. The uploadId is joined under the configured staging directory to locate an upload's files, so "." resolved to the staging directory itself and ".." to its parent: an operation keyed on a ".." uploadId acted OUTSIDE the staging directory. The most damaging path is cancelUpload (and finalize/expiry cleanup), which removes the upload directory with a recursive rmSync — keyed on ".." that would recursively delete the staging directory's parent; chunk writes for a ".." id also land outside staging. The validator now rejects "." and ".." before any filesystem operation. Every public method validates the uploadId there, so init, acceptChunk, finalize, status, and cancelUpload are all covered, and legitimate dotted IDs (for example "build.v2") are unaffected. **Security:** *Upload IDs of "." and ".." are refused (path-traversal / parent-directory deletion)* — fileUpload's uploadId regex allows the dot character, so the bare tokens "." and ".." passed validation and, joined under the staging directory, resolved to the staging directory or its parent. An operation keyed on a ".." id acted outside staging — most seriously cancelUpload / cleanup, whose recursive rmSync would have deleted the staging directory's parent, and chunk writes that would land outside staging. The validator now rejects "." and ".." up front; because every public method validates the id, init / acceptChunk / finalize / status / cancelUpload are all covered. Legitimate dotted IDs are unaffected. Operators who never pass caller-controlled upload IDs were not exposed.
|
|
12
|
+
|
|
13
|
+
- v0.15.43 (2026-06-28) — **`b.auth.lockout`'s failed-attempt counter now accumulates with an atomic compare-and-set, so a brute-force attacker spreading failed logins across multiple nodes can no longer lose increments and stay under the lockout threshold.** b.auth.lockout tracked failed attempts with a cache read-modify-write: read the counter, increment it, write it back. On a multi-node deployment two concurrent failures for the same account on different nodes both read the same value, each added one, and one write clobbered the other — a lost update that let an attacker spreading attempts across nodes exceed maxAttempts without ever triggering the lockout, weakening brute-force protection on a cluster. The counter now runs the whole decision (window decay, increment, lockout-ladder) through the cache's atomic update() (compare-and-set, retried on the cluster backend under contention), so every failure is counted regardless of which node records it. The lockout's documented fail-open posture is preserved — a genuinely unreachable cache still allows the attempt and signals the error — but a cache backend that cannot perform an atomic update now surfaces loud at first use instead of silently disabling the lockout. **Security:** *Lockout failure counter is atomic across nodes (no lost increments)* — b.auth.lockout accumulated failed attempts with a non-atomic cache get -> increment -> set, so concurrent failures for one account across a multi-node deployment lost increments and an attacker could exceed maxAttempts without engaging the lockout. The counter now uses the cache's atomic compare-and-set update(), counting every failure across nodes, with the existing exponential lockout ladder and window-decay logic unchanged. The cache backing a lockout must support atomic update() (b.cache does; the create() check now requires it), and a backend that can't commit an atomic update surfaces loud at first use rather than silently disabling brute-force protection. A genuinely unreachable cache still fails open by design.
|
|
14
|
+
|
|
11
15
|
- v0.15.42 (2026-06-28) — **The agent orchestrator and tenant registries serialize registration per name, so two concurrent register() calls for the same name can no longer both create a row (duplicate-create / lost registration), and a new b.safeAsync.keyedSerializer exposes that per-key serialization.** b.agent.orchestrator and b.agent.tenant registered a name with a check-then-create: read the backend row for the name, throw a duplicate error if it exists, otherwise write the new row. Because the read and the write are separated by an await, two concurrent register() calls for the same name both observed absence and both wrote — a duplicate-create where the second silently clobbered the first and both callers saw success, violating the one-row-per-name invariant. register() (and unregister()) now run through a per-name in-process serializer, so concurrent calls for the same name apply one at a time and the second is correctly refused as a duplicate; distinct names still run concurrently. The serializer is exposed as b.safeAsync.keyedSerializer() for any read-modify-write or check-then-create that must not interleave per key. It is in-process only: a registry backend shared across processes still needs its own atomic create or unique constraint to refuse a cross-process duplicate. **Added:** *b.safeAsync.keyedSerializer — serialize async work per key* — b.safeAsync.keyedSerializer() returns a { run(key, fn) } that queues fn behind any in-flight or queued work for the same key and runs it once they settle, so a read-modify-write or a check-then-create on a shared store cannot interleave with another call for the same key in the same process. Distinct keys run concurrently, and the per-key chain is dropped once it drains. It is the serialization the agent registries now use, and the same primitive backs the lockout and bot-challenge per-key failure counters. **Fixed:** *Agent orchestrator + tenant registries serialize registration per name (no duplicate-create race)* — b.agent.orchestrator.register and b.agent.tenant.register did a check-then-create (await backend.get -> throw-if-exists -> await backend.set) with an await between the read and the write, so two concurrent registrations of the same name both passed the duplicate check and both wrote — the second silently clobbering the first while both callers saw success. Registration now serializes per name in-process, so concurrent calls for one name apply sequentially and the second is refused with the duplicate error; distinct names are unaffected. A backend shared across processes still needs its own uniqueness constraint to refuse a cross-process duplicate. **Detectors:** *Build guard: a registry check-then-create must serialize per key* — A codebase guard now fails the build if a primitive does an async check-then-create on a pluggable backend (await backend.get -> throw a /duplicate error -> await backend.set) without serializing per key, so the duplicate-create race fixed here cannot reappear at a new registry.
|
|
12
16
|
|
|
13
17
|
- v0.15.41 (2026-06-28) — **Counters kept on a shared cache — byte quotas and the static server's bandwidth/concurrency caps — now accumulate with an atomic compare-and-set, so concurrent requests can no longer lose each other's charges and slip under the limit.** Several caps maintained a counter on a cache with a non-atomic read-modify-write: read the current value, add to it in memory, write it back. On a cache shared across nodes, two concurrent requests both read the same value, each added only its own contribution, and one write clobbered the other — a lost update that under-counted the cap and let a peer slip under it. b.network.byteQuota (and the b.middleware.dailyByteQuota that composes it) and the static server's per-actor/global bandwidth and per-actor concurrency caps all did this. They now accumulate through the cache's atomic update() (compare-and-set, with retry on the cluster backend under contention), so every concurrent charge is counted. A cache backing these caps must support atomic update(); b.cache provides it, and both primitives refuse a get/set-only cache at construction. The single-node in-memory paths were already safe (they accumulate synchronously). **Fixed:** *Byte quota on a shared cache counts concurrent requests atomically (no lost updates)* — b.network.byteQuota's cache backend accumulated bytes with a get → add → set that is not atomic, so concurrent requests from one peer on a multi-node deployment lost each other's byte charges and the rolling daily byte budget was under-enforced. Accounting now uses the cache's atomic compare-and-set update(), counting every concurrent request, and retries the cluster CAS under a contention burst rather than dropping a charge. A cache wired to a byte quota must support update() — b.cache does; a plain get/set cache is refused at create() (byte-quota/cache-no-atomic-update), and a backend that can't actually commit an atomic update surfaces loud on first use instead of silently disabling the quota. b.middleware.dailyByteQuota, which composes byteQuota, inherits the fix. · *Static server bandwidth + concurrency caps count concurrent requests atomically* — b.staticServe's per-actor and global bandwidth caps and its per-actor concurrency cap kept their counters on the cache with a non-atomic get → add → set, so concurrent downloads from one actor on a multi-replica deployment lost each other's charges and the caps were under-enforced (a bandwidth/concurrency limit bypass). The counters now accumulate through the cache's atomic update() with the same contention retry, and the quota cache is required to support update() at create(). **Detectors:** *Build guard: a cache-backed counter must use atomic update(), not get → set* — A codebase guard now fails the build if a primitive accumulates a counter on a cache with a get → mutate → set read-modify-write instead of the atomic update(), so the lost-update class fixed here cannot reappear at a new cap. Caches used for lookups or cache-aside (the value is replaced wholesale, not incremented), or whose writes are serialized in-process, are allowlisted with the reason they cannot lose an increment.
|
package/lib/auth/lockout.js
CHANGED
|
@@ -84,7 +84,6 @@ var numericBounds = require("../numeric-bounds");
|
|
|
84
84
|
var lazyRequire = require("../lazy-require");
|
|
85
85
|
var requestHelpers = require("../request-helpers");
|
|
86
86
|
var validateOpts = require("../validate-opts");
|
|
87
|
-
var safeAsync = require("../safe-async");
|
|
88
87
|
var { LockoutError } = require("../framework-error");
|
|
89
88
|
|
|
90
89
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
@@ -162,10 +161,11 @@ function create(opts) {
|
|
|
162
161
|
|
|
163
162
|
if (!opts.cache || typeof opts.cache !== "object" ||
|
|
164
163
|
typeof opts.cache.get !== "function" ||
|
|
165
|
-
typeof opts.cache.
|
|
166
|
-
typeof opts.cache.
|
|
164
|
+
typeof opts.cache.del !== "function" ||
|
|
165
|
+
typeof opts.cache.update !== "function") {
|
|
167
166
|
throw _err("BAD_OPT", "auth.lockout.create: opts.cache must be a b.cache " +
|
|
168
|
-
"instance (or shape with get/
|
|
167
|
+
"instance (or shape with get/del/update — the failure counter needs " +
|
|
168
|
+
"an atomic update). Pass b.cache.create({...}).");
|
|
169
169
|
}
|
|
170
170
|
_requireString("namespace", opts.namespace);
|
|
171
171
|
|
|
@@ -233,13 +233,6 @@ function create(opts) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
async function _writeState(key, state, ttlMs) {
|
|
237
|
-
try {
|
|
238
|
-
await cache.set(_scopedKey(key), state, { ttlMs: ttlMs });
|
|
239
|
-
} catch (_e) {
|
|
240
|
-
_signalCacheError("set");
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
236
|
|
|
244
237
|
async function _deleteState(key) {
|
|
245
238
|
try {
|
|
@@ -263,108 +256,125 @@ function create(opts) {
|
|
|
263
256
|
|
|
264
257
|
// ---- Public surface ----
|
|
265
258
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
async function
|
|
259
|
+
// The failure counter is a read-modify-write on a shared cache. A plain
|
|
260
|
+
// get -> increment -> set is not atomic, so concurrent recordFailure calls
|
|
261
|
+
// across nodes lose increments and a brute-force attacker spread across nodes
|
|
262
|
+
// stays under the lockout threshold. cache.update runs the whole decision
|
|
263
|
+
// under a compare-and-set (with retry on the cluster backend), so every
|
|
264
|
+
// failure is counted. The mutator is PURE (it may re-run on a CAS retry): it
|
|
265
|
+
// computes the next state, records the outcome for the post-commit audit /
|
|
266
|
+
// observability emits below, and captures any error it raises so a
|
|
267
|
+
// configuration fault surfaces instead of being read as a cache failure.
|
|
268
|
+
async function recordFailure(key, callOpts) {
|
|
276
269
|
_requireKey(key);
|
|
277
270
|
callOpts = callOpts || {};
|
|
278
271
|
var now = clock();
|
|
279
|
-
var
|
|
272
|
+
var outcome = null;
|
|
273
|
+
var mutatorErr = null;
|
|
274
|
+
try {
|
|
275
|
+
await cache.update(_scopedKey(key), function (state) {
|
|
276
|
+
try {
|
|
277
|
+
state = state || null;
|
|
278
|
+
// Currently locked: the lockout itself counts — don't accumulate during
|
|
279
|
+
// the cooldown. Abort (no write); the verdict comes from the read state.
|
|
280
|
+
if (state && state.lockedUntil && state.lockedUntil > now) {
|
|
281
|
+
outcome = { kind: "during-lock", attempts: state.attempts || 0,
|
|
282
|
+
lockNumber: state.lockNumber || 0, lockedUntil: state.lockedUntil };
|
|
283
|
+
return { abort: true };
|
|
284
|
+
}
|
|
285
|
+
// Window decay: failures older than windowMs reset the counter.
|
|
286
|
+
// Lock-number persists so an attacker who sleeps off a lockout doesn't
|
|
287
|
+
// get a fresh ladder rung.
|
|
288
|
+
if (state && state.lastFailureAt && (now - state.lastFailureAt) > windowMs) {
|
|
289
|
+
state = { attempts: 0, lockNumber: state.lockNumber || 0,
|
|
290
|
+
firstFailureAt: null, lastFailureAt: null, lockedUntil: null };
|
|
291
|
+
}
|
|
292
|
+
var attempts = (state && state.attempts) || 0;
|
|
293
|
+
var lockNumber = (state && state.lockNumber) || 0;
|
|
294
|
+
attempts += 1;
|
|
295
|
+
var lockedUntil = null, newLock = false;
|
|
296
|
+
if (attempts >= maxAttempts) {
|
|
297
|
+
lockNumber += 1;
|
|
298
|
+
lockedUntil = now + _resolveDuration(lockoutDurations, lockNumber);
|
|
299
|
+
newLock = true;
|
|
300
|
+
attempts = 0; // counter resets — the lockout window IS the punishment
|
|
301
|
+
}
|
|
302
|
+
var newState = {
|
|
303
|
+
attempts: attempts,
|
|
304
|
+
lockNumber: lockNumber,
|
|
305
|
+
firstFailureAt: (state && state.firstFailureAt) || now,
|
|
306
|
+
lastFailureAt: now,
|
|
307
|
+
lockedUntil: lockedUntil,
|
|
308
|
+
};
|
|
309
|
+
outcome = { kind: "recorded", attempts: attempts, lockNumber: lockNumber,
|
|
310
|
+
lockedUntil: lockedUntil, newLock: newLock };
|
|
311
|
+
// Keep the state only as long as it is meaningful: a non-locked counter
|
|
312
|
+
// expires after the decay window (so check()/attempts() read clean once
|
|
313
|
+
// it has elapsed), and a lock persists until lockedUntil plus a window
|
|
314
|
+
// so the lockNumber survives the cooldown — for the operator's actual
|
|
315
|
+
// configured duration, however long. A DURATION (the cache resolves it
|
|
316
|
+
// against its own clock) so an injectable lockout clock never desyncs
|
|
317
|
+
// the cache's expiry.
|
|
318
|
+
return { value: newState, ttlMs: lockedUntil ? (lockedUntil - now + windowMs) : windowMs };
|
|
319
|
+
} catch (me) {
|
|
320
|
+
mutatorErr = me;
|
|
321
|
+
throw me;
|
|
322
|
+
}
|
|
323
|
+
}, { ttlMs: windowMs });
|
|
324
|
+
} catch (e) {
|
|
325
|
+
// A configuration fault raised by the mutator (e.g. a lockoutDurations
|
|
326
|
+
// function returning an invalid value) must surface — not be swallowed as
|
|
327
|
+
// a backend failure and silently disable the lockout.
|
|
328
|
+
if (mutatorErr) throw mutatorErr;
|
|
329
|
+
// A backend that can't actually commit an atomic update (a get/set-only
|
|
330
|
+
// backend throws UNSUPPORTED at call time) must surface LOUD — failing
|
|
331
|
+
// open here would silently disable lockout. Any other cache error keeps
|
|
332
|
+
// the documented fail-OPEN posture (allow the attempt, signal it).
|
|
333
|
+
if (e && e.code === "UNSUPPORTED") {
|
|
334
|
+
throw _err("CACHE_NO_ATOMIC_UPDATE",
|
|
335
|
+
"auth.lockout: the cache backend does not support atomic update() — the " +
|
|
336
|
+
"failure counter cannot be enforced across nodes on a get/set-only backend; " +
|
|
337
|
+
"use a cache whose backend implements update (the memory or cluster backend).");
|
|
338
|
+
}
|
|
339
|
+
_signalCacheError("update");
|
|
340
|
+
return { locked: false, attempts: 0 };
|
|
341
|
+
}
|
|
280
342
|
|
|
281
|
-
|
|
282
|
-
// additional attempts during the cooldown. The caller saw locked=true
|
|
283
|
-
// from check() OR from a prior failure; this branch handles a caller
|
|
284
|
-
// that calls recordFailure() on a locked account anyway (e.g. they
|
|
285
|
-
// skipped check()).
|
|
286
|
-
if (state && state.lockedUntil && state.lockedUntil > now) {
|
|
343
|
+
if (outcome.kind === "during-lock") {
|
|
287
344
|
_emitObs("auth.lockout.failure_during_lock", { namespace: namespace });
|
|
288
345
|
if (auditFailures) {
|
|
289
346
|
_emitAudit("auth.lockout.failure", key, "denied",
|
|
290
|
-
{ duringLock: true, attempts:
|
|
291
|
-
|
|
292
|
-
lockedUntil: state.lockedUntil,
|
|
293
|
-
reason: callOpts.reason || null },
|
|
347
|
+
{ duringLock: true, attempts: outcome.attempts, lockNumber: outcome.lockNumber,
|
|
348
|
+
lockedUntil: outcome.lockedUntil, reason: callOpts.reason || null },
|
|
294
349
|
callOpts.req);
|
|
295
350
|
}
|
|
296
|
-
return {
|
|
297
|
-
locked: true,
|
|
298
|
-
attempts: state.attempts || 0,
|
|
299
|
-
lockedUntil: state.lockedUntil,
|
|
300
|
-
};
|
|
351
|
+
return { locked: true, attempts: outcome.attempts, lockedUntil: outcome.lockedUntil };
|
|
301
352
|
}
|
|
302
353
|
|
|
303
|
-
// Window decay: failures older than windowMs reset the counter.
|
|
304
|
-
// Lock-number persists across decay windows so an attacker who
|
|
305
|
-
// sleeps off a lockout doesn't get a fresh ladder rung.
|
|
306
|
-
if (state && state.lastFailureAt && (now - state.lastFailureAt) > windowMs) {
|
|
307
|
-
state = {
|
|
308
|
-
attempts: 0,
|
|
309
|
-
lockNumber: state.lockNumber || 0,
|
|
310
|
-
firstFailureAt: null,
|
|
311
|
-
lastFailureAt: null,
|
|
312
|
-
lockedUntil: null,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
var attempts = (state && state.attempts) || 0;
|
|
317
|
-
var lockNumber = (state && state.lockNumber) || 0;
|
|
318
|
-
attempts += 1;
|
|
319
|
-
|
|
320
|
-
var lockedUntil = null;
|
|
321
|
-
var newLock = false;
|
|
322
|
-
if (attempts >= maxAttempts) {
|
|
323
|
-
lockNumber += 1;
|
|
324
|
-
var dur = _resolveDuration(lockoutDurations, lockNumber);
|
|
325
|
-
lockedUntil = now + dur;
|
|
326
|
-
newLock = true;
|
|
327
|
-
attempts = 0; // counter resets — the lockout window IS the punishment
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
var newState = {
|
|
331
|
-
attempts: attempts,
|
|
332
|
-
lockNumber: lockNumber,
|
|
333
|
-
firstFailureAt: (state && state.firstFailureAt) || now,
|
|
334
|
-
lastFailureAt: now,
|
|
335
|
-
lockedUntil: lockedUntil,
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
// TTL: keep the state alive long enough that a follower-up failure
|
|
339
|
-
// after the window/lockout expires still finds the lockNumber. The
|
|
340
|
-
// longer of (windowMs after last failure) or (lockedUntil + windowMs).
|
|
341
|
-
var ttlMs = lockedUntil ? (lockedUntil - now + windowMs) : windowMs;
|
|
342
|
-
await _writeState(key, newState, ttlMs);
|
|
343
|
-
|
|
344
354
|
_emitObs("auth.lockout.failure", { namespace: namespace });
|
|
345
355
|
if (auditFailures) {
|
|
346
356
|
_emitAudit("auth.lockout.failure", key, "failure",
|
|
347
|
-
{ attempts:
|
|
357
|
+
{ attempts: outcome.attempts, lockNumber: outcome.lockNumber,
|
|
348
358
|
reason: callOpts.reason || null },
|
|
349
359
|
callOpts.req);
|
|
350
360
|
}
|
|
351
361
|
|
|
352
|
-
if (newLock) {
|
|
362
|
+
if (outcome.newLock) {
|
|
353
363
|
_emitObs("auth.lockout.engaged", {
|
|
354
364
|
namespace: namespace,
|
|
355
|
-
lockNumber: String(lockNumber),
|
|
365
|
+
lockNumber: String(outcome.lockNumber),
|
|
356
366
|
});
|
|
357
367
|
if (auditEngaged) {
|
|
358
368
|
_emitAudit("auth.lockout.engaged", key, "denied",
|
|
359
|
-
{ lockNumber: lockNumber, lockedUntil: lockedUntil,
|
|
360
|
-
durationMs: lockedUntil - now,
|
|
369
|
+
{ lockNumber: outcome.lockNumber, lockedUntil: outcome.lockedUntil,
|
|
370
|
+
durationMs: outcome.lockedUntil - now,
|
|
361
371
|
reason: callOpts.reason || null },
|
|
362
372
|
callOpts.req);
|
|
363
373
|
}
|
|
364
|
-
return { locked: true, attempts: 0, lockedUntil: lockedUntil };
|
|
374
|
+
return { locked: true, attempts: 0, lockedUntil: outcome.lockedUntil };
|
|
365
375
|
}
|
|
366
376
|
|
|
367
|
-
return { locked: false, attempts: attempts };
|
|
377
|
+
return { locked: false, attempts: outcome.attempts };
|
|
368
378
|
}
|
|
369
379
|
|
|
370
380
|
async function recordSuccess(key, callOpts) {
|
package/lib/cache.js
CHANGED
|
@@ -360,7 +360,8 @@ function _memoryBackend(cfg) {
|
|
|
360
360
|
if (entry) { _untrack(key, entry); entries.delete(key); }
|
|
361
361
|
return { updated: true, deleted: true };
|
|
362
362
|
}
|
|
363
|
-
var effExpires = (decision.
|
|
363
|
+
var effExpires = (decision.ttlMs !== undefined) ? now + decision.ttlMs
|
|
364
|
+
: (decision.expiresAt !== undefined ? decision.expiresAt : expiresAt);
|
|
364
365
|
await set(key, decision.value, effExpires, meta);
|
|
365
366
|
return { updated: true, value: decision.value };
|
|
366
367
|
}
|
|
@@ -640,7 +641,8 @@ function _clusterBackend(cfg) {
|
|
|
640
641
|
// The mutator may pin the entry's expiry to the value's own
|
|
641
642
|
// lifetime (e.g. a grant whose expiresAt the mutator just read);
|
|
642
643
|
// otherwise the caller-resolved ttl applies.
|
|
643
|
-
var effExpires = (decision.
|
|
644
|
+
var effExpires = (decision.ttlMs !== undefined) ? now + decision.ttlMs
|
|
645
|
+
: (decision.expiresAt !== undefined ? decision.expiresAt : expiresAt);
|
|
644
646
|
var storedExpires = (effExpires === Infinity) ? Number.MAX_SAFE_INTEGER : effExpires;
|
|
645
647
|
if (oldRaw === null) {
|
|
646
648
|
// Row was absent/expired — insert, but lose the race if another
|
|
@@ -1186,9 +1188,13 @@ function create(opts) {
|
|
|
1186
1188
|
*
|
|
1187
1189
|
* `mutatorFn` returns one of: `{ value }` to commit the new value,
|
|
1188
1190
|
* `{ abort: data }` to leave the entry untouched and surface `data` to
|
|
1189
|
-
* the caller, or `{ delete: true }` to remove the entry.
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1191
|
+
* the caller, or `{ delete: true }` to remove the entry. A committing
|
|
1192
|
+
* decision may also set the written value's lifetime — `{ value, ttlMs }`
|
|
1193
|
+
* (a duration the backend resolves against its own clock) or
|
|
1194
|
+
* `{ value, expiresAt }` (an absolute time) — when the new value's own
|
|
1195
|
+
* state decides how long it should live; otherwise the call `ttlMs`
|
|
1196
|
+
* applies. The call resolves to `{ updated: true, value }`,
|
|
1197
|
+
* `{ updated: true, deleted: true }`, or `{ aborted: data }`.
|
|
1192
1198
|
*
|
|
1193
1199
|
* @opts
|
|
1194
1200
|
* ttlMs: number | Infinity, // lifetime of the written value; default the instance ttlMs
|
package/lib/file-upload.js
CHANGED
|
@@ -245,9 +245,16 @@ var UPLOAD_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
|
245
245
|
var UPLOAD_ID_MAX_LENGTH = C.BYTES.bytes(128);
|
|
246
246
|
|
|
247
247
|
function _validateUploadId(id) {
|
|
248
|
+
// The char-class allows '.', so the bare path tokens "." and ".." pass the
|
|
249
|
+
// regex; joined under stagingDir they resolve to the staging dir / its PARENT
|
|
250
|
+
// (path.join(stagingDir, "..") escapes), and a later rmSync would recurse into
|
|
251
|
+
// the parent. Reject them before any filesystem op — every public method
|
|
252
|
+
// validates the id here, so this guards init / acceptChunk / finalize /
|
|
253
|
+
// status / cancelUpload alike.
|
|
248
254
|
if (typeof id !== "string" ||
|
|
249
255
|
id.length === 0 ||
|
|
250
256
|
id.length > UPLOAD_ID_MAX_LENGTH ||
|
|
257
|
+
id === "." || id === ".." ||
|
|
251
258
|
!UPLOAD_ID_RE.test(id)) {
|
|
252
259
|
var ID_PREVIEW_CHARS = C.BYTES.bytes(64);
|
|
253
260
|
throw _err("BAD_UPLOAD_ID",
|
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:bf9b0dcd-010c-4ef3-b922-fb94a9cb440b",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-28T12:15:14.692Z",
|
|
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.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.44",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.44",
|
|
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.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.44",
|
|
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.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.44",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|