@eryx-org/eryx 0.0.0 → 0.15.0

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 (63) hide show
  1. package/README.md +281 -4
  2. package/eryx-sandbox.conditional-host-import-0.wasm +0 -0
  3. package/eryx-sandbox.conditional-host-import-1.wasm +0 -0
  4. package/eryx-sandbox.conditional-suspending-1-i32-to-i32.wasm +0 -0
  5. package/eryx-sandbox.core.wasm +0 -0
  6. package/eryx-sandbox.core10.wasm +0 -0
  7. package/eryx-sandbox.core11.wasm +0 -0
  8. package/eryx-sandbox.core12.wasm +0 -0
  9. package/eryx-sandbox.core13.wasm +0 -0
  10. package/eryx-sandbox.core2.wasm +0 -0
  11. package/eryx-sandbox.core3.wasm +0 -0
  12. package/eryx-sandbox.core4.wasm +0 -0
  13. package/eryx-sandbox.core5.wasm +0 -0
  14. package/eryx-sandbox.core6.wasm +0 -0
  15. package/eryx-sandbox.core7.wasm +0 -0
  16. package/eryx-sandbox.core8.wasm +0 -0
  17. package/eryx-sandbox.core9.wasm +0 -0
  18. package/eryx-sandbox.d.ts +163 -0
  19. package/eryx-sandbox.js +43541 -0
  20. package/index.d.ts +331 -0
  21. package/index.js +698 -0
  22. package/instance.js +174 -0
  23. package/interfaces/eryx-net-tcp.d.ts +30 -0
  24. package/interfaces/eryx-net-tls.d.ts +24 -0
  25. package/interfaces/wasi-cli-environment.d.ts +3 -0
  26. package/interfaces/wasi-cli-exit.d.ts +3 -0
  27. package/interfaces/wasi-cli-stderr.d.ts +3 -0
  28. package/interfaces/wasi-cli-stdin.d.ts +3 -0
  29. package/interfaces/wasi-cli-stdout.d.ts +3 -0
  30. package/interfaces/wasi-cli-terminal-input.d.ts +8 -0
  31. package/interfaces/wasi-cli-terminal-output.d.ts +8 -0
  32. package/interfaces/wasi-cli-terminal-stderr.d.ts +3 -0
  33. package/interfaces/wasi-cli-terminal-stdin.d.ts +3 -0
  34. package/interfaces/wasi-cli-terminal-stdout.d.ts +3 -0
  35. package/interfaces/wasi-clocks-monotonic-clock.d.ts +8 -0
  36. package/interfaces/wasi-clocks-wall-clock.d.ts +7 -0
  37. package/interfaces/wasi-filesystem-preopens.d.ts +3 -0
  38. package/interfaces/wasi-filesystem-types.d.ts +207 -0
  39. package/interfaces/wasi-io-error.d.ts +8 -0
  40. package/interfaces/wasi-io-poll.d.ts +10 -0
  41. package/interfaces/wasi-io-streams.d.ts +33 -0
  42. package/interfaces/wasi-random-random.d.ts +2 -0
  43. package/interfaces/wasi-sockets-instance-network.d.ts +3 -0
  44. package/interfaces/wasi-sockets-ip-name-lookup.d.ts +15 -0
  45. package/interfaces/wasi-sockets-network.d.ts +92 -0
  46. package/interfaces/wasi-sockets-tcp-create-socket.d.ts +5 -0
  47. package/interfaces/wasi-sockets-tcp.d.ts +52 -0
  48. package/interfaces/wasi-sockets-udp-create-socket.d.ts +5 -0
  49. package/interfaces/wasi-sockets-udp.d.ts +51 -0
  50. package/package.json +69 -3
  51. package/python-stdlib.tar.gz +0 -0
  52. package/shims/callbacks.d.ts +45 -0
  53. package/shims/callbacks.js +469 -0
  54. package/shims/ip.js +46 -0
  55. package/shims/net-fetch.js +264 -0
  56. package/shims/net-node.js +236 -0
  57. package/shims/net.d.ts +84 -0
  58. package/shims/net.js +255 -0
  59. package/shims/preview2-node.js +35 -0
  60. package/shims/raw-json.js +111 -0
  61. package/shims/sockets.js +53 -0
  62. package/stdlib-loader.js +174 -0
  63. package/worker.js +100 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Browser transport for the eryx:net shims: HTTP(S) over fetch().
