From 7334d42488c07f83bf2c2c5b5956c8fa6c4deefb Mon Sep 17 00:00:00 2001 From: edlsh Date: Mon, 22 Dec 2025 15:05:07 -0500 Subject: [PATCH] fix(security): add path traversal protection to File.read and File.list --- packages/opencode/src/file/index.ts | 15 +++++++++++ .../opencode/test/file/path-traversal.test.ts | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 packages/opencode/test/file/path-traversal.test.ts diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 148ab45cb..9462ec573 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -7,6 +7,7 @@ import path from "path" import fs from "fs" import ignore from "ignore" import { Log } from "../util/log" +import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" @@ -235,6 +236,13 @@ export namespace File { using _ = log.time("read", { 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. + if (!Filesystem.contains(Instance.directory, full)) { + throw new Error(`Access denied: path escapes project directory`) + } + const bunFile = Bun.file(full) if (!(await bunFile.exists())) { @@ -288,6 +296,13 @@ export namespace File { ignored = ig.ignores.bind(ig) } 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. + if (!Filesystem.contains(Instance.directory, resolved)) { + throw new Error(`Access denied: path escapes project directory`) + } + const nodes: Node[] = [] for (const entry of await fs.promises .readdir(resolved, { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts new file mode 100644 index 000000000..993898c67 --- /dev/null +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -0,0 +1,25 @@ +import { test, expect } from "bun:test" +import { Filesystem } from "../../src/util/filesystem" + +test("Filesystem.contains blocks parent directory traversal", () => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) +}) + +test("Filesystem.contains blocks ../ traversal", () => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) +}) + +test("Filesystem.contains blocks absolute paths outside project", () => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) +}) + +test("Filesystem.contains handles edge cases", () => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) +})