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
26 changes: 26 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
112 changes: 88 additions & 24 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, RouteRequestError> {
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,
};
});
}

Expand Down