@bonniernews/stayput 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.js +17 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,7 +87,7 @@ stayput is a client-side footgun guard — the last line of defence, not the pla
87
87
 
88
88
  ## How it works
89
89
 
90
- Patches `net.Socket.prototype.connect` (sync throw on blocked IP literals; unix sockets always allowed) and `dns.lookup` (hostnames are vetted when they resolve). TLS and undici ride on `net.Socket`, so they're covered for free. This is a footgun guard, not a security boundary.
90
+ Patches `net.Socket.prototype.connect` (sync throw on blocked IP literals; unix sockets always allowed) and `dns.lookup` (hostnames are vetted when they resolve). TLS, http/https and undici ride on `net.Socket`, so they're covered for free. `server.listen` resolves its host through `dns.lookup` too, so binding is allowed for loopback and for the unspecified addresses (`0.0.0.0`, `::`) — binding a specific non-local IP needs `STAYPUT_ALLOW`. This is a footgun guard, not a security boundary.
91
91
 
92
92
  Requires node ≥ 20.6 (preloaded in CI with `NODE_OPTIONS=--import …/register.js`).
93
93
 
package/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import net from 'node:net';
9
9
  import dns from 'node:dns';
10
10
 
11
- const VERSION = '0.1.0';
11
+ const VERSION = '0.1.2';
12
12
  const STATE_KEY = Symbol.for('bonnier.stayput');
13
13
 
14
14
  // ---- address helpers ------------------------------------------------------
@@ -48,6 +48,14 @@ function isLoopback(address) {
48
48
  return unmapped === 'localhost' || unmapped === '::1' || inCidr4(unmapped, '127.0.0.0/8');
49
49
  }
50
50
 
51
+ // the unspecified addresses. `server.listen(port, host)` resolves host through dns.lookup, so a
52
+ // test binding 0.0.0.0 would otherwise be refused for "connecting" to it. As a connect target they
53
+ // mean this machine anyway, so allowing them costs nothing.
54
+ // ponytail: a bind to a specific non-local IP is still refused — STAYPUT_ALLOW is the escape hatch.
55
+ function isUnspecified(address) {
56
+ return address === '0.0.0.0' || address === '::' || address === '0:0:0:0:0:0:0:0';
57
+ }
58
+
51
59
  function isPrivate(address) {
52
60
  const unmapped = unmapIPv4(address);
53
61
  if (/^f[cd]/i.test(unmapped) || /^fe80:/i.test(unmapped)) return true; // IPv6 ULA + link-local
@@ -64,7 +72,7 @@ function matchesList(list, target) {
64
72
  function addressOk(address, state, hostAllowed) {
65
73
  if (matchesList(state.deny, address)) return false;
66
74
  if (hostAllowed || matchesList(state.allow, address)) return true;
67
- if (isLoopback(address)) return true;
75
+ if (isLoopback(address) || isUnspecified(address)) return true;
68
76
  return state.privateOk && isPrivate(address);
69
77
  }
70
78
 
@@ -98,13 +106,19 @@ function makePatchedConnect(originalConnect) {
98
106
  if (firstArg !== null && typeof firstArg === 'object') ({ host, port, path } = firstArg);
99
107
  else if (typeof firstArg === 'string' && Number.isNaN(Number(firstArg))) path = firstArg; // mirrors node's pipe-name detection
100
108
  else { port = firstArg; if (typeof args[1] === 'string') host = args[1]; }
101
- if (path === undefined) { // unix sockets always allowed
109
+ // unix sockets always allowed. Truthiness, not `=== undefined`: node's http/https agent
110
+ // passes `path: null` for every TCP request, which would skip the check entirely
111
+ if (!path) {
102
112
  const targetHost = String(host === undefined ? 'localhost' : host).toLowerCase();
103
113
  const isIpLiteral = net.isIP(unmapIPv4(targetHost)) !== 0;
104
114
  // non-IP hostnames (unless deny-listed) are vetted async in the dns.lookup patch —
105
115
  // connect is sync, DNS isn't
106
116
  if (matchesList(state.deny, targetHost) || (isIpLiteral && !addressOk(targetHost, state, false))) {
107
117
  notifyBlock(state, targetHost, port);
118
+ // drivers arm a connect timeout before attaching their socket 'error' listener (pg does),
119
+ // so a sync throw leaves nothing listening: destroying the socket first makes the driver's
120
+ // own destroy(err) a no-op instead of an unhandled 'error' event killing the test run
121
+ this.destroy();
108
122
  throw blockedError(`${targetHost}:${port}`); // sync throw — driver retry loops swallow emitted errors
109
123
  }
110
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonniernews/stayput",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Default-deny network guard for Node.js test environments — blocks non-local TCP at the socket level.",
5
5
  "license": "MIT",
6
6
  "repository": {