@tinvar/renderer 0.0.0-stage → 0.4.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.
package/src/sandbox.js ADDED
@@ -0,0 +1,710 @@
1
+ // Runs the renderer under Node's permission model.
2
+ //
3
+ // "The renderer gets no database credentials or secrets." Keeping them out
4
+ // of its environment is not enough: it runs as the same OS user as PHP, so
5
+ // any dependency it loads could simply read a key file, a .env, or the
6
+ // user's SSH keys from disk -- or spawn `cat` to do it. Under --permission
7
+ // it can read only what rendering needs, write only its socket directory,
8
+ // and cannot start processes or workers at all.
9
+ //
10
+ // What rendering needs:
11
+ // - the renderer's own package;
12
+ // - the app's built output (the directory above the entry, so the rsc
13
+ // entry can load the ssr build beside it);
14
+ // - each package in every node_modules directory above either of them
15
+ // that is there for real; and by its real path each package the app or
16
+ // the renderer depends on, directly or through another, found where
17
+ // Node finds it -- a workspace link, or a package in pnpm's store --
18
+ // unless it leads into the app's own source tree;
19
+ // - its socket, read and write -- and nothing else in that directory.
20
+ //
21
+ // One limit to know: the model checks the path as requested, not as
22
+ // resolved, and treats an allowed directory as covering everything beneath
23
+ // it -- so a symlink inside an allowed directory reaches wherever it points.
24
+ // That is why node_modules is allowed package by package rather than whole.
25
+ // Secrets still belong outside the project tree -- the generated app key is
26
+ // kept in the user's state directory -- and the sandbox is the second line,
27
+ // not the only one.
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { spawn } from 'node:child_process';
32
+ import os from 'node:os';
33
+
34
+ /**
35
+ * @param {{ rendererRoot: string, appEntry: string, socketPath: string, extraRead?: string[] }} options
36
+ * @returns {string[]} the node flags
37
+ */
38
+ export function sandboxFlags({ rendererRoot, appEntry, socketPath, extraRead = [], appRoot: givenAppRoot }) {
39
+ // Real paths: Node resolves a module to its real path before reading it,
40
+ // so an allowlist written through a symlink -- macOS's /var is one --
41
+ // would not cover the file actually read.
42
+ linksBeneath.clear();
43
+ const renderer = real(rendererRoot);
44
+ const appRoot = givenAppRoot ? real(givenAppRoot) : path.dirname(path.dirname(real(appEntry)));
45
+ assertBuildDirectory(appRoot, renderer);
46
+
47
+ /** @type {Set<string>} directories, granted with everything beneath them */
48
+ const trees = new Set([appRoot, ...extraRead.filter(Boolean).map((p) => checkedExtra(p, renderer))]);
49
+ /** @type {Set<string>} single files */
50
+ const files = new Set();
51
+ /** @type {Set<string>} links to packages in pnpm's store, granted at the path Node finds them by */
52
+ const linked = new Set();
53
+
54
+ // The renderer's own package entry by entry: a blanket grant would also
55
+ // cover any node_modules inside it, and with it every symlink there.
56
+ grantEntries(renderer, trees, files);
57
+
58
+ // Packages only, never a node_modules directory itself: Node treats an
59
+ // allowed directory as covering everything beneath it, symlinks included.
60
+ // A package there for real is granted; a link -- a workspace package, or
61
+ // under pnpm nearly every package -- only once rendering is found to
62
+ // depend on it, below.
63
+ for (const modules of new Set([...nodeModulesAbove(renderer), ...nodeModulesAbove(appRoot)])) {
64
+ for (const entry of packagesIn(modules)) {
65
+ if (!fs.lstatSync(entry).isSymbolicLink()) trees.add(entry);
66
+ }
67
+ }
68
+
69
+ // The packages rendering may load: what the app or the renderer depends
70
+ // on, and in turn what each of those depends on, each found where Node
71
+ // would find it -- the first node_modules above the package asking that
72
+ // holds the name -- and granted by its real path, which is what Node
73
+ // reads. Under pnpm that is the package's own directory in the store
74
+ // (node_modules/.pnpm/<name>@<version>/node_modules/<name>), and its
75
+ // dependencies are the links beside it there, so the store is granted
76
+ // package by package, never whole: whole, it held every package pnpm
77
+ // installed, and every link pnpm hoists into .pnpm/node_modules, which
78
+ // leads anywhere -- a workspace package nothing rendering loads, another
79
+ // app of the workspace and its database. A link is never granted for
80
+ // being there: granted by its presence alone, another app of the same
81
+ // workspace was granted whole, and so was every package of a checkout
82
+ // above this one. Not when it leads into the app's own source tree -- the
83
+ // renderer reaches the build directly -- nor to the disk's root or the
84
+ // home directory; a package in the store whole, being its own files; and
85
+ // any other only its package, not its node_modules or its
86
+ // dot-directories.
87
+ const pending = [
88
+ ...dependenciesOf(packageAbove(appRoot), { development: true }).map((name) => ({ name, from: appRoot })),
89
+ ...dependenciesOf(path.join(renderer, 'package.json'), { development: true }).map((name) => ({
90
+ name,
91
+ from: renderer,
92
+ })),
93
+ ];
94
+ const reached = new Set();
95
+ const home = process.env.HOME ? real(process.env.HOME) : null;
96
+ while (pending.length > 0) {
97
+ const { name, from } = /** @type {{ name: string, from: string }} */ (pending.pop());
98
+ const entry = resolvePackage(name, from);
99
+ if (entry === null) continue;
100
+ const target = real(entry);
101
+ if (appRoot === target || appRoot.startsWith(target + path.sep)) continue;
102
+ if (target === path.parse(target).root || target === home || renderer.startsWith(target + path.sep))
103
+ continue;
104
+ // Node reads a package at the path it found it by, a link's included,
105
+ // and the model checks that path as requested -- so a package found
106
+ // through a link loads only if the link is granted, and a granted link
107
+ // covers everything beneath its target. That is the package and no
108
+ // more for a package in pnpm's store, which holds its own files and
109
+ // puts its dependencies beside it, never a node_modules or a link of
110
+ // its own; so its link is granted, unless something beneath it is a
111
+ // link after all. A workspace package's directory holds its
112
+ // node_modules, its links and its dot-files, so its link is not: it is
113
+ // granted by its real path alone, and loaded through its link at run
114
+ // time it is refused -- the app's build bundles it.
115
+ const whole = inStore(target) && fs.statSync(target).isDirectory() && !linkBeneath(target);
116
+ if (entry !== target && whole) linked.add(entry);
117
+ if (reached.has(target)) continue;
118
+ reached.add(target);
119
+ if (!fs.statSync(target).isDirectory()) {
120
+ files.add(target);
121
+ continue;
122
+ }
123
+ // A package in the store whole, as its link already reaches it: one
124
+ // flag, not one per file, so a pnpm app's allowlist stays well inside
125
+ // a command line.
126
+ if (whole) trees.add(target);
127
+ else grantEntries(target, trees, files);
128
+ pending.push(
129
+ ...dependenciesOf(path.join(target, 'package.json'), { development: false }).map((dependency) => ({
130
+ name: dependency,
131
+ from: target,
132
+ })),
133
+ );
134
+ }
135
+
136
+ // The socket and nothing else beside it -- not even a read of its
137
+ // directory, which Node would extend to everything in it. The launcher
138
+ // creates the directory before the sandbox starts, so a shared one like
139
+ // /tmp stays out of reach.
140
+ const socket = real(socketPath);
141
+
142
+ const covered = (p) => [...trees].some((t) => t !== p && p.startsWith(t + path.sep));
143
+ const granted = (p) => trees.has(p) || files.has(p) || linked.has(p) || covered(p);
144
+ const everything = [...trees, ...files, ...linked];
145
+ // Node's model refuses a directory it was granted exactly when another
146
+ // grant runs on from it as text -- react beside react-dom, as pnpm puts
147
+ // them -- so such a link is granted as a prefix, `react*`, when every
148
+ // entry beside it that the prefix also names is granted anyway; when one
149
+ // is not, exactly, and the package fails to load rather than the grant
150
+ // widen.
151
+ const linkFlag = (link) => {
152
+ if (!everything.some((other) => other !== link && other.startsWith(link))) return link;
153
+ const base = path.basename(link);
154
+ const beside = fs
155
+ .readdirSync(path.dirname(link))
156
+ .filter((name) => name !== base && name.startsWith(base))
157
+ .map((name) => path.join(path.dirname(link), name));
158
+ return beside.every(granted) ? `${link}*` : link;
159
+ };
160
+
161
+ const links = [...linked].filter((l) => !covered(l)).map(linkFlag);
162
+ // What a prefix grant already covers is not granted again: Node's model
163
+ // also loses a prefix granted after a path that runs on from it.
164
+ const prefixes = links.filter((l) => l.endsWith('*')).map((l) => l.slice(0, -1));
165
+ const unprefixed = (p) => !prefixes.some((prefix) => p.startsWith(prefix));
166
+
167
+ return [
168
+ permissionFlag(),
169
+ ...links.map((l) => `--allow-fs-read=${l}`),
170
+ // Without the ones a broader grant already covers: a package reached by
171
+ // its dependencies that is also in a node_modules granted for real.
172
+ ...[...trees]
173
+ .filter((t) => !covered(t) && unprefixed(t))
174
+ .map((t) => `--allow-fs-read=${path.join(t, '*')}`),
175
+ ...[...files].filter((f) => !covered(f) && unprefixed(f)).map((f) => `--allow-fs-read=${f}`),
176
+ `--allow-fs-read=${socket}`,
177
+ `--allow-fs-write=${socket}`,
178
+ ];
179
+ }
180
+
181
+ /**
182
+ * The flag's name: `--permission` from Node 22.13, `--experimental-permission`
183
+ * before it. Asked of the running Node rather than assumed, so an older 22
184
+ * is sandboxed too instead of failing to start with "bad option".
185
+ */
186
+ export function permissionFlag() {
187
+ return process.allowedNodeEnvironmentFlags.has('--permission')
188
+ ? '--permission'
189
+ : '--experimental-permission';
190
+ }
191
+
192
+ /**
193
+ * The app's build directory is granted whole, so it must be a build
194
+ * directory. Derived from the entry as its grandparent (dist/rsc/index.js
195
+ * -> dist), an entry anywhere else made it the project's parent, or the
196
+ * root: every sibling project and its .env, or the whole disk.
197
+ */
198
+ function assertBuildDirectory(appRoot, renderer) {
199
+ const home = process.env.HOME ? real(process.env.HOME) : null;
200
+ if (appRoot === path.parse(appRoot).root || appRoot === home || renderer.startsWith(appRoot + path.sep)) {
201
+ throw new Error(
202
+ `[tinvar:renderer] Refusing to sandbox with ${appRoot} as the app's build directory: it would grant far ` +
203
+ 'more than the build. Set TINVAR_APP_ROOT to the build directory (the one holding rsc/ and ssr/).',
204
+ );
205
+ }
206
+ }
207
+
208
+ /**
209
+ * An extra read path from TINVAR_RENDERER_READ: absolute, and no wider than
210
+ * a build directory may be. `.` or `/` there granted the project or the
211
+ * whole disk.
212
+ */
213
+ function checkedExtra(p, renderer) {
214
+ if (!path.isAbsolute(p)) {
215
+ throw new Error(`[tinvar:renderer] TINVAR_RENDERER_READ entries must be absolute; got ${p}.`);
216
+ }
217
+ const resolved = real(p);
218
+ assertBuildDirectory(resolved, renderer);
219
+ return resolved;
220
+ }
221
+
222
+ /** Grants each entry of a package directory except node_modules and dot-entries. */
223
+ function grantEntries(dir, trees, files) {
224
+ for (const name of fs.readdirSync(dir)) {
225
+ if (name === 'node_modules' || name.startsWith('.')) continue;
226
+ const entry = path.join(dir, name);
227
+ const stat = fs.lstatSync(entry);
228
+ if (stat.isSymbolicLink()) continue;
229
+ if (stat.isDirectory()) trees.add(entry);
230
+ else files.add(entry);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Each package in a node_modules directory. Dot-entries are caches and
236
+ * tooling (.vite, .vitest, .cache) and pnpm's store (.pnpm), and are left
237
+ * out: what rendering loads from the store is granted package by package,
238
+ * as its dependencies reach it.
239
+ *
240
+ * @param {string} modules
241
+ * @returns {string[]}
242
+ */
243
+ function packagesIn(modules) {
244
+ const found = [];
245
+ const visit = (dir, scoped) => {
246
+ for (const name of fs.readdirSync(dir)) {
247
+ if (name.startsWith('.')) continue;
248
+ const entry = path.join(dir, name);
249
+ if (!scoped && name.startsWith('@') && fs.lstatSync(entry).isDirectory()) {
250
+ visit(entry, true);
251
+ continue;
252
+ }
253
+ found.push(entry);
254
+ }
255
+ };
256
+ visit(modules, false);
257
+ return found;
258
+ }
259
+
260
+ /**
261
+ * The real path of `p`, or -- for a path that does not exist yet, like a
262
+ * socket -- the real path of its nearest existing ancestor with the rest
263
+ * appended.
264
+ */
265
+ export function real(p) {
266
+ const absolute = path.resolve(p);
267
+ try {
268
+ return fs.realpathSync(absolute);
269
+ } catch {
270
+ const parent = path.dirname(absolute);
271
+ return parent === absolute ? absolute : path.join(real(parent), path.basename(absolute));
272
+ }
273
+ }
274
+
275
+ /**
276
+ * The package.json nearest above `start`: the app's, for its build
277
+ * directory. Null when there is none up to the root.
278
+ *
279
+ * @param {string} start
280
+ * @returns {string | null}
281
+ */
282
+ function packageAbove(start) {
283
+ for (let dir = path.resolve(start); ; dir = path.dirname(dir)) {
284
+ const candidate = path.join(dir, 'package.json');
285
+ if (fs.existsSync(candidate)) return candidate;
286
+ if (path.dirname(dir) === dir) return null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * The names a package.json depends on: what its package needs to run, and
292
+ * with `development`, what it builds with too -- for the app and the
293
+ * renderer, whose builds may leave any of them to be loaded at run time.
294
+ * None for a manifest that is missing or does not parse.
295
+ *
296
+ * @param {string | null} manifest
297
+ * @param {{ development: boolean }} options
298
+ * @returns {string[]}
299
+ */
300
+ function dependenciesOf(manifest, { development }) {
301
+ let parsed;
302
+ try {
303
+ parsed = manifest === null ? null : JSON.parse(fs.readFileSync(manifest, 'utf8'));
304
+ } catch {
305
+ return [];
306
+ }
307
+ const fields = ['dependencies', 'optionalDependencies', 'peerDependencies'];
308
+ if (development) fields.push('devDependencies');
309
+ return fields.flatMap((field) => {
310
+ const named = parsed?.[field];
311
+ return named !== null && typeof named === 'object' && !Array.isArray(named) ? Object.keys(named) : [];
312
+ });
313
+ }
314
+
315
+ /** Every existing node_modules directory from `start` up to the root. */
316
+ function nodeModulesAbove(start) {
317
+ const found = [];
318
+ let dir = path.resolve(start);
319
+ for (;;) {
320
+ const candidate = path.join(dir, 'node_modules');
321
+ if (fs.existsSync(candidate)) found.push(candidate);
322
+ const parent = path.dirname(dir);
323
+ if (parent === dir) return found;
324
+ dir = parent;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Where Node finds the package `name` from `from`: the first node_modules
330
+ * above it holding the name, as the path there -- a link, or the package.
331
+ * Null for one it would not find, and for a name that is not a package's:
332
+ * the names come from manifests, and one like `../../secrets` must not
333
+ * lead out of a node_modules.
334
+ *
335
+ * @param {string} name
336
+ * @param {string} from
337
+ * @returns {string | null}
338
+ */
339
+ function resolvePackage(name, from) {
340
+ if (!PACKAGE_NAME.test(name)) return null;
341
+ for (const modules of nodeModulesAbove(from)) {
342
+ const candidate = path.join(modules, ...name.split('/'));
343
+ try {
344
+ fs.lstatSync(candidate);
345
+ return candidate;
346
+ } catch {
347
+ // Not here: the next node_modules up.
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * Whether `dir` is a package in pnpm's store, where pnpm's isolated linker
355
+ * puts every package it installs:
356
+ * node_modules/.pnpm/<name>@<version>/node_modules/<name>.
357
+ *
358
+ * @param {string} dir a real path
359
+ */
360
+ function inStore(dir) {
361
+ const parts = dir.split(path.sep);
362
+ const at = parts.lastIndexOf('.pnpm');
363
+ if (at < 1 || parts[at - 1] !== 'node_modules' || parts[at + 2] !== 'node_modules') return false;
364
+ const rest = parts.slice(at + 3);
365
+ return rest.length === 1 || (rest.length === 2 && rest[0].startsWith('@'));
366
+ }
367
+
368
+ /**
369
+ * Whether anything beneath `dir` is a link: a link granted to a package
370
+ * would reach wherever such a one points.
371
+ *
372
+ * @param {string} dir
373
+ */
374
+ function linkBeneath(dir) {
375
+ let found = linksBeneath.get(dir);
376
+ if (found === undefined) {
377
+ found = fs
378
+ .readdirSync(dir, { withFileTypes: true })
379
+ .some(
380
+ (entry) => entry.isSymbolicLink() || (entry.isDirectory() && linkBeneath(path.join(dir, entry.name))),
381
+ );
382
+ linksBeneath.set(dir, found);
383
+ }
384
+ return found;
385
+ }
386
+
387
+ /** @type {Map<string, boolean>} what linkBeneath() found, by directory, for the one walk of a start */
388
+ const linksBeneath = new Map();
389
+
390
+ /** A package's name: an optional scope, then a name, neither starting with a dot or holding a path. */
391
+ const PACKAGE_NAME = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i;
392
+
393
+ /**
394
+ * The variables rendering needs, and no others.
395
+ *
396
+ * The permission model does not restrict the network or Unix sockets, so
397
+ * what the child can reach is decided by what it is told about: with
398
+ * SSH_AUTH_SOCK it could have the user's ssh-agent sign with their keys,
399
+ * and any other socket or token in the launching shell was one variable
400
+ * away. The child gets an allowlist instead of process.env.
401
+ */
402
+ const CHILD_ENV = ['PATH', 'NODE_ENV', 'TZ', 'LANG', 'LC_ALL', 'TINVAR_RENDERER_CODEC'];
403
+
404
+ /**
405
+ * @param {Record<string, string | undefined>} [env]
406
+ * @returns {Record<string, string>}
407
+ */
408
+ export function childEnvironment(env = process.env) {
409
+ /** @type {Record<string, string>} */
410
+ const out = {};
411
+ for (const name of CHILD_ENV) {
412
+ if (typeof env[name] === 'string') out[name] = env[name];
413
+ }
414
+ return out;
415
+ }
416
+
417
+ /**
418
+ * Cuts this process's own environment down to childEnvironment()'s
419
+ * allowlist, and `keep`, in place: for a renderer that runs where it was
420
+ * started rather than in a child -- the dev renderer, and the renderer with
421
+ * the sandbox turned off -- so the app and its dependencies see what a
422
+ * sandboxed child would, and nothing a shell happened to export.
423
+ *
424
+ * @param {string[]} [keep] names beyond the allowlist this renderer reads
425
+ * @param {Record<string, string | undefined>} [env]
426
+ */
427
+ export function scrubEnvironment(keep = [], env = process.env) {
428
+ const allowed = new Set([...CHILD_ENV, ...keep]);
429
+ for (const name of Object.keys(env)) {
430
+ if (!allowed.has(name)) delete env[name];
431
+ }
432
+ }
433
+
434
+ /** Whether this process is already running under the permission model. */
435
+ export function isSandboxed() {
436
+ return typeof process.permission?.has === 'function';
437
+ }
438
+
439
+ /**
440
+ * Starts `script` under the permission model and returns the child.
441
+ *
442
+ * Its standard input is its tether: a pipe of its own, whose other end this
443
+ * process holds and never writes to or reads. Killed outright, a parent can
444
+ * forward nothing, and the child would keep serving its socket as an
445
+ * orphan; but however the parent dies, its end closes, and the child stops
446
+ * at the end of its input (watchTether). Nothing of this process's is
447
+ * passed on -- an inherited descriptor is readable without any permission
448
+ * check, so its own standard input, a terminal or tinvar serve's tether,
449
+ * stays out of reach.
450
+ *
451
+ * Not an IPC channel, which the tether once was. Node reads a channel for
452
+ * as long as it is open and keeps what it reads until a message ends, so a
453
+ * compromised renderer writing to it without end had the supervisor hold
454
+ * all of it: 400 MB written grew the supervisor by 478 MB. The pipe is a
455
+ * socket pair, as Node makes them, so the child can write to its end too --
456
+ * and what it writes waits in the kernel's buffer for this end, which is
457
+ * never read: a few kilobytes, and then the child's writes wait.
458
+ *
459
+ * Some failures throw from spawn() itself rather than arriving as an
460
+ * 'error' event -- E2BIG, an allowlist too long for the command line, is
461
+ * one -- so a caller handles both.
462
+ *
463
+ * @param {string} script
464
+ * @param {string[]} args
465
+ * @param {string[]} flags from sandboxFlags()
466
+ * @param {{ detached?: boolean }} [options] `detached` gives the child a
467
+ * process group of its own, so a terminal's Ctrl-C reaches only the
468
+ * parent, which then decides what each child is told
469
+ * @returns {import('node:child_process').ChildProcess}
470
+ */
471
+ export function spawnSandboxed(script, args, flags, { detached = false } = {}) {
472
+ closeInheritedDescriptors();
473
+ const child = spawn(process.execPath, [...flags, script, ...args], {
474
+ stdio: ['pipe', 'inherit', 'inherit'],
475
+ env: childEnvironment(),
476
+ detached,
477
+ });
478
+ // Never written, so it has no EPIPE to report; but an 'error' with no
479
+ // listener would take the supervisor down, and every renderer with it.
480
+ child.stdin?.on('error', () => {});
481
+
482
+ return child;
483
+ }
484
+
485
+ /** O_CLOEXEC, as /proc/self/fdinfo writes a descriptor's flags: in octal. */
486
+ const O_CLOEXEC = 0o2000000;
487
+
488
+ /**
489
+ * Closes every descriptor above 2 this process inherited, so the sandboxed
490
+ * child it is about to start inherits none of them in turn. Linux only, and
491
+ * returns what it closed.
492
+ *
493
+ * PHP opens nothing close-on-exec, so tinvar serve, renderer:start and the
494
+ * test pool hand this process the database, the log, the key it read and
495
+ * each other's pipes, each under its own number -- pcntl_exec keeps every
496
+ * one. Node marks what it inherited close-on-exec as it starts, but only up
497
+ * to the first gap past descriptor 15 (uv_disable_stdio_inheritance()), and
498
+ * a PHP worker holding many is past one: libuv on Linux closes nothing
499
+ * above 2 in a child it spawns, and the permission model checks paths,
500
+ * never a descriptor already open, so the sandboxed renderer could read
501
+ * each one left. (On macOS libuv spawns with POSIX_SPAWN_CLOEXEC_DEFAULT,
502
+ * and the child gets only its stdio.)
503
+ *
504
+ * What would be handed on is told apart by its flags: everything Node and
505
+ * libuv open is close-on-exec, as is what Node's start marked, and
506
+ * nothing else is. The IPC channel, the one
507
+ * descriptor Node takes on inherited, is left; the renderer is started
508
+ * with none. No /proc to read is refused: what would be handed on cannot
509
+ * be known.
510
+ *
511
+ * @param {{ platform?: string, root?: string }} [options] for the tests
512
+ * @returns {number[]}
513
+ */
514
+ export function closeInheritedDescriptors({ platform = process.platform, root = '/proc/self' } = {}) {
515
+ if (platform !== 'linux') return [];
516
+
517
+ let names;
518
+ try {
519
+ names = fs.readdirSync(`${root}/fd`);
520
+ } catch (error) {
521
+ throw new Error(
522
+ `Cannot list this process's descriptors to keep them from the sandboxed renderer (${error.code ?? error.message}).`,
523
+ );
524
+ }
525
+ const channel = /** @type {{ fd?: unknown } | undefined} */ (process.channel)?.fd;
526
+ const closed = [];
527
+ for (const name of names) {
528
+ const fd = Number(name);
529
+ if (!Number.isInteger(fd) || fd <= 2 || fd === channel) continue;
530
+ let info;
531
+ try {
532
+ info = fs.readFileSync(`${root}/fdinfo/${fd}`, 'utf8');
533
+ } catch {
534
+ // Gone since the listing: the listing's own.
535
+ continue;
536
+ }
537
+ const flags = /^flags:\s*([0-7]+)$/m.exec(info);
538
+ if (flags !== null && (parseInt(flags[1], 8) & O_CLOEXEC) !== 0) continue;
539
+ try {
540
+ fs.closeSync(fd);
541
+ closed.push(fd);
542
+ } catch {
543
+ // Closed since it was read.
544
+ }
545
+ }
546
+ return closed;
547
+ }
548
+
549
+ /**
550
+ * Calls `gone` when the parent that started this process is gone, read off
551
+ * the tether spawnSandboxed() gives it: the end of its standard input. For
552
+ * the sandboxed child.
553
+ *
554
+ * Only a pipe or a socket is a tether. Started some other way, with a
555
+ * terminal or /dev/null for standard input -- which ends at once -- the
556
+ * process has nothing to watch, and serves until it is told to stop.
557
+ *
558
+ * @param {() => void} gone
559
+ * @returns {boolean} whether there was a tether to watch
560
+ */
561
+ export function watchTether(gone) {
562
+ let stat;
563
+ try {
564
+ stat = fs.fstatSync(0);
565
+ } catch {
566
+ return false;
567
+ }
568
+ if (!stat.isFIFO() && !stat.isSocket()) return false;
569
+
570
+ // Read for its end alone: nothing is written to it. An error counts as
571
+ // the end -- a peer that closes with what the child wrote still unread is
572
+ // a reset, not an end of input, to Linux.
573
+ process.stdin.once('end', gone);
574
+ process.stdin.on('error', gone);
575
+ process.stdin.resume();
576
+
577
+ return true;
578
+ }
579
+
580
+ /**
581
+ * Starts `script` again under the permission model, passing signals
582
+ * through, and resolves with its exit code.
583
+ *
584
+ * @param {string} script
585
+ * @param {string[]} args
586
+ * @param {string[]} flags from sandboxFlags()
587
+ */
588
+ export function relaunchSandboxed(script, args, flags) {
589
+ const failed = (error) => {
590
+ console.error(`[tinvar:renderer] could not start the sandboxed renderer: ${error.message}`);
591
+ return 1;
592
+ };
593
+
594
+ let child;
595
+ try {
596
+ child = spawnSandboxed(script, args, flags);
597
+ } catch (error) {
598
+ return Promise.resolve(failed(error));
599
+ }
600
+
601
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
602
+ process.on(signal, () => child.kill(signal));
603
+ }
604
+
605
+ return new Promise((resolve) => {
606
+ child.on('error', (error) => resolve(failed(error)));
607
+ child.on('exit', (code, signal) => resolve(exitCode(code, signal)));
608
+ });
609
+ }
610
+
611
+ /** A child's exit as a shell reports it: its code, or 128 + its signal's number. */
612
+ export function exitCode(code, signal) {
613
+ return code ?? (signal ? 128 + (os.constants.signals[signal] ?? 0) : 0);
614
+ }
615
+
616
+ /**
617
+ * What each networking module may still be called for, because it opens
618
+ * nothing: a server for the renderer's own socket (held to it below), the
619
+ * classes and checks that make or read no connection, and the settings
620
+ * that change none. Every other function the module exports is refused --
621
+ * by default, so an export a Node release adds is refused until it is
622
+ * named here. Each underscore export the default now catches made a raw
623
+ * handle: `dgram._createSocketHandle()` one that sent a datagram out of
624
+ * the sandbox, `net._createServerHandle()` a TCP server on any port.
625
+ */
626
+ export const OPENS_NOTHING = Object.freeze({
627
+ net: Object.freeze([
628
+ 'createServer',
629
+ 'Server',
630
+ 'Socket',
631
+ 'Stream',
632
+ 'BlockList',
633
+ 'SocketAddress',
634
+ 'isIP',
635
+ 'isIPv4',
636
+ 'isIPv6',
637
+ 'getDefaultAutoSelectFamily',
638
+ 'setDefaultAutoSelectFamily',
639
+ 'getDefaultAutoSelectFamilyAttemptTimeout',
640
+ 'setDefaultAutoSelectFamilyAttemptTimeout',
641
+ ]),
642
+ dgram: Object.freeze([]),
643
+ dns: Object.freeze(['getServers', 'getDefaultResultOrder', 'setDefaultResultOrder']),
644
+ });
645
+
646
+ /**
647
+ * Closes outbound networking from inside the sandboxed child.
648
+ *
649
+ * Node 22's permission model has no network flag, so a dependency could
650
+ * open a socket to the database, or to anywhere, and send what it read. This
651
+ * replaces every function node:net, node:dgram and node:dns export but the
652
+ * few that open nothing (OPENS_NOTHING) -- TCP and Unix-socket connects
653
+ * (which fetch, http, https, http2 and tls all go through), UDP, and DNS,
654
+ * itself a way to send data out -- with one that throws, and pins the
655
+ * replacements so code loaded later cannot put the originals back. The
656
+ * internal bindings these sit on are refused by the permission model itself
657
+ * (process.binding throws under --permission).
658
+ *
659
+ * The classes it leaves are held where they would open something: a
660
+ * socket's connect(), and a server's listen. Listening is closed but for the
661
+ * renderer's own socket: a server anyone who can reach the host could
662
+ * connect to -- a port, a descriptor, another socket file -- is a way out
663
+ * turned around, collecting what the renderer draws for every user. Every
664
+ * listen goes through the server's `_listen2`, so that is where it is held,
665
+ * not listen() alone. Defence in depth, not a network boundary --
666
+ * production should still run the renderer as its own user with no route
667
+ * out (see the spec's trust boundaries).
668
+ *
669
+ * @param {{ net: typeof import('node:net'), dgram: typeof import('node:dgram'), dns: typeof import('node:dns'), listenOn?: string }} modules
670
+ * `listenOn` the socket path the renderer serves, the one thing it
671
+ * may listen on; without one it may listen on nothing
672
+ */
673
+ export function lockNetwork({ net, dgram, dns, listenOn }) {
674
+ const refuse = (what) =>
675
+ function refused() {
676
+ const error = new Error(
677
+ `[tinvar:renderer] ${what} is not allowed: the renderer has no network access.`,
678
+ );
679
+ error.code = 'ERR_ACCESS_DENIED';
680
+ throw error;
681
+ };
682
+ const pin = (target, name, value) =>
683
+ Object.defineProperty(target, name, { value, writable: false, configurable: false, enumerable: true });
684
+
685
+ // Data properties only: a getter -- dns.promises is one -- is not called
686
+ // to find out what it holds.
687
+ const lock = (module, label, allowed) => {
688
+ for (const name of Object.getOwnPropertyNames(module)) {
689
+ const value = Object.getOwnPropertyDescriptor(module, name)?.value;
690
+ if (typeof value === 'function' && !allowed.includes(name))
691
+ pin(module, name, refuse(`${label}.${name}()`));
692
+ }
693
+ };
694
+ lock(net, 'net', OPENS_NOTHING.net);
695
+ lock(dgram, 'dgram', OPENS_NOTHING.dgram);
696
+ lock(dns, 'dns', OPENS_NOTHING.dns);
697
+ lock(dns.promises, 'dns.promises', OPENS_NOTHING.dns);
698
+
699
+ pin(net.Socket.prototype, 'connect', refuse('Opening a connection'));
700
+
701
+ // Every listen -- a port, a path, a handle, a descriptor -- reaches the
702
+ // server's handle through `_listen2(address, port, ...)`, with the path
703
+ // as the address for a socket file and null for a handle or descriptor.
704
+ const listen = net.Server.prototype._listen2;
705
+ const refuseListen = refuse('Listening anywhere but the renderer socket');
706
+ pin(net.Server.prototype, '_listen2', function listenOnOwnSocket(address, port, ...rest) {
707
+ if (typeof listenOn !== 'string' || address !== listenOn || port !== -1) refuseListen();
708
+ return listen.call(this, address, port, ...rest);
709
+ });
710
+ }