diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index eb68627a55c3..bcf14aae28bd 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -109,8 +109,56 @@ export namespace FSUtil { const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { const content = JSON.stringify(data, null, 2) - yield* fs.writeFileString(path, content) - if (mode) yield* fs.chmod(path, mode) + // Refuse symlink targets so credential/state writers (auth, mcp) cannot be redirected. + const isLink = yield* Effect.tryPromise({ + try: async () => { + try { + return (await NFS.lstat(path)).isSymbolicLink() + } catch (e) { + if (typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT") { + return false + } + throw e + } + }, + catch: (cause) => new FileSystemError({ method: "writeJson", cause }), + }) + if (isLink) { + return yield* Effect.fail( + new FileSystemError({ + method: "writeJson", + cause: new Error(`Refusing to write through symlink: ${path}`), + }), + ) + } + // Write through a descriptor opened with O_NOFOLLOW so the symlink + // check is bound to the open itself (no lstat/write TOCTOU race on the + // leaf; the lstat above remains as the Windows fallback). Symlinked + // parent directories stay allowed on purpose: dotfile managers + // commonly symlink config/data directories. + yield* Effect.tryPromise({ + try: async () => { + const flags = + NFS.constants.O_WRONLY | NFS.constants.O_CREAT | NFS.constants.O_TRUNC | (NFS.constants.O_NOFOLLOW ?? 0) + const handle = await NFS.open(path, flags, mode) + try { + await handle.writeFile(content) + if (mode) await handle.chmod(mode) + } finally { + await handle.close() + } + }, + catch: (cause) => { + const code = typeof cause === "object" && cause !== null && "code" in cause && (cause as { code: string }).code + if (code === "ELOOP" || code === "EMLINK") { + return new FileSystemError({ + method: "writeJson", + cause: new Error(`Refusing to write through symlink: ${path}`), + }) + } + return new FileSystemError({ method: "writeJson", cause }) + }, + }) }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 78a89174145d..908214ef9950 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" +import { chmod, constants, lstat, mkdir, open, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { realpathSync } from "fs" import { dirname, isAbsolute, join, resolve as pathResolve, win32 } from "path" @@ -58,21 +58,52 @@ function isEnoent(e: unknown): e is { code: "ENOENT" } { return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT" } -export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { +/** Refuse to write through an existing symlink (protects auth.json / credential stores). */ +async function assertNotSymlink(p: string): Promise { try { - if (mode) { - await writeFile(p, content, { mode }) - } else { - await writeFile(p, content) + const info = await lstat(p) + if (info.isSymbolicLink()) { + throw new Error(`Refusing to write through symlink: ${p}`) } } catch (e) { + if (isEnoent(e)) return + throw e + } +} + +// O_NOFOLLOW is unavailable on Windows; assertNotSymlink covers that platform. +const NO_FOLLOW_WRITE = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW ?? 0) + +function isSymlinkRefusal(e: unknown) { + if (typeof e !== "object" || e === null || !("code" in e)) return false + const code = (e as { code: string }).code + // ELOOP on Linux/macOS, EMLINK on some BSDs when O_NOFOLLOW hits a symlink leaf. + return code === "ELOOP" || code === "EMLINK" +} + +// Write through a descriptor opened with O_NOFOLLOW so the symlink check is +// bound to the open itself (no lstat/write TOCTOU race on the leaf). Symlinked +// parent directories stay allowed on purpose: dotfile managers commonly +// symlink config/data directories. +async function writeNoFollow(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { + const handle = await open(p, NO_FOLLOW_WRITE, mode) + try { + await handle.writeFile(content) + if (mode) await handle.chmod(mode) + } finally { + await handle.close() + } +} + +export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { + await assertNotSymlink(p) + try { + await writeNoFollow(p, content, mode) + } catch (e) { + if (isSymlinkRefusal(e)) throw new Error(`Refusing to write through symlink: ${p}`) if (isEnoent(e)) { await mkdir(dirname(p), { recursive: true }) - if (mode) { - await writeFile(p, content, { mode }) - } else { - await writeFile(p, content) - } + await writeNoFollow(p, content, mode) return } throw e diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 9b8e3f7977b7..c893123dff41 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -308,6 +308,34 @@ describe("filesystem", () => { }) }) + describe("write()", () => { + test("refuses to write through a symlink", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const target = path.join(tmp.path, "real.json") + const link = path.join(tmp.path, "link.json") + await fs.writeFile(target, "original") + await fs.symlink(target, link) + + await expect(Filesystem.write(link, "hijacked", 0o600)).rejects.toThrow(/symlink/i) + + expect(await fs.readFile(target, "utf-8")).toBe("original") + }) + + test("allows writes under a symlinked parent directory (dotfile setups)", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const real = path.join(tmp.path, "real-dir") + const link = path.join(tmp.path, "link-dir") + await fs.mkdir(real) + await fs.symlink(real, link) + + await Filesystem.write(path.join(link, "auth.json"), "ok", 0o600) + + expect(await fs.readFile(path.join(real, "auth.json"), "utf-8")).toBe("ok") + }) + }) + describe("writeJson()", () => { test("writes JSON data", async () => { await using tmp = await tmpdir()