@tinvar/renderer 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
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
|
+
}
|