Skip to content

Commit 409c603

Browse files
juliusmarmingecodex
authored andcommitted
fix(security): send sensitive config over a bootstrap fd (pingdotgg#1398)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 0bdb51f commit 409c603

9 files changed

Lines changed: 590 additions & 66 deletions

File tree

REMOTE.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth
66

77
The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
88

9-
| CLI flag | Env var | Notes |
10-
| ----------------------- | --------------------- | ---------------------------------- |
11-
| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12-
| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13-
| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14-
| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15-
| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16-
| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17-
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |
9+
| CLI flag | Env var | Notes |
10+
| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11+
| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12+
| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13+
| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14+
| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15+
| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16+
| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17+
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18+
| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
1819

1920
> TIP: Use the `--help` flag to see all available options and their descriptions.
2021
2122
## Security First
2223

2324
- Always set `--auth-token` before exposing the server outside localhost.
25+
- When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26+
With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
2427
- Treat the token like a password.
2528
- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
2629

apps/desktop/src/main.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
5656
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5757
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
5858
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59+
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
5960
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6061
const STATE_DIR = Path.join(BASE_DIR, "userdata");
6162
const DESKTOP_SCHEME = "t3";
@@ -118,6 +119,17 @@ function sanitizeLogValue(value: string): string {
118119
return value.replace(/\s+/g, " ").trim();
119120
}
120121

122+
function backendChildEnv(): NodeJS.ProcessEnv {
123+
const env = { ...process.env };
124+
delete env.T3CODE_PORT;
125+
delete env.T3CODE_AUTH_TOKEN;
126+
delete env.T3CODE_MODE;
127+
delete env.T3CODE_NO_BROWSER;
128+
delete env.T3CODE_HOST;
129+
delete env.T3CODE_DESKTOP_WS_URL;
130+
return env;
131+
}
132+
121133
function writeDesktopLogHeader(message: string): void {
122134
if (!desktopLogSink) return;
123135
desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
@@ -931,17 +943,6 @@ function configureAutoUpdater(): void {
931943
}, AUTO_UPDATE_POLL_INTERVAL_MS);
932944
updatePollTimer.unref();
933945
}
934-
function backendEnv(): NodeJS.ProcessEnv {
935-
return {
936-
...process.env,
937-
T3CODE_MODE: "desktop",
938-
T3CODE_NO_BROWSER: "1",
939-
T3CODE_PORT: String(backendPort),
940-
T3CODE_HOME: BASE_DIR,
941-
T3CODE_AUTH_TOKEN: backendAuthToken,
942-
};
943-
}
944-
945946
function scheduleBackendRestart(reason: string): void {
946947
if (isQuitting || restartTimer) return;
947948

@@ -965,16 +966,35 @@ function startBackend(): void {
965966
}
966967

967968
const captureBackendLogs = app.isPackaged && backendLogSink !== null;
968-
const child = ChildProcess.spawn(process.execPath, [backendEntry], {
969+
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
969970
cwd: resolveBackendCwd(),
970971
// In Electron main, process.execPath points to the Electron binary.
971972
// Run the child in Node mode so this backend process does not become a GUI app instance.
972973
env: {
973-
...backendEnv(),
974+
...backendChildEnv(),
974975
ELECTRON_RUN_AS_NODE: "1",
975976
},
976-
stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
977+
stdio: captureBackendLogs
978+
? ["ignore", "pipe", "pipe", "pipe"]
979+
: ["ignore", "inherit", "inherit", "pipe"],
977980
});
981+
const bootstrapStream = child.stdio[3];
982+
if (bootstrapStream && "write" in bootstrapStream) {
983+
bootstrapStream.write(
984+
`${JSON.stringify({
985+
mode: "desktop",
986+
noBrowser: true,
987+
port: backendPort,
988+
t3Home: BASE_DIR,
989+
authToken: backendAuthToken,
990+
})}\n`,
991+
);
992+
bootstrapStream.end();
993+
} else {
994+
child.kill("SIGTERM");
995+
scheduleBackendRestart("missing desktop bootstrap pipe");
996+
return;
997+
}
978998
backendProcess = child;
979999
let backendSessionClosed = false;
9801000
const closeBackendSession = (details: string) => {
@@ -1085,6 +1105,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
10851105
}
10861106

10871107
function registerIpcHandlers(): void {
1108+
ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1109+
ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1110+
event.returnValue = backendWsUrl;
1111+
});
1112+
10881113
ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
10891114
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
10901115
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
@@ -1333,9 +1358,9 @@ async function bootstrap(): Promise<void> {
13331358
);
13341359
writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
13351360
backendAuthToken = Crypto.randomBytes(24).toString("hex");
1336-
backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
1337-
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
1338-
writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
1361+
const baseUrl = `ws://127.0.0.1:${backendPort}`;
1362+
backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1363+
writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
13391364

