diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index eeed22d9857..c0fab8e2803 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -1,5 +1,5 @@ import { readFileSync, existsSync, statSync } from "node:fs"; -import { resolve, normalize } from "node:path"; +import { resolveFilesystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -44,8 +44,15 @@ export async function GET(req: Request) { ); } - // Normalize and resolve to prevent traversal - const resolved = resolve(normalize(filePath)); + const resolvedPath = resolveFilesystemPath(filePath); + if (!resolvedPath) { + return Response.json( + { error: "File not found" }, + { status: 404 }, + ); + } + + const resolved = resolvedPath.absolutePath; if (!existsSync(resolved)) { return Response.json( diff --git a/apps/web/app/api/workspace/copy/route.ts b/apps/web/app/api/workspace/copy/route.ts index 6431f3eafe4..4533469ea3c 100644 --- a/apps/web/app/api/workspace/copy/route.ts +++ b/apps/web/app/api/workspace/copy/route.ts @@ -1,6 +1,6 @@ import { cpSync, existsSync, statSync } from "node:fs"; import { dirname, basename, extname } from "node:path"; -import { safeResolvePath, safeResolveNewPath } from "@/lib/workspace"; +import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -28,36 +28,50 @@ export async function POST(req: Request) { ); } - const srcAbs = safeResolvePath(relPath); - if (!srcAbs) { + const sourceTarget = resolveFilesystemPath(relPath); + if (isProtectedSystemPath(sourceTarget)) { + return Response.json( + { error: "Cannot duplicate system file" }, + { status: 403 }, + ); + } + + if (!sourceTarget) { return Response.json( { error: "Source not found or path traversal rejected" }, { status: 404 }, ); } - let destRelPath: string; + let destinationInputPath: string; if (destinationPath && typeof destinationPath === "string") { - destRelPath = destinationPath; + destinationInputPath = destinationPath; } else { // Auto-generate "name copy.ext" or "name copy" for folders - const name = basename(relPath); - const dir = dirname(relPath); + const name = basename(sourceTarget.absolutePath); + const dir = dirname(sourceTarget.absolutePath); const ext = extname(name); const stem = ext ? name.slice(0, -ext.length) : name; const copyName = ext ? `${stem} copy${ext}` : `${stem} copy`; - destRelPath = dir === "." ? copyName : `${dir}/${copyName}`; + destinationInputPath = dir === "." ? copyName : `${dir}/${copyName}`; } - const destAbs = safeResolveNewPath(destRelPath); - if (!destAbs) { + const destinationTarget = resolveFilesystemPath(destinationInputPath, { allowMissing: true }); + if (!destinationTarget) { return Response.json( { error: "Invalid destination path" }, { status: 400 }, ); } - if (existsSync(destAbs)) { + if (isProtectedSystemPath(destinationTarget)) { + return Response.json( + { error: "Cannot duplicate to a protected system path" }, + { status: 403 }, + ); + } + + if (existsSync(destinationTarget.absolutePath)) { return Response.json( { error: "Destination already exists" }, { status: 409 }, @@ -65,9 +79,12 @@ export async function POST(req: Request) { } try { - const isDir = statSync(srcAbs).isDirectory(); - cpSync(srcAbs, destAbs, { recursive: isDir }); - return Response.json({ ok: true, sourcePath: relPath, newPath: destRelPath }); + const isDir = statSync(sourceTarget.absolutePath).isDirectory(); + cpSync(sourceTarget.absolutePath, destinationTarget.absolutePath, { recursive: isDir }); + const newPath = destinationTarget.workspaceRelativePath != null + ? destinationTarget.workspaceRelativePath + : destinationTarget.absolutePath; + return Response.json({ ok: true, sourcePath: relPath, newPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "Copy failed" }, diff --git a/apps/web/app/api/workspace/file-ops.test.ts b/apps/web/app/api/workspace/file-ops.test.ts index 41d66191232..3daa312403e 100644 --- a/apps/web/app/api/workspace/file-ops.test.ts +++ b/apps/web/app/api/workspace/file-ops.test.ts @@ -18,8 +18,8 @@ vi.mock("node:fs", () => ({ vi.mock("@/lib/workspace", () => ({ readWorkspaceFile: vi.fn(), safeResolvePath: vi.fn(), - safeResolveNewPath: vi.fn(), - isSystemFile: vi.fn(() => false), + resolveFilesystemPath: vi.fn(), + isProtectedSystemPath: vi.fn(() => false), resolveWorkspaceRoot: vi.fn(() => "/ws"), })); @@ -41,8 +41,8 @@ describe("Workspace File Operations API", () => { vi.mock("@/lib/workspace", () => ({ readWorkspaceFile: vi.fn(), safeResolvePath: vi.fn(), - safeResolveNewPath: vi.fn(), - isSystemFile: vi.fn(() => false), + resolveFilesystemPath: vi.fn(), + isProtectedSystemPath: vi.fn(() => false), resolveWorkspaceRoot: vi.fn(() => "/ws"), })); }); @@ -91,8 +91,13 @@ describe("Workspace File Operations API", () => { describe("POST /api/workspace/file", () => { it("writes file content successfully", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/doc.md", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "doc.md", + }); const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); const { POST } = await import("./file/route.js"); @@ -109,9 +114,37 @@ describe("Workspace File Operations API", () => { expect(mockWrite).toHaveBeenCalled(); }); + it("writes absolute browse-mode paths outside the workspace", async () => { + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/tmp/doc.md", + kind: "absolute", + withinWorkspace: false, + workspaceRelativePath: null, + }); + const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); + + const { POST } = await import("./file/route.js"); + const req = new Request("http://localhost/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/tmp/doc.md", content: "# Hello from browse mode" }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockMkdir).toHaveBeenCalled(); + expect(mockWrite).toHaveBeenCalledWith("/tmp/doc.md", "# Hello from browse mode", "utf-8"); + }); + it("returns 403 when attempting to modify a system file", async () => { - const { isSystemFile } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockReturnValueOnce(true); + const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/IDENTITY.md", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "IDENTITY.md", + }); + vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true); const { POST } = await import("./file/route.js"); const req = new Request("http://localhost/api/workspace/file", { @@ -146,8 +179,8 @@ describe("Workspace File Operations API", () => { }); it("returns 400 for path traversal", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValue(null); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue(null); const { POST } = await import("./file/route.js"); const req = new Request("http://localhost/api/workspace/file", { @@ -171,8 +204,13 @@ describe("Workspace File Operations API", () => { }); it("returns 500 on write error", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/doc.md", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "doc.md", + }); const { writeFileSync: mockWrite } = await import("node:fs"); vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); }); @@ -191,9 +229,13 @@ describe("Workspace File Operations API", () => { describe("DELETE /api/workspace/file", () => { it("deletes file successfully", async () => { - const { safeResolvePath, isSystemFile } = await import("@/lib/workspace"); - vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt"); - vi.mocked(isSystemFile).mockReturnValue(false); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/file.txt", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "file.txt", + }); const { DELETE } = await import("./file/route.js"); const req = new Request("http://localhost/api/workspace/file", { @@ -208,8 +250,14 @@ describe("Workspace File Operations API", () => { }); it("returns 403 for system file", async () => { - const { isSystemFile } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockReturnValue(true); + const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/.object.yaml", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: ".object.yaml", + }); + vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true); const { DELETE } = await import("./file/route.js"); const req = new Request("http://localhost/api/workspace/file", { @@ -222,8 +270,8 @@ describe("Workspace File Operations API", () => { }); it("returns 404 when file not found", async () => { - const { safeResolvePath, isSystemFile } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockReturnValue(false); + const { resolveFilesystemPath, safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue(null); vi.mocked(safeResolvePath).mockReturnValue(null); const { DELETE } = await import("./file/route.js"); @@ -263,8 +311,13 @@ describe("Workspace File Operations API", () => { describe("POST /api/workspace/mkdir", () => { it("creates directory successfully", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue({ + absolutePath: "/ws/new-folder", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "new-folder", + }); const { POST } = await import("./mkdir/route.js"); const req = new Request("http://localhost/api/workspace/mkdir", { @@ -290,8 +343,8 @@ describe("Workspace File Operations API", () => { }); it("returns 400 for traversal attempt", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValue(null); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValue(null); const { POST } = await import("./mkdir/route.js"); const req = new Request("http://localhost/api/workspace/mkdir", { diff --git a/apps/web/app/api/workspace/file/route.ts b/apps/web/app/api/workspace/file/route.ts index 2b588913982..b227763be12 100644 --- a/apps/web/app/api/workspace/file/route.ts +++ b/apps/web/app/api/workspace/file/route.ts @@ -1,6 +1,11 @@ import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs"; import { dirname } from "node:path"; -import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace"; +import { + readWorkspaceFile, + safeResolvePath, + resolveFilesystemPath, + isProtectedSystemPath, +} from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -49,16 +54,15 @@ export async function POST(req: Request) { ); } - if (isSystemFile(relPath)) { + const targetPath = resolveFilesystemPath(relPath, { allowMissing: true }); + if (isProtectedSystemPath(targetPath)) { return Response.json( { error: "Cannot modify system file" }, { status: 403 }, ); } - // Use safeResolveNewPath (not safeResolvePath) because the file may not exist yet - const absPath = safeResolveNewPath(relPath); - if (!absPath) { + if (!targetPath) { return Response.json( { error: "Invalid path or path traversal rejected" }, { status: 400 }, @@ -66,8 +70,8 @@ export async function POST(req: Request) { } try { - mkdirSync(dirname(absPath), { recursive: true }); - writeFileSync(absPath, content, "utf-8"); + mkdirSync(dirname(targetPath.absolutePath), { recursive: true }); + writeFileSync(targetPath.absolutePath, content, "utf-8"); return Response.json({ ok: true, path: relPath }); } catch (err) { return Response.json( @@ -100,14 +104,15 @@ export async function DELETE(req: Request) { ); } - if (isSystemFile(relPath)) { + const targetPath = resolveFilesystemPath(relPath); + if (isProtectedSystemPath(targetPath)) { return Response.json( { error: "Cannot delete system file" }, { status: 403 }, ); } - const absPath = safeResolvePath(relPath); + const absPath = targetPath?.absolutePath ?? safeResolvePath(relPath); if (!absPath) { return Response.json( { error: "File not found or path traversal rejected" }, diff --git a/apps/web/app/api/workspace/mkdir/route.ts b/apps/web/app/api/workspace/mkdir/route.ts index fcbe54acb73..3ae78367816 100644 --- a/apps/web/app/api/workspace/mkdir/route.ts +++ b/apps/web/app/api/workspace/mkdir/route.ts @@ -1,6 +1,6 @@ import { mkdirSync, existsSync } from "node:fs"; import { resolve, normalize } from "node:path"; -import { safeResolveNewPath } from "@/lib/workspace"; +import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -30,29 +30,25 @@ export async function POST(req: Request) { ); } - let absPath: string | null; + const targetPath = useAbsolute && !rawPath.startsWith("/") && !rawPath.startsWith("~/") + ? resolveFilesystemPath(resolve(normalize(rawPath)), { allowMissing: true }) + : resolveFilesystemPath(rawPath, { allowMissing: true }); - if (useAbsolute) { - const normalized = normalize(rawPath); - if (normalized.includes("/../") || normalized.includes("/..")) { - return Response.json( - { error: "Path traversal rejected" }, - { status: 400 }, - ); - } - absPath = resolve(normalized); - } else { - absPath = safeResolveNewPath(rawPath); - } - - if (!absPath) { + if (!targetPath) { return Response.json( { error: "Invalid path or path traversal rejected" }, { status: 400 }, ); } - if (existsSync(absPath)) { + if (isProtectedSystemPath(targetPath)) { + return Response.json( + { error: "Cannot create a protected system path" }, + { status: 403 }, + ); + } + + if (existsSync(targetPath.absolutePath)) { return Response.json( { error: "Directory already exists" }, { status: 409 }, @@ -60,8 +56,13 @@ export async function POST(req: Request) { } try { - mkdirSync(absPath, { recursive: true }); - return Response.json({ ok: true, path: absPath }); + mkdirSync(targetPath.absolutePath, { recursive: true }); + return Response.json({ + ok: true, + path: targetPath.workspaceRelativePath != null + ? targetPath.workspaceRelativePath + : targetPath.absolutePath, + }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "mkdir failed" }, diff --git a/apps/web/app/api/workspace/move/route.ts b/apps/web/app/api/workspace/move/route.ts index a2670a60240..7f70cbf2f76 100644 --- a/apps/web/app/api/workspace/move/route.ts +++ b/apps/web/app/api/workspace/move/route.ts @@ -1,6 +1,6 @@ import { renameSync, existsSync, statSync } from "node:fs"; import { join, basename } from "node:path"; -import { safeResolvePath, isSystemFile } from "@/lib/workspace"; +import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -28,23 +28,23 @@ export async function POST(req: Request) { ); } - if (isSystemFile(sourcePath)) { + const sourceTarget = resolveFilesystemPath(sourcePath); + if (isProtectedSystemPath(sourceTarget)) { return Response.json( { error: "Cannot move system file" }, { status: 403 }, ); } - const srcAbs = safeResolvePath(sourcePath); - if (!srcAbs) { + if (!sourceTarget) { return Response.json( { error: "Source not found or path traversal rejected" }, { status: 404 }, ); } - const destDirAbs = safeResolvePath(destinationDir); - if (!destDirAbs) { + const destinationDirTarget = resolveFilesystemPath(destinationDir); + if (!destinationDirTarget) { return Response.json( { error: "Destination not found or path traversal rejected" }, { status: 404 }, @@ -52,7 +52,7 @@ export async function POST(req: Request) { } // Destination must be a directory - if (!statSync(destDirAbs).isDirectory()) { + if (!statSync(destinationDirTarget.absolutePath).isDirectory()) { return Response.json( { error: "Destination is not a directory" }, { status: 400 }, @@ -60,16 +60,31 @@ export async function POST(req: Request) { } // Prevent moving a folder into itself or its children - const srcAbsNorm = srcAbs + "/"; - if (destDirAbs.startsWith(srcAbsNorm) || destDirAbs === srcAbs) { + const srcAbsNorm = `${sourceTarget.absolutePath}/`; + if (destinationDirTarget.absolutePath.startsWith(srcAbsNorm) || destinationDirTarget.absolutePath === sourceTarget.absolutePath) { return Response.json( { error: "Cannot move a folder into itself" }, { status: 400 }, ); } - const itemName = basename(srcAbs); - const destAbs = join(destDirAbs, itemName); + const itemName = basename(sourceTarget.absolutePath); + const destAbs = join(destinationDirTarget.absolutePath, itemName); + const destinationTarget = resolveFilesystemPath(destAbs, { allowMissing: true }); + + if (!destinationTarget) { + return Response.json( + { error: "Invalid destination path" }, + { status: 400 }, + ); + } + + if (isProtectedSystemPath(destinationTarget)) { + return Response.json( + { error: "Cannot move a file to a protected system path" }, + { status: 403 }, + ); + } if (existsSync(destAbs)) { return Response.json( @@ -78,12 +93,12 @@ export async function POST(req: Request) { ); } - // Build new relative path - const newRelPath = destinationDir === "." ? itemName : `${destinationDir}/${itemName}`; - try { - renameSync(srcAbs, destAbs); - return Response.json({ ok: true, oldPath: sourcePath, newPath: newRelPath }); + renameSync(sourceTarget.absolutePath, destinationTarget.absolutePath); + const newPath = destinationTarget.workspaceRelativePath != null + ? destinationTarget.workspaceRelativePath + : destinationTarget.absolutePath; + return Response.json({ ok: true, oldPath: sourcePath, newPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "Move failed" }, diff --git a/apps/web/app/api/workspace/raw-file.test.ts b/apps/web/app/api/workspace/raw-file.test.ts index 142a3aff158..75e83ad3873 100644 --- a/apps/web/app/api/workspace/raw-file.test.ts +++ b/apps/web/app/api/workspace/raw-file.test.ts @@ -8,10 +8,9 @@ vi.mock("node:fs", () => ({ })); vi.mock("@/lib/workspace", () => ({ - safeResolvePath: vi.fn(), - safeResolveNewPath: vi.fn(), + resolveFilesystemPath: vi.fn(), resolveWorkspaceRoot: vi.fn(() => "/ws"), - isSystemFile: vi.fn(() => false), + isProtectedSystemPath: vi.fn(() => false), })); describe("POST /api/workspace/raw-file", () => { @@ -24,10 +23,9 @@ describe("POST /api/workspace/raw-file", () => { mkdirSync: vi.fn(), })); vi.mock("@/lib/workspace", () => ({ - safeResolvePath: vi.fn(), - safeResolveNewPath: vi.fn(), + resolveFilesystemPath: vi.fn(), resolveWorkspaceRoot: vi.fn(() => "/ws"), - isSystemFile: vi.fn(() => false), + isProtectedSystemPath: vi.fn(() => false), })); }); @@ -46,8 +44,14 @@ describe("POST /api/workspace/raw-file", () => { }); it("returns 403 when path is a system file (protects workspace.duckdb, etc.)", async () => { - const { isSystemFile } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockReturnValueOnce(true); + const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/workspace.duckdb", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "workspace.duckdb", + }); + vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true); const { POST } = await import("./raw-file/route.js"); const req = new Request( @@ -60,9 +64,9 @@ describe("POST /api/workspace/raw-file", () => { expect(json.error).toContain("system file"); }); - it("returns 400 when safeResolveNewPath rejects the path (path traversal attack)", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce(null); + it("returns 400 when path resolution rejects the path (path traversal attack)", async () => { + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null); const { POST } = await import("./raw-file/route.js"); const req = new Request( @@ -76,8 +80,13 @@ describe("POST /api/workspace/raw-file", () => { }); it("writes binary data to the resolved path and creates parent dirs", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data/report.xlsx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/data/report.xlsx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "data/report.xlsx", + }); const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); @@ -101,8 +110,13 @@ describe("POST /api/workspace/raw-file", () => { }); it("returns 500 when writeFileSync throws (disk full, permission denied)", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data.xlsx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/data.xlsx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "data.xlsx", + }); const { writeFileSync: mockWrite } = await import("node:fs"); vi.mocked(mockWrite).mockImplementationOnce(() => { @@ -121,8 +135,13 @@ describe("POST /api/workspace/raw-file", () => { }); it("preserves binary content exactly as received (no encoding corruption)", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/file.xlsx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/file.xlsx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "file.xlsx", + }); const { writeFileSync: mockWrite } = await import("node:fs"); vi.mocked(mockWrite).mockClear(); @@ -143,21 +162,25 @@ describe("POST /api/workspace/raw-file", () => { expect(writtenBuffer[4]).toBe(0x01); }); - it("calls isSystemFile before safeResolveNewPath (rejects early, prevents resolve overhead)", async () => { - const { isSystemFile, safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockClear(); - vi.mocked(safeResolveNewPath).mockClear(); - vi.mocked(isSystemFile).mockReturnValueOnce(true); + it("writes external absolute .object.yaml files when they are outside the managed workspace", async () => { + const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/tmp/.object.yaml", + kind: "absolute", + withinWorkspace: false, + workspaceRelativePath: null, + }); + vi.mocked(isProtectedSystemPath).mockReturnValueOnce(false); + const { writeFileSync: mockWrite } = await import("node:fs"); const { POST } = await import("./raw-file/route.js"); const req = new Request( - "http://localhost/api/workspace/raw-file?path=workspace.duckdb", + "http://localhost/api/workspace/raw-file?path=/tmp/.object.yaml", { method: "POST", body: new ArrayBuffer(1) }, ); - await POST(req); - - expect(isSystemFile).toHaveBeenCalledWith("workspace.duckdb"); - expect(safeResolveNewPath).not.toHaveBeenCalled(); + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockWrite).toHaveBeenCalledWith("/tmp/.object.yaml", expect.any(Buffer)); }); }); @@ -171,10 +194,9 @@ describe("GET /api/workspace/raw-file", () => { mkdirSync: vi.fn(), })); vi.mock("@/lib/workspace", () => ({ - safeResolvePath: vi.fn(), - safeResolveNewPath: vi.fn(), + resolveFilesystemPath: vi.fn(), resolveWorkspaceRoot: vi.fn(() => "/ws"), - isSystemFile: vi.fn(() => false), + isProtectedSystemPath: vi.fn(() => false), })); }); @@ -183,8 +205,13 @@ describe("GET /api/workspace/raw-file", () => { }); it("returns DOCX MIME type for .docx files (required for browser/editor interoperability)", async () => { - const { safeResolvePath } = await import("@/lib/workspace"); - vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/docs/spec.docx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/docs/spec.docx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "docs/spec.docx", + }); const { readFileSync } = await import("node:fs"); vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([0x50, 0x4b])); @@ -199,8 +226,13 @@ describe("GET /api/workspace/raw-file", () => { }); it("returns text/plain for .txt files (ensures plain-text previews render correctly)", async () => { - const { safeResolvePath } = await import("@/lib/workspace"); - vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/notes/today.txt"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/notes/today.txt", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "notes/today.txt", + }); const { readFileSync } = await import("node:fs"); vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from("hello")); @@ -213,8 +245,13 @@ describe("GET /api/workspace/raw-file", () => { }); it("falls back to octet-stream for unknown extensions (prevents incorrect sniffing assumptions)", async () => { - const { safeResolvePath } = await import("@/lib/workspace"); - vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/blob.unknown"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/blob.unknown", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "blob.unknown", + }); const { readFileSync } = await import("node:fs"); vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([1, 2, 3])); @@ -233,8 +270,8 @@ describe("GET /api/workspace/raw-file", () => { }); it("returns 404 when file cannot be resolved (prevents leaking host paths)", async () => { - const { safeResolvePath, resolveWorkspaceRoot } = await import("@/lib/workspace"); - vi.mocked(safeResolvePath).mockReturnValueOnce(null); + const { resolveFilesystemPath, resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null); vi.mocked(resolveWorkspaceRoot).mockReturnValueOnce(null); const { GET } = await import("./raw-file/route.js"); diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 9bbf05484d8..2c527ae565c 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -1,6 +1,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import { safeResolvePath, safeResolveNewPath, resolveWorkspaceRoot, isSystemFile } from "@/lib/workspace"; +import { + resolveFilesystemPath, + resolveWorkspaceRoot, + isProtectedSystemPath, +} from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -50,18 +54,10 @@ const MIME_MAP: Record = { * Security note: this is a local-only dev server; it never runs in production. */ function resolveFile(path: string): string | null { - // 1. Absolute path — serve directly if it exists on disk - if (path.startsWith("/")) { - const abs = resolve(path); - if (existsSync(abs)) {return abs;} - // Fall through to workspace-relative in case the leading / is accidental - } + const resolvedPath = resolveFilesystemPath(path); + if (resolvedPath) {return resolvedPath.absolutePath;} - // 2. Standard workspace-relative resolution - const resolved = safeResolvePath(path); - if (resolved) {return resolved;} - - // 3. Try common subdirectories in case the path is a bare filename + // 2. Try common subdirectories in case the path is a bare filename const root = resolveWorkspaceRoot(); if (!root) {return null;} const rootAbs = resolve(root); @@ -139,12 +135,12 @@ export async function POST(req: Request) { return new Response("Missing path", { status: 400 }); } - if (isSystemFile(path)) { + const targetPath = resolveFilesystemPath(path, { allowMissing: true }); + if (isProtectedSystemPath(targetPath)) { return Response.json({ error: "Cannot modify system file" }, { status: 403 }); } - const absPath = safeResolveNewPath(path); - if (!absPath) { + if (!targetPath) { return Response.json( { error: "Invalid path or path traversal rejected" }, { status: 400 }, @@ -153,8 +149,8 @@ export async function POST(req: Request) { try { const buffer = Buffer.from(await req.arrayBuffer()); - mkdirSync(dirname(absPath), { recursive: true }); - writeFileSync(absPath, buffer); + mkdirSync(dirname(targetPath.absolutePath), { recursive: true }); + writeFileSync(targetPath.absolutePath, buffer); return Response.json({ ok: true, path }); } catch (err) { return Response.json( diff --git a/apps/web/app/api/workspace/rename/route.ts b/apps/web/app/api/workspace/rename/route.ts index 43066d2c316..694031ed20c 100644 --- a/apps/web/app/api/workspace/rename/route.ts +++ b/apps/web/app/api/workspace/rename/route.ts @@ -1,6 +1,6 @@ import { renameSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; -import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace"; +import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -28,7 +28,8 @@ export async function POST(req: Request) { ); } - if (isSystemFile(relPath)) { + const sourcePath = resolveFilesystemPath(relPath); + if (isProtectedSystemPath(sourcePath)) { return Response.json( { error: "Cannot rename system file" }, { status: 403 }, @@ -43,28 +44,31 @@ export async function POST(req: Request) { ); } - const absPath = safeResolvePath(relPath); - if (!absPath) { + if (!sourcePath) { return Response.json( { error: "Source not found or path traversal rejected" }, { status: 404 }, ); } - const parentDir = dirname(absPath); + const parentDir = dirname(sourcePath.absolutePath); const newAbsPath = join(parentDir, newName); + const destinationPath = resolveFilesystemPath(newAbsPath, { allowMissing: true }); - // Ensure the new path stays within workspace - const parentRel = dirname(relPath); - const newRelPath = parentRel === "." ? newName : `${parentRel}/${newName}`; - const validated = safeResolveNewPath(newRelPath); - if (!validated) { + if (!destinationPath) { return Response.json( { error: "Invalid destination path" }, { status: 400 }, ); } + if (isProtectedSystemPath(destinationPath)) { + return Response.json( + { error: "Cannot rename to a protected system file" }, + { status: 403 }, + ); + } + if (existsSync(newAbsPath)) { return Response.json( { error: `A file named '${newName}' already exists` }, @@ -73,8 +77,11 @@ export async function POST(req: Request) { } try { - renameSync(absPath, newAbsPath); - return Response.json({ ok: true, oldPath: relPath, newPath: newRelPath }); + renameSync(sourcePath.absolutePath, destinationPath.absolutePath); + const newPath = destinationPath.workspaceRelativePath != null + ? destinationPath.workspaceRelativePath + : destinationPath.absolutePath; + return Response.json({ ok: true, oldPath: relPath, newPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "Rename failed" }, diff --git a/apps/web/app/api/workspace/write-binary.test.ts b/apps/web/app/api/workspace/write-binary.test.ts index 0d5d579533f..4bd8715612a 100644 --- a/apps/web/app/api/workspace/write-binary.test.ts +++ b/apps/web/app/api/workspace/write-binary.test.ts @@ -6,8 +6,8 @@ vi.mock("node:fs", () => ({ })); vi.mock("@/lib/workspace", () => ({ - safeResolveNewPath: vi.fn(), - isSystemFile: vi.fn(() => false), + resolveFilesystemPath: vi.fn(), + isProtectedSystemPath: vi.fn(() => false), })); describe("POST /api/workspace/write-binary", () => { @@ -18,8 +18,8 @@ describe("POST /api/workspace/write-binary", () => { mkdirSync: vi.fn(), })); vi.mock("@/lib/workspace", () => ({ - safeResolveNewPath: vi.fn(), - isSystemFile: vi.fn(() => false), + resolveFilesystemPath: vi.fn(), + isProtectedSystemPath: vi.fn(() => false), })); }); @@ -67,8 +67,14 @@ describe("POST /api/workspace/write-binary", () => { }); it("returns 403 when writing a system file (prevents protected-file tampering)", async () => { - const { isSystemFile, safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(isSystemFile).mockReturnValueOnce(true); + const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/workspace.duckdb", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "workspace.duckdb", + }); + vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true); const { POST } = await import("./write-binary/route.js"); const form = new FormData(); @@ -80,12 +86,11 @@ describe("POST /api/workspace/write-binary", () => { }); const res = await POST(req); expect(res.status).toBe(403); - expect(safeResolveNewPath).not.toHaveBeenCalled(); }); - it("returns 400 when safe path resolution fails (blocks path traversal attacks)", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce(null); + it("returns 400 when path resolution fails (blocks path traversal attacks)", async () => { + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null); const { POST } = await import("./write-binary/route.js"); const form = new FormData(); @@ -102,8 +107,13 @@ describe("POST /api/workspace/write-binary", () => { }); it("writes binary bytes exactly to the resolved destination", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/docs/report.docx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/docs/report.docx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "docs/report.docx", + }); const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); const { POST } = await import("./write-binary/route.js"); @@ -131,9 +141,38 @@ describe("POST /api/workspace/write-binary", () => { expect(written[4]).toBe(0xff); }); + it("writes absolute browse-mode DOCX paths outside the workspace", async () => { + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/tmp/report.docx", + kind: "absolute", + withinWorkspace: false, + workspaceRelativePath: null, + }); + const { writeFileSync: mockWrite } = await import("node:fs"); + + const { POST } = await import("./write-binary/route.js"); + const form = new FormData(); + form.append("path", "/tmp/report.docx"); + form.append("file", new Blob([new Uint8Array([1, 2, 3])])); + const req = new Request("http://localhost/api/workspace/write-binary", { + method: "POST", + body: form, + }); + const res = await POST(req); + + expect(res.status).toBe(200); + expect(mockWrite).toHaveBeenCalledWith("/tmp/report.docx", expect.any(Buffer)); + }); + it("returns 500 when disk write throws (surfaces actionable failure)", async () => { - const { safeResolveNewPath } = await import("@/lib/workspace"); - vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/docs/report.docx"); + const { resolveFilesystemPath } = await import("@/lib/workspace"); + vi.mocked(resolveFilesystemPath).mockReturnValueOnce({ + absolutePath: "/ws/docs/report.docx", + kind: "workspaceRelative", + withinWorkspace: true, + workspaceRelativePath: "docs/report.docx", + }); const { writeFileSync: mockWrite } = await import("node:fs"); vi.mocked(mockWrite).mockImplementationOnce(() => { throw new Error("ENOSPC: no space left on device"); diff --git a/apps/web/app/api/workspace/write-binary/route.ts b/apps/web/app/api/workspace/write-binary/route.ts index 6d74c5e2271..3a56cfcb524 100644 --- a/apps/web/app/api/workspace/write-binary/route.ts +++ b/apps/web/app/api/workspace/write-binary/route.ts @@ -1,6 +1,6 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { safeResolveNewPath, isSystemFile } from "@/lib/workspace"; +import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -28,12 +28,12 @@ export async function POST(req: Request) { return Response.json({ error: "Missing 'file' field (Blob)" }, { status: 400 }); } - if (isSystemFile(relPath)) { + const targetPath = resolveFilesystemPath(relPath, { allowMissing: true }); + if (isProtectedSystemPath(targetPath)) { return Response.json({ error: "Cannot modify system file" }, { status: 403 }); } - const absPath = safeResolveNewPath(relPath); - if (!absPath) { + if (!targetPath) { return Response.json( { error: "Invalid path or path traversal rejected" }, { status: 400 }, @@ -42,8 +42,8 @@ export async function POST(req: Request) { try { const buffer = Buffer.from(await file.arrayBuffer()); - mkdirSync(dirname(absPath), { recursive: true }); - writeFileSync(absPath, buffer); + mkdirSync(dirname(targetPath.absolutePath), { recursive: true }); + writeFileSync(targetPath.absolutePath, buffer); return Response.json({ ok: true, path: relPath }); } catch (err) { return Response.json(