@blamejs/core 0.5.2 → 0.5.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.
- package/CHANGELOG.md +2 -0
- package/lib/http-client.js +66 -21
- package/lib/middleware/bot-guard.js +12 -7
- package/lib/middleware/cors.js +46 -27
- package/lib/middleware/csrf-protect.js +13 -7
- package/lib/middleware/rate-limit.js +13 -5
- package/lib/middleware/security-headers.js +12 -2
- package/lib/object-store/http-put.js +7 -1
- package/lib/request-helpers.js +69 -0
- package/lib/session.js +17 -1
- package/lib/ssrf-guard.js +7 -4
- 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.3** (2026-04-30) — security cleanup: trustProxy primitive + Vary merge + HSTS gate
|
|
12
|
+
- **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
|
|
11
13
|
- **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
|
|
12
14
|
- **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
|
|
13
15
|
|
package/lib/http-client.js
CHANGED
|
@@ -151,7 +151,7 @@ function _originKey(u) {
|
|
|
151
151
|
(u.port || (u.protocol === "https:" ? 443 : 80));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
function _makeH1Transport(u) {
|
|
154
|
+
function _makeH1Transport(u, ips) {
|
|
155
155
|
var lib = u.protocol === "https:" ? https : http;
|
|
156
156
|
// HTTPS path goes through pqcAgent.create so the framework's PQC-only
|
|
157
157
|
// posture is enforced via the single primitive. Cleartext HTTP stays
|
|
@@ -159,14 +159,38 @@ function _makeH1Transport(u) {
|
|
|
159
159
|
var agent = u.protocol === "https:"
|
|
160
160
|
? pqcAgent.create(HTTP_CLIENT_AGENT_OPTS)
|
|
161
161
|
: new lib.Agent(HTTP_CLIENT_AGENT_OPTS);
|
|
162
|
-
return { kind: "h1", lib: lib, agent: agent };
|
|
162
|
+
return { kind: "h1", lib: lib, agent: agent, lookup: _pinnedLookupFor(ips) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Build a `lookup` callback that pins outbound connections to IPs the
|
|
166
|
+
// SSRF guard already validated. Closes the TOCTOU window between
|
|
167
|
+
// SSRF resolution and the kernel's connect — without this, a hostile
|
|
168
|
+
// (or compromised) DNS could rotate the answer between guard-check
|
|
169
|
+
// and connect-time and route the request to a private / metadata IP
|
|
170
|
+
// that bypassed the gate. ips comes from `ssrfGuard.checkUrl` — its
|
|
171
|
+
// classification ran on these exact addresses.
|
|
172
|
+
function _pinnedLookupFor(ips) {
|
|
173
|
+
if (!Array.isArray(ips) || ips.length === 0) return undefined;
|
|
174
|
+
var families = ips.map(function (i) { return { address: i.address, family: i.family || 4 }; });
|
|
175
|
+
return function pinnedLookup(hostname, options, callback) {
|
|
176
|
+
if (typeof options === "function") { callback = options; options = {}; }
|
|
177
|
+
options = options || {};
|
|
178
|
+
if (options.all) {
|
|
179
|
+
callback(null, families);
|
|
180
|
+
} else {
|
|
181
|
+
callback(null, families[0].address, families[0].family);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
163
184
|
}
|
|
164
185
|
|
|
165
186
|
// Connect an h2 session to an HTTPS origin via ALPN. If the server picks
|
|
166
187
|
// http/1.1, fall back to an h1 transport for that origin.
|
|
167
|
-
function _connectHttpsWithAlpn(u) {
|
|
188
|
+
function _connectHttpsWithAlpn(u, ips) {
|
|
168
189
|
return new Promise(function (resolve, reject) {
|
|
169
|
-
var
|
|
190
|
+
var connectOpts = Object.assign({}, DEFAULT_H2_TLS_OPTS);
|
|
191
|
+
var pinned = _pinnedLookupFor(ips);
|
|
192
|
+
if (pinned) connectOpts.lookup = pinned;
|
|
193
|
+
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
170
194
|
var settled = false;
|
|
171
195
|
function _done(t) { if (!settled) { settled = true; resolve(t); } }
|
|
172
196
|
function _fail(err) { if (!settled) { settled = true; reject(err); } }
|
|
@@ -180,7 +204,7 @@ function _connectHttpsWithAlpn(u) {
|
|
|
180
204
|
}
|
|
181
205
|
// Server picked http/1.1 — close the h2 session, return h1 transport.
|
|
182
206
|
try { session.close(); } catch (_e) {}
|
|
183
|
-
_done(_makeH1Transport(u));
|
|
207
|
+
_done(_makeH1Transport(u, ips));
|
|
184
208
|
});
|
|
185
209
|
session.once("error", function (err) {
|
|
186
210
|
try { session.close(); } catch (_e) {}
|
|
@@ -191,9 +215,12 @@ function _connectHttpsWithAlpn(u) {
|
|
|
191
215
|
|
|
192
216
|
// Connect an h2c session (cleartext h2). No ALPN, no fallback — caller
|
|
193
217
|
// has attested via preferH2 that the server speaks h2c.
|
|
194
|
-
function _connectH2c(u) {
|
|
218
|
+
function _connectH2c(u, ips) {
|
|
195
219
|
return new Promise(function (resolve, reject) {
|
|
196
|
-
var
|
|
220
|
+
var connectOpts = {};
|
|
221
|
+
var pinned = _pinnedLookupFor(ips);
|
|
222
|
+
if (pinned) connectOpts.lookup = pinned;
|
|
223
|
+
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
197
224
|
session.once("connect", function () {
|
|
198
225
|
_wireH2Session(session, _originKey(u));
|
|
199
226
|
resolve({ kind: "h2", session: session });
|
|
@@ -218,23 +245,29 @@ function _wireH2Session(session, key) {
|
|
|
218
245
|
});
|
|
219
246
|
}
|
|
220
247
|
|
|
221
|
-
// Async transport selection. Returns Promise<transport>.
|
|
222
|
-
|
|
248
|
+
// Async transport selection. Returns Promise<transport>. `ips` is the
|
|
249
|
+
// validated address list returned by `ssrfGuard.checkUrl`; the transport
|
|
250
|
+
// uses it to pin connections so a hostile DNS rebind can't redirect
|
|
251
|
+
// the actual TCP connect to a private / metadata IP.
|
|
252
|
+
function _getTransport(u, opts, ips) {
|
|
223
253
|
var key = _originKey(u);
|
|
224
254
|
var cached = _transports.get(key);
|
|
225
255
|
if (cached) {
|
|
226
|
-
// Could be a resolved transport OR a pending Promise.
|
|
256
|
+
// Could be a resolved transport OR a pending Promise. Cached
|
|
257
|
+
// transports keep whatever IP pinning was set when they were
|
|
258
|
+
// first created — subsequent SSRF checks still gate the request,
|
|
259
|
+
// and the transport's TCP socket is bound to its original IP.
|
|
227
260
|
return Promise.resolve(cached);
|
|
228
261
|
}
|
|
229
262
|
|
|
230
263
|
var promise;
|
|
231
264
|
if (u.protocol === "https:") {
|
|
232
|
-
promise = _connectHttpsWithAlpn(u);
|
|
265
|
+
promise = _connectHttpsWithAlpn(u, ips);
|
|
233
266
|
} else if (opts && opts.preferH2) {
|
|
234
|
-
promise = _connectH2c(u);
|
|
267
|
+
promise = _connectH2c(u, ips);
|
|
235
268
|
} else {
|
|
236
269
|
// HTTP without preferH2 → h1 only.
|
|
237
|
-
promise = Promise.resolve(_makeH1Transport(u));
|
|
270
|
+
promise = Promise.resolve(_makeH1Transport(u, ips));
|
|
238
271
|
}
|
|
239
272
|
|
|
240
273
|
// Cache the in-flight Promise immediately so concurrent calls
|
|
@@ -600,22 +633,27 @@ function _requestSingle(opts) {
|
|
|
600
633
|
}
|
|
601
634
|
|
|
602
635
|
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
603
|
-
// / reserved IP destinations by default.
|
|
604
|
-
//
|
|
636
|
+
// / reserved IP destinations by default. The returned `ips` are
|
|
637
|
+
// threaded into transport creation so the actual TCP connect pins
|
|
638
|
+
// to those exact addresses, closing the DNS-rebinding TOCTOU window.
|
|
605
639
|
return ssrfGuard.checkUrl(u, {
|
|
606
640
|
allowInternal: opts.allowInternal,
|
|
607
641
|
errorClass: opts.errorClass,
|
|
608
|
-
}).then(function () {
|
|
609
|
-
|
|
642
|
+
}).then(function (ssrfResult) {
|
|
643
|
+
var ips = ssrfResult && ssrfResult.ips;
|
|
644
|
+
// Caller-supplied agent bypasses transport cache (h1 only). The
|
|
645
|
+
// operator owns the agent's connection pool — we still pass the
|
|
646
|
+
// pinned lookup through per-request so the SSRF check's IPs win.
|
|
610
647
|
if (opts.agent) {
|
|
611
648
|
return _requestH1({
|
|
612
|
-
kind:
|
|
613
|
-
lib:
|
|
614
|
-
agent:
|
|
649
|
+
kind: "h1",
|
|
650
|
+
lib: u.protocol === "https:" ? https : http,
|
|
651
|
+
agent: opts.agent,
|
|
652
|
+
lookup: _pinnedLookupFor(ips),
|
|
615
653
|
}, u, opts);
|
|
616
654
|
}
|
|
617
655
|
|
|
618
|
-
return _getTransport(u, opts).then(function (transport) {
|
|
656
|
+
return _getTransport(u, opts, ips).then(function (transport) {
|
|
619
657
|
if (transport.kind === "h2") return _requestH2(transport, u, opts);
|
|
620
658
|
return _requestH1(transport, u, opts);
|
|
621
659
|
});
|
|
@@ -655,6 +693,9 @@ function _requestH1(transport, u, opts) {
|
|
|
655
693
|
agent: transport.agent,
|
|
656
694
|
timeout: typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS,
|
|
657
695
|
};
|
|
696
|
+
// Pin DNS to the IPs the SSRF guard validated. Closes the
|
|
697
|
+
// rebinding TOCTOU between guard-check and actual TCP connect.
|
|
698
|
+
if (transport.lookup) reqOpts.lookup = transport.lookup;
|
|
658
699
|
|
|
659
700
|
if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h1" });
|
|
660
701
|
|
|
@@ -990,4 +1031,8 @@ module.exports = {
|
|
|
990
1031
|
_resetForTest: _resetForTest,
|
|
991
1032
|
_getCachedTransportCount: _getCachedTransportCount,
|
|
992
1033
|
_getCachedTransportKind: _getCachedTransportKind,
|
|
1034
|
+
// Test-only — exposes the SSRF-pinned DNS lookup builder so unit
|
|
1035
|
+
// tests can confirm the callback shape matches Node's documented
|
|
1036
|
+
// `lookup(hostname, options, callback)` contract.
|
|
1037
|
+
_pinnedLookupForTest: _pinnedLookupFor,
|
|
993
1038
|
};
|
|
@@ -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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return (req
|
|
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); });
|
package/lib/middleware/cors.js
CHANGED
|
@@ -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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/request-helpers.js
CHANGED
|
@@ -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); }
|
|
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/lib/ssrf-guard.js
CHANGED
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
* fd00:ec2::254
|
|
32
32
|
*
|
|
33
33
|
* Hostnames are resolved via dns.lookup before classification, so a
|
|
34
|
-
* malicious hostname pointing at a private IP fails the guard.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* malicious hostname pointing at a private IP fails the guard.
|
|
35
|
+
*
|
|
36
|
+
* The validated IPs are returned in the result and `b.httpClient` pins
|
|
37
|
+
* the actual TCP connect to those exact addresses (via a custom
|
|
38
|
+
* `lookup` callback passed to https / http2 connect). A hostile DNS
|
|
39
|
+
* server cannot rebind between guard-check and connect to redirect
|
|
40
|
+
* traffic at a private / metadata address.
|
|
38
41
|
*/
|
|
39
42
|
|
|
40
43
|
var dns = require("node:dns").promises;
|