@blamejs/core 0.5.6 → 0.5.8

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
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - **0.5.7** (2026-04-30) — defensive validation + queue closure capture + audit context
12
+ - **0.5.6** (2026-04-30) — break-glass: trustProxy honored, cache require hoisted
11
13
  - **0.5.5** (2026-04-30) — strict default CSP + IPv6 special-range expansion
12
14
  - **0.5.4** (2026-04-30) — close SSRF DNS-rebinding window with pinned outbound DNS
13
15
  - **0.5.3** (2026-04-30) — security cleanup: trustProxy primitive + Vary merge + HSTS gate
package/index.js CHANGED
@@ -106,6 +106,7 @@ var forms = require("./lib/forms");
106
106
  var app = require("./lib/app");
107
107
  var jobs = require("./lib/jobs");
108
108
  var breakGlass = require("./lib/break-glass");
109
+ var uuid = require("./lib/uuid");
109
110
  var mail = require("./lib/mail");
110
111
  var mailBounce = require("./lib/mail-bounce");
111
112
  var websocketChannels = require("./lib/websocket-channels");
@@ -208,6 +209,7 @@ module.exports = {
208
209
  createApp: app.createApp,
209
210
  jobs: jobs,
210
211
  breakGlass: breakGlass,
212
+ uuid: uuid,
211
213
  mail: mail,
212
214
  mailBounce: mailBounce,
213
215
  websocketChannels: websocketChannels,
package/lib/auth/jwt.js CHANGED
@@ -243,6 +243,19 @@ async function verify(token, opts) {
243
243
 
244
244
  // Time-based claim validation
245
245
  var nowSec = Math.floor((opts.now || Date.now()) / 1000);
246
+ if (opts.clockToleranceSec !== undefined && opts.clockToleranceSec !== null) {
247
+ if (typeof opts.clockToleranceSec !== "number" ||
248
+ !isFinite(opts.clockToleranceSec) ||
249
+ opts.clockToleranceSec < 0) {
250
+ // Tier-A: a negative tolerance over-tightens the window (legit
251
+ // tokens get rejected as expired or not-yet-valid). A non-finite
252
+ // value would NaN-poison the comparison. Catch both at config
253
+ // time rather than producing surprise rejections in production.
254
+ throw new AuthError("auth-jwt/bad-clock-tolerance",
255
+ "verify: clockToleranceSec must be a non-negative finite number, got " +
256
+ JSON.stringify(opts.clockToleranceSec));
257
+ }
258
+ }
246
259
  var tol = typeof opts.clockToleranceSec === "number" ? opts.clockToleranceSec : 0;
247
260
  var p = decoded.payload;
248
261
  if (typeof p.exp === "number" && p.exp + tol < nowSec) {
@@ -776,12 +776,23 @@ function _reasonForAudit(reason, mode) {
776
776
 
777
777
  // ---- Use a grant ----
778
778
 
779
- async function unsealRow(grantHandle, table, rowId) {
779
+ async function unsealRow(grantHandle, table, rowId, opts) {
780
780
  _requireInit();
781
781
  if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
782
782
  throw new BreakGlassError("breakglass/bad-grant-opts",
783
783
  "unsealRow: grant handle is required (returned from b.breakGlass.grant())");
784
784
  }
785
+ // Optional opts.req lets the caller thread the originating request
786
+ // into per-row audit emits so the 5 W's (ip / userAgent / sessionId /
787
+ // requestId / method / route) populate alongside the grant's actor
788
+ // userId. Backward-compatible — calls without opts continue to work
789
+ // and simply audit with userId-only actor.
790
+ opts = opts || {};
791
+ function _actorFor(grantRow) {
792
+ return requestHelpers.extractActorContext(opts.req, {
793
+ userId: grantRow.issuedToActorId,
794
+ });
795
+ }
785
796
  if (typeof table !== "string" || table.length === 0) {
786
797
  throw new BreakGlassError("breakglass/bad-grant-opts",
787
798
  "unsealRow: table must be a non-empty string");
@@ -806,7 +817,7 @@ async function unsealRow(grantHandle, table, rowId) {
806
817
  audit.safeEmit({
807
818
  action: "breakglass.unsealrow",
808
819
  outcome: "denied",
809
- actor: { userId: grantRow.issuedToActorId },
820
+ actor: _actorFor(grantRow),
810
821
  reason: "grant-table-mismatch",
811
822
  metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
812
823
  });
@@ -827,7 +838,7 @@ async function unsealRow(grantHandle, table, rowId) {
827
838
  audit.safeEmit({
828
839
  action: "breakglass.grant.expired",
829
840
  outcome: "success",
830
- actor: { userId: grantRow.issuedToActorId },
841
+ actor: _actorFor(grantRow),
831
842
  metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
832
843
  });
833
844
  throw new BreakGlassError("breakglass/grant-expired",
@@ -840,7 +851,7 @@ async function unsealRow(grantHandle, table, rowId) {
840
851
  audit.safeEmit({
841
852
  action: "breakglass.grant.exhausted",
842
853
  outcome: "success",
843
- actor: { userId: grantRow.issuedToActorId },
854
+ actor: _actorFor(grantRow),
844
855
  metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
845
856
  });
846
857
  throw new BreakGlassError("breakglass/grant-exhausted",
@@ -932,7 +943,7 @@ async function unsealRow(grantHandle, table, rowId) {
932
943
  audit.safeEmit({
933
944
  action: "breakglass.unsealrow",
934
945
  outcome: "success",
935
- actor: { userId: grantRow.issuedToActorId },
946
+ actor: _actorFor(grantRow),
936
947
  reason: reasonForAudit.cleartext,
937
948
  metadata: {
938
949
  grantId: grantRow._id,
package/lib/cookies.js CHANGED
@@ -151,7 +151,17 @@ function serialize(name, value, attrs) {
151
151
  parts.push("Expires=" + d.toUTCString());
152
152
  }
153
153
  if (attrs.domain) {
154
- parts.push("Domain=" + _scrubAttr(String(attrs.domain)));
154
+ var dom = _scrubAttr(String(attrs.domain));
155
+ // RFC 6265 §4.1.2.3 + §5.1.3: Domain attribute is a host name.
156
+ // A leading "." is tolerated by browsers (legacy form). Anything
157
+ // else (URLs, paths, spaces, scheme prefixes) makes browsers
158
+ // either ignore the cookie or apply it inconsistently.
159
+ if (!/^\.?[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i.test(dom)) {
160
+ throw new CookieError("cookies/invalid-attr",
161
+ "cookie attr domain must be a valid host name " +
162
+ "(letters/digits/dots/hyphens, optional leading dot), got " + JSON.stringify(dom));
163
+ }
164
+ parts.push("Domain=" + dom);
155
165
  }
156
166
  if (attrs.path !== undefined && attrs.path !== null) {
157
167
  parts.push("Path=" + _scrubAttr(String(attrs.path)));
package/lib/error-page.js CHANGED
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  var lazyRequire = require("./lazy-require");
42
+ var requestHelpers = require("./request-helpers");
42
43
  var template = require("./template");
43
44
  var audit = lazyRequire(function () { return require("./audit"); });
44
45
 
@@ -280,6 +281,10 @@ function create(opts) {
280
281
  var auditAction = typeof opts.auditAction === "string" && opts.auditAction.length > 0
281
282
  ? opts.auditAction
282
283
  : "request.error";
284
+ // Same proxy-trust boundary as the rest of the framework — without
285
+ // trustProxy, X-Forwarded-For is ignored as attacker-forgeable.
286
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
287
+ ? opts.trustProxy : false;
283
288
  // defaultFormat:
284
289
  // "auto" (default) — negotiate via Accept header
285
290
  // "json" — always JSON (API-style middleware default)
@@ -355,12 +360,16 @@ function create(opts) {
355
360
  audit().emit({
356
361
  action: auditAction,
357
362
  outcome: info.status >= 500 ? "failure" : "denied",
358
- actor: {
359
- ip: req && req.headers && req.headers["x-forwarded-for"] ||
360
- (req && req.socket && req.socket.remoteAddress),
361
- userAgent: req && req.headers && req.headers["user-agent"],
362
- sessionId: req && req.session && req.session.sid,
363
- },
363
+ actor: requestHelpers.extractActorContext(req, {
364
+ // Honor the framework's trustProxy boundary — extractActorContext
365
+ // reads from req.socket.remoteAddress, but we want X-Forwarded-For
366
+ // resolution gated by the operator's opt.
367
+ ip: requestHelpers.clientIp(req, { trustProxy: trustProxy }),
368
+ // sessionId from session.sid for back-compat with the
369
+ // pre-extractActorContext shape (extractActorContext also
370
+ // checks session.id; passing both via override is safe).
371
+ sessionId: req && req.session && (req.session.sid || req.session.id),
372
+ }),
364
373
  metadata: {
365
374
  status: info.status,
366
375
  code: info.code,
@@ -174,7 +174,13 @@ function create(opts) {
174
174
  }
175
175
 
176
176
  var methods = (opts.methods || ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).join(", ");
177
- var headers = (opts.headers || ["Content-Type", "Authorization", "X-Request-Id"]).join(", ");
177
+ var headersList = (opts.headers || ["Content-Type", "Authorization", "X-Request-Id"]).slice();
178
+ var headers = headersList.join(", ");
179
+ // Pre-compute the lowercase allowlist once at create() time so the
180
+ // preflight path doesn't re-derive it per-request from the joined
181
+ // string. Avoids coupling allow-list validation to the wire-format
182
+ // serialization.
183
+ var allowedHeadersSet = headersList.map(function (h) { return String(h).trim().toLowerCase(); });
178
184
  var exposeHeaders = (opts.exposeHeaders || ["X-Request-Id"]).join(", ");
179
185
  var credentials = !!opts.credentials;
180
186
  var maxAge = String(opts.maxAgeSeconds || 600);
@@ -230,10 +236,9 @@ function create(opts) {
230
236
  if (refuseUnknown) {
231
237
  var requestedHdrs = req.headers["access-control-request-headers"];
232
238
  if (requestedHdrs) {
233
- var allowedSet = headers.toLowerCase().split(",").map(function (s) { return s.trim(); });
234
239
  var asked = String(requestedHdrs).toLowerCase().split(",").map(function (s) { return s.trim(); }).filter(Boolean);
235
240
  for (var ah = 0; ah < asked.length; ah++) {
236
- if (allowedSet.indexOf(asked[ah]) === -1) {
241
+ if (allowedHeadersSet.indexOf(asked[ah]) === -1) {
237
242
  if (typeof res.writeHead === "function") {
238
243
  res.writeHead(403, { "Content-Type": "text/plain" });
239
244
  res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
@@ -240,6 +240,14 @@ function create(opts) {
240
240
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
241
241
  var emitHeaders = opts.header !== false;
242
242
  var skipPaths = opts.skipPaths || [];
243
+ // Tier-A: each entry must be a string prefix or a RegExp. Anything
244
+ // else would crash _shouldSkip with TypeError on the first request.
245
+ for (var sp = 0; sp < skipPaths.length; sp++) {
246
+ if (typeof skipPaths[sp] !== "string" && !(skipPaths[sp] instanceof RegExp)) {
247
+ throw new Error("middleware.rateLimit: skipPaths[" + sp +
248
+ "] must be a string prefix or RegExp, got " + typeof skipPaths[sp]);
249
+ }
250
+ }
243
251
  var scope = opts.scope || "global";
244
252
 
245
253
  var backend = _resolveBackend(opts);
package/lib/pagination.js CHANGED
@@ -214,6 +214,15 @@ async function cursor(query, opts) {
214
214
  }
215
215
  var limit = _resolveLimit(opts);
216
216
  var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
217
+ // Tier-A on orderBy — the value is interpolated into a raw SQL fragment
218
+ // for the keyset where-clause. Restrict to identifier-safe characters
219
+ // so a careless caller piping `req.query.orderBy` through doesn't
220
+ // create an SQL-injection vector.
221
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
222
+ throw new PaginationError("pagination/bad-orderby",
223
+ "cursor: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
224
+ JSON.stringify(orderBy));
225
+ }
217
226
  var direction = (opts.direction === "desc") ? "desc" : "asc";
218
227
 
219
228
  // Decode incoming cursor (if any) and override direction from cursor.
@@ -332,6 +341,13 @@ async function offset(query, opts) {
332
341
  var page = parseInt(opts.page, 10);
333
342
  if (isNaN(page) || page < 1) page = 1;
334
343
  var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
344
+ // Same Tier-A on offset() as cursor() — orderBy passes through to the
345
+ // db Query; identifier-safe-only to prevent SQL injection.
346
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
347
+ throw new PaginationError("pagination/bad-orderby",
348
+ "offset: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
349
+ JSON.stringify(orderBy));
350
+ }
335
351
  var direction = (opts.direction === "desc") ? "desc" : "asc";
336
352
 
337
353
  // Count gives total — required for totalPages calculation. Cheap on
package/lib/queue.js CHANGED
@@ -70,7 +70,12 @@ function init(opts) {
70
70
  }
71
71
 
72
72
  backends = {};
73
- for (var name in opts.backends) {
73
+ // IIFE per-iteration so each backend's wrappers close over its own
74
+ // raw / breaker / cfg. With `var` (function-scoped) those bindings
75
+ // would otherwise be shared across iterations and every wrapper
76
+ // would end up using the LAST backend's breaker + retry config —
77
+ // multi-backend isolation broken without a single error surfacing.
78
+ Object.keys(opts.backends).forEach(function (name) {
74
79
  var cfg = opts.backends[name];
75
80
  var proto = dispatcher.resolve(cfg.protocol);
76
81
  var raw = proto.create(cfg);
@@ -114,7 +119,7 @@ function init(opts) {
114
119
  dlqRetry: raw.dlqRetry ? wrapWithRetry(raw.dlqRetry) : null,
115
120
  dlqSize: raw.dlqSize ? wrapWithRetry(raw.dlqSize) : null,
116
121
  };
117
- }
122
+ });
118
123
 
119
124
  defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
120
125
 
package/lib/uuid.js ADDED
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * uuid — RFC 4122 v4 (random) + RFC 9562 v7 (time-ordered).
4
+ *
5
+ * Two flavors:
6
+ *
7
+ * b.uuid.v4() — fully random 128-bit UUID. Standard, portable,
8
+ * the default choice when ordering doesn't matter.
9
+ *
10
+ * b.uuid.v7() — Unix-millisecond timestamp prefix + 74 random
11
+ * bits. Time-ordered (sorts by creation time even
12
+ * lexicographically), ideal as a database PK
13
+ * because B-tree inserts stay near the right edge
14
+ * — no random scattering across the index.
15
+ *
16
+ * b.uuid.parse(str) — { ok, version, bytes }. Validates the canonical
17
+ * 8-4-4-4-12 hex form and the version + variant
18
+ * bits. Returns ok:false (no throw) on bad input.
19
+ *
20
+ * b.uuid.isValid(str) — boolean shorthand. No version/variant check
21
+ * beyond shape — operators who care use parse().
22
+ *
23
+ * All entropy comes from `b.crypto.generateBytes`, which routes through
24
+ * `node:crypto.randomBytes` — same source as `crypto.randomUUID()`.
25
+ *
26
+ * Why ship v7 ourselves? Native `crypto.randomUUID()` only emits v4.
27
+ * v7 is the modern recommendation for any UUID landing in a sortable
28
+ * column (jobs queue, audit chain extensions, anything where insertion
29
+ * order matters for index locality).
30
+ */
31
+ var { generateBytes } = require("./crypto");
32
+
33
+ // Canonical UUID layout: 8-4-4-4-12 hex digits, version nibble at byte
34
+ // 6 high-nibble, variant bits at byte 8 high two bits (must be 10).
35
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
36
+ // Loose form for `isValid` shape-only check — accepts any version 1-8 and
37
+ // any variant top-2-bit value. Operators wanting strict version+variant
38
+ // gating use `parse()`.
39
+ var UUID_LOOSE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
+
41
+ function _bytesToString(bytes) {
42
+ var hex = bytes.toString("hex");
43
+ return hex.slice(0, 8) + "-" +
44
+ hex.slice(8, 12) + "-" +
45
+ hex.slice(12, 16) + "-" +
46
+ hex.slice(16, 20) + "-" +
47
+ hex.slice(20, 32);
48
+ }
49
+
50
+ function v4() {
51
+ var b = generateBytes(16);
52
+ // version = 4 (0100): high nibble of byte 6
53
+ b[6] = (b[6] & 0x0f) | 0x40;
54
+ // variant = RFC 4122 (10xx): top two bits of byte 8
55
+ b[8] = (b[8] & 0x3f) | 0x80;
56
+ return _bytesToString(b);
57
+ }
58
+
59
+ function v7(opts) {
60
+ // RFC 9562 §5.7 layout:
61
+ // bytes 0-5 : 48-bit big-endian Unix timestamp in milliseconds
62
+ // bytes 6-7 : version nibble (7) + 12 bits random_a
63
+ // bytes 8-15 : variant bits + 62 bits random_b
64
+ var ms = (opts && typeof opts.now === "number") ? opts.now : Date.now();
65
+ var b = generateBytes(16);
66
+ // 48-bit ms timestamp (big-endian) into bytes 0-5
67
+ // ms can exceed 2^32 (we're in 2026, ms is ~1.78e12), so use Math + bit ops carefully
68
+ var msHi = Math.floor(ms / 0x100000000); // top 16 bits live in low 16 of msHi
69
+ var msLo = ms >>> 0; // bottom 32 bits unsigned
70
+ b[0] = (msHi >> 8) & 0xff;
71
+ b[1] = msHi & 0xff;
72
+ b[2] = (msLo >>> 24) & 0xff;
73
+ b[3] = (msLo >>> 16) & 0xff;
74
+ b[4] = (msLo >>> 8) & 0xff;
75
+ b[5] = msLo & 0xff;
76
+ // version = 7 (0111) in high nibble of byte 6, random_a in low nibble + byte 7
77
+ b[6] = (b[6] & 0x0f) | 0x70;
78
+ // variant = RFC 4122 (10xx) in top two bits of byte 8
79
+ b[8] = (b[8] & 0x3f) | 0x80;
80
+ return _bytesToString(b);
81
+ }
82
+
83
+ function parse(str) {
84
+ if (typeof str !== "string") return { ok: false, reason: "not-a-string" };
85
+ if (!UUID_RE.test(str)) return { ok: false, reason: "malformed" };
86
+ var hex = str.replace(/-/g, "");
87
+ var bytes = Buffer.from(hex, "hex");
88
+ // Version is the high nibble of byte 6.
89
+ var version = (bytes[6] >> 4) & 0x0f;
90
+ // Variant: top two bits of byte 8 must be 10 for RFC 4122 / 9562.
91
+ var variant = (bytes[8] >> 6) & 0x03;
92
+ if (variant !== 0b10) return { ok: false, reason: "bad-variant" };
93
+ return { ok: true, version: version, bytes: bytes };
94
+ }
95
+
96
+ function isValid(str) {
97
+ return typeof str === "string" && UUID_LOOSE_RE.test(str);
98
+ }
99
+
100
+ module.exports = {
101
+ v4: v4,
102
+ v7: v7,
103
+ parse: parse,
104
+ isValid: isValid,
105
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",