3
+ *
4
+ * Browsers can't open raw sockets, but TLS is terminated host-side (Python's
5
+ * ssl module calls tls.upgrade), so everything the guest writes is plaintext
6
+ * HTTP. We buffer the request bytes, replay them with fetch() on the first
7
+ * read, and serve the response back as raw HTTP/1.1 bytes.
8
+ *
9
+ * Limits: HTTP only, one request per connection (responses always say
10
+ * `Connection: close`), and CORS applies. Requests always go to the host the
11
+ * guest connected to, and redirects aren't followed: the guest's HTTP client
12
+ * sees the 3xx and connects again, through the policy. Browsers hide a
13
+ * redirect's target, so there a redirect fails instead.
14
+ */
15
+
16
+ // windows-1252 maps every byte to one UTF-16 unit, so string offsets of the
17
+ // decoded request are byte offsets into it.
18
+ import { embeddedIPv4, ipv4Bytes, ipv6Bytes } from "./ip.js";
19
+
20
+ const latin1 = new TextDecoder("latin1");
21
+ const utf8 = new TextEncoder();
22
+
23
+ const tcpErr = (val) => ({ payload: val });
24
+ // tls-error wraps tcp-level failures in its `tcp` case.
25
+ const tlsErr = (val) => ({ payload: { tag: "tcp", val } });
26
+
27
+ // Set by the browser (or rejected by fetch) rather than taken from the guest.
28
+ const SKIP_REQUEST_HEADERS = new Set([
29
+ "host", "connection", "content-length", "transfer-encoding", "keep-alive",
30
+ "proxy-connection", "upgrade", "te", "trailer", "accept-encoding",
31
+ ]);
32
+ // fetch() has already decoded and de-chunked the body; we re-frame it.
33
+ const SKIP_RESPONSE_HEADERS = new Set([
34
+ "content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive",
35
+ ]);
36
+
37
+ // HTTP/2 and HTTP/3 have no reason phrase, so statusText is usually empty.
38
+ // Python's http.HTTPStatus phrases, for clients that show the reason.
39
+ const REASONS = {
40
+ 100: "Continue", 101: "Switching Protocols", 200: "OK", 201: "Created", 202: "Accepted",
41
+ 203: "Non-Authoritative Information", 204: "No Content", 205: "Reset Content",
42
+ 206: "Partial Content", 207: "Multi-Status", 300: "Multiple Choices", 301: "Moved Permanently",
43
+ 302: "Found", 303: "See Other", 304: "Not Modified", 307: "Temporary Redirect",
44
+ 308: "Permanent Redirect", 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required",
45
+ 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
46
+ 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict", 410: "Gone",
47
+ 411: "Length Required", 412: "Precondition Failed", 413: "Content Too Large",
48
+ 414: "URI Too Long", 415: "Unsupported Media Type", 416: "Range Not Satisfiable",
49
+ 417: "Expectation Failed", 418: "I'm a Teapot", 421: "Misdirected Request",
50
+ 422: "Unprocessable Content", 423: "Locked", 424: "Failed Dependency", 425: "Too Early",
51
+ 426: "Upgrade Required", 428: "Precondition Required", 429: "Too Many Requests",
52
+ 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons",
53
+ 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
54
+ 503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported",
55
+ 507: "Insufficient Storage", 508: "Loop Detected", 511: "Network Authentication Required",
56
+ };
57
+
58
+ /**
59
+ * `(address, family) => allowed` for `blockedNetworks` minus
60
+ * `allowedNetworks`, matching like the Node transport's BlockList. Throws a
61
+ * TypeError on an invalid network address.
62
+ */
63
+ export function networkFilter({ blockedNetworks, allowedNetworks }) {
64
+ const parse = (cidrs) =>
65
+ cidrs.map((cidr) => {
66
+ const [addr, prefix] = cidr.split("/");
67
+ const bytes = addr.includes(":") ? ipv6Bytes(addr) : ipv4Bytes(addr);
68
+ if (!bytes) throw new TypeError(`invalid CIDR '${cidr}'`);
69
+ return { bytes, prefix: Number(prefix) };
70
+ });
71
+ const inNet = (b, { bytes, prefix }) => {
72
+ if (b.length !== bytes.length) return false;
73
+ for (let bit = 0; bit < prefix; bit++) {
74
+ const mask = 0x80 >> bit % 8;
75
+ if ((b[bit >> 3] & mask) !== (bytes[bit >> 3] & mask)) return false;
76
+ }
77
+ return true;
78
+ };
79
+ const blocked = parse(blockedNetworks);
80
+ const allowed = parse(allowedNetworks);
81
+ const matches = (nets, b) => {
82
+ const v4 = b.length === 16 ? embeddedIPv4(b) : null;
83
+ return nets.some((n) => inNet(b, n) || (v4 !== null && inNet(v4, n)));
84
+ };
85
+ return (address, family) => {
86
+ const b = family === 6 ? ipv6Bytes(address) : ipv4Bytes(address);
87
+ return b !== null && (!matches(blocked, b) || matches(allowed, b));
88
+ };
89
+ }
90
+
91
+ /** 4 or 6 if `host` is an IP address (in the canonical form net.js passes), else 0. */
92
+ const ipFamily = (host) => (host.includes(":") ? 6 : ipv4Bytes(host) ? 4 : 0);
93
+
94
+ function open(conns, c) {
95
+ const h = conns.next++;
96
+ conns.set(h, c);
97
+ return h;
98
+ }
99
+
100
+ function concat(chunks) {
101
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
102
+ let off = 0;
103
+ for (const c of chunks) {
104
+ out.set(c, off);
105
+ off += c.length;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function parseRequest(buf) {
111
+ const text = latin1.decode(buf);
112
+ const headEnd = text.indexOf("\r\n\r\n");
113
+ const [requestLine, ...lines] = text.slice(0, Math.max(headEnd, 0)).split("\r\n");
114
+ const m = /^([A-Z]+) (\S+) HTTP\/1\.[01]$/.exec(requestLine);
115
+ if (headEnd < 0 || !m) {
116
+ throw { tag: "not-permitted", val: "only HTTP(S) requests are supported in the browser" };
117
+ }
118
+ const headers = new Headers();
119
+ let contentLength = 0;
120
+ for (const line of lines) {
121
+ const i = line.indexOf(":");
122
+ if (i <= 0) continue;
123
+ const name = line.slice(0, i).trim().toLowerCase();
124
+ const value = line.slice(i + 1).trim();
125
+ if (name === "content-length") contentLength = Number(value);
126
+ if (name === "transfer-encoding" && value.toLowerCase() !== "identity") {
127
+ throw { tag: "io-error", val: "chunked request bodies are not supported in the browser" };
128
+ }
129
+ if (!SKIP_REQUEST_HEADERS.has(name)) headers.append(name, value);
130
+ }
131
+ const body = buf.subarray(headEnd + 4, headEnd + 4 + contentLength);
132
+ if (body.length < contentLength) {
133
+ throw { tag: "io-error", val: "connection read before the HTTP request body was fully written" };
134
+ }
135
+ return { method: m[1], target: m[2], headers, body };
136
+ }
137
+
138
+ async function send(c, timeoutMs) {
139
+ const req = parseRequest(concat(c.request));
140
+ const defaultPort = c.scheme === "https" ? 443 : 80;
141
+ const host = c.host.includes(":") ? `[${c.host}]` : c.host;
142
+ const origin = `${c.scheme}://${c.port === defaultPort ? host : `${host}:${c.port}`}`;
143
+ // The URL always comes from the connection. An absolute-form target (as
144
+ // sent to a proxy) is only accepted for the connected origin.
145
+ let path = req.target;
146
+ if (!path.startsWith("/")) {
147
+ let target = null;
148
+ try {
149
+ target = new URL(path);
150
+ } catch {}
151
+ if (target?.origin !== origin) {
152
+ throw { tag: "not-permitted", val: `request target '${path}' is not on the connected host ${origin}` };
153
+ }
154
+ path = target.pathname + target.search;
155
+ }
156
+ const url = origin + path;
157
+
158
+ let resp;
159
+ try {
160
+ resp = await fetch(url, {
161
+ method: req.method,
162
+ headers: req.headers,
163
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
164
+ // Never let sandboxed code ride the embedding page's cookies.
165
+ credentials: "omit",
166
+ // Following a redirect would reach hosts the policy never checked.
167
+ redirect: "manual",
168
+ signal: timeoutMs ? AbortSignal.any([c.abort.signal, AbortSignal.timeout(timeoutMs)]) : c.abort.signal,
169
+ });
170
+ } catch (e) {
171
+ if (e?.name === "TimeoutError") throw { tag: "timed-out" };
172
+ throw {
173
+ tag: "io-error",
174
+ val: `fetch ${url} failed: ${e?.message ?? e} (in the browser, the server must allow cross-origin requests)`,
175
+ };
176
+ }
177
+
178
+ if (resp.type === "opaqueredirect") {
179
+ throw { tag: "io-error", val: `${url} redirected; browsers hide the redirect target, so it can't be followed` };
180
+ }
181
+ const body = req.method === "HEAD" ? new Uint8Array(0) : new Uint8Array(await resp.arrayBuffer());
182
+ let head = `HTTP/1.1 ${resp.status} ${resp.statusText || (REASONS[resp.status] ?? "")}\r\n`;
183
+ for (const [name, value] of resp.headers) {
184
+ if (!SKIP_RESPONSE_HEADERS.has(name)) head += `${name}: ${value}\r\n`;
185
+ }
186
+ if (req.method !== "HEAD") head += `content-length: ${body.length}\r\n`;
187
+ head += "connection: close\r\n\r\n";
188
+ return concat([utf8.encode(head), body]);
189
+ }
190
+
191
+ function write(conns, h, data, wrapErr) {
192
+ const c = conns.get(h);
193
+ if (!c) throw wrapErr({ tag: "invalid-handle" });
194
+ if (c.response) throw wrapErr({ tag: "io-error", val: "only one HTTP request per connection is supported in the browser" });
195
+ c.request.push(data.slice());
196
+ return data.length;
197
+ }
198
+
199
+ async function read(conns, h, len, timeoutMs, wrapErr) {
200
+ const c = conns.get(h);
201
+ if (!c) throw wrapErr({ tag: "invalid-handle" });
202
+ c.response ??= send(c, timeoutMs);
203
+ let bytes;
204
+ try {
205
+ bytes = await c.response;
206
+ } catch (e) {
207
+ throw wrapErr(e);
208
+ }
209
+ const out = bytes.subarray(c.offset, c.offset + len);
210
+ c.offset += out.length;
211
+ return out;
212
+ }
213
+
214
+ /** `isAllowed` is a networkFilter() result, or null to skip the address check. */
215
+ function connect(conns, host, port, isAllowed) {
216
+ // Browsers resolve names themselves, so only IP addresses can be checked.
217
+ const family = ipFamily(host);
218
+ if (family && isAllowed && !isAllowed(host, family)) {
219
+ throw tcpErr({ tag: "not-permitted", val: `host '${host}' resolves to a blocked network` });
220
+ }
221
+ const abort = new AbortController();
222
+ return open(conns, { host, port, scheme: "http", request: [], response: null, offset: 0, abort });
223
+ }
224
+
225
+ // The SNI hostname is ignored: fetch() verifies the certificate against the
226
+ // URL host, and keeping the connect() host stops an upgrade from retargeting
227
+ // an allowed connection at a host the policy never checked.
228
+ function upgrade(conns, tcpHandle) {
229
+ const c = conns.get(tcpHandle);
230
+ if (!c) throw tlsErr({ tag: "invalid-handle" });
231
+ conns.delete(tcpHandle);
232
+ return open(conns, { ...c, scheme: "https" });
233
+ }
234
+
235
+ /**
236
+ * A transport with its own connection table, so each sandbox instance only
237
+ * sees (and closes) its own handles.
238
+ */
239
+ export function createTransport() {
240
+ const conns = new Map();
241
+ conns.next = 1;
242
+ const close = (h) => {
243
+ conns.get(h)?.abort.abort();
244
+ conns.delete(h);
245
+ };
246
+ return {
247
+ openConnections: () => conns.size,
248
+ closeAll() {
249
+ for (const h of [...conns.keys()]) close(h);
250
+ },
251
+ tcp: {
252
+ connect: async (host, port, _timeoutMs, isAllowed) => connect(conns, host, port, isAllowed),
253
+ read: (h, len, timeoutMs) => read(conns, h, len, timeoutMs, tcpErr),
254
+ write: async (h, _timeoutMs, data) => write(conns, h, data, tcpErr),
255
+ close,
256
+ },
257
+ tls: {
258
+ upgrade: async (h, _hostname, _timeoutMs) => upgrade(conns, h),
259
+ read: (h, len, timeoutMs) => read(conns, h, len, timeoutMs, tlsErr),
260
+ write: async (h, _timeoutMs, data) => write(conns, h, data, tlsErr),
261
+ close,
262
+ },
263
+ };
264
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Node.js transport for the eryx:net shims: real TCP/TLS via node:net and
3
+ * node:tls. Host policy is checked in net.js; the resolved-address check
4
+ * happens here, since only the transport sees the address it connects to.
5
+ */
6
+
7
+ import { embeddedIPv4, ipv6Bytes } from "./ip.js";
8
+
9
+ // Received bytes buffered per connection before the socket is paused until
10
+ // the guest reads, so a fast peer can't fill host memory.
11
+ const HIGH_WATER_BYTES = 1 << 20;
12
+
13
+ const tcpErr = (val) => ({ payload: val });
14
+ // tls-error wraps tcp-level failures in its `tcp` case.
15
+ const tlsErr = (val) => ({ payload: { tag: "tcp", val } });
16
+
17
+ function track(conns, socket) {
18
+ const c = { socket, chunks: [], buffered: 0, ended: false, error: null, wake: null };
19
+ const notify = () => {
20
+ const w = c.wake;
21
+ c.wake = null;
22
+ w?.();
23
+ };
24
+ c.onData = (b) => {
25
+ c.chunks.push(new Uint8Array(b));
26
+ c.buffered += b.length;
27
+ if (c.buffered >= HIGH_WATER_BYTES) socket.pause();
28
+ notify();
29
+ };
30
+ c.onEnd = () => { c.ended = true; notify(); };
31
+ c.onError = (e) => { c.error = e; notify(); };
32
+ socket.on("data", c.onData).on("end", c.onEnd).on("error", c.onError);
33
+ const h = conns.next++;
34
+ conns.set(h, c);
35
+ return h;
36
+ }
37
+
38
+ function untrack(conns, h) {
39
+ const c = conns.get(h);
40
+ conns.delete(h);
41
+ if (c) {
42
+ c.socket.off("data", c.onData).off("end", c.onEnd).off("error", c.onError);
43
+ }
44
+ return c;
45
+ }
46
+
47
+ function mapTcpError(e) {
48
+ switch (e?.code) {
49
+ case "ECONNREFUSED": return { tag: "connection-refused" };
50
+ case "ECONNRESET": return { tag: "connection-reset" };
51
+ case "ETIMEDOUT": return { tag: "timed-out" };
52
+ case "ENOTFOUND":
53
+ case "EAI_AGAIN": return { tag: "host-not-found" };
54
+ case "EERYX_BLOCKED": return { tag: "not-permitted", val: e.message };
55
+ default: return { tag: "io-error", val: String(e?.message ?? e) };
56
+ }
57
+ }
58
+
59
+ /** Resolves true when `c` has news, or false after `ms`. */
60
+ function wait(c, ms) {
61
+ return new Promise((resolve) => {
62
+ const timer = setTimeout(() => { c.wake = null; resolve(false); }, ms);
63
+ c.wake = () => { clearTimeout(timer); resolve(true); };
64
+ });
65
+ }
66
+
67
+ /** Reject with `err` if `promise` hasn't settled after `ms`. */
68
+ function withTimeout(promise, ms, err) {
69
+ let timer;
70
+ return Promise.race([
71
+ promise,
72
+ new Promise((_, reject) => { timer = setTimeout(() => reject(err), ms); }),
73
+ ]).finally(() => clearTimeout(timer));
74
+ }
75
+
76
+ // net.js passes timeouts already clamped to the host's.
77
+ async function read(conns, h, len, timeoutMs, wrapErr) {
78
+ const c = conns.get(h);
79
+ if (!c) throw wrapErr({ tag: "invalid-handle" });
80
+ const deadline = Date.now() + timeoutMs;
81
+ while (!c.chunks.length && !c.ended && !c.error) {
82
+ if (!(await wait(c, deadline - Date.now()))) throw wrapErr({ tag: "timed-out" });
83
+ }
84
+ if (c.chunks.length) {
85
+ const chunk = c.chunks.shift();
86
+ if (chunk.length > len) c.chunks.unshift(chunk.subarray(len));
87
+ const out = chunk.subarray(0, len);
88
+ c.buffered -= out.length;
89
+ if (c.buffered < HIGH_WATER_BYTES) c.socket.resume();
90
+ return out;
91
+ }
92
+ if (c.error) throw wrapErr(mapTcpError(c.error));
93
+ return new Uint8Array(0);
94
+ }
95
+
96
+ async function write(conns, h, data, timeoutMs, wrapErr) {
97
+ const c = conns.get(h);
98
+ if (!c) throw wrapErr({ tag: "invalid-handle" });
99
+ const written = new Promise((resolve, reject) =>
100
+ c.socket.write(data, (e) => (e ? reject(wrapErr(mapTcpError(e))) : resolve())),
101
+ );
102
+ await withTimeout(written, timeoutMs, wrapErr({ tag: "timed-out" }));
103
+ return data.length;
104
+ }
105
+
106
+ function close(conns, h) {
107
+ untrack(conns, h)?.socket.destroy();
108
+ }
109
+
110
+ // Synchronous so net.js can build (and so validate) the filter in setNetConfig.
111
+ const net = await import(/* webpackIgnore: true */ "node:net");
112
+ const dns = await import(/* webpackIgnore: true */ "node:dns");
113
+ const tlsMod = await import(/* webpackIgnore: true */ "node:tls");
114
+
115
+ // The address stays out of the message: telling the guest would let it map
116
+ // internal DNS names to addresses.
117
+ function blockedError(host) {
118
+ const e = new Error(`host '${host}' resolves to a blocked network`);
119
+ e.code = "EERYX_BLOCKED";
120
+ return e;
121
+ }
122
+
123
+ /** The IPv4 address an IPv6 address stands for on the wire (see ip.js), or null. */
124
+ function embeddedIPv4Address(address) {
125
+ const b = ipv6Bytes(address);
126
+ return (b && embeddedIPv4(b)?.join(".")) ?? null;
127
+ }
128
+
129
+ /**
130
+ * `(address, family) => allowed` for `blockedNetworks` minus `allowedNetworks`.
131
+ * CIDR syntax is checked by net.js; BlockList throws on a bad address or prefix.
132
+ */
133
+ export function networkFilter({ blockedNetworks, allowedNetworks }) {
134
+ const list = (cidrs) => {
135
+ const bl = new net.BlockList();
136
+ for (const cidr of cidrs) {
137
+ const [addr, prefix] = cidr.split("/");
138
+ bl.addSubnet(addr, Number(prefix), net.isIPv6(addr) ? "ipv6" : "ipv4");
139
+ }
140
+ return bl;
141
+ };
142
+ const blocked = list(blockedNetworks);
143
+ const allowed = list(allowedNetworks);
144
+ const matches = (bl, address, type) => {
145
+ const v4 = type === "ipv6" ? embeddedIPv4Address(address) : null;
146
+ return bl.check(address, type) || (v4 !== null && bl.check(v4, "ipv4"));
147
+ };
148
+ return (address, family) => {
149
+ const type = family === 6 ? "ipv6" : "ipv4";
150
+ return !matches(blocked, address, type) || matches(allowed, address, type);
151
+ };
152
+ }
153
+
154
+ /** `isAllowed` is a networkFilter() result, or null to skip the address check. */
155
+ async function connect(conns, host, port, timeoutMs, isAllowed) {
156
+ isAllowed ??= () => true;
157
+ // Node skips `lookup` for IP literals, so check those here.
158
+ const literal = net.isIP(host);
159
+ if (literal && !isAllowed(host, literal)) {
160
+ throw tcpErr(mapTcpError(blockedError(host)));
161
+ }
162
+ // Filter the DNS answer before Node picks an address, so the address
163
+ // connected to is one that was checked.
164
+ const lookup = (hostname, options, cb) =>
165
+ dns.lookup(hostname, { ...options, all: true }, (err, addrs) => {
166
+ if (err) return cb(err);
167
+ const ok = addrs.filter((a) => isAllowed(a.address, a.family));
168
+ if (!ok.length) return cb(blockedError(hostname));
169
+ if (options.all) return cb(null, ok);
170
+ cb(null, ok[0].address, ok[0].family);
171
+ });
172
+ const s = net.connect({ host, port, lookup });
173
+ conns.pending.add(s);
174
+ const connected = new Promise((resolve, reject) => {
175
+ s.once("connect", () => resolve(track(conns, s)));
176
+ s.once("error", (e) => reject(tcpErr(mapTcpError(e))));
177
+ s.once("close", () => reject(tcpErr({ tag: "connection-reset" })));
178
+ });
179
+ return withTimeout(connected, timeoutMs, tcpErr({ tag: "timed-out" }))
180
+ .catch((e) => {
181
+ s.destroy();
182
+ throw e;
183
+ })
184
+ .finally(() => conns.pending.delete(s));
185
+ }
186
+
187
+ async function upgrade(conns, tcpHandle, hostname, timeoutMs) {
188
+ const c = untrack(conns, tcpHandle);
189
+ if (!c) throw tlsErr({ tag: "invalid-handle" });
190
+ const s = tlsMod.connect({ socket: c.socket, servername: hostname });
191
+ conns.pending.add(c.socket).add(s);
192
+ const handshake = new Promise((resolve, reject) => {
193
+ s.once("secureConnect", () => resolve(track(conns, s)));
194
+ s.once("error", (e) => reject({ payload: { tag: "handshake-failed", val: String(e.message) } }));
195
+ s.once("close", () => reject(tlsErr({ tag: "connection-reset" })));
196
+ });
197
+ return withTimeout(handshake, timeoutMs, tlsErr({ tag: "timed-out" }))
198
+ .catch((e) => {
199
+ s.destroy();
200
+ throw e;
201
+ })
202
+ .finally(() => {
203
+ conns.pending.delete(c.socket);
204
+ conns.pending.delete(s);
205
+ });
206
+ }
207
+
208
+ /**
209
+ * A transport with its own connection table, so each sandbox instance only
210
+ * sees (and closes) its own handles.
211
+ */
212
+ export function createTransport() {
213
+ const conns = new Map();
214
+ conns.next = 1;
215
+ // Sockets still connecting or handshaking, which have no handle yet.
216
+ conns.pending = new Set();
217
+ return {
218
+ openConnections: () => conns.size,
219
+ closeAll() {
220
+ for (const h of [...conns.keys()]) close(conns, h);
221
+ for (const s of conns.pending) s.destroy();
222
+ },
223
+ tcp: {
224
+ connect: (host, port, timeoutMs, isAllowed) => connect(conns, host, port, timeoutMs, isAllowed),
225
+ read: (h, len, timeoutMs) => read(conns, h, len, timeoutMs, tcpErr),
226
+ write: (h, timeoutMs, data) => write(conns, h, data, timeoutMs, tcpErr),
227
+ close: (h) => close(conns, h),
228
+ },
229
+ tls: {
230
+ upgrade: (h, hostname, timeoutMs) => upgrade(conns, h, hostname, timeoutMs),
231
+ read: (h, len, timeoutMs) => read(conns, h, len, timeoutMs, tlsErr),
232
+ write: (h, timeoutMs, data) => write(conns, h, data, timeoutMs, tlsErr),
233
+ close: (h) => close(conns, h),
234
+ },
235
+ };
236
+ }
package/shims/net.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Network policy for sandboxed code. Mirrors the Rust crate's `NetConfig`.
3
+ *
4
+ * In Node.js, sandboxed code gets real TCP/TLS sockets. In browsers only
5
+ * HTTP(S) works: requests are replayed through `fetch()`, so CORS applies, and
6
+ * the browser resolves names itself, so a name that resolves to a private
7
+ * address can't be refused there. Use `allowedHosts` rather than
8
+ * `allowAllHosts` in browsers.
9
+ *
10
+ * Connections still open when a call into the sandbox finishes are closed.
11
+ */
12
+ export interface NetConfig {
13
+ /** Allow every host not matched by `blockedHosts` (default false). */
14
+ allowAllHosts?: boolean;
15
+ /** Host patterns to allow; `*` is a wildcard (e.g. `*.example.com`). */
16
+ allowedHosts?: readonly string[];
17
+ /**
18
+ * Host patterns always refused. Defaults to {@link DEFAULT_BLOCKED_HOSTS}.
19
+ * Hosts are matched as written and in WHATWG URL form, so `2130706433` and
20
+ * `0x7f.1` match `127.*`. `allowedHosts` and `internalHosts` match the URL
21
+ * form only.
22
+ */
23
+ blockedHosts?: readonly string[];
24
+ /**
25
+ * CIDRs (e.g. `"10.0.0.0/8"`) that an IP address host, or (in Node.js) the
26
+ * addresses a host name resolves to, must not fall in, so a public name
27
+ * pointing at an internal address is refused. Defaults to
28
+ * {@link DEFAULT_BLOCKED_NETWORKS}. Browsers don't expose resolved
29
+ * addresses, so there only IP address hosts are checked.
30
+ */
31
+ blockedNetworks?: readonly string[];
32
+ /** CIDRs exempt from `blockedNetworks`. */
33
+ allowedNetworks?: readonly string[];
34
+ /**
35
+ * Host patterns allowed to resolve into `blockedNetworks`, for trusted names
36
+ * routed internally on purpose. Only list names whose DNS you control.
37
+ */
38
+ internalHosts?: readonly string[];
39
+ /** Most connections open at once (default 10; `0` for no limit). */
40
+ maxConnections?: number;
41
+ /**
42
+ * Ceiling for connect and TLS handshake timeouts, in milliseconds (default
43
+ * 30000). Sandboxed code can set a shorter timeout, never a longer one.
44
+ * Must be greater than zero.
45
+ */
46
+ connectTimeoutMs?: number;
47
+ /** Ceiling for read and write timeouts, in milliseconds (default 60000; greater than zero). */
48
+ ioTimeoutMs?: number;
49
+ }
50
+
51
+ /** Localhost and private network ranges, blocked unless `blockedHosts` is overridden (frozen). */
52
+ export const DEFAULT_BLOCKED_HOSTS: readonly string[];
53
+
54
+ /** Every non-public address range, blocked unless `blockedNetworks` is overridden (frozen). */
55
+ export const DEFAULT_BLOCKED_NETWORKS: readonly string[];
56
+
57
+ /**
58
+ * Set the default policy, or disable networking again with `null` (the
59
+ * initial default). Each instance from {@link createNet} copies it when it is
60
+ * created; existing instances keep theirs. It also applies to the default
61
+ * instance behind {@link closeConnections} and the `tcp`/`tls` exports.
62
+ * Throws a TypeError if a network isn't a valid CIDR or `maxConnections`
63
+ * isn't a non-negative integer.
64
+ */
65
+ export function setNetConfig(config: NetConfig | null): void;
66
+
67
+ /** Close every connection the default instance's code left open. */
68
+ export function closeConnections(): void;
69
+
70
+ /** A transport's TCP and TLS imports. Internal: the shapes follow the eryx:net WIT. */
71
+ type NetImports = Record<string, (...args: any[]) => unknown>;
72
+
73
+ /**
74
+ * The eryx:net imports for one sandbox instance, with its own connection
75
+ * table and policy. The policy starts as the default from
76
+ * {@link setNetConfig}; the instance's `setNetConfig()` replaces it for that
77
+ * instance only, for connections opened after the call.
78
+ */
79
+ export function createNet(): {
80
+ tcp: NetImports;
81
+ tls: NetImports;
82
+ closeConnections(): void;
83
+ setNetConfig(config: NetConfig | null): void;
84
+ };