@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 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
@@ -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 session = http2.connect(u.protocol + "//" + u.host, DEFAULT_H2_TLS_OPTS);
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 session = http2.connect(u.protocol + "//" + u.host);
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
- function _getTransport(u, opts) {
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. Operators on internal mesh
604
- // pass `allowInternal: true` (or a CIDR list for narrower bypass).
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
- // Caller-supplied agent bypasses transport cache (h1 only).
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: "h1",
613
- lib: u.protocol === "https:" ? https : http,
614
- agent: opts.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. The
35
- * resolved IP is the one used for the actual connection — DNS rebinding
36
- * between this check and the connect is out of scope (operators with
37
- * that threat model pin to IP literals or use a sealed internal DNS).
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",