@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/src/server.js ADDED
@@ -0,0 +1,466 @@
1
+ // The renderer: a private Unix socket server next to PHP.
2
+ //
3
+ // Hardening the spec requires of this process:
4
+ // - no secrets: it starts with a scrubbed environment and holds no credentials
5
+ // - private socket, mode 0600, owned by the app user -- or, run as a user
6
+ // of its own, 0660 in a group of that user and PHP's (ADR 0028)
7
+ // - frames capped at a configured size
8
+ // - it never decodes client payloads, which is the class of bug behind
9
+ // React's CVE-2025-55182
10
+ // - per-render isolation: each render has its own context and nothing is
11
+ // cached across requests
12
+
13
+ import net from 'node:net';
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { FrameReader, DEFAULT_MAX_FRAME_BYTES, createCodec, encodeFrame, msgpackCodec } from './codec.js';
17
+ import { logLine, requestIdOf } from './log.js';
18
+ import { FRAME, assertFromPhp } from './protocol.js';
19
+ import { RenderSession } from './render-session.js';
20
+
21
+ /**
22
+ * Credentials must never reach this process. Refuse to start if they have.
23
+ *
24
+ * By shape as well as by name. A fixed list missed the names applications
25
+ * actually use -- TINVAR_APP_KEY, TINVAR_DB_PASSWORD, TINVAR_DSN -- and a
26
+ * renderer started from a shell that exported them started without a word.
27
+ * Anything whose last segment names a key, password, secret or connection
28
+ * string counts, under any prefix.
29
+ *
30
+ * The live hub's keys too (ADR 0048). PHP signs the hub's access and
31
+ * publisher tokens with them -- TINVAR_LIVE_SUBSCRIBER_KEY and
32
+ * TINVAR_LIVE_PUBLISHER_KEY, or Mercure's own MERCURE_*_JWT_KEY -- and a
33
+ * renderer holding the publisher key could tell every open page anything.
34
+ * Their names end in neither APP_KEY nor PRIVATE_KEY, and passed.
35
+ *
36
+ * And the probes' and the trace exporter's (ADR 0041): TINVAR_PROBE_TOKEN
37
+ * opens /_metrics and /_ready to whoever holds it, and
38
+ * OTEL_EXPORTER_OTLP_HEADERS -- or its per-signal form -- is where a
39
+ * collector's API key or bearer token is written. Neither name ends in a
40
+ * key, a password or a secret, and both passed.
41
+ *
42
+ * And where a private key's file is (ADR 0116): TINVAR_MAIL_DKIM_PRIVATE_KEY
43
+ * ends in PRIVATE_KEY, and its _FILE form names a file this process might
44
+ * read.
45
+ *
46
+ * And then every name the list did not reach: STRIPE_SECRET_KEY,
47
+ * OPENAI_API_KEY, GITHUB_TOKEN, AWS_SESSION_TOKEN, TINVAR_SESSION_KEY,
48
+ * TINVAR_SMTP_PASS. Any name with a segment that says key, token, pass,
49
+ * secret, credential or private, anywhere in it -- so a _FILE form, which
50
+ * names where one is, too. None of the names the renderer is told anything
51
+ * by (TINVAR_RENDERER_*, TINVAR_APP_*, NODE_*) has one.
52
+ */
53
+ const FORBIDDEN_ENV =
54
+ /(^|_)(KEYS?|APIKEY|TOKENS?|AUTHTOKEN|PASS|PASSWORD|PASSWD|PASSPHRASE|SECRETS?|CREDENTIALS?|PRIVATE|DSN|DATABASE_URL|DB_USERNAME|DB_USER|OTLP_HEADERS|OTLP_(TRACES|METRICS|LOGS)_HEADERS)(_|$)/i;
55
+
56
+ /**
57
+ * A URL with credentials in it -- `redis://:password@cache` -- anywhere in
58
+ * a value: a connection string carries its password whatever its variable
59
+ * is called. An `@` in the URL's authority, before any path, query or
60
+ * fragment.
61
+ */
62
+ const URL_WITH_USERINFO = /[a-z][a-z0-9+.-]*:\/\/[^/?#\s]*@/i;
63
+
64
+ /**
65
+ * @param {Record<string, string | undefined>} [env]
66
+ * @throws {Error} naming each variable that holds a credential, never its value
67
+ */
68
+ export function assertScrubbedEnvironment(env = process.env) {
69
+ const present = Object.keys(env).filter((key) => {
70
+ const value = env[key];
71
+ if (typeof value !== 'string' || value === '') return false;
72
+ return FORBIDDEN_ENV.test(key) || URL_WITH_USERINFO.test(value);
73
+ });
74
+ if (present.length > 0) {
75
+ throw new Error(
76
+ `The renderer must start with a scrubbed environment; found ${present.join(', ')}. ` +
77
+ 'It never needs credentials, and holding them widens what a compromised dependency reaches.',
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * How long a renderer told to stop goes on serving the renders it has:
84
+ * PHP's overall cap on one render, the 15 s deadline four times over, which
85
+ * counts a slow client too. Past it PHP has stopped waiting for the render,
86
+ * so there is nothing left to finish it for.
87
+ */
88
+ export const DEFAULT_DRAIN_MS = 60_000;
89
+
90
+ /** How long a draining renderer goes on accepting once its socket's path is gone; see drain(). */
91
+ const BACKLOG_GRACE_MS = 100;
92
+
93
+ /**
94
+ * Has the app load what its renders will, before the socket says it is
95
+ * ready: an entry's `warm()`, where it exports one -- every client
96
+ * component's module, so the first page's head preloads what every later
97
+ * one's does (@tinvar/renderer/warm). What does not load is reported, and
98
+ * left for the render that needs it to fail as it would have.
99
+ *
100
+ * @param {Record<string, unknown>} app the app's rsc entry
101
+ */
102
+ export async function warmApp(app) {
103
+ if (typeof app.warm !== 'function') return;
104
+ try {
105
+ const warmed = await app.warm();
106
+ const failed = Array.isArray(warmed?.failed) ? warmed.failed : [];
107
+ if (failed.length > 0) {
108
+ console.error(`[tinvar:renderer] could not load ahead of the first render: ${failed.join(', ')}`);
109
+ }
110
+ } catch (error) {
111
+ console.error(
112
+ '[tinvar:renderer] could not warm the app:',
113
+ error instanceof Error ? error.message : error,
114
+ );
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {{socketPath: string, appEntry: string, maxFrameBytes?: number, checkEnvironment?: boolean, codec?: 'msgpack' | 'json-lines', socketGroup?: number | null}} options
120
+ * `socketGroup` shares the socket with that group (0660), for PHP
121
+ * running as another user in it; 0600 without one. `codec` is the
122
+ * frames' encoding, msgpack unless set to json-lines to match PHP
123
+ */
124
+ export async function createRenderer(options) {
125
+ const {
126
+ socketPath,
127
+ appEntry,
128
+ maxFrameBytes = DEFAULT_MAX_FRAME_BYTES,
129
+ checkEnvironment = true,
130
+ codec: codecName = 'msgpack',
131
+ socketGroup = null,
132
+ } = options;
133
+
134
+ const codec = typeof codecName === 'string' ? createCodec(codecName) : (codecName ?? msgpackCodec);
135
+
136
+ if (checkEnvironment) assertScrubbedEnvironment();
137
+
138
+ const app = await import(appEntry);
139
+ if (typeof app.renderHtml !== 'function') {
140
+ throw new Error(`${appEntry} does not export renderHtml(frame, client).`);
141
+ }
142
+ await warmApp(app);
143
+
144
+ await fs.rm(socketPath, { force: true });
145
+
146
+ // The directory is locked down first, so the socket is never reachable even
147
+ // for the instant before its own mode is set.
148
+ // Under the sandbox the launcher made it, and the renderer may neither look
149
+ // into it nor write there beyond its own socket.
150
+ if (typeof process.permission?.has !== 'function') {
151
+ const made = await fs.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
152
+ if (made !== undefined && socketGroup !== null) {
153
+ await shareDirectory(path.dirname(socketPath), socketGroup);
154
+ }
155
+ }
156
+
157
+ // What a health check reports, and what a drain waits for.
158
+ const state = { inFlight: 0, served: 0 };
159
+ const connections = new Set();
160
+
161
+ const server = net.createServer((socket) => {
162
+ connections.add(socket);
163
+ socket.on('close', () => connections.delete(socket));
164
+ socket.on('error', (error) => logLine('warning', 'socket error', {}, error));
165
+ handleConnection(socket, app, maxFrameBytes, codec, state);
166
+ });
167
+
168
+ // listen() creates the socket with 0666 & ~umask. chmod afterwards leaves a
169
+ // window in which any local user can connect, so the mode is set by umask
170
+ // before the socket exists rather than corrected after.
171
+ const previousUmask = process.umask(0o177);
172
+ try {
173
+ await new Promise((resolve, reject) => {
174
+ server.once('error', reject);
175
+ server.listen(socketPath, resolve);
176
+ });
177
+ } finally {
178
+ process.umask(previousUmask);
179
+ }
180
+
181
+ // Belt and braces on platforms that ignore umask for sockets, then verify:
182
+ // the spec requires mode 0600, so starting wider is a failure, not a warning.
183
+ await fs.chmod(socketPath, 0o600);
184
+
185
+ // Shared with PHP's user through a group of the two, as a renderer run as
186
+ // a user of its own must be: given to the group while still 0600, and only
187
+ // then opened to it, so it is never open to a group it was not meant for
188
+ // -- Linux makes it in this process's group, macOS in its directory's.
189
+ if (socketGroup !== null) {
190
+ await fs.chown(socketPath, -1, socketGroup);
191
+ await fs.chmod(socketPath, 0o660);
192
+ }
193
+
194
+ const listening = await fs.stat(socketPath);
195
+ const mode = listening.mode & 0o777;
196
+ const wanted = socketGroup === null ? 0o600 : 0o660;
197
+ if (mode !== wanted || (socketGroup !== null && listening.gid !== socketGroup)) {
198
+ await new Promise((resolve) => server.close(resolve));
199
+ throw new Error(
200
+ socketGroup === null
201
+ ? `Renderer socket is mode ${mode.toString(8)}; it must be 600.`
202
+ : `Renderer socket is mode ${mode.toString(8)} in group ${listening.gid}; it must be 660 in group ${socketGroup}.`,
203
+ );
204
+ }
205
+
206
+ let draining = null;
207
+
208
+ /**
209
+ * Stops taking renders and finishes the ones it has: the graceful stop.
210
+ * Renders still open after `timeoutMs` are cut off, and PHP reports each
211
+ * as an incomplete render, the error it gives any renderer that goes away
212
+ * mid-render.
213
+ */
214
+ const drain = (timeoutMs = DEFAULT_DRAIN_MS) => {
215
+ draining ??= (async () => {
216
+ // The path goes first, so that PHP finds no renderer here at once and
217
+ // picks another, rather than connecting to a socket nobody accepts on.
218
+ // A pool gives each renderer a path of its own and never reuses one,
219
+ // so this can never remove a successor's.
220
+ await fs.rm(socketPath, { force: true });
221
+
222
+ // Then the listener stays open a moment longer. A connect that found
223
+ // the path just before it went is waiting in the kernel's backlog --
224
+ // connected, as far as PHP knows, with its render frame sent -- and
225
+ // closing the listener under it reset it: a render dropped by a
226
+ // rolling restart. With the path gone no more can arrive; the wait,
227
+ // then one more pass through the loop's poll phase (setImmediate runs
228
+ // after it, where a timer runs before), accepts every one still there.
229
+ await new Promise((resolve) => setTimeout(resolve, BACKLOG_GRACE_MS));
230
+ await new Promise((resolve) => setImmediate(resolve));
231
+
232
+ // The server reports itself closed once it has counted its last
233
+ // connection out, which is before that connection's own 'close' --
234
+ // its handle closes a loop turn later -- so the connections are
235
+ // waited for too, or a drain could end with a render's last bytes
236
+ // still unwritten.
237
+ const finished = new Promise((resolve) => server.close(() => resolve())).then(() =>
238
+ Promise.all(
239
+ [...connections].map((socket) => new Promise((resolve) => socket.once('close', resolve))),
240
+ ),
241
+ );
242
+
243
+ let timer;
244
+ const late = new Promise((resolve) => {
245
+ timer = setTimeout(() => resolve('late'), timeoutMs);
246
+ });
247
+ if ((await Promise.race([finished, late])) === 'late') {
248
+ for (const socket of connections) socket.destroy();
249
+ await finished;
250
+ }
251
+ clearTimeout(timer);
252
+ })();
253
+
254
+ return draining;
255
+ };
256
+
257
+ return {
258
+ server,
259
+ socketPath,
260
+ codec: codec.name,
261
+ mode: process.env.NODE_ENV ?? 'development',
262
+ drain,
263
+ close: () => drain(),
264
+ /** Renders open now, and renders finished since it started. */
265
+ stats: () => ({ ...state }),
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Opens a socket's directory, made 0700, to the group its socket is shared
271
+ * with: 0750, so PHP's user can find the socket and no one else can look.
272
+ * Given to the group first, then opened to it.
273
+ */
274
+ export async function shareDirectory(directory, socketGroup) {
275
+ await fs.chown(directory, -1, socketGroup);
276
+ await fs.chmod(directory, 0o750);
277
+ }
278
+
279
+ function handleConnection(socket, app, maxFrameBytes, codec, state) {
280
+ const reader = new FrameReader(maxFrameBytes, codec);
281
+ // write() says when the socket's buffer is full, and drained() when it has
282
+ // room again, so the session reads the render no faster than PHP takes it.
283
+ const write = (bytes) => (socket.destroyed ? true : socket.write(bytes));
284
+ const drained = () =>
285
+ new Promise((resolve) => {
286
+ if (socket.destroyed || !socket.writableNeedDrain) {
287
+ resolve();
288
+ return;
289
+ }
290
+ const done = () => {
291
+ socket.off('drain', done);
292
+ socket.off('close', done);
293
+ resolve();
294
+ };
295
+ socket.on('drain', done);
296
+ socket.on('close', done);
297
+ });
298
+
299
+ // One session per connection: PHP opens a socket per request, so there is no
300
+ // state to carry between renders.
301
+ const session = new RenderSession(write, app, { codec, maxFrameBytes, drained });
302
+ let rendering = null;
303
+ let checked = false;
304
+ let greeted = false;
305
+
306
+ // Counted from the render frame until the connection closes, which is
307
+ // when PHP has everything: a drain that waited only for the render to
308
+ // settle could exit with its last chunks still in the socket's buffer.
309
+ let counted = false;
310
+ socket.on('close', () => {
311
+ if (counted) state.inFlight--;
312
+ });
313
+
314
+ socket.on('data', (chunk) => {
315
+ let frames;
316
+ try {
317
+ frames = reader.push(new Uint8Array(chunk));
318
+ } catch (error) {
319
+ logLine('warning', 'framing error', {}, error);
320
+ socket.destroy();
321
+ return;
322
+ }
323
+
324
+ for (const frame of frames) {
325
+ let type;
326
+ try {
327
+ type = assertFromPhp(frame);
328
+ } catch (error) {
329
+ logLine('warning', 'protocol violation', {}, error);
330
+ socket.destroy();
331
+ return;
332
+ }
333
+
334
+ if (type === FRAME.HELLO) {
335
+ // First on its connection, and once: PHP asks before it sends
336
+ // anything, and reads who answered from the kernel's credentials
337
+ // on the reply. Later, it would be a reply to nothing PHP waits on.
338
+ if (greeted || rendering || checked) {
339
+ logLine('warning', 'a hello frame arrived on a connection already in use');
340
+ socket.destroy();
341
+ return;
342
+ }
343
+ greeted = true;
344
+ write(encodeFrame({ t: FRAME.READY }, maxFrameBytes, codec));
345
+ continue;
346
+ }
347
+
348
+ if (type === FRAME.HEALTH) {
349
+ // Alone on its connection: a health check that rode along with a
350
+ // render could report on a render it was itself part of.
351
+ if (rendering || checked) {
352
+ logLine('warning', 'a health frame arrived on a connection already in use');
353
+ socket.destroy();
354
+ return;
355
+ }
356
+ checked = true;
357
+ write(
358
+ encodeFrame(
359
+ { t: FRAME.HEALTHY, pid: process.pid, inFlight: state.inFlight, served: state.served },
360
+ maxFrameBytes,
361
+ codec,
362
+ ),
363
+ );
364
+ socket.end();
365
+ return;
366
+ }
367
+
368
+ if (type === FRAME.RENDER || type === FRAME.FRAGMENT || type === FRAME.MAIL) {
369
+ if (rendering || checked) {
370
+ logLine('warning', 'a second render frame arrived on one connection');
371
+ socket.destroy();
372
+ return;
373
+ }
374
+ counted = true;
375
+ state.inFlight++;
376
+ rendering = (type === FRAME.MAIL ? session.mail(frame) : session.render(frame)).then(
377
+ () => {
378
+ // Served only where PHP was there to take it: a render
379
+ // abandoned when its socket closed settles too.
380
+ if (!socket.destroyed) state.served++;
381
+ socket.end();
382
+ },
383
+ (error) => {
384
+ logLine('error', 'unhandled render failure', { request_id: requestIdOf(frame) }, error);
385
+ socket.destroy();
386
+ },
387
+ );
388
+ continue;
389
+ }
390
+
391
+ if (type === FRAME.RESULT) {
392
+ try {
393
+ session.settle(normalizeResults(frame.r));
394
+ } catch (error) {
395
+ // As for minted below: thrown here, it is outside any try and
396
+ // takes the process -- every render on it -- down with this one.
397
+ logLine('warning', 'malformed result frame', {}, error);
398
+ socket.destroy();
399
+ return;
400
+ }
401
+ continue;
402
+ }
403
+
404
+ if (type === FRAME.MINTED) {
405
+ // Not a reply at all, so it settles no batch: each minted frame is
406
+ // taken as the answer to the oldest mint frame still waiting, and
407
+ // boundaries it leaves out are refused.
408
+ if (!Array.isArray(frame.m)) continue;
409
+ try {
410
+ session.settleMints(normalizeMints(frame.m));
411
+ } catch (error) {
412
+ // Thrown inside socket.on('data'), which is outside any try the
413
+ // caller has, so an unchecked destructure here takes the process
414
+ // down. PHP validates its side; this is the symmetric half.
415
+ logLine('warning', 'malformed minted frame', {}, error);
416
+ socket.destroy();
417
+ return;
418
+ }
419
+ }
420
+ }
421
+ });
422
+
423
+ // BOTH clients. The mint client was left out, and RenderSession's own
424
+ // finally cannot cover it: that finally runs when the render settles, and
425
+ // the render is awaiting the mint promise -- so a refused mint left the
426
+ // session, the React tree and the promise alive for the life of the
427
+ // process, once per request to a deferred page.
428
+ //
429
+ // And the render itself: the pump read on into a destroyed socket and
430
+ // React rendered the page to its end for no one. abort() cancels the
431
+ // app's stream and fires the signal its entries hand React.
432
+ socket.on('close', () => {
433
+ session.abort('socket_closed');
434
+ session.client?.close('socket_closed');
435
+ session.mints?.close('socket_closed');
436
+ });
437
+ }
438
+
439
+ /**
440
+ * MessagePack gives back BigInt for int64 and Uint8Array for bin. Query results
441
+ * are handed to React, so they are normalized to ordinary JS values first.
442
+ */
443
+ /** @param {unknown} tokens */
444
+ function normalizeMints(tokens) {
445
+ if (!Array.isArray(tokens)) return [];
446
+
447
+ return tokens.map((entry) => {
448
+ if (entry === null || typeof entry !== 'object') {
449
+ throw new TypeError('A minted entry must be an object.');
450
+ }
451
+
452
+ return { id: Number(entry.id), token: entry.token };
453
+ });
454
+ }
455
+
456
+ function normalizeResults(results) {
457
+ if (!Array.isArray(results)) return [];
458
+
459
+ return results.map((result) => {
460
+ if (result === null || typeof result !== 'object') {
461
+ throw new TypeError('A result entry must be an object.');
462
+ }
463
+
464
+ return { ...result, id: Number(result.id) };
465
+ });
466
+ }
@@ -0,0 +1,72 @@
1
+ // The group a renderer run as a user of its own shares its socket with
2
+ // (ADR 0028).
3
+ //
4
+ // A renderer's socket is 0600: only its own user may connect, which is PHP
5
+ // when both run as one user. Run as a user of its own -- the stronger
6
+ // boundary -- the renderer must let PHP connect, and no one else: its
7
+ // socket is made 0660 in a group of exactly the two, and its directory
8
+ // 0750 in the same group. Which group is the deployment's to say, and to
9
+ // keep to those two members; the renderer checks only that it is in it,
10
+ // since it cannot give a socket to a group it is not in.
11
+
12
+ import fs from 'node:fs';
13
+
14
+ /**
15
+ * The gid `given` names: a number, or a group's name looked up in
16
+ * /etc/group -- where Linux keeps the groups a deployment makes, and where
17
+ * a group only a directory service knows of is not, so its number is
18
+ * asked for instead.
19
+ *
20
+ * @param {string} given
21
+ * @param {{ groupFile?: string, gids?: number[] }} [options] `gids` are the
22
+ * groups this process is in: its own by default
23
+ * @returns {number}
24
+ */
25
+ export function resolveSocketGroup(given, { groupFile = '/etc/group', gids = processGroups() } = {}) {
26
+ let gid;
27
+ if (/^[0-9]{1,10}$/.test(given)) {
28
+ gid = Number(given);
29
+ } else if (/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(given)) {
30
+ gid = groupNamed(given, groupFile);
31
+ if (gid === null) {
32
+ throw new Error(
33
+ `--socket-group names '${given}', and ${groupFile} has no such group; give the group's number instead.`,
34
+ );
35
+ }
36
+ } else {
37
+ throw new Error(`--socket-group must be a group's name or number; got '${given}'.`);
38
+ }
39
+
40
+ if (gid === 0) {
41
+ throw new Error("--socket-group names root's group; the socket is shared with a group of its own.");
42
+ }
43
+ if (!gids.includes(gid)) {
44
+ throw new Error(
45
+ `--socket-group names group ${gid}, and this process is not in it, so it cannot give its socket to it. ` +
46
+ 'Start the renderer in that group (systemd Group=, sudo -g).',
47
+ );
48
+ }
49
+
50
+ return gid;
51
+ }
52
+
53
+ /** Every group this process is in: its effective one and the rest. */
54
+ function processGroups() {
55
+ if (typeof process.getegid !== 'function') return [];
56
+ return [process.getegid(), ...(process.getgroups?.() ?? [])];
57
+ }
58
+
59
+ /** The gid of the group named `name` in `groupFile`, or null. */
60
+ function groupNamed(name, groupFile) {
61
+ let text;
62
+ try {
63
+ text = fs.readFileSync(groupFile, 'utf8');
64
+ } catch {
65
+ return null;
66
+ }
67
+ for (const line of text.split('\n')) {
68
+ const [entry, , gid] = line.split(':');
69
+ if (entry === name && /^[0-9]{1,10}$/.test(gid ?? '')) return Number(gid);
70
+ }
71
+ return null;
72
+ }