@blamejs/core 0.8.0 → 0.8.4

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/audit-sign.js +1 -1
  3. package/lib/audit.js +62 -2
  4. package/lib/auth/jwt.js +13 -0
  5. package/lib/auth/lockout.js +16 -3
  6. package/lib/auth/oauth.js +15 -1
  7. package/lib/auth/password.js +22 -2
  8. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  9. package/lib/auth/sd-jwt-vc.js +7 -2
  10. package/lib/break-glass.js +53 -14
  11. package/lib/cache-redis.js +1 -1
  12. package/lib/cache.js +6 -1
  13. package/lib/cli.js +3 -3
  14. package/lib/cluster.js +24 -1
  15. package/lib/compliance-ai-act-logging.js +7 -3
  16. package/lib/compliance.js +10 -2
  17. package/lib/config-drift.js +2 -2
  18. package/lib/crypto-field.js +21 -1
  19. package/lib/crypto.js +82 -1
  20. package/lib/db.js +35 -4
  21. package/lib/dev.js +30 -3
  22. package/lib/dual-control.js +19 -1
  23. package/lib/external-db.js +10 -0
  24. package/lib/file-upload.js +30 -3
  25. package/lib/flag.js +1 -1
  26. package/lib/guard-all.js +33 -16
  27. package/lib/guard-csv.js +16 -2
  28. package/lib/guard-html.js +35 -0
  29. package/lib/guard-svg.js +20 -0
  30. package/lib/http-client.js +57 -11
  31. package/lib/inbox.js +34 -10
  32. package/lib/log-stream-syslog.js +8 -0
  33. package/lib/log-stream.js +1 -1
  34. package/lib/mail.js +40 -0
  35. package/lib/middleware/attach-user.js +25 -2
  36. package/lib/middleware/bearer-auth.js +71 -6
  37. package/lib/middleware/body-parser.js +13 -0
  38. package/lib/middleware/cors.js +10 -0
  39. package/lib/middleware/csrf-protect.js +34 -3
  40. package/lib/middleware/dpop.js +3 -3
  41. package/lib/middleware/host-allowlist.js +1 -1
  42. package/lib/middleware/require-aal.js +2 -2
  43. package/lib/middleware/trace-propagate.js +1 -1
  44. package/lib/mtls-ca.js +23 -29
  45. package/lib/mtls-engine-default.js +21 -1
  46. package/lib/network-tls.js +21 -6
  47. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  48. package/lib/observability-otlp-exporter.js +35 -2
  49. package/lib/outbox.js +3 -3
  50. package/lib/permissions.js +10 -1
  51. package/lib/pqc-agent.js +22 -1
  52. package/lib/pubsub.js +8 -4
  53. package/lib/redact.js +26 -1
  54. package/lib/retention.js +26 -0
  55. package/lib/router.js +1 -0
  56. package/lib/scheduler.js +57 -1
  57. package/lib/session.js +3 -3
  58. package/lib/ssrf-guard.js +19 -4
  59. package/lib/static.js +12 -0
  60. package/lib/totp.js +16 -0
  61. package/lib/ws-client.js +158 -9
  62. package/package.json +1 -1
  63. package/sbom.cyclonedx.json +6 -6
