diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 472bff83dd3..27f52c5acd8 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,6 +1,6 @@ import { realpathSync } from "fs" import { exists } from "fs/promises" -import { dirname, join, relative } from "path" +import { basename, dirname, isAbsolute, join, relative, resolve } from "path" export namespace Filesystem { /** @@ -23,7 +23,27 @@ export namespace Filesystem { } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const realpath = (p: string) => { + try { + return realpathSync.native?.(p) ?? realpathSync(p) + } catch { + return resolve(p) + } + } + + const parentReal = realpath(parent) + const childAbsolute = isAbsolute(child) ? child : resolve(parent, child) + + let childReal: string + try { + childReal = realpath(childAbsolute) + } catch { + const dirReal = realpath(dirname(childAbsolute)) + childReal = join(dirReal, basename(childAbsolute)) + } + + const rel = relative(parentReal, childReal) + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)) } export async function findUp(target: string, start: string, stop?: string) { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index c20c76a2e7f..5a7c67a70da 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import fs from "fs/promises" import path from "path" import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" @@ -28,6 +29,23 @@ describe("Filesystem.contains", () => { expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) expect(Filesystem.contains("/project", "/projectfile")).toBe(false) }) + + test("blocks symlinks escaping project", async () => { + if (process.platform === "win32") { + expect(true).toBe(true) + return + } + + await using tmp = await tmpdir() + await using outside = await tmpdir() + + const link = path.join(tmp.path, "link") + const target = path.join(outside.path, "outside.txt") + await fs.writeFile(target, "secret") + await fs.symlink(target, link) + + expect(Filesystem.contains(tmp.path, link)).toBe(false) + }) }) /* @@ -83,6 +101,29 @@ describe("File.read path traversal protection", () => { }, }) }) + + test("rejects symlink that points outside project", async () => { + if (process.platform === "win32") { + expect(true).toBe(true) + return + } + + await using tmp = await tmpdir({ + init: async (dir) => { + await using external = await tmpdir() + const target = path.join(external.path, "outside.txt") + await fs.writeFile(target, "external") + await fs.symlink(target, path.join(dir, "link.txt")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("link.txt")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) }) describe("File.list path traversal protection", () => {