refactor(api): migrate workspace routes to resolveFilesystemPath

All workspace API routes use resolveFilesystemPath and isProtectedSystemPath, enabling browse-mode writes to external paths.
This commit is contained in:
kumarabhirup 2026-03-15 04:17:11 -07:00
parent e490380d01
commit 3fe5a91033
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
11 changed files with 348 additions and 171 deletions

View File

@ -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(

View File

@ -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" },

View File

@ -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", {

View File

@ -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" },

View File

@ -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" },

View File

@ -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" },

View File

@ -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");

View File

@ -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<string, string> = {
* 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(

View File

@ -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" },

View File

@ -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");

View File

@ -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(