diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a31..6c4d653fd 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1621,6 +1621,32 @@ describe("WebSocket Server", () => { expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); }); + it("rejects projects.writeFile paths that escape through a symlinked directory", async () => { + const workspace = makeTempDir("t3code-ws-write-file-symlink-workspace-"); + const outsideDir = makeTempDir("t3code-ws-write-file-symlink-outside-"); + const symlinkPath = path.join(workspace, "linked-outside"); + fs.symlinkSync(outsideDir, symlinkPath, process.platform === "win32" ? "junction" : "dir"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "linked-outside/escape.md", + contents: "# owned\n", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain( + "Workspace file path must stay within the project root.", + ); + expect(fs.existsSync(path.join(outsideDir, "escape.md"))).toBe(false); + }); + it("routes git core methods over websocket", async () => { const listBranches = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..04a0b6e10 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -7,6 +7,7 @@ * @module Server */ import http from "node:http"; +import { existsSync, realpathSync } from "node:fs"; import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; @@ -157,41 +158,104 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -function resolveWorkspaceWritePath(params: { - workspaceRoot: string; - relativePath: string; +function isPathWithinRoot(params: { + rootPath: string; + candidatePath: string; path: Path.Path; -}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { - const normalizedInputPath = params.relativePath.trim(); - if (params.path.isAbsolute(normalizedInputPath)) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must be relative to the project root.", - }), - ); - } - - const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); +}): boolean { const relativeToRoot = toPosixRelativePath( - params.path.relative(params.workspaceRoot, absolutePath), + params.path.relative(params.rootPath, params.candidatePath), ); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || + return !( relativeToRoot.startsWith("../") || relativeToRoot === ".." || params.path.isAbsolute(relativeToRoot) - ) { - return Effect.fail( + ); +} + +function resolveCanonicalPath(params: { + inputPath: string; + path: Path.Path; +}): Effect.Effect { + return Effect.try({ + try: () => { + const unresolvedSegments: string[] = []; + let existingPath = params.inputPath; + while (!existsSync(existingPath)) { + const parentPath = params.path.dirname(existingPath); + if (parentPath === existingPath) { + throw new Error(`No existing ancestor found for path: ${params.inputPath}`); + } + unresolvedSegments.unshift(params.path.basename(existingPath)); + existingPath = parentPath; + } + + const canonicalExistingPath = realpathSync.native(existingPath); + return unresolvedSegments.reduce( + (currentPath, segment) => params.path.join(currentPath, segment), + canonicalExistingPath, + ); + }, + catch: () => new RouteRequestError({ message: "Workspace file path must stay within the project root.", }), + }); +} + +function resolveWorkspaceWritePath(params: { + workspaceRoot: string; + relativePath: string; + path: Path.Path; +}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { + return Effect.gen(function* () { + const normalizedInputPath = params.relativePath.trim(); + if (params.path.isAbsolute(normalizedInputPath)) { + return yield* new RouteRequestError({ + message: "Workspace file path must be relative to the project root.", + }); + } + + const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath( + params.path.relative(params.workspaceRoot, absolutePath), ); - } + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + params.path.isAbsolute(relativeToRoot) + ) { + return yield* new RouteRequestError({ + message: "Workspace file path must stay within the project root.", + }); + } + + const canonicalWorkspaceRoot = yield* resolveCanonicalPath({ + inputPath: params.workspaceRoot, + path: params.path, + }); + const canonicalTargetPath = yield* resolveCanonicalPath({ + inputPath: absolutePath, + path: params.path, + }); + if ( + !isPathWithinRoot({ + rootPath: canonicalWorkspaceRoot, + candidatePath: canonicalTargetPath, + path: params.path, + }) + ) { + return yield* new RouteRequestError({ + message: "Workspace file path must stay within the project root.", + }); + } - return Effect.succeed({ - absolutePath, - relativePath: relativeToRoot, + return { + absolutePath: canonicalTargetPath, + relativePath: relativeToRoot, + }; }); }