From d4b6df4f636b7f8ce89f1faa9884d525ce75c1ab Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:49:14 +0200 Subject: [PATCH] Create config files with mode 0o600 (#1017) Config files hold the auth secret and tokens, so the default 0o644 lets every user on the system read them. Config.save, the ctl config written by the controller bootstrap, ctl host create-config and the config migration in create now pass mode 0o600. safeOutputFile also applies the mode to the temporary file it writes, since writeFile only honours mode on creation and a leftover temporary from a failed write would otherwise keep its old mode. Closes #1005 Co-authored-by: Claude Fable 5.1 --- packages/controller/controller.ts | 2 +- packages/create/create.js | 2 +- packages/ctl/src/commands_host.ts | 2 +- packages/lib/src/config/classes.ts | 2 +- packages/lib/src/file_ops.ts | 4 ++++ test/lib/config/classes.js | 9 +++++++++ test/lib/file_ops.js | 10 ++++++++++ 7 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 2551aee0..a61a0080 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -206,7 +206,7 @@ async function handleBootstrapCommand( console.log(content); } else { logger.info(`Writing ${args.output}`); - await lib.safeOutputFile(args.output, content); + await lib.safeOutputFile(args.output, content, { mode: 0o600 }); } } } diff --git a/packages/create/create.js b/packages/create/create.js index 457dae2b..5980f9d2 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -234,7 +234,7 @@ async function migrateRename(args) { if (await pathExists(source) && !await pathExists(destination)) { await safeOutputFile(destination, JSON.stringify( renameConfig(JSON.parse(await fs.readFile(source))), null, "\t" - )); + ), { mode: 0o600 }); logger.info(`Migrated ${source} to ${destination}`); } } diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index fc269e3b..e2ad8283 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -87,7 +87,7 @@ hostCommands.add(new lib.Command({ } else { logger.info(`Writing ${args.output}`); try { - await fs.writeFile(args.output, content, { flag: "wx" }); + await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 }); } catch (err: any) { if (err.code === "EEXIST") { throw new lib.CommandError(`File ${args.output} already exists`); diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index 6ca54746..769079a0 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -468,7 +468,7 @@ export class Config< if (!this.filepath) { throw new Error("Cannot save config which has no filepath"); } - await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t")); + await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 }); this.dirty = false; } diff --git a/packages/lib/src/file_ops.ts b/packages/lib/src/file_ops.ts index b8899406..908f57a3 100644 --- a/packages/lib/src/file_ops.ts +++ b/packages/lib/src/file_ops.ts @@ -168,6 +168,10 @@ export async function safeOutputFile( throw err; } } + if (options?.mode !== undefined) { + // writeFile only applies mode when creating, a leftover temporary keeps its old mode. + await fs.chmod(temporary, options.mode); + } await fs.rename(temporary, file); } diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index 17ed8100..c953ea13 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -395,6 +395,15 @@ describe("lib/config/classes", function() { const json = JSON.parse(await fs.readFile(filepath, "utf8")); assert.deepEqual(json, testInstance.toJSON()); }); + it("should not be readable by other users", async function() { + if (process.platform === "win32") { + this.skip(); + } + const testInstance = new TestConfig("local", { "alpha.foo": "a" }, filepath); + testInstance.set("alpha.foo", "b"); // Sets the dirty flag + await testInstance.save(); + assert.equal((await fs.stat(filepath)).mode & 0o777, 0o600); + }); it("should clear the dirty flag after saving", async function() { const testInstance = new TestConfig("local", { "alpha.foo": "a" }, filepath); testInstance.set("alpha.foo", "b"); // Sets the dirty flag diff --git a/test/lib/file_ops.js b/test/lib/file_ops.js index 036544a4..2f4993e7 100644 --- a/test/lib/file_ops.js +++ b/test/lib/file_ops.js @@ -100,6 +100,16 @@ describe("lib/file_ops", function() { await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind"); assert.equal(await fs.readFile(target, "utf8"), "current"); }); + it("should apply mode to a leftover temporary file", async function() { + if (process.platform === "win32") { + this.skip(); + } + let target = path.join(baseDir, "safe", "mode.txt"); + await fs.writeFile(target.replace(".txt", ".tmp.txt"), "stale", { mode: 0o644 }); + await lib.safeOutputFile(target, "private", { mode: 0o600 }); + assert.equal((await fs.stat(target)).mode & 0o777, 0o600); + assert.equal(await fs.readFile(target, "utf8"), "private"); + }); it("should handle creating file in current working directory", async function() { let target = "temporary-file-made-to-test-cwd.txt"; try {