From ea300fb6a4059a17f02184d6dc0b2c3931d9a6e2 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Mon, 12 Oct 2026 22:38:02 +0000 Subject: [PATCH] fix(server): file saves no longer follow symlinks out of the workspace projectsWriteFile only checked the requested path lexically, so a symlink inside the workspace (to a file, a directory, or a missing outside target) let a save overwrite or create files outside the project, and recursive parent creation could make directories there. Reads already rejected the same path. Writes now canonicalize the workspace root and the deepest existing part of the target path, reject anything that resolves outside the real root, and write to the resolved path. Missing nested directories, symlinks that stay inside the workspace, and symlinked workspace roots keep working. Co-Authored-By: Claude Opus 5.5 --- .../src/workspace/WorkspaceFileSystem.test.ts | 134 ++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 112 +++++++++++++-- 2 files changed, 231 insertions(+), 15 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 3e32e5537692..eb51dcac7612 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -429,5 +429,139 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(escapedStat).toBeNull(); }), ); + + it.effect.skipIf(!symlinksSupported)( + "rejects writes through a file symlink that resolves outside the workspace root", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "important.txt", "ORIGINAL\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "important.txt"), + path.join(cwd, "linked.txt"), + ); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: "linked.txt", contents: "OVERWRITTEN\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked.txt", + resolvedWorkspaceRoot: yield* fileSystem.realPath(cwd), + resolvedPath: yield* fileSystem.realPath(path.join(outsideDir, "important.txt")), + }); + expect(yield* fileSystem.readFileString(path.join(outsideDir, "important.txt"))).toBe( + "ORIGINAL\n", + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects writes through a dangling symlink that points outside the workspace root", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* fileSystem.symlink( + path.join(outsideDir, "created.txt"), + path.join(cwd, "dangling.txt"), + ); + + yield* workspaceFileSystem + .writeFile({ cwd, relativePath: "dangling.txt", contents: "OUTSIDE\n" }) + .pipe(Effect.flip); + + expect(yield* fileSystem.exists(path.join(outsideDir, "created.txt"))).toBe(false); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects writes below a directory symlink that resolves outside the workspace root", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* fileSystem.symlink(outsideDir, path.join(cwd, "linked")); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: "linked/new/deep.txt", contents: "OUTSIDE\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(yield* fileSystem.exists(path.join(outsideDir, "new"))).toBe(false); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "writes through symlinks whose targets stay inside the workspace root", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "docs/readme.md", "old\n"); + yield* fileSystem.symlink(path.join(cwd, "docs"), path.join(cwd, "docs-link")); + yield* fileSystem.symlink( + path.join(cwd, "docs/readme.md"), + path.join(cwd, "readme-link.md"), + ); + + const nested = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "docs-link/new/guide.md", + contents: "# Guide\n", + }); + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "readme-link.md", + contents: "new\n", + }); + + expect(nested).toEqual({ relativePath: "docs-link/new/guide.md" }); + expect(yield* fileSystem.readFileString(path.join(cwd, "docs/new/guide.md"))).toBe( + "# Guide\n", + ); + expect(yield* fileSystem.readFileString(path.join(cwd, "docs/readme.md"))).toBe("new\n"); + expect(yield* fileSystem.readLink(path.join(cwd, "readme-link.md"))).toBe( + path.join(cwd, "docs/readme.md"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("writes into a workspace root reached by a symlink", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realRoot = yield* makeTempDir; + const linkParent = yield* makeTempDir; + const cwd = path.join(linkParent, "workspace-link"); + yield* fileSystem.symlink(realRoot, cwd); + + const result = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "src/new/index.ts", + contents: "export {};\n", + }); + + expect(result).toEqual({ relativePath: "src/new/index.ts" }); + expect(yield* fileSystem.readFileString(path.join(realRoot, "src/new/index.ts"))).toBe( + "export {};\n", + ); + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 335b18c791b0..d56c6b9a2887 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -194,6 +194,15 @@ export const make = Effect.gen(function* () { return { entries: input.paths.map((value) => byPath.get(value) ?? null) }; }); + const isOutsideRoot = (realWorkspaceRoot: string, realPath: string) => { + const relativeRealPath = path.relative(realWorkspaceRoot, realPath); + return ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ); + }; + /** * Resolves the file a read targets. Workspace-relative paths must stay inside the * root, symlinks included. An absolute path reads a host file in place, such as a @@ -248,12 +257,7 @@ export const make = Effect.gen(function* () { cause, }), }); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { + if (isOutsideRoot(realWorkspaceRoot, realTargetPath)) { return yield* new WorkspaceFilePathEscapeError({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -357,35 +361,113 @@ export const make = Effect.gen(function* () { ); }); - const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( - "WorkspaceFileSystem.writeFile", - )(function* (input) { + /** + * Resolves where a write lands. The deepest part of the path that already + * exists (the target itself, when it does) is canonicalized, so a symlink + * anywhere along it cannot carry the write or its new parent directories + * outside the root. The missing remainder is appended to that real path. + */ + const resolveWriteTarget = Effect.fn("WorkspaceFileSystem.resolveWriteTarget")(function* ( + input: ProjectWriteFileInput, + ) { const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + + // lstat, so a dangling symlink counts as existing and fails realpath below + // instead of being written through. The root exists, so this terminates. + let existingPath = target.absolutePath; + const missingSegments: string[] = []; + while (true) { + const exists = yield* Effect.tryPromise({ + try: () => + NodeFSP.lstat(existingPath).then( + () => true, + (cause: NodeJS.ErrnoException) => { + if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return false; + throw cause; + }, + ), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: existingPath, + operation: "stat", + cause, + }), + }); + if (exists) break; + missingSegments.unshift(path.basename(existingPath)); + existingPath = path.dirname(existingPath); + } + + const realExistingPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(existingPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: existingPath, + operation: "realpath-target", + cause, + }), + }); + const realTargetPath = path.join(realExistingPath, ...missingSegments); + if (isOutsideRoot(realWorkspaceRoot, realTargetPath)) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + return { relativePath: target.relativePath, realTargetPath }; + }); + + const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( + "WorkspaceFileSystem.writeFile", + )(function* (input) { + const target = yield* resolveWriteTarget(input); + const realTargetPath = target.realTargetPath; + + yield* fileSystem.makeDirectory(path.dirname(realTargetPath), { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: path.dirname(target.absolutePath), + resolvedPath: realTargetPath, + operationPath: path.dirname(realTargetPath), operation: "make-directory", cause, }), ), ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + yield* fileSystem.writeFileString(realTargetPath, input.contents).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, operation: "write-file", cause, }),