diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 9462ec57369..feb92d7754f 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -237,8 +237,6 @@ 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. if (!Filesystem.contains(Instance.directory, full)) { throw new Error(`Access denied: path escapes project directory`) } @@ -297,8 +295,6 @@ 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. if (!Filesystem.contains(Instance.directory, resolved)) { throw new Error(`Access denied: path escapes project directory`) } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 98fbe533de3..b8e3d70bfce 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -22,8 +22,39 @@ export namespace Filesystem { return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") } + /** + * Check if a relative path is contained (doesn't escape via .. or cross-drive) + */ + function isContained(rel: string): boolean { + // On Windows, check for cross-drive paths (e.g., "D:\..." from "C:\...") + if (process.platform === "win32" && /^[A-Za-z]:/.test(rel)) { + return false + } + return !rel.startsWith("..") + } + export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + // Try to resolve each path individually to prevent symlink escapes. + // Use resolved paths when available for maximum security. + let resolvedParent = parent + let resolvedChild = child + + try { + resolvedParent = realpathSync(parent) + } catch { + // Parent doesn't exist or can't be resolved, use original + } + + try { + resolvedChild = realpathSync(child) + } catch { + // Child doesn't exist yet (common for new files), use original + // But if parent was resolved, still use resolved parent for safety + } + + // Use the best available paths (resolved when possible) + const rel = relative(resolvedParent, resolvedChild) + return isContained(rel) } export async function findUp(target: string, start: string, stop?: string) {