@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 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
@@ -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
  };
@@ -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' 'unsafe-inline'; " +
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. 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;
@@ -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];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",