@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.
- package/README.md +1 -1
- package/index.js +17 -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.
|
|
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
|
-
|
|
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
|
}
|