@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 +2 -0
- package/lib/auth/jwt.js +13 -0
- package/lib/break-glass.js +30 -8
- package/lib/cookies.js +11 -1
- package/lib/error-page.js +15 -6
- package/lib/middleware/cors.js +8 -3
- package/lib/middleware/rate-limit.js +8 -0
- package/lib/pagination.js +16 -0
- package/lib/queue.js +7 -2
- package/package.json +1 -1
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) {
|
package/lib/break-glass.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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,
|
package/lib/middleware/cors.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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
|
|