file-permissions.ts
370 lines · browse/src/file-permissions.ts at 3f79d81
1/**
2 * Cross-platform file permission restriction for sensitive gstack state.
3 *
4 * Why this exists
5 * ----------------
6 * POSIX mode bits (`0o600` for files, `0o700` for dirs) are how gstack marks
7 * sensitive state files — auth tokens, PTY session state, tab context. On
8 * Linux and macOS,
9 * `fs.chmodSync(path, 0o600)` and `fs.writeFileSync(path, data, { mode: 0o600 })`
10 * do exactly what you'd hope: the file ends up readable and writable only
11 * by the owning user, no access for group / other.
12 *
13 * On Windows, both calls are effectively no-ops. NTFS uses ACLs, not POSIX
14 * mode bits, and Node's fs module doesn't translate. So on every Windows
15 * install, sensitive gstack state files inherit whatever ACL the parent
16 * directory grants — typically user-full + inherited admin-full. That's
17 * fine on a single-user laptop but leaks on:
18 *
19 * - Self-hosted CI runners (GitHub Actions / GitLab / Jenkins agents
20 * running as a different service account on the same box — they can
21 * read developer state)
22 * - Shared development machines (agencies, studios, lab machines)
23 * - Multi-tenant servers with shared home directories
24 * - Malware running as the same user (no in-user-account isolation)
25 *
26 * This module wraps the platform-correct call. POSIX: chmod. Windows:
27 * icacls with inheritance break + explicit user grant. Failures on either
28 * platform are best-effort — the filesystem is still functional if ACL
29 * restriction fails; we just don't hit the intended hardening target.
30 *
31 * Warning behavior: to avoid spamming the console on a machine where
32 * icacls is unavailable (rare — it ships in System32 on every Windows
33 * version since 7), we log the first failure per process and stay silent
34 * afterward. The warning includes the advice "sensitive files may be
35 * readable by other accounts on this machine" so operators know to audit
36 * their runner / share setup.
37 */
39import { execFileSync } from 'child_process';
40import * as fs from 'fs';
41import * as os from 'os';
43let warnedOnce = false;
45let cachedSid: string | null | undefined;
47/**
48 * Resolve the current user's SID, cached for the process lifetime.
49 *
50 * Returns null if `whoami` is unavailable or its output cannot be parsed,
51 * in which case callers fall back to a domain-qualified account name.
52 */
53function currentUserSid(): string | null {
54 if (cachedSid !== undefined) return cachedSid;
55 try {
56 // Pin to the System32 binary. A bare `whoami` resolves to the MSYS/Git
57 // Bash build under a bash-flavoured PATH, which rejects `/user` — the
58 // lookup would then silently fail on one of the most common Windows
59 // setups for this tool.
60 const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
61 const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], {
62 encoding: 'utf8',
63 windowsHide: true,
64 });
65 const match = out.match(/S-1-[\d-]+/);
66 cachedSid = match ? match[0] : null;
67 } catch {
68 cachedSid = null;
69 }
70 return cachedSid;
71}
73/**
74 * The principal to hand icacls for "the current user".
75 *
76 * An unqualified username is ambiguous: on a machine whose hostname equals
77 * the username, it fails to resolve to the user account and icacls silently
78 * writes an ACE for the machine SID instead. Combined with `/inheritance:r`
79 * that leaves a directory whose only ACE matches nobody — locking out the
80 * process that just created it.
81 *
82 * `*<SID>` is icacls' literal-SID form and is immune to that ambiguity.
83 * The domain-qualified name is the fallback.
84 */
85function currentUserPrincipal(): string {
86 const sid = currentUserSid();
87 if (sid) return `*${sid}`;
88 const domain = process.env.USERDOMAIN || os.hostname();
89 return `${domain}\\${os.userInfo().username}`;
90}
92function warnIcaclsFailure(fsPath: string, err: unknown): void {
93 if (warnedOnce) return;
94 warnedOnce = true;
95 const msg = err instanceof Error ? err.message : String(err);
96 // biome-ignore lint/suspicious/noConsole: intentional user-facing warning
97 console.warn(
98 `[gstack] Failed to restrict Windows ACL on ${fsPath}: ${msg}\n` +
99 ` Sensitive files may be readable by other accounts on this machine.\n` +
100 ` This warning appears once per process; subsequent failures are silent.`
101 );
102}
104/**
105 * Restrict a file to owner-only access (POSIX 0o600 equivalent).
106 *
107 * POSIX: `fs.chmodSync(path, 0o600)`. Idempotent if the file was already
108 * written with `{ mode: 0o600 }`, so safe to call regardless.
109 *
110 * Windows: invokes `icacls /inheritance:r /grant:r <user>:(F)` to remove
111 * any inherited ACLs and replace the ACL with a single entry granting the
112 * current user full control.
113 */
114export function restrictFilePermissions(filePath: string): void {
115 if (process.platform === 'win32') {
116 try {
117 const user = currentUserPrincipal();
118 execFileSync(
119 'icacls',
120 [filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
121 { stdio: 'ignore', windowsHide: true },
122 );
123 } catch (err) {
124 warnIcaclsFailure(filePath, err);
125 }
126 return;
127 }
128 try { fs.chmodSync(filePath, 0o600); } catch { /* best-effort */ }
129}
131/**
132 * Restrict a directory to owner-only access (POSIX 0o700 equivalent),
133 * with new children inheriting the restricted ACL.
134 *
135 * POSIX: `fs.chmodSync(path, 0o700)`. Idempotent if the dir was already
136 * created with `{ mode: 0o700 }`.
137 *
138 * Owner-only hardening is for directories gstack creates and owns. Two
139 * pre-existing shapes must never be chmodded, so the POSIX branch refuses
140 * them:
141 *
142 * - A directory owned by another user. On most hosts the chmod just
143 * fails EPERM, but run as root (or with CAP_FOWNER — containers, CI
144 * sandboxes) it SUCCEEDS and takes the directory away from its owner.
145 * - A shared sticky world-writable directory (`/tmp`, `/var/tmp`).
146 * Callers land here when a state file is configured directly inside
147 * the system temp dir (`BROWSE_STATE_FILE=/tmp/foo.json` makes
148 * `path.dirname()` derive `/tmp` as the state dir). A 0o700 `/tmp`
149 * locks every other process on the machine out of it: access(2)-based
150 * checks (`fs.existsSync`) return EACCES→false machine-wide, even
151 * while stat keeps working for capability-holding processes.
152 *
153 * Windows: `icacls /inheritance:r /grant:r <user>:(OI)(CI)(F)`. The
154 * `(OI)(CI)` flags make new files (OI = object inherit) and subdirs
155 * (CI = container inherit) inherit the single-user-full ACL — important
156 * because child creations in `fs.writeFileSync(...)` without explicit
157 * `restrictFilePermissions` still end up owner-only.
158 *
159 * Symlinked dirs are warned about and SKIPPED, never followed: both
160 * `chmod` and `icacls` dereference the link, so restricting through a
161 * symlink hardens whatever the link points at — a target the caller never
162 * vetted (and, with `/inheritance:r`, one we could lock its real owner out
163 * of). Skipping is best-effort-consistent with the rest of this module:
164 * the filesystem stays functional, we just don't hit the hardening target.
165 */
166export function restrictDirectoryPermissions(dirPath: string): void {
167 try {
168 if (fs.lstatSync(dirPath).isSymbolicLink()) {
169 // biome-ignore lint/suspicious/noConsole: intentional user-facing warning
170 console.warn(
171 `[gstack] Refusing to restrict permissions through symlink ${dirPath} — skipping.\n` +
172 ` Restricting through a symlink would alter the link target instead. ` +
173 `Harden the real directory directly.`
174 );
175 return;
176 }
177 } catch {
178 // Path doesn't exist (or lstat failed) — fall through; both platform
179 // branches below already swallow failures on missing paths.
180 }
181 if (process.platform === 'win32') {
182 try {
183 const user = currentUserPrincipal();
184 execFileSync(
185 'icacls',
186 [dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
187 { stdio: 'ignore', windowsHide: true },
188 );
189 } catch (err) {
190 warnIcaclsFailure(dirPath, err);
191 }
192 return;
193 }
194 try {
195 // fd-anchored check-then-act: statSync + chmodSync resolve the path twice,
196 // so a symlink swapped in between would let the chmod land on a directory
197 // the check never saw (exactly the CAP_FOWNER hosts this guard exists
198 // for). O_NOFOLLOW refuses a symlinked state dir outright; fstat + fchmod
199 // pin both the check and the act to the same inode.
200 const fd = fs.openSync(
201 dirPath,
202 fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
203 );
204 try {
205 if (!shouldHardenDir(fs.fstatSync(fd))) {
206 warnUnhardenedDirOnce(dirPath);
207 return;
208 }
209 fs.fchmodSync(fd, 0o700);
210 } finally {
211 fs.closeSync(fd);
212 }
213 } catch (err: any) {
214 // The fd path refuses two shapes the old chmodSync handled, and a silent
215 // skip here would quietly lose hardening (the module's own rule is that
216 // refusals are never silent):
217 // ELOOP/ENOTDIR — dirPath is a symlink (dotfiles-managed ~/.gstack via
218 // stow/chezmoi). Never follow it blind; warn so the operator knows.
219 // EACCES — an OWNED dir stuck without the read bit (mode 0300/0000)
220 // can't be opened but could always be repaired by plain chmod. Keep
221 // that self-repair: re-check ownership via lstat (no link follow) and
222 // chmod only a real, owned, non-shared directory.
223 if (err?.code === 'EACCES') {
224 try {
225 const st = fs.lstatSync(dirPath);
226 if (st.isDirectory() && shouldHardenDir(st)) {
227 fs.chmodSync(dirPath, 0o700);
228 return;
229 }
230 } catch { /* fall through to the warning */ }
231 }
232 if (err?.code === 'ELOOP' || err?.code === 'ENOTDIR' || err?.code === 'EACCES') {
233 warnUnhardenedDirOnce(dirPath);
234 }
235 /* anything else: best-effort, matching the old behavior */
236 }
237}
239/** Owner-only hardening applies to a real dir we own that isn't shared. */
240function shouldHardenDir(st: fs.Stats): boolean {
241 // chmod authorization is judged on the EFFECTIVE uid; fall back to the
242 // real uid where geteuid is unavailable (Windows — unreachable here).
243 const uid = process.geteuid?.() ?? process.getuid?.();
244 if (st.uid !== uid) return false;
245 // sticky + world-writable = shared temp dir (/tmp, /var/tmp) — never ours
246 // to restrict. Running as root, treat ANY world-writable dir as shared:
247 // root "owns" docker/CI volume mounts (0777, no sticky bit) that other
248 // users depend on, and a 0700 there locks them all out.
249 if ((st.mode & 0o1002) === 0o1002) return false;
250 if (uid === 0 && (st.mode & 0o002) === 0o002) return false;
251 return true;
252}
254// The POSIX refusal must not be silent (the Windows branch already warns when
255// icacls misses its hardening target): an operator who points state at a
256// shared or foreign-owned directory should learn it was left unhardened.
257const unhardenedWarned = new Set<string>();
258function warnUnhardenedDirOnce(dirPath: string): void {
259 if (unhardenedWarned.has(dirPath)) return;
260 unhardenedWarned.add(dirPath);
261 // biome-ignore lint/suspicious/noConsole: intentional user-facing warning
262 console.warn(
263 `[gstack] directory ${dirPath} is shared, symlinked, or owned by another user; ` +
264 'gstack left its permissions untouched — use a private, non-symlinked directory for owner-only hardening',
265 );
266}
268/**
269 * Write a file and restrict it to owner-only access, cross-platform.
270 * Replaces `fs.writeFileSync(path, data, { mode: 0o600 })` + Windows ACL.
271 */
272export function writeSecureFile(
273 filePath: string,
274 data: string | NodeJS.ArrayBufferView,
275): void {
276 fs.writeFileSync(filePath, data, { mode: 0o600 });
277 restrictFilePermissions(filePath);
278}
280/**
281 * Append to a file with owner-only permissions, cross-platform.
282 * Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.
283 *
284 * ACL is applied only on first write — subsequent appends are fire-and-forget
285 * (no need to re-run icacls on every log line).
286 */
287export function appendSecureFile(
288 filePath: string,
289 data: string | NodeJS.ArrayBufferView,
290): void {
291 const existed = fs.existsSync(filePath);
292 const payload = typeof data === 'string' ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
293 fs.appendFileSync(filePath, payload, { mode: 0o600 });
294 if (!existed) restrictFilePermissions(filePath);
295}
297/**
298 * Windows only: probe whether the current process can actually list the
299 * directory. `fs.accessSync` doesn't consult NTFS ACLs on Windows, so a
300 * real readdir is the only honest check.
301 */
302function canListDir(dirPath: string): boolean {
303 try { fs.readdirSync(dirPath); return true; } catch { return false; }
304}
306/**
307 * Windows only: repair a broken DACL on a state directory (#1605).
308 *
309 * `icacls /inheritance:r /grant:r <user>:(F)` is a single command, but the
310 * two halves can partially fail: inheritance gets stripped while the user
311 * grant doesn't resolve (localized account names, domain accounts, roaming
312 * profiles). The result is a DACL with no usable ACE — often just a machine
313 * SID — and the client can't read its own state files. `/reset` restores
314 * inherited ACLs from the parent, making the directory functional again.
315 * Functional-but-unhardened beats hardened-but-unusable.
316 */
317export function repairBrokenDacl(dirPath: string): void {
318 if (process.platform !== 'win32') return;
319 try {
320 execFileSync('icacls', [dirPath, '/reset', '/T', '/C', '/Q'], { stdio: 'ignore', windowsHide: true });
321 } catch (err) {
322 warnIcaclsFailure(dirPath, err);
323 }
324}
326function isExistingDirectory(dirPath: string): boolean {
327 try {
328 return fs.statSync(dirPath).isDirectory();
329 } catch {
330 return false;
331 }
332}
334/**
335 * `mkdir -p` with owner-only directory permissions, cross-platform.
336 * Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
337 * Safe to call on an existing directory — re-applies the ACL idempotently,
338 * except on directories gstack doesn't own (another user's dir, or a shared
339 * sticky world-writable dir like `/tmp`), which are left untouched — see
340 * `restrictDirectoryPermissions`.
341 *
342 * Windows: after applying the restricted ACL, verifies the directory is
343 * still listable by this process and repairs a broken DACL (#1605) if not.
344 */
345export function mkdirSecure(dirPath: string): void {
346 try {
347 fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
348 } catch (err: any) {
349 // Bun on Windows has thrown EEXIST from a recursive mkdir of an existing
350 // directory (#2048, #2635); recursive mkdir is idempotent, so only a
351 // non-directory at the path is a real conflict.
352 if (err?.code !== 'EEXIST' || !isExistingDirectory(dirPath)) throw err;
353 }
354 restrictDirectoryPermissions(dirPath);
355 if (process.platform === 'win32' && !canListDir(dirPath)) {
356 repairBrokenDacl(dirPath);
357 restrictDirectoryPermissions(dirPath);
358 // If re-hardening broke access again, reset once more and leave the
359 // directory with inherited ACLs — the client must be able to read
360 // its own state.
361 if (!canListDir(dirPath)) repairBrokenDacl(dirPath);
362 }
363}
365/**
366 * Reset the once-per-process warning gate. Test-only.
367 */
368export function __resetWarnedForTests(): void {
369 warnedOnce = false;
370}