diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 76b7be4b72b..a527d8306cf 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -277,8 +277,7 @@ export namespace File { const project = Instance.project const full = path.join(Instance.directory, file) - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + // Check if path is lexically contained first (fast check) if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } @@ -289,6 +288,11 @@ export namespace File { return { type: "text", content: "" } } + const real = await fs.promises.realpath(full) + if (!Instance.containsPath(real)) { + throw new Error(`Access denied: path escapes project directory`) + } + const encode = await shouldEncode(bunFile) if (encode) { @@ -337,12 +341,20 @@ export namespace File { } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + // Check if path is lexically contained first (fast check) if (!Instance.containsPath(resolved)) { throw new Error(`Access denied: path escapes project directory`) } + try { + const real = await fs.promises.realpath(resolved) + if (!Instance.containsPath(real)) { + throw new Error(`Access denied: path escapes project directory`) + } + } catch (err: any) { + if (err.message && err.message.startsWith("Access denied")) throw err + } + const nodes: Node[] = [] for (const entry of await fs.promises .readdir(resolved, { diff --git a/packages/opencode/test/security/symlink.test.ts b/packages/opencode/test/security/symlink.test.ts new file mode 100644 index 00000000000..80bd452a66c --- /dev/null +++ b/packages/opencode/test/security/symlink.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import os from "os" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { File } from "../../src/file" + +describe("security", () => { + test("prevents reading files outside project via symlink", async () => { + // Create a "secret" file outside the project + const secretDir = path.join(os.tmpdir(), "secret-" + Math.random().toString(36).slice(2)) + await fs.mkdir(secretDir, { recursive: true }) + const secretFile = path.join(secretDir, "passwd") + await Bun.write(secretFile, "secret-data") + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a symlink to the secret file + await fs.symlink(secretFile, path.join(dir, "link-to-secret")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Try to read the symlink + // This should FAIL (throw error) if security check works + // We expect "Access denied: path escapes project directory" + try { + await File.read("link-to-secret") + // If we get here, it failed to throw + throw new Error("Security check failed: File.read succeeded but should have failed") + } catch (err: any) { + expect(err.message).toContain("Access denied") + } + }, + }) + } finally { + // Clean up secret dir + await fs.rm(secretDir, { recursive: true, force: true }) + } + }) +})