@blamejs/core 0.5.5 → 0.5.7

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.6** (2026-04-30) — break-glass: trustProxy honored, cache require hoisted
12
+ - **0.5.5** (2026-04-30) — strict default CSP + IPv6 special-range expansion
11
13
  - **0.5.4** (2026-04-30) — close SSRF DNS-rebinding window with pinned outbound DNS
12
14
  - **0.5.3** (2026-04-30) — security cleanup: trustProxy primitive + Vary merge + HSTS gate
13
15
  - **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
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) {
@@ -39,6 +39,7 @@
39
39
  */
40
40
  var audit = require("./audit");
41
41
  var C = require("./constants");
42
+ var cache = require("./cache");
42
43
  var clusterStorage = require("./cluster-storage");
43
44
  var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
44
45
  var cryptoField = require("./crypto-field");
@@ -76,6 +77,10 @@ var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
76
77
  // Populated on first access per-table; invalidated on policy.set/delete.
77
78
  var policyCache = new Map(); // table -> policy
78
79
  var initialized = false;
80
+ // Framework-wide trustProxy setting (set at init). When true, the
81
+ // break-glass primitive consults X-Forwarded-For to populate the
82
+ // grant row's `ip` field — same trust boundary as middleware.
83
+ var _trustProxy = false;
79
84
 
80
85
  // Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
81
86
  // TOTP codes against break-glass gets shut out after a few failures.
@@ -85,7 +90,6 @@ var _factorLockout = null;
85
90
  var _factorLockoutCache = null;
86
91
  function _ensureFactorLockout() {
87
92
  if (_factorLockout) return _factorLockout;
88
- var cache = require("./cache");
89
93
  _factorLockoutCache = cache.create({
90
94
  namespace: "breakglass.factor",
91
95
  backend: "memory",
@@ -305,10 +309,12 @@ async function migrate(table, opts) {
305
309
 
306
310
  function init(opts) {
307
311
  opts = opts || {};
308
- validateOpts(opts, ["now"], "breakGlass.init");
312
+ validateOpts(opts, ["now", "trustProxy"], "breakGlass.init");
309
313
  initialized = true;
310
314
  policyCache.clear();
311
315
  _factorLockout = null;
316
+ _trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
317
+ ? opts.trustProxy : false;
312
318
  }
313
319
 
314
320
  function _resetForTest() {
@@ -320,6 +326,7 @@ function _resetForTest() {
320
326
  }
321
327
  _factorLockout = null;
322
328
  _factorLockoutCache = null;
329
+ _trustProxy = false;
323
330
  }
324
331
 
325
332
  function _requireInit() {
@@ -695,7 +702,11 @@ async function grant(opts) {
695
702
  var nowMs = Date.now();
696
703
  var grantId = "bg-" + generateToken(16);
697
704
  var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
698
- var ipFromReq = (opts.req && opts.req.socket && opts.req.socket.remoteAddress) || null;
705
+ // Honor the framework-wide trustProxy setting from init() same
706
+ // boundary as middleware. Without trustProxy, X-Forwarded-For is
707
+ // ignored as attacker-forgeable, and the grant pins to the socket
708
+ // remoteAddress only.
709
+ var ipFromReq = requestHelpers.clientIp(opts.req, { trustProxy: _trustProxy });
699
710
 
700
711
  var grantRow = {
701
712
  _id: grantId,
@@ -765,12 +776,23 @@ function _reasonForAudit(reason, mode) {
765
776
 
766
777
  // ---- Use a grant ----
767
778
 
768
- async function unsealRow(grantHandle, table, rowId) {
779
+ async function unsealRow(grantHandle, table, rowId, opts) {
769
780
  _requireInit();
770
781
  if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
771
782
  throw new BreakGlassError("breakglass/bad-grant-opts",
772
783
  "unsealRow: grant handle is required (returned from b.breakGlass.grant())");
773
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
+ }
774
796
  if (typeof table !== "string" || table.length === 0) {
775
797
  throw new BreakGlassError("breakglass/bad-grant-opts",
776
798
  "unsealRow: table must be a non-empty string");
@@ -795,7 +817,7 @@ async function unsealRow(grantHandle, table, rowId) {
795
817
  audit.safeEmit({
796
818
  action: "breakglass.unsealrow",
797
819
  outcome: "denied",
798
- actor: { userId: grantRow.issuedToActorId },
820
+ actor: _actorFor(grantRow),
799
821
  reason: "grant-table-mismatch",
800
822
  metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
801
823
  });
@@ -816,7 +838,7 @@ async function unsealRow(grantHandle, table, rowId) {
816
838
  audit.safeEmit({
817
839
  action: "breakglass.grant.expired",
818
840
  outcome: "success",
819
- actor: { userId: grantRow.issuedToActorId },
841
+ actor: _actorFor(grantRow),
820
842
  metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
821
843
  });
822
844
  throw new BreakGlassError("breakglass/grant-expired",
@@ -829,7 +851,7 @@ async function unsealRow(grantHandle, table, rowId) {
829
851
  audit.safeEmit({
830
852
  action: "breakglass.grant.exhausted",
831
853
  outcome: "success",
832
- actor: { userId: grantRow.issuedToActorId },
854
+ actor: _actorFor(grantRow),
833
855
  metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
834
856
  });
835
857
  throw new BreakGlassError("breakglass/grant-exhausted",
@@ -921,7 +943,7 @@ async function unsealRow(grantHandle, table, rowId) {
921
943
  audit.safeEmit({
922
944
  action: "breakglass.unsealrow",
923
945
  outcome: "success",
924
- actor: { userId: grantRow.issuedToActorId },
946
+ actor: _actorFor(grantRow),
925
947
  reason: reasonForAudit.cleartext,
926
948
  metadata: {
927
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",