@blamejs/core 0.5.2 → 0.5.3

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
11
12
  - **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
12
13
  - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
13
14
 
@@ -50,20 +50,25 @@ var validateOpts = require("../validate-opts");
50
50
  var audit = lazyRequire(function () { return require("../audit"); });
51
51
 
52
52
  // Bot-guard's "trust the proxy header" semantics for actor.ip — the
53
- // audit event records the apparent source even when behind a CDN.
54
- // extractActorContext defaults to socket.remoteAddress; we override.
55
- function _xffIp(req) {
56
- var xff = req.headers && req.headers["x-forwarded-for"];
57
- if (xff) return String(xff).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || null;
53
+ // audit event records the apparent source even when behind a CDN, but
54
+ // only when the operator opts in to trustProxy. Without the opt, we
55
+ // stick to socket.remoteAddress so an attacker-forged XFF can't
56
+ // pollute audit attribution.
57
+ function _xffIpFor(trustProxy) {
58
+ return function (req) {
59
+ return requestHelpers.clientIp(req, { trustProxy: trustProxy });
60
+ };
59
61
  }
60
62
 
61
63
  function create(opts) {
62
64
  opts = opts || {};
63
65
  validateOpts(opts, [
64
66
  "mode", "onlyForHtml", "allowedAgents", "blockedAgents",
65
- "skipPaths", "statusOnBlock", "bodyOnBlock",
67
+ "skipPaths", "statusOnBlock", "bodyOnBlock", "trustProxy",
66
68
  ], "middleware.botGuard");
69
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
70
+ ? opts.trustProxy : false;
71
+ var _xffIp = _xffIpFor(trustProxy);
67
72
  var mode = opts.mode || "block";
68
73
  var onlyForHtml = opts.onlyForHtml !== false;
69
74
  var allowedAgents = (opts.allowedAgents || []).map(function (r) { return r instanceof RegExp ? r : new RegExp(r); });
@@ -49,13 +49,13 @@ var safeUrl = require("../safe-url");
49
49
  var validateOpts = require("../validate-opts");
50
50
  var { defineClass } = require("../framework-error");
51
51
 
52
- // CORS audit events trust the proxy header for actor.ip same
53
- // semantics as bot-guard (the apparent origin is what matters when
54
- // blocking cross-origin attempts behind a CDN).
55
- function _xffIp(req) {
56
- var xff = req.headers && req.headers["x-forwarded-for"];
57
- if (xff) return String(xff).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || null;
52
+ // CORS audit events use the proxy-aware client IP only when the
53
+ // operator opts in via `trustProxy`. Default refuses forwarded
54
+ // headers same boundary as the rest of the v0.5.3 trustProxy sweep.
55
+ function _xffIpFor(trustProxy) {
56
+ return function (req) {
57
+ return requestHelpers.clientIp(req, { trustProxy: trustProxy });
58
+ };
59
59
  }
60
60
 
61
61
  var CorsError = defineClass("CorsError", { alwaysPermanent: true });
@@ -94,26 +94,17 @@ function _canonicalOrigin(input) {
94
94
  // supplied. Works for direct deployments (no proxy); operators behind
95
95
  // a TLS-terminating proxy that doesn't forward correct Host should set
96
96
  // opts.siteOrigin explicitly.
97
- function _inferRequestOrigin(req) {
97
+ function _inferRequestOrigin(req, trustProxy) {
98
98
  if (!req || !req.headers) return null;
99
99
  var host = req.headers.host;
100
100
  if (!host) return null;
101
- // X-Forwarded-Proto wins when present (operator behind a TLS-
102
- // terminator, intermediate set the header). Otherwise infer from
103
- // the socket req.socket.encrypted is set by node:tls.
104
- var fwdProto = req.headers["x-forwarded-proto"];
105
- var proto;
106
- if (typeof fwdProto === "string" && fwdProto.length > 0) {
107
- proto = fwdProto.split(",")[0].trim().toLowerCase();
108
- } else if (req.socket && req.socket.encrypted) {
109
- proto = "https";
110
- } else {
111
- proto = "http";
112
- }
101
+ // Protocol resolution honors the operator's trustProxy opt — without
102
+ // it, X-Forwarded-Proto is ignored as attacker-forgeable.
103
+ var proto = requestHelpers.requestProtocol(req, { trustProxy: trustProxy });
113
104
  return _canonicalOrigin(proto + "://" + host);
114
105
  }
115
106
 
116
- function _isSameOrigin(req, originHeader, configuredSiteOrigins) {
107
+ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy) {
117
108
  // Origin: null + Sec-Fetch-Site: same-origin|none — browser opaqued
118
109
  // the Origin (typically because of Referrer-Policy: no-referrer on the
119
110
  // page) but is also explicitly telling us the request is same-origin.
@@ -133,8 +124,10 @@ function _isSameOrigin(req, originHeader, configuredSiteOrigins) {
133
124
  }
134
125
  return false;
135
126
  }
136
- // Fall back to inferring from the request itself.
137
- var reqOrigin = _inferRequestOrigin(req);
127
+ // Fall back to inferring from the request itself. trustProxy threads
128
+ // through so operators behind a TLS terminator with X-Forwarded-Proto
129
+ // can opt in to consult the header.
130
+ var reqOrigin = _inferRequestOrigin(req, trustProxy);
138
131
  return reqOrigin !== null && reqOrigin === canonOrigin;
139
132
  }
140
133
 
@@ -143,8 +136,11 @@ function create(opts) {
143
136
 
144
137
  validateOpts(opts, [
145
138
  "origins", "siteOrigin", "methods", "headers", "exposeHeaders",
146
- "credentials", "maxAgeSeconds", "refuseUnknown",
139
+ "credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
147
140
  ], "middleware.cors");
141
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
142
+ ? opts.trustProxy : false;
143
+ var _xffIp = _xffIpFor(trustProxy);
148
144
 
149
145
  var origins = opts.origins || [];
150
146
 
@@ -191,7 +187,7 @@ function create(opts) {
191
187
  // Same-origin POST/PUT/etc. carry an Origin header per the Fetch
192
188
  // spec but should not be subject to CORS allow-listing — they're
193
189
  // the operator's own site talking to itself.
194
- if (_isSameOrigin(req, origin, siteOrigins)) return next();
190
+ if (_isSameOrigin(req, origin, siteOrigins, trustProxy)) return next();
195
191
 
196
192
  var matched = _matchOrigin(origin, origins);
197
193
  if (!matched) {
@@ -217,13 +213,36 @@ function create(opts) {
217
213
 
218
214
  if (typeof res.setHeader === "function") {
219
215
  res.setHeader("Access-Control-Allow-Origin", matched);
220
- res.setHeader("Vary", "Origin");
216
+ // Append "Origin" to Vary instead of overwriting — compression /
217
+ // auth helpers may have set their own Vary tokens that the cache
218
+ // layer needs to keep.
219
+ requestHelpers.appendVary(res, "Origin");
221
220
  if (credentials) res.setHeader("Access-Control-Allow-Credentials", "true");
222
221
  res.setHeader("Access-Control-Expose-Headers", exposeHeaders);
223
222
  }
224
223
 
225
224
  if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
226
- // Preflight
225
+ // Preflight. In refuseUnknown mode, validate the requested
226
+ // headers against the configured allow-list — refuse with 403
227
+ // if the client asks for a header we don't allow. Spec says
228
+ // browsers enforce, but server-side enforcement keeps the
229
+ // framework's strict-by-default posture consistent.
230
+ if (refuseUnknown) {
231
+ var requestedHdrs = req.headers["access-control-request-headers"];
232
+ if (requestedHdrs) {
233
+ var allowedSet = headers.toLowerCase().split(",").map(function (s) { return s.trim(); });
234
+ var asked = String(requestedHdrs).toLowerCase().split(",").map(function (s) { return s.trim(); }).filter(Boolean);
235
+ for (var ah = 0; ah < asked.length; ah++) {
236
+ if (allowedSet.indexOf(asked[ah]) === -1) {
237
+ if (typeof res.writeHead === "function") {
238
+ res.writeHead(403, { "Content-Type": "text/plain" });
239
+ res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
240
+ }
241
+ return;
242
+ }
243
+ }
244
+ }
245
+ }
227
246
  if (typeof res.setHeader === "function") {
228
247
  res.setHeader("Access-Control-Allow-Methods", methods);
229
248
  res.setHeader("Access-Control-Allow-Headers", headers);
@@ -98,13 +98,15 @@ function _parseCookieHeader(header) {
98
98
  return out;
99
99
  }
100
100
 
101
- function _isHttps(req) {
102
- if (req && req.socket && req.socket.encrypted) return true;
103
- var fwd = req && req.headers && req.headers["x-forwarded-proto"];
104
- if (typeof fwd === "string" && fwd.length > 0) {
105
- return fwd.split(",")[0].trim().toLowerCase() === "https";
106
- }
107
- return false;
101
+ // `_isHttps` defers to `requestHelpers.requestProtocol` so the
102
+ // per-middleware `trustProxy` opt gates whether X-Forwarded-Proto is
103
+ // consulted. Without trustProxy, an attacker could otherwise forge
104
+ // the header to force the Secure cookie attribute (and inversely,
105
+ // suppress it) on direct-to-server connections.
106
+ function _isHttpsFor(trustProxy) {
107
+ return function (req) {
108
+ return requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https";
109
+ };
108
110
  }
109
111
 
110
112
  function _formatSetCookie(name, value, opts) {
@@ -159,7 +161,11 @@ function create(opts) {
159
161
 
160
162
  validateOpts(opts, [
161
163
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
164
+ "trustProxy",
162
165
  ], "middleware.csrfProtect");
166
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
167
+ ? opts.trustProxy : false;
168
+ var _isHttps = _isHttpsFor(trustProxy);
163
169
 
164
170
  // Tier A — exactly one issuance source.
165
171
  var hasCookie = opts.cookie != null && opts.cookie !== false;
@@ -52,10 +52,15 @@ var clusterStorage = require("../cluster-storage");
52
52
  var audit = lazyRequire(function () { return require("../audit"); });
53
53
  var logger = lazyRequire(function () { return require("../log").boot("rate-limit"); });
54
54
 
55
- function _clientIp(req) {
56
- var fwd = req.headers && req.headers["x-forwarded-for"];
57
- if (fwd) return String(fwd).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || "unknown";
55
+ // `_clientIp` defers to `requestHelpers.clientIp`, threading the
56
+ // per-middleware `trustProxy` opt. Default refuses forwarded headers
57
+ // (returning the socket address only) operators behind a sanitizing
58
+ // reverse proxy opt in via `trustProxy: true` (or a hop count).
59
+ function _clientIpFor(trustProxy) {
60
+ return function (req) {
61
+ var ip = requestHelpers.clientIp(req, { trustProxy: trustProxy });
62
+ return ip || "unknown";
63
+ };
59
64
  }
60
65
 
61
66
  // ---- Memory backend (token bucket) ----
@@ -221,12 +226,15 @@ function create(opts) {
221
226
  opts = opts || {};
222
227
  validateOpts(opts, [
223
228
  "keyFn", "statusOnLimit", "bodyOnLimit", "header", "skipPaths", "scope",
224
- "backend",
229
+ "backend", "trustProxy",
225
230
  // memory backend
226
231
  "burst", "refillPerSecond",
227
232
  // cluster backend
228
233
  "limit", "windowMs", "pruneIntervalMs",
229
234
  ], "middleware.rateLimit");
235
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
236
+ ? opts.trustProxy : false;
237
+ var _clientIp = _clientIpFor(trustProxy);
230
238
  var keyFn = opts.keyFn || _clientIp;
231
239
  var statusOnLimit = opts.statusOnLimit || 429;
232
240
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
@@ -35,6 +35,7 @@
35
35
  * }
36
36
  */
37
37
 
38
+ var requestHelpers = require("../request-helpers");
38
39
  var validateOpts = require("../validate-opts");
39
40
 
40
41
  var DEFAULT_PERMISSIONS = [
@@ -62,8 +63,10 @@ function create(opts) {
62
63
  validateOpts(opts, [
63
64
  "hsts", "contentTypeOptions", "frameOptions", "referrerPolicy",
64
65
  "permissionsPolicy", "coop", "coep", "corp",
65
- "originAgentCluster", "dnsPrefetchControl", "csp",
66
+ "originAgentCluster", "dnsPrefetchControl", "csp", "trustProxy",
66
67
  ], "middleware.securityHeaders");
68
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
69
+ ? opts.trustProxy : false;
67
70
  var hsts = opts.hsts === undefined ? "max-age=63072000; includeSubDomains; preload" : opts.hsts;
68
71
  var ctOpts = opts.contentTypeOptions === undefined ? "nosniff" : opts.contentTypeOptions;
69
72
  var frameOpts = opts.frameOptions === undefined ? "DENY" : opts.frameOptions;
@@ -78,7 +81,14 @@ function create(opts) {
78
81
 
79
82
  return function securityHeaders(req, res, next) {
80
83
  if (typeof res.setHeader !== "function") return next();
81
- if (hsts) res.setHeader("Strict-Transport-Security", hsts);
84
+ // RFC 6797 §7.2: HSTS over plain HTTP is meaningless (UAs ignore
85
+ // it). Skip the header on non-TLS requests so dev-over-HTTP doesn't
86
+ // surface confusing "Strict-Transport-Security on http://" lines.
87
+ // requestProtocol respects trustProxy — operators behind a TLS
88
+ // terminator opt in to read X-Forwarded-Proto.
89
+ if (hsts && requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https") {
90
+ res.setHeader("Strict-Transport-Security", hsts);
91
+ }
82
92
  if (ctOpts) res.setHeader("X-Content-Type-Options", ctOpts);
83
93
  if (frameOpts) res.setHeader("X-Frame-Options", frameOpts);
84
94
  if (refPolicy) res.setHeader("Referrer-Policy", refPolicy);
@@ -56,7 +56,13 @@ function _keyToUrl(baseUrl, key) {
56
56
  }
57
57
  var b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
58
58
  var k = key.startsWith("/") ? key.slice(1) : key;
59
- return b + "/" + k;
59
+ // URL-encode each path segment so reserved characters (?, #, %, space,
60
+ // unicode, etc.) round-trip safely and don't cross-pollute keys
61
+ // (e.g. `a%2Fb` and `a/b` would otherwise collide on the wire).
62
+ // Slashes between segments stay literal (operators use them as a
63
+ // namespace separator, matching S3 / GCS / Azure conventions).
64
+ var encoded = k.split("/").map(encodeURIComponent).join("/");
65
+ return b + "/" + encoded;
60
66
  }
61
67
 
62
68
  function create(config) {
@@ -112,6 +112,71 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
112
112
  return extractActorContext(callerOpts && callerOpts.req, override);
113
113
  }
114
114
 
115
+ // ---- Proxy-trust primitives (v0.5.3) ----
116
+ //
117
+ // `X-Forwarded-For` and `X-Forwarded-Proto` are operator-trust headers —
118
+ // behind a sanitizing reverse proxy they carry the apparent origin /
119
+ // scheme; without one they're attacker-forgeable. Default is to NOT
120
+ // trust them; operators behind a proxy set `trustProxy: true` (or a
121
+ // hop count for multi-hop chains) per-middleware to opt in.
122
+ //
123
+ // clientIp(req, { trustProxy }) → string | null
124
+ //
125
+ // trustProxy false (default): socket.remoteAddress only
126
+ // trustProxy true: leftmost x-forwarded-for hop, else socket
127
+ // trustProxy <integer N>: Nth-from-rightmost xff hop (skip-N-trusted-hops)
128
+ //
129
+ // Middleware accepts `trustProxy` as an opt and threads it through;
130
+ // the framework refuses to silently pick up forwarded headers without
131
+ // the operator's explicit acknowledgement.
132
+
133
+ function clientIp(req, opts) {
134
+ if (!req) return null;
135
+ var trust = opts && opts.trustProxy;
136
+ if (trust && req.headers) {
137
+ var xff = req.headers["x-forwarded-for"];
138
+ if (xff) {
139
+ var hops = String(xff).split(",").map(function (s) { return s.trim(); });
140
+ if (trust === true) return hops[0];
141
+ if (typeof trust === "number" && trust >= 1 && hops.length >= trust) {
142
+ return hops[hops.length - trust];
143
+ }
144
+ }
145
+ }
146
+ if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
147
+ if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
148
+ return null;
149
+ }
150
+
151
+ function requestProtocol(req, opts) {
152
+ if (!req) return "http";
153
+ var trust = opts && opts.trustProxy;
154
+ if (trust && req.headers) {
155
+ var fwd = req.headers["x-forwarded-proto"];
156
+ if (typeof fwd === "string" && fwd.length > 0) {
157
+ return String(fwd).split(",")[0].trim().toLowerCase();
158
+ }
159
+ }
160
+ if (req.socket && req.socket.encrypted) return "https";
161
+ if (req.connection && req.connection.encrypted) return "https";
162
+ return "http";
163
+ }
164
+
165
+ // Append a token to a `Vary` response header without dropping prior
166
+ // values (compression middleware sets `Vary: Accept-Encoding`, an
167
+ // auth helper might set `Vary: Authorization`, etc.). Idempotent —
168
+ // re-adding an existing token is a no-op.
169
+ function appendVary(res, value) {
170
+ if (!res || typeof res.getHeader !== "function" || typeof res.setHeader !== "function") return;
171
+ var existing = res.getHeader("Vary");
172
+ if (existing == null || existing === "") { res.setHeader("Vary", value); return; }
173
+ var tokens = String(existing).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
174
+ var lower = value.toLowerCase();
175
+ for (var i = 0; i < tokens.length; i++) if (tokens[i].toLowerCase() === lower) return;
176
+ tokens.push(value);
177
+ res.setHeader("Vary", tokens.join(", "));
178
+ }
179
+
115
180
  function resolveRoute(req) {
116
181
  if (req && typeof req.routePattern === "string" && req.routePattern.length > 0) {
117
182
  return req.routePattern;
@@ -201,4 +266,8 @@ module.exports = {
201
266
  extractActorContext: extractActorContext,
202
267
  resolveActorWithOverride: resolveActorWithOverride,
203
268
  parseQualityList: parseQualityList,
269
+ // v0.5.3 — proxy-trust primitives (default refuses forwarded headers)
270
+ clientIp: clientIp,
271
+ requestProtocol: requestProtocol,
272
+ appendVary: appendVary,
204
273
  };
package/lib/session.js CHANGED
@@ -128,7 +128,23 @@ async function verify(token) {
128
128
  var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
129
129
  var data = null;
130
130
  if (unsealed.data) {
131
- try { data = safeJson.parse(unsealed.data); } catch (_e) { data = null; }
131
+ try { data = safeJson.parse(unsealed.data); }
132
+ catch (e) {
133
+ // Decrypt-then-parse failure is rare but operationally important —
134
+ // it usually signals key-rotation skew, DB corruption, or
135
+ // tampering. Emit an audit event so ops can spot it before the
136
+ // operator notices empty-`data` flows. data stays null so the
137
+ // session remains usable for non-data flows.
138
+ data = null;
139
+ try {
140
+ audit.safeEmit({
141
+ action: "auth.session.data_unparseable",
142
+ outcome: "failure",
143
+ reason: (e && e.message) || String(e),
144
+ metadata: { hasUserId: !!unsealed.userId },
145
+ });
146
+ } catch (_ignored) { /* audit best-effort */ }
147
+ }
132
148
  }
133
149
  return {
134
150
  userId: unsealed.userId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",