Skip to content
23 changes: 22 additions & 1 deletion packages/studio-server/src/helpers/safePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { walkDir } from "./safePath";
import { isPrivateProjectFile, walkDir } from "./safePath";

const hooks = vi.hoisted(() => ({ unreadable: new Map<string, string>() }));
vi.mock("node:fs", async (importOriginal) => {
Expand Down Expand Up @@ -82,3 +82,24 @@ describe("walkDir", () => {
expect(() => walkDir(projectDir)).toThrow("EACCES");
});
});

describe("isPrivateProjectFile", () => {
it("names everything under .hyperframes/ but Studio's own files and request pictures, in any case", () => {
for (const name of [
"agent-handoff.json",
"agent-handoff-read.json",
"app-history-seen.json",
"share.json",
])
expect(isPrivateProjectFile("/p", `/p/.hyperframes/${name}`)).toBe(true);
expect(isPrivateProjectFile("/p", "/p/.HyperFrames/App-History.JSONL")).toBe(true);
expect(isPrivateProjectFile("/p", "/p/.hyperframes")).toBe(true);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-manual-edits.json")).toBe(false);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets")).toBe(false);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests/1/before.png")).toBe(false);
expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests-old/x")).toBe(true);
expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false);
});
});
33 changes: 31 additions & 2 deletions packages/studio-server/src/helpers/safePath.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
import { readdirSync, type Dirent } from "node:fs";
import { realpath, resolveWithinProject } from "@hyperframes/core/safe-path";
import { realpath, resolveWithinProject as resolveInProject } from "@hyperframes/core/safe-path";
import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsRenderScript.js";
import { STUDIO_MOTION_PATH } from "./studioMotionRenderScript.js";

// `isSafePath` lives at the package root so non-studio-api layers (compiler,
// CLI, engine) can share it without a backwards dependency on studio-api.
Expand All @@ -12,9 +14,14 @@ export {
mkdirWithinProject,
realpath,
realProjectRoot,
resolveWithinProject,
} from "@hyperframes/core/safe-path";

/** Core's `resolveWithinProject`, closed to the desktop app's private files for every route a request path reaches. */
export function resolveWithinProject(base: string, relativePath: string): string | null {
const resolved = resolveInProject(base, relativePath);
return resolved && !isPrivateProjectFile(base, resolved) ? resolved : null;
}

/** The real path; for a path not there (yet, or any more), the nearest existing folder's real path plus the rest. */
export function realFilePath(filePath: string): string {
try {
Expand All @@ -25,6 +32,28 @@ export function realFilePath(filePath: string): string {
}
}

// `.hyperframes/` holds the desktop app's and the CLI's own records (a hand-off, chat history, read marks): no route
// reaches any of it, the folder itself included, but Studio's own files and the app's request pictures.
const STUDIO_FILES = new Set([STUDIO_MOTION_PATH, STUDIO_MANUAL_EDITS_PATH]);
const STUDIO_FOLDERS = [".hyperframes/prepared-assets", ".hyperframes/requests"];

/** For a project-relative path with `/` separators. */
export function isPrivateProjectPath(relPath: string): boolean {
const path = relPath.toLowerCase();
return (
(path === ".hyperframes" || path.startsWith(".hyperframes/")) &&
!STUDIO_FILES.has(path) &&
!STUDIO_FOLDERS.some((folder) => path === folder || path.startsWith(`${folder}/`))
);
}

export function isPrivateProjectFile(projectDir: string, filePath: string): boolean {
const rel = (from: string, to: string) => relative(from, to).split(sep).join("/");
return [rel(projectDir, filePath), rel(realFilePath(projectDir), realFilePath(filePath))].some(
isPrivateProjectPath,
);
}

/**
* `resolveWithinProject` with every link inside the project resolved now, so a write to the result lands on the
* file checked here even if a link on the way is retargeted before the write.
Expand Down
121 changes: 121 additions & 0 deletions packages/studio-server/src/routes/files.pathSafety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,3 +695,124 @@ describe("rename reference updates", () => {
},
);
});

describe("the desktop app's private files", () => {
it("are never read, written or deleted through the file routes, however the path is spelled", async (context) => {
const { app, project } = fixture();
mkdirSync(join(project, ".hyperframes"));
writeFileSync(
join(project, ".hyperframes", "agent-handoff.json"),
'{"engine":"claude","sessionId":"secret"}',
);
writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}');
writeFileSync(
join(project, ".hyperframes", "agent-handoff-read.json"),
'{"sessionId":"secret"}',
);
for (const path of [
".hyperframes/agent-handoff.json",
".hyperframes/app-history.jsonl",
".HyperFrames/Agent-Handoff.JSON",
".hyperframes/agent-handoff-read.json",
]) {
const res = await app.request(`/projects/p/files/${path}`);
expect([403, 404]).toContain(res.status);
expect(await res.text()).not.toContain("secret");
}
const put = await app.request("/projects/p/files/.hyperframes/agent-handoff.json", {
method: "PUT",
body: "{}",
});
expect(put.status).toBe(403);
const del = await app.request("/projects/p/files/.hyperframes/app-history.jsonl", {
method: "DELETE",
});
expect(del.status).toBe(403);
linkOrSkip(
context,
join(project, ".hyperframes", "agent-handoff.json"),
join(project, "link.json"),
"file",
);
const viaLink = await app.request("/projects/p/files/link.json");
expect(await viaLink.text()).not.toContain("secret");
});

it("can't be reached by renaming their folder", async () => {
const { app, project } = fixture();
mkdirSync(join(project, ".hyperframes"));
writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}');
const rename = await app.request("/projects/p/files/.hyperframes", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newPath: "x" }),
});
expect(rename.status).toBeGreaterThanOrEqual(400);
expect(existsSync(join(project, "x"))).toBe(false);
});

it("are neither read nor rewritten when a rename updates references", async () => {
const { app, project } = fixture();
mkdirSync(join(project, ".hyperframes"));
const share = join(project, ".hyperframes", "share.json");
writeFileSync(share, '{"ref":"inside.txt"}');
const response = await app.request("/projects/p/files/inside.txt", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newPath: "moved.txt" }),
});
expect(response.status).toBe(200);
expect(readFileSync(share, "utf8")).toBe('{"ref":"inside.txt"}');
expect((await response.json()).updatedReferences).toBe(0);
});

it("leave Studio's own files under .hyperframes/ reachable", async () => {
const { app, project } = fixture();
mkdirSync(join(project, ".hyperframes", "prepared-assets", "gif"), { recursive: true });
writeFileSync(
join(project, ".hyperframes", "studio-motion.json"),
'{"version":1,"motions":[]}',
);
writeFileSync(join(project, ".hyperframes", "prepared-assets", "gif", "a.mp4"), "mp4");
for (const path of ["studio-motion.json", "prepared-assets/gif/a.mp4"])
expect((await app.request(`/projects/p/files/.hyperframes/${path}`)).status).toBe(200);
});
});

describe("routes that write a path from the request", () => {
it("never plant or copy a file into .hyperframes/", async () => {
const { app, project } = fixture();
mkdirSync(join(project, ".hyperframes"));
writeFileSync(
join(project, ".hyperframes", "agent-handoff.json"),
'{"engine":"claude","sessionId":"secret"}',
);
const rename = await app.request("/projects/p/files/inside.txt", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newPath: ".hyperframes/app-history.jsonl" }),
});
expect(rename.status).toBeGreaterThanOrEqual(400);
expect(existsSync(join(project, ".hyperframes", "app-history.jsonl"))).toBe(false);
const duplicate = await app.request("/projects/p/duplicate-file", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: ".hyperframes/agent-handoff.json" }),
});
expect(duplicate.status).toBeGreaterThanOrEqual(400);
expect(readdirSync(join(project, ".hyperframes"))).toEqual(["agent-handoff.json"]);
const form = new FormData();
// A file uploads accept anywhere else, so the folder is the only reason it is refused.
form.append("file", new File(["plain words"], "notes.txt"));
const upload = await app.request("/projects/p/upload?dir=.hyperframes", {
method: "POST",
body: form,
});
expect(upload.status).toBe(403);
expect(existsSync(join(project, ".hyperframes", "notes.txt"))).toBe(false);
const elsewhere = new FormData();
elsewhere.append("file", new File(["plain words"], "notes.txt"));
const fine = await app.request("/projects/p/upload", { method: "POST", body: elsewhere });
expect((await fine.json()).files).toEqual(["notes.txt"]);
});
});
5 changes: 3 additions & 2 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import {
folderGone,
isInHiddenOrVendorDir,
isPrivateProjectFile,
isSafePath,
mkdirWithinProject,
pinWithinProject,
Expand Down Expand Up @@ -802,7 +803,7 @@ function updateReferences(
const rewrite = referenceRewriter(oldPath, newPath, isDirectory, projectPaths(projectDir));
let updatedCount = 0;
for (const file of textFiles) {
if (!isSafePath(projectDir, file)) continue;
if (!isSafePath(projectDir, file) || isPrivateProjectFile(projectDir, file)) continue;
const content = readableText(file);
if (content === null) continue;

Expand Down Expand Up @@ -2481,7 +2482,7 @@ async function processUploadedFiles(

// The collision suffix chooses a different path; validate that destination
// too, including dangling symlinks that existsSync treats as absent.
if (!isSafePath(projectDir, finalPath)) continue;
if (!isSafePath(projectDir, finalPath) || isPrivateProjectFile(projectDir, finalPath)) continue;

const buffer = Buffer.from(await value.arrayBuffer());
const validation = validateUploadedMediaBuffer(finalName, buffer);
Expand Down
31 changes: 31 additions & 0 deletions packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2281,3 +2281,34 @@ describe("preview asset byte ranges", () => {
expect(res.headers.get("Content-Range")).toBe("bytes */3");
});
});

describe("the desktop app's private folder", () => {
it("is served by neither the asset route nor the sub-composition route", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes"), { recursive: true });
writeFileSync(
join(projectDir, ".hyperframes", "agent-handoff.json"),
'{"engine":"claude","sessionId":"secret"}',
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
for (const route of ["preview", "preview/comp"]) {
const res = await app.request(
`http://localhost/projects/demo/${route}/.hyperframes/agent-handoff.json`,
);
expect(res.status).toBe(404);
expect(await res.text()).not.toContain("secret");
}
});
it("still serves the app's request pictures", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "requests", "1"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "requests", "1", "before.png"), "png");
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request(
"http://localhost/projects/demo/preview/.hyperframes/requests/1/before.png",
);
expect(res.status).toBe(200);
});
});
8 changes: 6 additions & 2 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js";
import {
isPrivateProjectFile,
isProjectRootMissing,
resolveWithinProject,
} from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml, hasBaseElement } from "../helpers/subComposition.js";
import {
Expand Down Expand Up @@ -615,7 +619,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
// use resolveWithinProject because saves write their data-hf-id values.
const candidate = resolve(project.dir, subPath);
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
if (!file) {
if (!file || isPrivateProjectFile(project.dir, file)) {
return c.text("not found", 404);
}
recordPreviewRead(project.dir, file);
Expand Down
17 changes: 9 additions & 8 deletions packages/studio-server/src/routes/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></bo
// - .cache/examples/ a vendored dot-dir. walkDir does NOT special-case
// it, so it stays listed in the file tree, but it is
// gated out of composition discovery (isInHiddenOrVendorDir).
// - .hyperframes/examples/ vendored under Studio's dir — also listed in the
// file tree, also gated out of discovery.
// - .hyperframes/backup/ Studio's internal snapshots — the only thing hidden
// from the file tree (walkDir's shouldIgnoreDir).
// - .hyperframes/examples/ under the app's folder — gated out of discovery,
// and hidden from the tree like every non-Studio record.
// - .hyperframes/backup/ Studio's internal snapshots — hidden from the tree.
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
tempDirs.push(projectDir);
Expand Down Expand Up @@ -149,8 +148,10 @@ describe("registerProjectRoutes — composition discovery (#1384)", () => {
expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html");
});

it("lists vendored dot-directory files in the file tree but hides Studio backups", async () => {
it("lists vendored dot-directory files in the file tree, but nothing under .hyperframes/ that isn't Studio's", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, ".hyperframes", "app-history.jsonl"), "{}");
writeFileSync(join(projectDir, ".hyperframes", "studio-motion.json"), "{}");
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));

Expand All @@ -159,9 +160,9 @@ describe("registerProjectRoutes — composition discovery (#1384)", () => {

// Vendored dot-dirs stay browsable — discovery is gated, the file tree is not.
expect(payload.files).toContain(".cache/examples/preset.html");
expect(payload.files).toContain(".hyperframes/examples/preset.html");
// Only Studio's own backup snapshots are hidden from the tree (#1366).
expect(payload.files).not.toContain(".hyperframes/backup/snapshot.html");
expect(payload.files).toContain(".hyperframes/studio-motion.json");
for (const hidden of ["examples/preset.html", "backup/snapshot.html", "app-history.jsonl"])
expect(payload.files).not.toContain(`.hyperframes/${hidden}`);
});

it("omits generated cache files from the project file tree", async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/studio-server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
import { isInHiddenOrVendorDir, isPrivateProjectPath, walkDir } from "../helpers/safePath.js";
import { isCompositionSource } from "../helpers/hfIdPersist.js";
import { resolveProjectSignature } from "../helpers/projectSignature.js";

Expand Down Expand Up @@ -50,7 +50,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
api.get("/projects/:id", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
const files = walkDir(project.dir).filter((file) => !isPrivateProjectPath(file));
const compositions = await filterCompositionFiles(project.dir, files);
return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions });
});
Expand Down
Loading
Loading