Skip to content

Commit 72435a1

Browse files
Merge pull request #5 from ConsultingFuture4200/fix/secrets-file-permissions
fix(storage): write userData JSON files owner-only
2 parents 2d5861a + 67c730e commit 72435a1

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

app/src/json-store.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ describe("json-store", () => {
3434
expect(entries).toEqual(["data.json"]);
3535
});
3636

37+
// These files hold API keys and conversation history; the default umask
38+
// would leave them readable by other accounts on the machine.
39+
it.skipIf(process.platform === "win32")("writes owner-only files", () => {
40+
writeJson(file, { token: "value" });
41+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
42+
});
43+
44+
it.skipIf(process.platform === "win32")("keeps the file owner-only on rewrite", () => {
45+
writeJson(file, { token: "first" });
46+
writeJson(file, { token: "second" });
47+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
48+
});
49+
50+
it.skipIf(process.platform === "win32")("stays owner-only when a stale temp file exists", () => {
51+
const stale = `${file}.tmp-${process.pid}`;
52+
fs.writeFileSync(stale, "leftover", { mode: 0o666 });
53+
writeJson(file, { token: "value" });
54+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
55+
});
56+
57+
// A file written by an older build is only ever read if its contents never
58+
// change, so tightening on write alone would never reach it.
59+
it.skipIf(process.platform === "win32")("tightens an existing world-readable file on read", () => {
60+
fs.writeFileSync(file, JSON.stringify({ token: "value" }), { mode: 0o644 });
61+
expect(readJson(file, {})).toEqual({ token: "value" });
62+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
63+
});
64+
3765
it("backs up and falls back to the default when the file is corrupted", () => {
3866
fs.writeFileSync(file, "{ not valid json");
3967
const result = readJson(file, { safe: true });

app/src/json-store.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export function readJson<T>(filePath: string, fallback: T): T {
3030
return fallback;
3131
}
3232

33+
restrictExistingPermissions(filePath);
34+
3335
try {
3436
return JSON.parse(raw) as T;
3537
} catch (err) {
@@ -43,9 +45,48 @@ export function readJson<T>(filePath: string, fallback: T): T {
4345
}
4446
}
4547

48+
// These files hold provider API keys (secrets.json) and full conversation
49+
// history. Written with the process umask they land as 0644 — or 0664 under
50+
// the umask 002 several distributions ship — leaving their contents readable
51+
// to anything that reaches them: another account on a shared machine, a
52+
// backup or sync tool, an archive unpacked somewhere else. The userData
53+
// directory is usually restrictive enough to cover that on a single-user
54+
// desktop, but a stored credential shouldn't depend on its parent directory's
55+
// mode.
56+
const PRIVATE_FILE_MODE = 0o600;
57+
58+
// Files written before this existed keep the mode they were created with, and
59+
// writeJson alone would never reach them: a key set once and never changed is
60+
// only ever read. So the mode is also tightened on first read, once per path
61+
// per run to keep it off the hot path.
62+
const permissionsChecked = new Set<string>();
63+
64+
function restrictExistingPermissions(filePath: string): void {
65+
if (process.platform === "win32" || permissionsChecked.has(filePath)) return;
66+
permissionsChecked.add(filePath);
67+
try {
68+
if ((fs.statSync(filePath).mode & 0o777) !== PRIVATE_FILE_MODE) {
69+
fs.chmodSync(filePath, PRIVATE_FILE_MODE);
70+
}
71+
} catch (err) {
72+
// Best effort — unusual ownership or an exotic filesystem must not
73+
// stop the app from reading its own data.
74+
logger.error(`Failed to restrict permissions on ${filePath}: ${(err as Error).message}`);
75+
}
76+
}
77+
4678
export function writeJson(filePath: string, data: unknown): void {
4779
fs.mkdirSync(path.dirname(filePath), { recursive: true });
4880
const tmpPath = `${filePath}.tmp-${process.pid}`;
49-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
81+
// The mode goes on the temp file, because the rename below replaces the
82+
// destination inode and takes the temp file's mode with it. Chmod'ing the
83+
// destination afterwards would leave a window where the contents are
84+
// readable, and would be undone by the next write.
85+
//
86+
// Removed first so writeFileSync always creates the file and so always
87+
// applies `mode` — it ignores the option for a path that already exists,
88+
// and an interrupted earlier write can leave one behind under this pid.
89+
fs.rmSync(tmpPath, { force: true });
90+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: PRIVATE_FILE_MODE });
5091
fs.renameSync(tmpPath, filePath);
5192
}

0 commit comments

Comments
 (0)