@warlock.js/cache 4.15.0 → 5.0.0

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 CHANGED
@@ -4,6 +4,27 @@ All notable changes to `@warlock.js/cache` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.0.0 - 2026-08-25
8
+
9
+ ### Changed
10
+
11
+ - This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
12
+
13
+ ## 4.16.0 - 2026-08-18
14
+
15
+ ### Security
16
+
17
+ - **File driver path traversal (Critical):** cache keys were mapped to on-disk paths with `path.resolve(directory, key)` and no sanitization, so a key containing `../` (reachable through `set`/`get`/`remove`/`removeNamespace`, including keys derived from user input via `cached()` auto-keys) escaped the cache directory and allowed arbitrary file read, write, and recursive delete. The file driver now percent-encodes `%`, `/`, and `\` when mapping a key to its directory (each key becomes exactly one contained directory component; the logical `.`-delimited namespace scheme is unchanged) and additionally asserts the resolved path stays inside the cache root, throwing `CacheError` otherwise. Memory/redis/pg key semantics are unaffected.
18
+ - Redis `removeNamespace` now escapes glob metacharacters (`*`, `?`, `[`, `\`) before building its `KEYS` pattern, so a namespace carrying untrusted input can no longer widen the match and delete keys outside its own prefix.
19
+ - Removed the raw `console.log(value)` dump of the full cached payload when `structuredClone` fails in `parseCachedData` — cached values (potentially PII/tokens) no longer leak to stdout; the structured error log with the value's type is kept.
20
+ - **Credential leak via error logging (Medium):** `logError()` and the Redis driver's `connect()` failure path printed the raw `Error` object straight to stdout (`console.log`) or to `log.fatal`, which could include the connection URL — and password — that some Redis/Node client errors echo back in `error.message`/`cause` on connection failure. Both call sites now go through a new `safeErrorInfo()` helper that logs only a redacted `{ message, code }` shape (never the raw error object), with any `scheme://user:pass@` credentials in the message masked to `scheme://[REDACTED]@`. The bare `console.log(error)`/`console.log("Err", error)` calls are gone entirely.
21
+ - **Redis `removeNamespace` blocking `KEYS` scan (Medium):** replaced the blocking `KEYS` command with a non-blocking `SCAN` cursor loop (`client.scanIterator`), so clearing a namespace on a large keyspace no longer stalls the single-threaded Redis event loop for every other tenant/consumer. The existing glob-escaping fix (above) is unchanged.
22
+ - **File driver `removeNamespace` dotted-key gap (Medium):** dotted keys (`ns.a`) are stored as *sibling* directories under the cache root (see the path-traversal fix above), so removing namespace `ns` — which only ever deleted a directory literally named `ns` — silently left every `ns.*` key on disk. `removeNamespace` now lists the cache root's immediate children, decodes each back to its logical key, and removes every directory whose logical key equals the namespace or starts with `<namespace>.`, matching the boundary semantics the `pg` driver already used for the same contract. Honors `globalPrefix` (previously ignored, so a global flush could wipe the whole cache root instead of scoping to the tenant) and preserves the existing path-containment guard.
23
+
24
+ ### Dependencies
25
+
26
+ - Bumped `@mongez/reinforcements` to `^4.0.1`. The major makes `Random.string/nanoid/id/token/uuid` CSPRNG-backed (WebCrypto) and removes `Random.seed()` support; audited this package's source and tests for `Random.seed(` and for seeded/reproducible use of `Random.*`, no hits, so no code changes were needed.
27
+
7
28
  ## 4.2.11
8
29
 
9
30
  ### Changed
package/cjs/index.cjs CHANGED
@@ -460,6 +460,36 @@ function cosineSimilarity(a, b) {
460
460
  if (normA === 0 || normB === 0) return 0;
461
461
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
462
462
  }
463
+ /**
464
+ * Matches userinfo credentials embedded in a connection URL
465
+ * (e.g. `redis://user:pass@host`). Node/Redis/Postgres client errors
466
+ * frequently echo the target connection string — including the password —
467
+ * back in `error.message` on connection failure.
468
+ */
469
+ const CREDENTIALS_IN_URL = /(:\/\/)[^\s/@]+:[^\s/@]+@/g;
470
+ /**
471
+ * Strip embedded `user:pass@` credentials from a string before it reaches
472
+ * any log sink.
473
+ */
474
+ function redactCredentials(message) {
475
+ return message.replace(CREDENTIALS_IN_URL, "$1[REDACTED]@");
476
+ }
477
+ /**
478
+ * Reduce an unknown thrown value to a log-safe `{ message, code? }` shape.
479
+ * Callers must never log the raw error object itself — it may carry the
480
+ * connection URL (with password) in the top-level message, `cause`, or
481
+ * driver-specific fields, all of which `console.log`/`util.inspect` would
482
+ * still print in full.
483
+ */
484
+ function safeErrorInfo(error) {
485
+ if (error instanceof Error) {
486
+ const info = { message: redactCredentials(error.message) };
487
+ const code = error.code;
488
+ if (code) info.code = String(code);
489
+ return info;
490
+ }
491
+ return { message: redactCredentials(String(error)) };
492
+ }
463
493
  let CACHE_FOR = /* @__PURE__ */ function(CACHE_FOR) {
464
494
  /**
465
495
  * Cache for 30 Minutes (in seconds)
@@ -2088,10 +2118,15 @@ var BaseCacheDriver = class {
2088
2118
  }
2089
2119
  /**
2090
2120
  * Log error message
2121
+ *
2122
+ * Never logs the raw `error` object — driver connection errors (Redis,
2123
+ * Postgres, ...) can carry the connection string, including the password,
2124
+ * in the message/cause. Only a redacted `{ message, code }` shape is
2125
+ * passed to the structured logger.
2091
2126
  */
2092
2127
  logError(message, error) {
2093
- _warlock_js_logger.log.error("cache." + this.name, "error", message);
2094
- if (error) console.log(error);
2128
+ if (error) _warlock_js_logger.log.error("cache." + this.name, "error", message, safeErrorInfo(error));
2129
+ else _warlock_js_logger.log.error("cache." + this.name, "error", message);
2095
2130
  }
2096
2131
  /**
2097
2132
  * Get the default TTL in seconds. Parses human-readable strings (`"1h"`, `"30m"`)
@@ -2137,7 +2172,6 @@ var BaseCacheDriver = class {
2137
2172
  try {
2138
2173
  return structuredClone(value);
2139
2174
  } catch (error) {
2140
- console.log(value);
2141
2175
  this.logError(`Failed to clone cached value for ${key}, typeof value: ${typeof value}`, error);
2142
2176
  throw error;
2143
2177
  }
@@ -2293,12 +2327,74 @@ var FileCacheDriver = class extends BaseCacheDriver {
2293
2327
  return "cache.json";
2294
2328
  }
2295
2329
  /**
2330
+ * Map a parsed cache key (or namespace) to its on-disk directory.
2331
+ *
2332
+ * The key must become exactly one directory component: `%` and the path
2333
+ * separators are percent-encoded so a hostile key (`../../etc`,
2334
+ * `..\\..\\evil`) turns into an inert directory name instead of a path
2335
+ * traversal, while distinct keys can never collide after encoding. Dots are
2336
+ * left untouched — the `.`-delimited namespace scheme is purely logical and
2337
+ * only ever produces a single filesystem component here.
2338
+ */
2339
+ keyDirectory(parsedKey) {
2340
+ return this.containedPath(this.encodeKeySegment(parsedKey));
2341
+ }
2342
+ /**
2343
+ * Percent-encode a parsed key into an inert, single-component directory
2344
+ * name. Dots are left untouched — see {@link keyDirectory}.
2345
+ */
2346
+ encodeKeySegment(parsedKey) {
2347
+ return parsedKey.replace(/%/g, "%25").replace(/\//g, "%2F").replace(/\\/g, "%5C");
2348
+ }
2349
+ /**
2350
+ * Reverse of {@link encodeKeySegment} — recovers the logical, dot-delimited
2351
+ * parsed key from an on-disk directory name. Used by {@link removeNamespace}
2352
+ * to test which sibling directories logically belong to a namespace, since
2353
+ * dotted keys (`ns.a`) are stored as sibling directories rather than nested
2354
+ * ones (see {@link keyDirectory}'s doc comment).
2355
+ */
2356
+ decodeKeySegment(encoded) {
2357
+ return encoded.replace(/%2F/g, "/").replace(/%5C/g, "\\").replace(/%25/g, "%");
2358
+ }
2359
+ /**
2360
+ * Resolve `segment` against the cache root and throw when the result lands
2361
+ * outside it — the last line of defense against path traversal, independent
2362
+ * of how the key was encoded.
2363
+ */
2364
+ containedPath(segment) {
2365
+ const base = path.default.resolve(this.directory);
2366
+ const resolved = path.default.resolve(base, segment);
2367
+ const relative = path.default.relative(base, resolved);
2368
+ if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new CacheError(`Cache key resolves outside the cache directory: "${segment}"`);
2369
+ return resolved;
2370
+ }
2371
+ /**
2296
2372
  * {@inheritdoc}
2373
+ *
2374
+ * Dotted keys (`ns.a`) are stored as sibling directories, not nested ones
2375
+ * (see {@link keyDirectory}) — a directory literally named `<namespace>`
2376
+ * rarely exists on its own. Namespace removal therefore has to scan the
2377
+ * cache root's immediate children and remove every directory whose
2378
+ * *logical* (decoded) name equals the namespace or starts with
2379
+ * `<namespace>.`, mirroring the `key = $1 OR key LIKE $2` boundary
2380
+ * semantics the `pg` driver already uses for the same contract.
2297
2381
  */
2298
2382
  async removeNamespace(namespace) {
2383
+ const parsedNamespace = this.parseKey(namespace);
2299
2384
  this.log("clearing", namespace);
2300
2385
  try {
2301
- await (0, _warlock_js_fs.removeDirectoryAsync)(path.default.resolve(this.directory, namespace));
2386
+ const root = this.containedPath("");
2387
+ if (parsedNamespace === "") {
2388
+ await (0, _warlock_js_fs.removeDirectoryAsync)(root);
2389
+ this.log("cleared", namespace);
2390
+ return this;
2391
+ }
2392
+ const prefix = `${parsedNamespace}.`;
2393
+ const entries = await (0, _warlock_js_fs.listDirectoriesAsync)(root).catch(() => []);
2394
+ await Promise.all(entries.map(async (entryPath) => {
2395
+ const decoded = this.decodeKeySegment(path.default.basename(entryPath));
2396
+ if (decoded === parsedNamespace || decoded.startsWith(prefix)) await (0, _warlock_js_fs.removeDirectoryAsync)(entryPath);
2397
+ }));
2302
2398
  this.log("cleared", namespace);
2303
2399
  } catch (error) {}
2304
2400
  return this;
@@ -2322,7 +2418,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
2322
2418
  existing: null
2323
2419
  };
2324
2420
  const data = this.prepareDataForStorage(value, ttl, staleAt);
2325
- const fileDirectory = path.default.resolve(this.directory, parsedKey);
2421
+ const fileDirectory = this.keyDirectory(parsedKey);
2326
2422
  await (0, _warlock_js_fs.ensureDirectoryAsync)(fileDirectory);
2327
2423
  await (0, _warlock_js_fs.putJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName), data);
2328
2424
  if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
@@ -2362,7 +2458,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
2362
2458
  */
2363
2459
  async getEntry(key) {
2364
2460
  const parsedKey = this.parseKey(key);
2365
- const fileDirectory = path.default.resolve(this.directory, parsedKey);
2461
+ const fileDirectory = this.keyDirectory(parsedKey);
2366
2462
  try {
2367
2463
  const entry = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
2368
2464
  if (!entry) return null;
@@ -2378,7 +2474,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
2378
2474
  async get(key) {
2379
2475
  const parsedKey = this.parseKey(key);
2380
2476
  this.log("fetching", parsedKey);
2381
- const fileDirectory = path.default.resolve(this.directory, parsedKey);
2477
+ const fileDirectory = this.keyDirectory(parsedKey);
2382
2478
  try {
2383
2479
  const value = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
2384
2480
  const result = await this.parseCachedData(parsedKey, value);
@@ -2401,7 +2497,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
2401
2497
  async remove(key) {
2402
2498
  const parsedKey = this.parseKey(key);
2403
2499
  this.log("removing", parsedKey);
2404
- const fileDirectory = path.default.resolve(this.directory, parsedKey);
2500
+ const fileDirectory = this.keyDirectory(parsedKey);
2405
2501
  try {
2406
2502
  await (0, _warlock_js_fs.removeDirectoryAsync)(fileDirectory);
2407
2503
  this.log("removed", parsedKey);
@@ -3796,8 +3892,13 @@ var RedisCacheDriver = class extends BaseCacheDriver {
3796
3892
  async removeNamespace(namespace) {
3797
3893
  namespace = this.parseKey(namespace);
3798
3894
  this.log("clearing", namespace);
3799
- const keys = await this.client?.keys(`${namespace}*`);
3800
- if (!keys || keys.length === 0) {
3895
+ const pattern = namespace.replace(/[\\*?[\]]/g, "\\$&");
3896
+ const keys = [];
3897
+ if (this.client) for await (const key of this.client.scanIterator({
3898
+ MATCH: `${pattern}*`,
3899
+ COUNT: 100
3900
+ })) keys.push(key);
3901
+ if (keys.length === 0) {
3801
3902
  this.log("notFound", namespace);
3802
3903
  return;
3803
3904
  }
@@ -3967,15 +4068,15 @@ var RedisCacheDriver = class extends BaseCacheDriver {
3967
4068
  const { createClient } = RedisClient;
3968
4069
  this.client = createClient(clientOptions);
3969
4070
  this.client.on("error", (error) => {
3970
- if (error.code === "ECONNREFUSED") this.log("connectionFailed", error);
3971
- else this.log("error", error.message);
4071
+ const { message } = safeErrorInfo(error);
4072
+ if (error.code === "ECONNREFUSED") this.log("connectionFailed", message);
4073
+ else this.log("error", message);
3972
4074
  });
3973
4075
  await this.client.connect();
3974
4076
  this.log("connected");
3975
4077
  await this.emit("connected");
3976
4078
  } catch (error) {
3977
- console.log("Err", error);
3978
- _warlock_js_logger.log.fatal("cache", "redis", error);
4079
+ _warlock_js_logger.log.fatal("cache", "redis", "Failed to connect", safeErrorInfo(error));
3979
4080
  await this.emit("error", { error });
3980
4081
  }
3981
4082
  }
@@ -4088,5 +4189,7 @@ exports.normalizeToOptions = normalizeToOptions;
4088
4189
  exports.normalizeToRememberOptions = normalizeToRememberOptions;
4089
4190
  exports.parseCacheKey = parseCacheKey;
4090
4191
  exports.parseTtl = parseTtl;
4192
+ exports.redactCredentials = redactCredentials;
4091
4193
  exports.resolveTtl = resolveTtl;
4194
+ exports.safeErrorInfo = safeErrorInfo;
4092
4195
  //# sourceMappingURL=index.cjs.map