From 25fe200a9cbef2669c84b552ad26ebef7d37b29d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 13 Sep 2026 16:31:10 +0200 Subject: [PATCH] fix: keep imported auth profiles private --- src/auth/importers.permissions.test.ts | 123 +++++++++++++++++++++++++ src/auth/importers.ts | 8 +- src/auth/profileStore.ts | 21 +++-- src/filesystem/io.ts | 18 +++- 4 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 src/auth/importers.permissions.test.ts diff --git a/src/auth/importers.permissions.test.ts b/src/auth/importers.permissions.test.ts new file mode 100644 index 00000000..a669c3e9 --- /dev/null +++ b/src/auth/importers.permissions.test.ts @@ -0,0 +1,123 @@ +import { chmod, mkdtemp, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { readUtf8File, removeDirectory, writeUtf8File } from "../filesystem/index.js"; + +import { importClaudeCodeAuth, importCodexAuth, importEnvFile } from "./importers.js"; +import { ensureAuthProfile, requireAuthProfile, setAuthProfileEnv } from "./profileStore.js"; +import type { ResolvedAuthProfile } from "./types.js"; + +const sources = [ + { kind: "codex", file: "auth.json", importAuth: importCodexAuth }, + { kind: "claude-code", file: ".credentials.json", importAuth: importClaudeCodeAuth } +] as const; +const temporaryDirectories: string[] = []; +const previousSpawnfileHome = process.env.SPAWNFILE_HOME; +let previousUmask: number; + +const createTempDirectory = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-auth-permissions-")); + temporaryDirectories.push(directory); + return directory; +}; + +const expectMode = async (entry: string, mode: number): Promise => { + expect((await stat(entry)).mode & 0o7777, entry).toBe(mode); +}; + +const expectPrivateProfile = async (profile: ResolvedAuthProfile): Promise => { + await expectMode(profile.authHome, 0o700); + await expectMode(path.dirname(profile.profileDirectory), 0o700); + await expectMode(profile.profileDirectory, 0o700); + await expectMode(profile.profilePath, 0o600); + for (const source of sources) { + const entry = profile.imports[source.kind]; + if (!entry) continue; + await expectMode(path.dirname(entry.path), 0o700); + await expectMode(entry.path, 0o700); + await expectMode(path.join(entry.path, source.file), 0o600); + } +}; + +describe.skipIf(process.platform === "win32")("private auth imports under umask 022", () => { + beforeEach(async () => { + previousUmask = process.umask(0o022); + process.env.SPAWNFILE_HOME = path.join(await createTempDirectory(), "new-home"); + }); + + afterEach(async () => { + process.umask(previousUmask); + if (previousSpawnfileHome === undefined) delete process.env.SPAWNFILE_HOME; + else process.env.SPAWNFILE_HOME = previousSpawnfileHome; + await Promise.all(temporaryDirectories.splice(0).map(removeDirectory)); + }); + + it("creates an empty profile with private directories and metadata", async () => { + await expectPrivateProfile(await ensureAuthProfile("dev")); + }); + + it.each(sources)("creates private $kind credentials in a clean home", async (source) => { + const sourceDirectory = await createTempDirectory(); + const sourceFile = path.join(sourceDirectory, source.file); + const content = '{"token":"first-account"}\n'; + await writeUtf8File(sourceFile, content); + await chmod(sourceFile, 0o644); + + const profile = await source.importAuth("dev", sourceDirectory); + + await expectMode(path.join(profile.imports[source.kind]!.path, source.file), 0o600); + await expectPrivateProfile(profile); + expect(await readUtf8File(path.join(profile.imports[source.kind]!.path, source.file))).toBe(content); + expect(await readUtf8File(sourceFile)).toBe(content); + await expectMode(sourceFile, 0o644); + }); + + it.each(sources)("keeps a replaced $kind import private and preserves other auth", async (source) => { + const sourceDirectory = await createTempDirectory(); + await setAuthProfileEnv("dev", { SERVICE_API_KEY: "existing-env" }); + for (const entry of sources) { + await writeUtf8File(path.join(sourceDirectory, entry.file), `{"token":"${entry.kind}"}\n`); + await entry.importAuth("dev", sourceDirectory); + } + const before = await requireAuthProfile("dev"); + const directory = before.imports[source.kind]!.path; + const file = path.join(directory, source.file); + await chmod(directory, 0o700); + await chmod(file, 0o600); + await chmod(before.profilePath, 0o600); + await writeUtf8File(path.join(sourceDirectory, source.file), '{"token":"replacement-account"}\n'); + + const after = await source.importAuth("dev", sourceDirectory); + + await expectMode(file, 0o600); + await expectPrivateProfile(after); + expect(after).toEqual(before); + expect(await requireAuthProfile("dev")).toEqual(before); + expect(await readUtf8File(file)).toBe('{"token":"replacement-account"}\n'); + const other = sources.find((entry) => entry.kind !== source.kind)!; + expect(await readUtf8File(path.join(after.imports[other.kind]!.path, other.file))).toBe( + `{"token":"${other.kind}"}\n` + ); + }); + + it("keeps env credentials private when creating and updating legacy profiles", async () => { + const envFile = path.join(await createTempDirectory(), ".env"); + await writeUtf8File(envFile, "SERVICE_API_KEY=first\n"); + const initial = await importEnvFile("dev", envFile); + await expectPrivateProfile(initial); + await chmod(initial.profilePath, 0o644); + await chmod(initial.profileDirectory, 0o755); + await chmod(path.dirname(initial.profileDirectory), 0o755); + await chmod(initial.authHome, 0o755); + await writeUtf8File(envFile, "OTHER_API_KEY=second\n"); + + const updated = await importEnvFile("dev", envFile); + + await expectPrivateProfile(updated); + expect(updated.env).toEqual({ SERVICE_API_KEY: "first", OTHER_API_KEY: "second" }); + expect(await requireAuthProfile("dev")).toEqual(updated); + }); +}); diff --git a/src/auth/importers.ts b/src/auth/importers.ts index 78fca080..6fb8af7f 100644 --- a/src/auth/importers.ts +++ b/src/auth/importers.ts @@ -2,10 +2,9 @@ import path from "node:path"; import os from "node:os"; import { - ensureDirectory, fileExists, readUtf8File, - writeUtf8File + writePrivateUtf8File } from "../filesystem/index.js"; import { SpawnfileError } from "../shared/index.js"; @@ -108,7 +107,7 @@ export const importCodexAuth = async ( } const { directory, profile } = await registerImportedAuth(profileName, "codex"); - await writeUtf8File(path.join(directory, "auth.json"), await readUtf8File(authFilePath)); + await writePrivateUtf8File(path.join(directory, "auth.json"), await readUtf8File(authFilePath)); return profile; }; @@ -142,7 +141,6 @@ export const importClaudeCodeAuth = async ( } const { directory, profile } = await registerImportedAuth(profileName, "claude-code"); - await ensureDirectory(directory); - await writeUtf8File(path.join(directory, ".credentials.json"), credentialsContent); + await writePrivateUtf8File(path.join(directory, ".credentials.json"), credentialsContent); return profile; }; diff --git a/src/auth/profileStore.ts b/src/auth/profileStore.ts index ab9947ef..9c6364e7 100644 --- a/src/auth/profileStore.ts +++ b/src/auth/profileStore.ts @@ -3,11 +3,11 @@ import path from "node:path"; import { z } from "zod"; import { - ensureDirectory, + ensurePrivateDirectory, fileExists, readUtf8File, removeDirectory, - writeUtf8File + writePrivateUtf8File } from "../filesystem/index.js"; import { SpawnfileError } from "../shared/index.js"; @@ -15,7 +15,8 @@ import { resolveAuthHome, resolveImportedAuthDirectory, resolveProfileDirectory, - resolveProfilePath + resolveProfilePath, + resolveProfilesRoot } from "./paths.js"; import type { AuthProfile, ImportedAuthKind, ResolvedAuthProfile } from "./types.js"; @@ -110,16 +111,15 @@ export const ensureAuthProfile = async ( return existing; } - const profileDirectory = resolveProfileDirectory(profileName); - await ensureDirectory(profileDirectory); - await writeUtf8File(resolveProfilePath(profileName), `${JSON.stringify(createEmptyProfile(), null, 2)}\n`); - return createResolvedAuthProfile(profileName, createEmptyProfile()); + return writeProfile(profileName, createEmptyProfile()); }; const writeProfile = async (profileName: string, profile: AuthProfile): Promise => { const profileDirectory = resolveProfileDirectory(profileName); - await ensureDirectory(profileDirectory); - await writeUtf8File(resolveProfilePath(profileName), `${JSON.stringify(profile, null, 2)}\n`); + await ensurePrivateDirectory(resolveAuthHome()); + await ensurePrivateDirectory(resolveProfilesRoot()); + await ensurePrivateDirectory(profileDirectory); + await writePrivateUtf8File(resolveProfilePath(profileName), `${JSON.stringify(profile, null, 2)}\n`); return createResolvedAuthProfile(profileName, profile); }; @@ -152,8 +152,9 @@ export const registerImportedAuth = async ( ): Promise<{ directory: string; profile: ResolvedAuthProfile }> => { const current = (await loadAuthProfile(profileName)) ?? (await ensureAuthProfile(profileName)); const importDirectory = resolveImportedAuthDirectory(profileName, kind); + await ensurePrivateDirectory(path.dirname(importDirectory)); await removeDirectory(importDirectory); - await ensureDirectory(importDirectory); + await ensurePrivateDirectory(importDirectory); const nextProfile: AuthProfile = { env: { ...current.env }, diff --git a/src/filesystem/io.ts b/src/filesystem/io.ts index 099978c5..94972080 100644 --- a/src/filesystem/io.ts +++ b/src/filesystem/io.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { cp, mkdir, lstat, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, cp, mkdir, lstat, open, readFile, rm, stat, writeFile } from "node:fs/promises"; export interface CopyDirectoryOptions { filter?: (sourcePath: string, destinationPath: string) => boolean; @@ -21,6 +21,11 @@ export const ensureDirectory = async (directoryPath: string): Promise => { await mkdir(directoryPath, { recursive: true }); }; +export const ensurePrivateDirectory = async (directoryPath: string): Promise => { + await mkdir(directoryPath, { recursive: true, mode: 0o700 }); + await chmod(directoryPath, 0o700); +}; + export const fileExists = async (filePath: string): Promise => { try { await stat(filePath); @@ -83,3 +88,14 @@ export const writeUtf8File = async ( ): Promise => { await writeFile(filePath, content, "utf8"); }; + +export const writePrivateUtf8File = async (filePath: string, content: string): Promise => { + const handle = await open(filePath, "w", 0o600); + try { + // Creation mode does not change an existing file's permissions. + await handle.chmod(0o600); + await handle.writeFile(content, "utf8"); + } finally { + await handle.close(); + } +};