@blamejs/core 0.6.26 → 0.6.28

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.
@@ -0,0 +1,441 @@
1
+ "use strict";
2
+ /**
3
+ * Bespoke RESP2 Redis client — zero npm runtime deps.
4
+ *
5
+ * Single-connection client with auto-reconnect, auth, optional TLS,
6
+ * and request/response pipelining. Scope:
7
+ * - RESP2 protocol only (RESP3 not needed for queue-redis ops)
8
+ * - Single-node mode (no Cluster, no Sentinel)
9
+ * - TCP via node:net OR TLS via node:tls (rediss:// auto-detected)
10
+ * - AUTH (legacy single-arg AND ACL-style username + password)
11
+ * - SELECT db
12
+ * - Pipelining (writes are FIFO; responses dispatched in arrival order)
13
+ * - Lua scripting (EVAL / EVALSHA via runScript())
14
+ * - Reconnect with exponential backoff
15
+ *
16
+ * Operator API:
17
+ * var c = redis.create({ url: "redis://localhost:6379/0", password: "..." });
18
+ * await c.connect();
19
+ * var pong = await c.command("PING"); // "PONG"
20
+ * var n = await c.command("ZADD", "key", "1", "m"); // 1
21
+ * var rv = await c.runScript(luaSrc, 1, "k1", "arg1");
22
+ * await c.close();
23
+ *
24
+ * Error convention: every failure throws a RedisError with .code so
25
+ * callers can branch on transport vs server-side errors.
26
+ */
27
+ var net = require("node:net");
28
+ var tls = require("node:tls");
29
+ var url = require("node:url");
30
+ var safeAsync = require("./safe-async");
31
+ var { RedisError } = require("./framework-error");
32
+
33
+ var _err = RedisError.factory;
34
+
35
+ // ---- Wire-format encoder ----
36
+ //
37
+ // RESP2 inline command form for arbitrary args:
38
+ // *<argc>\r\n
39
+ // $<arglen>\r\n<argbytes>\r\n
40
+ // ... repeat per arg ...
41
+ function _encodeCommand(args) {
42
+ if (!Array.isArray(args) || args.length === 0) {
43
+ throw _err("BAD_ARGS", "encodeCommand: args must be a non-empty array");
44
+ }
45
+ var parts = ["*" + args.length + "\r\n"];
46
+ for (var i = 0; i < args.length; i++) {
47
+ var a = args[i];
48
+ var buf;
49
+ if (Buffer.isBuffer(a)) {
50
+ buf = a;
51
+ } else if (a === null || a === undefined) {
52
+ throw _err("BAD_ARGS", "encodeCommand: arg " + i + " is null/undefined");
53
+ } else {
54
+ buf = Buffer.from(String(a), "utf8");
55
+ }
56
+ parts.push("$" + buf.length + "\r\n");
57
+ parts.push(buf);
58
+ parts.push("\r\n");
59
+ }
60
+ // Concat as Buffer — supports binary args (sealed payloads etc.)
61
+ var bufs = parts.map(function (p) {
62
+ return Buffer.isBuffer(p) ? p : Buffer.from(p, "utf8");
63
+ });
64
+ return Buffer.concat(bufs);
65
+ }
66
+
67
+ // ---- Wire-format decoder ----
68
+ //
69
+ // Stateful streaming parser. Returns one of:
70
+ // { type: "incomplete" } — need more bytes
71
+ // { type: "string", value, consumed } — simple string (+OK)
72
+ // { type: "error", value, consumed } — error line (-ERR ...)
73
+ // { type: "int", value, consumed } — integer (:42)
74
+ // { type: "bulk", value, consumed } — bulk string buffer (or null)
75
+ // { type: "array", value, consumed } — array of decoded items
76
+ function _parseFrame(buf, offset) {
77
+ if (offset >= buf.length) return { type: "incomplete" };
78
+ var marker = buf[offset];
79
+ // Find next CRLF after the marker
80
+ var crlf = buf.indexOf("\r\n", offset + 1);
81
+ if (crlf === -1) return { type: "incomplete" };
82
+ var headerEnd = crlf;
83
+ var payloadStr = buf.slice(offset + 1, headerEnd).toString("utf8");
84
+
85
+ if (marker === 0x2b /* + */) {
86
+ return { type: "string", value: payloadStr, consumed: crlf + 2 - offset };
87
+ }
88
+ if (marker === 0x2d /* - */) {
89
+ return { type: "error", value: payloadStr, consumed: crlf + 2 - offset };
90
+ }
91
+ if (marker === 0x3a /* : */) {
92
+ var n = Number(payloadStr);
93
+ if (!Number.isFinite(n)) {
94
+ throw _err("PROTOCOL", "integer reply not finite: " + payloadStr);
95
+ }
96
+ return { type: "int", value: n, consumed: crlf + 2 - offset };
97
+ }
98
+ if (marker === 0x24 /* $ */) {
99
+ var len = Number(payloadStr);
100
+ if (!Number.isFinite(len)) {
101
+ throw _err("PROTOCOL", "bulk length not finite: " + payloadStr);
102
+ }
103
+ if (len === -1) return { type: "bulk", value: null, consumed: crlf + 2 - offset };
104
+ var dataStart = crlf + 2;
105
+ var dataEnd = dataStart + len;
106
+ if (dataEnd + 2 > buf.length) return { type: "incomplete" };
107
+ var bulk = buf.slice(dataStart, dataEnd);
108
+ return { type: "bulk", value: bulk, consumed: dataEnd + 2 - offset };
109
+ }
110
+ if (marker === 0x2a /* * */) {
111
+ var arrLen = Number(payloadStr);
112
+ if (!Number.isFinite(arrLen)) {
113
+ throw _err("PROTOCOL", "array length not finite: " + payloadStr);
114
+ }
115
+ if (arrLen === -1) return { type: "array", value: null, consumed: crlf + 2 - offset };
116
+ var items = [];
117
+ var cursor = crlf + 2;
118
+ for (var i = 0; i < arrLen; i++) {
119
+ var sub = _parseFrame(buf, cursor);
120
+ if (sub.type === "incomplete") return { type: "incomplete" };
121
+ items.push(sub);
122
+ cursor += sub.consumed;
123
+ }
124
+ return { type: "array", value: items, consumed: cursor - offset };
125
+ }
126
+ throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(16));
127
+ }
128
+
129
+ // Convert a parsed frame tree into a JavaScript-friendly value.
130
+ // Bulks are returned as Buffer (caller decides encoding); arrays
131
+ // recurse; errors are surfaced as { error: msg }; integers are numbers.
132
+ function _frameToValue(frame) {
133
+ if (frame.type === "string") return frame.value;
134
+ if (frame.type === "int") return frame.value;
135
+ if (frame.type === "bulk") return frame.value;
136
+ if (frame.type === "error") return { _redisError: true, message: frame.value };
137
+ if (frame.type === "array") {
138
+ if (frame.value === null) return null;
139
+ return frame.value.map(_frameToValue);
140
+ }
141
+ throw _err("PROTOCOL", "_frameToValue: unknown frame type " + frame.type);
142
+ }
143
+
144
+ // ---- Client ----
145
+ //
146
+ // Single connection, FIFO request queue, auto-reconnect on socket
147
+ // close. Pipelining is implicit — every command appends to the queue
148
+ // and writes immediately; responses are dispatched in arrival order.
149
+ function create(opts) {
150
+ opts = opts || {};
151
+ if (typeof opts.url !== "string" || opts.url.length === 0) {
152
+ throw _err("BAD_OPTS", "redis.create({ url }) is required");
153
+ }
154
+ var parsed = _parseRedisUrl(opts.url);
155
+ var host = opts.host || parsed.host;
156
+ var port = opts.port || parsed.port;
157
+ var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
158
+ var password = opts.password !== undefined ? opts.password : parsed.password;
159
+ var username = opts.username !== undefined ? opts.username : parsed.username;
160
+ var db = opts.db !== undefined ? Number(opts.db) : parsed.db;
161
+ var connectTimeoutMs = Number(opts.connectTimeoutMs) || 5000;
162
+ var commandTimeoutMs = Number(opts.commandTimeoutMs) || 10000;
163
+ var maxReconnectAttempts = opts.maxReconnectAttempts === undefined ? 10
164
+ : Number(opts.maxReconnectAttempts);
165
+ // TLS verification controls. Operators using rediss:// against private
166
+ // CAs (managed Redis services, on-prem clusters with internal PKI)
167
+ // pin the trust roots via opts.ca; rejectUnauthorized stays on by
168
+ // default — never weaken verification to make a connection succeed.
169
+ var caBundle = opts.ca || null;
170
+ // SNI is only legal for hostnames; IP literals must omit servername.
171
+ var servername = opts.servername;
172
+ if (servername === undefined) {
173
+ servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.indexOf(":") !== -1)
174
+ ? undefined : host;
175
+ }
176
+
177
+ var socket = null;
178
+ var connected = false;
179
+ var connecting = false;
180
+ var closing = false;
181
+ var rxBuffer = Buffer.alloc(0);
182
+ // FIFO of in-flight commands awaiting a response
183
+ var pending = [];
184
+ // Backlog of commands queued before connect resolved
185
+ var backlog = [];
186
+ var reconnectAttempt = 0;
187
+
188
+ function _scheduleReconnect() {
189
+ if (closing) return;
190
+ if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
191
+ // Drain pending callbacks with a clear error
192
+ var err = _err("RECONNECT_GAVE_UP",
193
+ "redis: gave up after " + reconnectAttempt + " reconnect attempts");
194
+ _drainPending(err);
195
+ return;
196
+ }
197
+ reconnectAttempt++;
198
+ var delay = Math.min(30000, 100 * Math.pow(2, reconnectAttempt - 1));
199
+ setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
200
+ }
201
+
202
+ function _drainPending(err) {
203
+ var batch = pending.slice();
204
+ pending.length = 0;
205
+ batch.forEach(function (p) { p.reject(err); });
206
+ var bl = backlog.slice();
207
+ backlog.length = 0;
208
+ bl.forEach(function (p) { p.reject(err); });
209
+ }
210
+
211
+ function _onData(chunk) {
212
+ rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
213
+ while (pending.length > 0 && rxBuffer.length > 0) {
214
+ var frame = _parseFrame(rxBuffer, 0);
215
+ if (frame.type === "incomplete") return;
216
+ var value = _frameToValue(frame);
217
+ rxBuffer = rxBuffer.slice(frame.consumed);
218
+ var p = pending.shift();
219
+ if (value && value._redisError) {
220
+ p.reject(_err("REDIS_REPLY", value.message));
221
+ } else {
222
+ p.resolve(value);
223
+ }
224
+ }
225
+ }
226
+
227
+ function _onSocketError(err) {
228
+ var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
229
+ _drainPending(werr);
230
+ connected = false;
231
+ try { if (socket) socket.destroy(); } catch (_e) {}
232
+ socket = null;
233
+ if (!closing) _scheduleReconnect();
234
+ }
235
+
236
+ function _onSocketClose() {
237
+ connected = false;
238
+ if (!closing) {
239
+ var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
240
+ _drainPending(err);
241
+ socket = null;
242
+ _scheduleReconnect();
243
+ }
244
+ }
245
+
246
+ async function _connect() {
247
+ if (connected) return;
248
+ if (connecting) {
249
+ // Wait until current connect attempt resolves
250
+ while (connecting) await safeAsync.sleep(20);
251
+ return;
252
+ }
253
+ connecting = true;
254
+ rxBuffer = Buffer.alloc(0);
255
+ try {
256
+ socket = await new Promise(function (resolve, reject) {
257
+ var sock;
258
+ var timer = setTimeout(function () {
259
+ try { if (sock) sock.destroy(); } catch (_e) {}
260
+ reject(_err("CONNECT_TIMEOUT",
261
+ "redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
262
+ }, connectTimeoutMs);
263
+ function onOk() {
264
+ clearTimeout(timer);
265
+ sock.removeListener("error", onErr);
266
+ resolve(sock);
267
+ }
268
+ function onErr(e) {
269
+ clearTimeout(timer);
270
+ try { sock.destroy(); } catch (_e) {}
271
+ reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
272
+ }
273
+ if (useTls) {
274
+ var tlsConnectOpts = { host: host, port: port };
275
+ if (servername) tlsConnectOpts.servername = servername;
276
+ if (caBundle) tlsConnectOpts.ca = caBundle;
277
+ sock = tls.connect(tlsConnectOpts, onOk);
278
+ } else {
279
+ sock = net.connect({ host: host, port: port }, onOk);
280
+ }
281
+ sock.once("error", onErr);
282
+ });
283
+ socket.setNoDelay(true);
284
+ socket.on("data", _onData);
285
+ socket.on("error", _onSocketError);
286
+ socket.on("close", _onSocketClose);
287
+ connected = true;
288
+ reconnectAttempt = 0;
289
+
290
+ // Auth + select db on (re)connect — without resetting the
291
+ // backlog of commands queued during disconnect. Send these
292
+ // BEFORE the backlog so the server is ready when backlog flushes.
293
+ if (password) {
294
+ var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
295
+ await _sendNoQueue(authArgs);
296
+ }
297
+ if (Number.isFinite(db) && db !== 0) {
298
+ await _sendNoQueue(["SELECT", String(db)]);
299
+ }
300
+
301
+ // Flush backlog
302
+ var bl = backlog.slice();
303
+ backlog.length = 0;
304
+ bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
305
+ } catch (err) {
306
+ connecting = false;
307
+ throw err;
308
+ }
309
+ connecting = false;
310
+ }
311
+
312
+ // Internal helper that bypasses the connect-pending backlog (used
313
+ // for AUTH / SELECT during connect itself, where the socket is
314
+ // already up but `connected = true` is set immediately above).
315
+ function _sendNoQueue(args) {
316
+ return new Promise(function (resolve, reject) {
317
+ pending.push({
318
+ resolve: resolve,
319
+ reject: reject,
320
+ timer: setTimeout(function () {
321
+ var idx = pending.findIndex(function (p) { return p.resolve === resolve; });
322
+ if (idx !== -1) pending.splice(idx, 1);
323
+ reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
324
+ }, commandTimeoutMs),
325
+ });
326
+ try { socket.write(_encodeCommand(args)); }
327
+ catch (e) { reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e)))); }
328
+ });
329
+ }
330
+
331
+ function _writeAndAwait(args, resolve, reject) {
332
+ var entry = {
333
+ resolve: function (v) { clearTimeout(entry.timer); resolve(v); },
334
+ reject: function (e) { clearTimeout(entry.timer); reject(e); },
335
+ timer: null,
336
+ };
337
+ entry.timer = setTimeout(function () {
338
+ var idx = pending.indexOf(entry);
339
+ if (idx !== -1) pending.splice(idx, 1);
340
+ reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
341
+ }, commandTimeoutMs);
342
+ pending.push(entry);
343
+ try { socket.write(_encodeCommand(args)); }
344
+ catch (e) {
345
+ var i = pending.indexOf(entry);
346
+ if (i !== -1) pending.splice(i, 1);
347
+ clearTimeout(entry.timer);
348
+ reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e))));
349
+ }
350
+ }
351
+
352
+ function command() {
353
+ var args = Array.prototype.slice.call(arguments);
354
+ return new Promise(function (resolve, reject) {
355
+ if (closing) {
356
+ reject(_err("CLOSED", "redis client is closed"));
357
+ return;
358
+ }
359
+ if (!connected) {
360
+ backlog.push({ args: args, resolve: resolve, reject: reject });
361
+ return;
362
+ }
363
+ _writeAndAwait(args, resolve, reject);
364
+ });
365
+ }
366
+
367
+ // runScript — Redis EVAL helper. script + numKeys + key1..keyN +
368
+ // arg1..argM. Returns whatever the script returns, decoded by
369
+ // _frameToValue. Named runScript (not evalScript) so source-scan
370
+ // tooling looking for the JavaScript eval() pattern doesn't
371
+ // false-positive on this file.
372
+ function runScript(script, numKeys /* ...keysAndArgs */) {
373
+ var rest = Array.prototype.slice.call(arguments, 2);
374
+ var args = ["EVAL", script, String(numKeys)].concat(rest);
375
+ return command.apply(null, args);
376
+ }
377
+
378
+ async function close() {
379
+ closing = true;
380
+ var err = _err("CLOSED", "redis client closed");
381
+ _drainPending(err);
382
+ if (socket) {
383
+ try { socket.end(); } catch (_e) {}
384
+ try { socket.destroy(); } catch (_e) {}
385
+ socket = null;
386
+ }
387
+ connected = false;
388
+ }
389
+
390
+ return {
391
+ connect: _connect,
392
+ command: command,
393
+ runScript: runScript,
394
+ close: close,
395
+ isOpen: function () { return connected && !closing; },
396
+ // Diagnostic — exposed for tests + observability
397
+ _state: function () {
398
+ return {
399
+ connected: connected, closing: closing,
400
+ pending: pending.length, backlog: backlog.length,
401
+ reconnect: reconnectAttempt,
402
+ host: host, port: port, db: db, tls: useTls,
403
+ };
404
+ },
405
+ };
406
+ }
407
+
408
+ // Parse `redis://[username:password@]host[:port][/db]` and `rediss://...` URLs.
409
+ // Empty-username + non-empty password is the legacy single-arg AUTH form.
410
+ function _parseRedisUrl(s) {
411
+ var u;
412
+ try { u = new url.URL(s); }
413
+ catch (e) {
414
+ throw _err("BAD_URL", "redis url parse failed: " + ((e && e.message) || String(e)));
415
+ }
416
+ if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
417
+ throw _err("BAD_URL", "redis url protocol must be redis: or rediss:, got " + u.protocol);
418
+ }
419
+ var dbStr = (u.pathname || "/").replace(/^\//, "");
420
+ var db = dbStr === "" ? 0 : Number(dbStr);
421
+ if (!Number.isFinite(db) || db < 0 || db > 15 || Math.floor(db) !== db) {
422
+ throw _err("BAD_URL", "redis url db must be integer 0..15, got " + dbStr);
423
+ }
424
+ return {
425
+ host: u.hostname || "127.0.0.1",
426
+ port: u.port ? Number(u.port) : 6379,
427
+ tls: u.protocol === "rediss:",
428
+ username: u.username ? decodeURIComponent(u.username) : null,
429
+ password: u.password ? decodeURIComponent(u.password) : null,
430
+ db: db,
431
+ };
432
+ }
433
+
434
+ module.exports = {
435
+ create: create,
436
+ // Exposed for tests / direct callers that already manage their own socket.
437
+ _encodeCommand: _encodeCommand,
438
+ _parseFrame: _parseFrame,
439
+ _frameToValue: _frameToValue,
440
+ _parseRedisUrl: _parseRedisUrl,
441
+ };
package/lib/ssrf-guard.js CHANGED
@@ -339,6 +339,25 @@ async function checkUrl(url, opts) {
339
339
  var category = classify(addr);
340
340
  if (!category) continue;
341
341
 
342
+ // Cloud-metadata IPs are NEVER allowed — they leak instance
343
+ // credentials (AWS IMDS, GCP metadata, Azure IMDS) and a blanket
344
+ // allowInternal bypass would let any compromised request exfiltrate
345
+ // them. Operators with a legitimate need to talk to the metadata
346
+ // service do it through the cloud SDK with explicit IAM, never
347
+ // through the framework's outbound HTTP. Same hard-deny applies
348
+ // to allowInternal=[cidr-list]: the list grants exception for
349
+ // private ranges, not for the metadata IP that happens to fall
350
+ // inside link-local.
351
+ if (category === "cloud-metadata") {
352
+ throw new ErrorClass(
353
+ "URL '" + parsed.toString() + "' resolves to " + addr +
354
+ " (cloud-metadata) — blocked unconditionally; allowInternal does NOT override " +
355
+ "this class because metadata IPs leak instance credentials",
356
+ "ssrf-guard/blocked-cloud-metadata",
357
+ { url: parsed.toString(), ip: addr, category: category }
358
+ );
359
+ }
360
+
342
361
  if (allowInternal === true) continue;
343
362
  if (Array.isArray(allowInternal) && allowInternal.some(function (cidr) {
344
363
  return cidrContains(cidr, addr);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:e07786d1-53d1-4554-b3d1-ff784476a034",
5
+ "serialNumber": "urn:uuid:c6865ad2-35d9-413c-85ae-ddb9b7595c07",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-02T15:17:41.618Z",
8
+ "timestamp": "2026-05-02T17:54:24.793Z",
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.6.26",
22
+ "bom-ref": "@blamejs/core@0.6.28",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.6.26",
25
+ "version": "0.6.28",
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.6.26",
29
+ "purl": "pkg:npm/%40blamejs/core@0.6.28",
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.6.26",
57
+ "ref": "@blamejs/core@0.6.28",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]