package/lib/static.js CHANGED
@@ -100,6 +100,12 @@ var DEFAULT_CONTENT_TYPES = {
100
100
  var DEFAULTS = Object.freeze({
101
101
  defaultMaxAge: DEFAULT_MAX_AGE_SEC,
102
102
  acceptRanges: true,
103
+ // Per-range byte cap — slowloris-range defense. A single Range
104
+ // request that asks for 1 GiB pins a worker on a long read; many
105
+ // concurrent requests asking for the same exhaust the process pool.
106
+ // The cap rejects ranges larger than maxRangeBytes with 416. Set to
107
+ // Infinity to opt out (audited reason).
108
+ maxRangeBytes: C.BYTES.mib(64),
103
109
  // Empty array = no MIME allowlist gate.
104
110
  allowedFileTypes: Object.freeze([]),
105
111
  // Bandwidth + concurrency caps default to 0 = "no cap". Operators opt
@@ -844,6 +850,12 @@ function create(opts) {
844
850
  return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
845
851
  "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
846
852
  }
853
+ if (range && cfg.maxRangeBytes !== Infinity && range.length > cfg.maxRangeBytes) {
854
+ stats.failures += 1;
855
+ _emitObs("staticServe.range_too_large", 1, { route: urlPath });
856
+ return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_too_large",
857
+ "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
858
+ }
847
859
  if (range) {
848
860
  stats.rangeRequests += 1;
849
861
  _emitObs("staticServe.range_requests", 1, { route: urlPath });
package/lib/totp.js CHANGED
@@ -134,6 +134,22 @@ function _resolveOpts(opts) {
134
134
  throw new AuthError("auth-totp/bad-alg",
135
135
  "algorithm must be one of " + SUPPORTED_ALGORITHMS.join(", ") + " (got: " + alg + ")");
136
136
  }
137
+ // SHA-256 is supported for back-compat with authenticator apps that
138
+ // don't yet honor SHA-512. Emit an audit signal each time it's
139
+ // selected so operator compliance dashboards see which accounts run
140
+ // on the weaker hash and can plan the migration.
141
+ if (alg === "sha256") {
142
+ setImmediate(function () {
143
+ try {
144
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense
145
+ auditMod.safeEmit({
146
+ action: "auth.totp.algorithm_downgraded",
147
+ outcome: "success",
148
+ metadata: { algorithm: alg, frameworkDefault: DEFAULT_ALGORITHM },
149
+ });
150
+ } catch (_e) { /* drop-silent */ }
151
+ });
152
+ }
137
153
  var digits = opts.digits != null ? opts.digits : DEFAULT_DIGITS;
138
154
  if (typeof digits !== "number" || digits < 6 || digits > 10) {
139
155
  throw new AuthError("auth-totp/bad-digits", "digits must be 6–10 (got: " + digits + ")");
package/lib/ws-client.js CHANGED
@@ -58,6 +58,8 @@ var fwCrypto = lazyRequire(function () { return require("./crypto"); });
58
58
  var websocket = lazyRequire(function () { return require("./websocket"); });
59
59
  var audit = lazyRequire(function () { return require("./audit"); });
60
60
  var networkTls = lazyRequire(function () { return require("./network-tls"); });
61
+ var safeJson = lazyRequire(function () { return require("./safe-json"); });
62
+ var ssrfGuard = require("./ssrf-guard");
61
63
  var C = require("./constants");
62
64
  var { defineClass } = require("./framework-error");
63
65
 
@@ -164,9 +166,27 @@ function connect(target, opts) {
164
166
  "maxMessageBytes", "maxFrameBytes",
165
167
  "handshakeTimeoutMs", "reconnect",
166
168
  "permessageDeflate", "audit", "origin",
167
- "handshakeGuid",
169
+ "handshakeGuid", "allowInternal",
170
+ "parse", "parser",
171
+ "urlFor", "tlsOptsFor",
168
172
  ], "wsClient.connect");
169
173
 
174
+ // Per-dial state-injection callbacks. Both fire on every connect
175
+ // attempt (initial + each reconnect) so operators can rotate
176
+ // bearer tokens, DNS targets, or TLS material between hops without
177
+ // tearing the client down.
178
+ // urlFor(attempt) -> string — overrides target URL
179
+ // tlsOptsFor(attempt) -> object — overrides TLS opts
180
+ // attempt is a 0-based hop index; 0 is the initial dial.
181
+ if (opts.urlFor != null && typeof opts.urlFor !== "function") {
182
+ throw new WsClientError("ws-client/bad-url-for",
183
+ "wsClient.connect: urlFor must be a function");
184
+ }
185
+ if (opts.tlsOptsFor != null && typeof opts.tlsOptsFor !== "function") {
186
+ throw new WsClientError("ws-client/bad-tls-opts-for",
187
+ "wsClient.connect: tlsOptsFor must be a function");
188
+ }
189
+
170
190
  // Operators with a non-RFC-6455 GUID (private protocols on top of
171
191
  // the WebSocket framing layer, framework-specific handshake variants)
172
192
  // pass a custom handshakeGuid. The server-side b.websocket already
@@ -219,8 +239,29 @@ function connect(target, opts) {
219
239
  permessageDeflate: permessageDeflate,
220
240
  auditOn: auditOn,
221
241
  handshakeGuid: handshakeGuid,
242
+ allowInternal: opts.allowInternal,
243
+ parse: opts.parse || null,
244
+ parser: typeof opts.parser === "function" ? opts.parser : null,
245
+ urlFor: typeof opts.urlFor === "function" ? opts.urlFor : null,
246
+ tlsOptsFor: typeof opts.tlsOptsFor === "function" ? opts.tlsOptsFor : null,
247
+ });
248
+ // SSRF gate — refuse private / loopback / link-local / cloud-metadata /
249
+ // reserved IP destinations by default. Symmetric to b.httpClient. The
250
+ // returned `ips` are pinned through tls.connect / net.connect so the
251
+ // actual TCP connect targets the validated address (closes the DNS-
252
+ // rebinding TOCTOU window). Cloud-metadata IPs are unconditional
253
+ // hard-deny — `allowInternal: true` does not bypass them.
254
+ var hostnameForUrl = parsed.protocol === "wss:" ? "https:" : "http:";
255
+ var probeUrl = new url.URL(hostnameForUrl + "//" + parsed.host + parsed.pathname + parsed.search);
256
+ ssrfGuard.checkUrl(probeUrl, {
257
+ allowInternal: opts.allowInternal,
258
+ errorClass: WsClientError,
259
+ }).then(function (result) {
260
+ client._ssrfPinnedIps = result && result.ips;
261
+ client._dial();
262
+ }).catch(function (e) {
263
+ setImmediate(function () { client._handleSocketError(e); });
222
264
  });
223
- client._dial();
224
265
  return client;
225
266
  }
226
267
 
@@ -278,13 +319,74 @@ class WsClient extends EventEmitter {
278
319
  var opts = this._opts;
279
320
  this._readyState = "connecting";
280
321
 
281
- var parsed = opts.parsedUrl;
322
+ // Per-dial overrides — urlFor swaps the target URL, tlsOptsFor
323
+ // overrides TLS material. Both fire every dial including reconnects
324
+ // so callers can rotate state. urlFor's result is re-validated
325
+ // through ssrfGuard so a hostile upstream can't direct the client
326
+ // at a private address mid-reconnect.
327
+ var attempt = this._reconnectAttempt || 0;
328
+ var dialTarget = opts.target;
329
+ var dialParsed = opts.parsedUrl;
330
+ if (typeof opts.urlFor === "function") {
331
+ try {
332
+ var nextTarget = opts.urlFor(attempt);
333
+ if (typeof nextTarget === "string" && nextTarget.length > 0 && nextTarget !== dialTarget) {
334
+ dialParsed = _parseUrl(nextTarget);
335
+ dialTarget = nextTarget;
336
+ var probeProto = dialParsed.protocol === "wss:" ? "https:" : "http:";
337
+ var probeUrl = new url.URL(probeProto + "//" + dialParsed.host + dialParsed.pathname + dialParsed.search);
338
+ var probe = ssrfGuard.checkUrl(probeUrl, {
339
+ allowInternal: opts.allowInternal,
340
+ errorClass: WsClientError,
341
+ });
342
+ this._ssrfPinnedIps = probe && probe.ips ? probe.ips : null;
343
+ }
344
+ } catch (e) {
345
+ return self._handleSocketError(e);
346
+ }
347
+ }
348
+
349
+ var dialTlsOpts = opts.tlsOpts;
350
+ if (typeof opts.tlsOptsFor === "function") {
351
+ try {
352
+ var override = opts.tlsOptsFor(attempt);
353
+ if (override && typeof override === "object") {
354
+ dialTlsOpts = Object.assign({}, opts.tlsOpts || {}, override);
355
+ }
356
+ } catch (e) {
357
+ return self._handleSocketError(e);
358
+ }
359
+ }
360
+
361
+ var parsed = dialParsed;
282
362
  var port = parsed.port ? parseInt(parsed.port, 10) :
283
363
  (parsed.protocol === "wss:" ? 443 : 80); // allow:raw-byte-literal — TLS / HTTP default port
284
364
  var host = parsed.hostname;
285
365
 
286
366
  function _onError(err) { self._handleSocketError(err); }
287
367
 
368
+ // Pin the connect to the SSRF-validated IPs returned by
369
+ // ssrfGuard.checkUrl — closes the DNS-rebinding TOCTOU window where
370
+ // the gate resolves a public IP and the kernel re-resolves to a
371
+ // private one between the check and the connect.
372
+ var pinnedIps = self._ssrfPinnedIps || null;
373
+ var lookup = null;
374
+ if (pinnedIps && pinnedIps.length > 0) {
375
+ lookup = function (h, lookupOpts, cb) {
376
+ // Node's lookup callback signatures:
377
+ // (err, address, family) for legacy { all: false } (default)
378
+ // (err, addresses) for { all: true }
379
+ if (typeof lookupOpts === "function") { cb = lookupOpts; lookupOpts = {}; }
380
+ var first = pinnedIps[0];
381
+ if (lookupOpts && lookupOpts.all) {
382
+ return cb(null, pinnedIps.map(function (ip) {
383
+ return { address: ip.address, family: ip.family };
384
+ }));
385
+ }
386
+ return cb(null, first.address, first.family);
387
+ };
388
+ }
389
+
288
390
  var socket;
289
391
  if (parsed.protocol === "wss:") {
290
392
  var tls = require("tls"); // allow:inline-require — node:tls only on TLS path
@@ -294,7 +396,8 @@ class WsClient extends EventEmitter {
294
396
  servername: host,
295
397
  rejectUnauthorized: true,
296
398
  minVersion: "TLSv1.3",
297
- }, opts.tlsOpts || {});
399
+ }, dialTlsOpts || {});
400
+ if (lookup) tlsOpts.lookup = lookup;
298
401
  try {
299
402
  var pqcShares = networkTls().pqc.getKeyShares();
300
403
  if (Array.isArray(pqcShares) && pqcShares.length > 0 && !tlsOpts.curves) {
@@ -303,7 +406,9 @@ class WsClient extends EventEmitter {
303
406
  } catch (_e) { /* drop-silent — tls module pre-init or non-Node */ }
304
407
  socket = tls.connect(tlsOpts);
305
408
  } else {
306
- socket = net.connect({ host: host, port: port });
409
+ var netOpts = { host: host, port: port };
410
+ if (lookup) netOpts.lookup = lookup;
411
+ socket = net.connect(netOpts);
307
412
  }
308
413
  this._socket = socket;
309
414
  socket.on("error", _onError);
@@ -406,8 +511,17 @@ class WsClient extends EventEmitter {
406
511
  }
407
512
  var status = parseInt(match[1], 10);
408
513
  if (status !== 101) { // allow:raw-byte-literal — HTTP 101
409
- this._handleSocketError(new WsClientError("ws-client/bad-status",
410
- "handshake response status was " + status + " (expected 101 Switching Protocols)"));
514
+ // Body bytes after the header section are the server's
515
+ // explanation. Surface them on the error so callers can branch
516
+ // on the status code and inspect the body without re-parsing
517
+ // the message string.
518
+ var bodyText = "";
519
+ try { bodyText = rest.toString("utf8"); } catch (_e) { /* drop-silent */ }
520
+ var statusErr = new WsClientError("ws-client/bad-status",
521
+ "handshake response status was " + status + " (expected 101 Switching Protocols)");
522
+ statusErr.status = status;
523
+ statusErr.body = bodyText;
524
+ this._handleSocketError(statusErr);
411
525
  return;
412
526
  }
413
527
 
@@ -624,7 +738,29 @@ class WsClient extends EventEmitter {
624
738
  return;
625
739
  }
626
740
  }
627
- this.emit("message", data, isBinary);
741
+ // Auto-parse text frames as JSON when the operator opted in via
742
+ // `parse: "json"` or supplied `parser: fn`. JSON-only protocols
743
+ // (most modern WS APIs) get a typed message argument without a
744
+ // wrapper layer; parse failures surface as 'error' events
745
+ // rather than crashing the message handler.
746
+ var parsed = data;
747
+ var parsedOk = true;
748
+ if (!isBinary && this._opts.parse === "json") {
749
+ try { parsed = safeJson().parse(data, { maxBytes: this._opts.maxMessageBytes }); }
750
+ catch (e) {
751
+ parsedOk = false;
752
+ this.emit("error", new WsClientError("ws-client/json-parse",
753
+ "text frame is not valid JSON: " + ((e && e.message) || String(e))));
754
+ }
755
+ } else if (!isBinary && typeof this._opts.parser === "function") {
756
+ try { parsed = this._opts.parser(data); }
757
+ catch (e) {
758
+ parsedOk = false;
759
+ this.emit("error", new WsClientError("ws-client/parser-failed",
760
+ "operator parser threw: " + ((e && e.message) || String(e))));
761
+ }
762
+ }
763
+ if (parsedOk) this.emit("message", parsed, isBinary);
628
764
  }
