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 <noreply@anthropic.com>
This commit is contained in:
BastiaanandClaude Fable 5.1 authored and GitHub committed 2026-09-12 01:49:14 +01:00
1 parent 8c03ac15ab
commit d4b6df4f63
7 files changed
+27 -4

No files matched your search

+1 -1
View File
@@ -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 });
}
}
}
+1 -1
View File
@@ -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}`);
}
}
+1 -1
View File
@@ -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`);
+1 -1
View File
@@ -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;
}
+4
View File
@@ -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);
}
+9
View File
@@ -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
+10
View File
@@ -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 {