@tinvar/renderer 0.4.0 → 0.4.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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/sandbox.js +72 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinvar/renderer",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "src",
@@ -41,6 +41,6 @@
41
41
  "vite": "^8.3.5"
42
42
  },
43
43
  "bin": {
44
- "tinvar-renderer": "./bin/renderer.js"
44
+ "tinvar-renderer": "bin/renderer.js"
45
45
  }
46
46
  }
package/src/sandbox.js CHANGED
@@ -166,6 +166,7 @@ export function sandboxFlags({ rendererRoot, appEntry, socketPath, extraRead = [
166
166
 
167
167
  return [
168
168
  permissionFlag(),
169
+ ...networkFlags(),
169
170
  ...links.map((l) => `--allow-fs-read=${l}`),
170
171
  // Without the ones a broader grant already covers: a package reached by
171
172
  // its dependencies that is also in a node_modules granted for real.
@@ -189,6 +190,24 @@ export function permissionFlag() {
189
190
  : '--experimental-permission';
190
191
  }
191
192
 
193
+ /**
194
+ * The network, granted where Node's permission model governs it: from
195
+ * Node 25 it refuses every socket without `--allow-net`, the renderer's own
196
+ * among them, and the flag is all or nothing. Node 22's model has no
197
+ * network rule, so there the flag is unknown and left out. On both,
198
+ * lockNetwork() closes the network from inside the child -- every
199
+ * connection, lookup and datagram, and every listen but the renderer's
200
+ * socket -- so the renderer reaches the same as on Node 22 (ADR 0176).
201
+ *
202
+ * @returns {string[]}
203
+ */
204
+ export function networkFlags() {
205
+ // Node calls --allow-net experimental, and says so once per renderer.
206
+ return process.allowedNodeEnvironmentFlags.has('--allow-net')
207
+ ? ['--allow-net', '--disable-warning=ExperimentalWarning']
208
+ : [];
209
+ }
210
+
192
211
  /**
193
212
  * The app's build directory is granted whole, so it must be a build
194
213
  * directory. Derived from the entry as its grandparent (dist/rsc/index.js
@@ -646,7 +665,9 @@ export const OPENS_NOTHING = Object.freeze({
646
665
  /**
647
666
  * Closes outbound networking from inside the sandboxed child.
648
667
  *
649
- * Node 22's permission model has no network flag, so a dependency could
668
+ * Node 22's permission model has no network flag, and from Node 25 the
669
+ * renderer is granted --allow-net to serve its socket (networkFlags()), so
670
+ * on either a dependency could
650
671
  * open a socket to the database, or to anywhere, and send what it read. This
651
672
  * replaces every function node:net, node:dgram and node:dns export but the
652
673
  * few that open nothing (OPENS_NOTHING) -- TCP and Unix-socket connects
@@ -671,6 +692,15 @@ export const OPENS_NOTHING = Object.freeze({
671
692
  * may listen on; without one it may listen on nothing
672
693
  */
673
694
  export function lockNetwork({ net, dgram, dns, listenOn }) {
695
+ // Taken now, before any code the renderer loads runs: every original is
696
+ // called through these, never through a method looked up when it is
697
+ // called. A Function.prototype.call or apply swapped in later would be
698
+ // handed the original, and a WeakSet.prototype.has swapped in could
699
+ // answer yes for any handle (ADR 0176).
700
+ const apply = Reflect.apply;
701
+ const { has: weakHas, add: weakAdd } = WeakSet.prototype;
702
+ const isIn = (set, value) => apply(weakHas, set, [value]);
703
+ const putIn = (set, value) => apply(weakAdd, set, [value]);
674
704
  const refuse = (what) =>
675
705
  function refused() {
676
706
  const error = new Error(
@@ -703,8 +733,48 @@ export function lockNetwork({ net, dgram, dns, listenOn }) {
703
733
  // as the address for a socket file and null for a handle or descriptor.
704
734
  const listen = net.Server.prototype._listen2;
705
735
  const refuseListen = refuse('Listening anywhere but the renderer socket');
736
+
737
+ // The handle classes under net -- the Unix-socket one above all -- are
738
+ // reachable without a binding, as the constructor of a handle the
739
+ // process already holds: its standard input is a socket pair (the
740
+ // tether), and its own server's handle is one too. Their prototypes
741
+ // carry bind(), listen(), connect() and open(), which reach the network
742
+ // past everything above. So each one is held where it opens something:
743
+ // no connect and no open; a bind to the renderer's socket alone; and a
744
+ // listen only on the handle that bound it (ADR 0176).
745
+ const held = new WeakSet();
746
+ const bound = new WeakSet();
747
+ const hold = (Handle) => {
748
+ if (typeof Handle !== 'function' || isIn(held, Handle)) return;
749
+ putIn(held, Handle);
750
+ const proto = Handle.prototype;
751
+ for (const name of ['connect', 'connect6', 'open'])
752
+ if (typeof proto[name] === 'function') pin(proto, name, refuse(`A handle's ${name}()`));
753
+ for (const name of ['bind', 'bind6']) {
754
+ const original = proto[name];
755
+ if (typeof original !== 'function') continue;
756
+ pin(proto, name, function bindOwnSocket(address, ...rest) {
757
+ if (typeof listenOn !== 'string' || address !== listenOn) refuseListen();
758
+ const result = apply(original, this, [address, ...rest]);
759
+ putIn(bound, this);
760
+ return result;
761
+ });
762
+ }
763
+ const originalListen = proto.listen;
764
+ if (typeof originalListen === 'function')
765
+ pin(proto, 'listen', function listenOnBound(...args) {
766
+ if (!isIn(bound, this)) refuseListen();
767
+ return apply(originalListen, this, args);
768
+ });
769
+ };
770
+ // Made now, before their classes are held: Node makes each standard
771
+ // stream's handle on first use, with the open() held above.
772
+ for (const stream of [process.stdin, process.stdout, process.stderr]) hold(stream?._handle?.constructor);
773
+
706
774
  pin(net.Server.prototype, '_listen2', function listenOnOwnSocket(address, port, ...rest) {
707
775
  if (typeof listenOn !== 'string' || address !== listenOn || port !== -1) refuseListen();
708
- return listen.call(this, address, port, ...rest);
776
+ const result = apply(listen, this, [address, port, ...rest]);
777
+ hold(this._handle?.constructor);
778
+ return result;
709
779
  });
710
780
  }