13401365
registerIpcHandlers();
13411366
writeDesktopLogHeader("bootstrap ipc handlers registered");

apps/desktop/src/preload.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1212
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1313
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
14-
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
14+
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
1515

1616
contextBridge.exposeInMainWorld("desktopBridge", {
17-
getWsUrl: () => wsUrl,
17+
getWsUrl: () => {
18+
const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
19+
return typeof result === "string" ? result : null;
20+
},
1821
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
1922
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2023
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),

apps/server/src/bootstrap.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as NFS from "node:fs";
2+
import * as path from "node:path";
3+
import { execFileSync, spawn } from "node:child_process";
4+
import * as NodeServices from "@effect/platform-node/NodeServices";
5+
import { assert, it } from "@effect/vitest";
6+
import { FileSystem, Schema } from "effect";
7+
import * as Duration from "effect/Duration";
8+
import * as Effect from "effect/Effect";
9+
import * as Fiber from "effect/Fiber";
10+
import { TestClock } from "effect/testing";
11+
12+
import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";
13+
import { assertNone, assertSome } from "@effect/vitest/utils";
14+
15+
const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });
16+
17+
it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
18+
it.effect("uses platform-specific fd paths", () =>
19+
Effect.sync(() => {
20+
assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3");
21+
assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3");
22+
assert.equal(resolveFdPath(3, "win32"), undefined);
23+
}),
24+
);
25+
26+
it.effect("reads a bootstrap envelope from a provided fd", () =>
27+
Effect.gen(function* () {
28+
const fs = yield* FileSystem.FileSystem;
29+
const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
30+
31+
yield* fs.writeFileString(
32+
filePath,
33+
`${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({
34+
mode: "desktop",
35+
})}\n`,
36+
);
37+
38+
const fd = yield* Effect.acquireRelease(
39+
Effect.sync(() => NFS.openSync(filePath, "r")),
40+
(fd) => Effect.sync(() => NFS.closeSync(fd)),
41+
);
42+
43+
const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
44+
assertSome(payload, {
45+
mode: "desktop",
46+
});
47+
}),
48+
);
49+
50+
it.effect("returns none when the fd is unavailable", () =>
51+
Effect.gen(function* () {
52+
const fd = NFS.openSync("/dev/null", "r");
53+
NFS.closeSync(fd);
54+
55+
const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
56+
assertNone(payload);
57+
}),
58+
);
59+
60+
it.effect("returns none when the bootstrap read times out before any value arrives", () =>
61+
Effect.gen(function* () {
62+
const fs = yield* FileSystem.FileSystem;
63+
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" });
64+
const fifoPath = path.join(tempDir, "bootstrap.pipe");
65+
66+
yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath]));
67+
68+
const _writer = yield* Effect.acquireRelease(
69+
Effect.sync(() =>
70+
spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], {
71+
stdio: ["ignore", "ignore", "ignore"],
72+
}),
73+
),
74+
(writer) =>
75+
Effect.sync(() => {
76+
writer.kill("SIGKILL");
77+
}),
78+
);
79+
80+
const fd = yield* Effect.acquireRelease(
81+
Effect.sync(() => NFS.openSync(fifoPath, "r")),
82+
(fd) => Effect.sync(() => NFS.closeSync(fd)),
83+
);
84+
85+
const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
86+
timeoutMs: 100,
87+
}).pipe(Effect.forkScoped);
88+
89+
yield* Effect.yieldNow;
90+
yield* TestClock.adjust(Duration.millis(100));
91+
92+
const payload = yield* Fiber.join(fiber);
93+
assertNone(payload);
94+
}).pipe(Effect.provide(TestClock.layer())),
95+
);
96+
});

0 commit comments

Comments
 (0)