629
765
  }
630
766
 
@@ -741,12 +877,25 @@ class WsClient extends EventEmitter {
741
877
  }
742
878
 
743
879
  _handleSocketError(err) {
880
+ // Swallow post-close socket errors. After a consumer-initiated
881
+ // close() the framework waits for the server to send back its
882
+ // close frame; if the server tears down its end with an
883
+ // ECONNRESET / EPIPE / "premature close" instead of a graceful
884
+ // close frame, the underlying socket emits an error that bubbles
885
+ // up here. From the consumer's perspective the close already
886
+ // happened — surfacing a "socket error" event after `close` was
887
+ // called is noise that hides real bugs.
888
+ if (this._closed && err && (
889
+ err.code === "ECONNRESET" || err.code === "EPIPE" ||
890
+ err.code === "ECONNABORTED" || err.code === "ERR_STREAM_PREMATURE_CLOSE")) {
891
+ return;
892
+ }
744
893
  var permanent = _isPermanentError(err);
745
894
  if (this._opts.auditOn) {
746
895
  try {
747
896
  audit().safeEmit({
748
897
  action: "wsclient.error",
749
- outcome: "fail",
898
+ outcome: "failure",
750
899
  actor: null,
751
900
  metadata: {
752
901
  host: this._opts.parsedUrl.host,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.0",
3
+ "version": "0.8.4",
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:4bde75d4-1385-4f13-b927-d67aa27d910d",
5
+ "serialNumber": "urn:uuid:91e8b760-7fe3-4fef-a317-8b1e4f8cc238",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T18:52:54.402Z",
8
+ "timestamp": "2026-05-06T22:02:35.725Z",
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.8.0",
22
+ "bom-ref": "@blamejs/core@0.8.4",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.0",
25
+ "version": "0.8.4",
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.8.0",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.4",
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.8.0",
57
+ "ref": "@blamejs/core@0.8.4",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]