@blamejs/core 0.5.3 → 0.5.5
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/security-headers.js +7 -1
- package/lib/ssrf-guard.js +38 -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.4** (2026-04-30) — close SSRF DNS-rebinding window with pinned outbound DNS
|
|
12
|
+
- **0.5.3** (2026-04-30) — security cleanup: trustProxy primitive + Vary merge + HSTS gate
|
|
11
13
|
- **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
|
|
12
14
|
- **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
|
|
13
15
|
- **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
|
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
|
};
|
|
@@ -46,10 +46,16 @@ var DEFAULT_PERMISSIONS = [
|
|
|
46
46
|
"screen-wake-lock=()", "sync-xhr=()", "usb=()", "web-share=()", "xr-spatial-tracking=()",
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
+
// Strict CSP — no 'unsafe-inline' on script-src OR style-src. Operators
|
|
50
|
+
// with inline styles / scripts wire `b.middleware.cspNonce()` and use
|
|
51
|
+
// `{{ cspNonce }}` in their views (or req.cspNonce in handlers); the
|
|
52
|
+
// nonce hooks into the policy via the `csp` opt or a custom value.
|
|
53
|
+
// Operators in transitional environments who genuinely need inline
|
|
54
|
+
// styles set `csp` explicitly to the loose form.
|
|
49
55
|
var DEFAULT_CSP =
|
|
50
56
|
"default-src 'self'; " +
|
|
51
57
|
"script-src 'self'; " +
|
|
52
|
-
"style-src 'self'
|
|
58
|
+
"style-src 'self'; " +
|
|
53
59
|
"img-src 'self' data:; " +
|
|
54
60
|
"font-src 'self'; " +
|
|
55
61
|
"connect-src 'self'; " +
|
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;
|
|
@@ -91,6 +94,23 @@ var IPV6_PRIVATE_PREFIX = _ipv6ToBytes("fc00::");
|
|
|
91
94
|
var IPV6_LINK_LOCAL_PREFIX = _ipv6ToBytes("fe80::");
|
|
92
95
|
var IPV6_DOC_PREFIX = _ipv6ToBytes("2001:db8::"); // documentation
|
|
93
96
|
var IPV6_V4_MAPPED_PREFIX = _ipv6ToBytes("::ffff:0:0"); // IPv4-mapped (::ffff:0:0/96)
|
|
97
|
+
// Multicast ff00::/8 — RFC 4291 §2.7. Refused for outbound HTTP same as
|
|
98
|
+
// IPv4 multicast 224/4 — there's no legitimate fetch from a multicast
|
|
99
|
+
// destination on a production gateway.
|
|
100
|
+
var IPV6_MULTICAST_PREFIX = _ipv6ToBytes("ff00::");
|
|
101
|
+
// NAT64 well-known prefix 64:ff9b::/96 — RFC 6052. Translates to IPv4
|
|
102
|
+
// embedded in the lower 32 bits; the framework refuses since the
|
|
103
|
+
// underlying v4 address is what gets contacted and might be private.
|
|
104
|
+
// (Operators with NAT64 deployments flip allowInternal.)
|
|
105
|
+
var IPV6_NAT64_PREFIX = _ipv6ToBytes("64:ff9b::");
|
|
106
|
+
// 6to4 2002::/16 — RFC 3056. Carries an embedded IPv4 in bytes 2-5;
|
|
107
|
+
// hostile use is to tunnel through to a v4 destination the v4 guard
|
|
108
|
+
// would have refused. Refused.
|
|
109
|
+
var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
|
|
110
|
+
// Discard prefix 100::/64 — RFC 6666. Dropped by routers; fetching
|
|
111
|
+
// from it is operationally meaningless and a likely sign of mis-config
|
|
112
|
+
// or attempted exfil to a sinkhole.
|
|
113
|
+
var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
|
|
94
114
|
|
|
95
115
|
// ---- Cloud metadata addresses (string-equality, exact match) ----
|
|
96
116
|
var CLOUD_METADATA_IPS = [
|
|
@@ -232,6 +252,20 @@ function classify(ip) {
|
|
|
232
252
|
if (_ipv6PrefixMatch(IPV6_LINK_LOCAL_PREFIX, 10, bytes)) return "link-local";
|
|
233
253
|
if (_ipv6PrefixMatch(IPV6_PRIVATE_PREFIX, 7, bytes)) return "private";
|
|
234
254
|
if (_ipv6PrefixMatch(IPV6_DOC_PREFIX, 32, bytes)) return "reserved";
|
|
255
|
+
if (_ipv6PrefixMatch(IPV6_MULTICAST_PREFIX, 8, bytes)) return "reserved";
|
|
256
|
+
if (_ipv6PrefixMatch(IPV6_DISCARD_PREFIX, 64, bytes)) return "reserved";
|
|
257
|
+
// 6to4 (2002::/16) embeds a v4 address in bytes 2–5; classify the
|
|
258
|
+
// embedded v4 so a 6to4-wrapped private/metadata address is refused
|
|
259
|
+
// for the same reason its v4 form would be.
|
|
260
|
+
if (_ipv6PrefixMatch(IPV6_6TO4_PREFIX, 16, bytes)) {
|
|
261
|
+
var v4From6to4 = bytes[2] + "." + bytes[3] + "." + bytes[4] + "." + bytes[5];
|
|
262
|
+
return classify(v4From6to4) || "reserved";
|
|
263
|
+
}
|
|
264
|
+
// NAT64 well-known prefix (64:ff9b::/96) embeds v4 in bytes 12–15.
|
|
265
|
+
if (_ipv6PrefixMatch(IPV6_NAT64_PREFIX, 96, bytes)) {
|
|
266
|
+
var v4FromNat64 = bytes[12] + "." + bytes[13] + "." + bytes[14] + "." + bytes[15];
|
|
267
|
+
return classify(v4FromNat64) || "reserved";
|
|
268
|
+
}
|
|
235
269
|
// IPv4-mapped addresses (::ffff:a.b.c.d/96): re-classify the v4 portion.
|
|
236
270
|
if (_ipv6PrefixMatch(IPV6_V4_MAPPED_PREFIX, 96, bytes)) {
|
|
237
271
|
var mappedV4 = bytes[12] + "." + bytes[13] + "." + bytes[14] + "." + bytes[15];
|