Skip to content
Open
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
24 changes: 22 additions & 2 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand All @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/file/path-traversal.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
})
})

/*
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading