fix(design): create the OpenAI key file owner-only, closing the write-then-chmod race

saveApiKey wrote ~/.gstack/openai.json at the default umask and tightened to
0600 afterwards, leaving the API key briefly world-readable between write and
chmod (CWE-377/367). Pass mode 0o600 at create; the trailing chmodSync stays
as a backstop to tighten a pre-existing loose file.

Contributed by @bunlongheng (PR #2468).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 19:24:30 -07:00
parent a03f571147
commit a2ae26d44c
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 25 additions and 1 deletions

View File

@ -111,7 +111,10 @@ export function describeApiKeySource(resolution: ApiKeyResolution): string {
export function saveApiKey(key: string): void {
const dir = path.dirname(configPath());
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2));
// Create the file owner-only up front so the API key is never briefly
// world/group-readable in the window between write and chmod. The trailing
// chmodSync is kept as a backstop to tighten a pre-existing loose file.
fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2), { mode: 0o600 });
fs.chmodSync(configPath(), 0o600);
}

View File

@ -111,6 +111,27 @@ describe("resolveApiKeyInfo", () => {
});
});
describe("saveApiKey", () => {
test("stores the key file owner-only, even under a permissive umask", () => {
// The OpenAI key file must never be group/other-readable. saveApiKey now
// creates it with mode 0600 up front (matching session.ts / #859) instead
// of writing at the default umask and tightening afterwards, so the key is
// not briefly world-readable in the write-then-chmod window (CWE-377/367).
const prevUmask = process.umask(0o000);
try {
saveApiKey("sk-secret-value");
} finally {
process.umask(prevUmask);
}
const keyPath = path.join(tmpHome, ".gstack", "openai.json");
const mode = fs.statSync(keyPath).mode & 0o777;
expect(mode).toBe(0o600);
// No group/other read/write/exec bits.
expect(mode & 0o077).toBe(0);
});
});
describe("requireApiKey", () => {
test("prints source disclosure without leaking the key", () => {
process.env.OPENAI_API_KEY = "sk-secret-value";