Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions packages/core/src/fs-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 42 additions & 11 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<void> {
/** Refuse to write through an existing symlink (protects auth.json / credential stores). */
async function assertNotSymlink(p: string): Promise<void> {
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<void> {
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<void> {
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
Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/test/util/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading