@blamejs/core 0.5.3 → 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 +1 -0
- package/lib/http-client.js +66 -21
- package/lib/ssrf-guard.js +7 -4
- package/package.json +1 -1
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.3** (2026-04-30) — security cleanup: trustProxy primitive + Vary merge + HSTS gate
|
|
11
12
|
- **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
|
|
12
13
|
- **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
|
|
13
14
|
- **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
|
};
|
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;
|