Skip to content

Commit fed45ba

Browse files
fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* fix(studio-server): serve projects whose names contain @ % # & or ? Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms 404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL. * fix(studio-server): thumbnails and waveforms stay inside the project for any name The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it, and neither route checked that the decoded path stays inside the project. Thumbnails now use the composition guard and an encoded URL; waveforms use the read-only asset guard. * fix(cli): play and present serve assets whose names contain @ % # & or ? Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the five studio-server routes, so one helper owns how a request path becomes a file path. * fix(studio-server): a thumbnail request for the project folder answers 404 An empty thumbnail path resolved to the project directory and crashed reading it (500). * fix(studio-server): read the thumbnail's composition with one open Checking the path with stat and then reading it let the file change in between (CodeQL js/file-system-race). One open now serves the type check, the mtime and the read; a missing file still thumbnails from the preview as before, anything that is not a file answers 404.
1 parent 1926903 commit fed45ba

10 files changed

Lines changed: 262 additions & 72 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { Hono } from "hono";
6+
import type { ProjectDir } from "../utils/project.js";
7+
import { registerCompositionRoute } from "./play.js";
8+
import { registerPresentCompositionRoute } from "./present.js";
9+
10+
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded; Windows file names cannot hold "?".
11+
const NAMES = [
12+
"A @HyperFrames launch",
13+
"50% off",
14+
"#2 take",
15+
"Tom & Jerry",
16+
...(process.platform === "win32" ? [] : ["why?"]),
17+
"two spaces",
18+
"café crème",
19+
"🎬 film",
20+
];
21+
22+
const ROUTES = [
23+
["play", (app: Hono, project: ProjectDir) => registerCompositionRoute(app, project, false)],
24+
["present", registerPresentCompositionRoute],
25+
] as const;
26+
27+
let dir: string | undefined;
28+
29+
afterEach(() => {
30+
if (dir) rmSync(dir, { recursive: true, force: true });
31+
dir = undefined;
32+
});
33+
34+
async function requestAsset(
35+
register: (app: Hono, project: ProjectDir) => Promise<void>,
36+
name: string,
37+
) {
38+
dir = mkdtempSync(join(tmpdir(), "hf-composition-route-"));
39+
mkdirSync(join(dir, "scenes"));
40+
writeFileSync(join(dir, "scenes", `${name}.txt`), `ASSET ${name}`);
41+
const app = new Hono();
42+
await register(app, { dir, name: "test-project", indexPath: join(dir, "index.html") });
43+
const response = await app.request(`/composition/scenes/${encodeURIComponent(`${name}.txt`)}`);
44+
return { status: response.status, text: await response.text() };
45+
}
46+
47+
describe.each(ROUTES)("%s /composition/*", (_route, register) => {
48+
it.each(NAMES)("serves an asset named %j", async (name) => {
49+
const result = await requestAsset(register, name);
50+
expect(result.status).toBe(200);
51+
expect(result.text).toBe(`ASSET ${name}`);
52+
});
53+
});

‎packages/cli/src/commands/play.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const examples: Example[] = [
2020
];
2121
import { resolve } from "node:path";
2222
import type { Hono } from "hono";
23+
import { requestSubPath } from "@hyperframes/studio-server";
2324
import * as clack from "@clack/prompts";
2425
import { c } from "../ui/colors.js";
2526
import { resolveProject, type ProjectDir } from "../utils/project.js";
@@ -213,7 +214,7 @@ export async function registerCompositionRoute(
213214

214215
// fallow-ignore-next-line complexity
215216
app.get("/composition/*", async (ctx) => {
216-
const reqPath = ctx.req.path.replace("/composition/", "");
217+
const reqPath = requestSubPath(ctx.req.url, "composition");
217218
const filePath = resolve(project.dir, reqPath);
218219

219220
// Security: don't allow path traversal outside project dir. isSafePath

‎packages/cli/src/commands/present.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export const examples: Example[] = [
1313
import { resolve } from "node:path";
1414
import * as clack from "@clack/prompts";
1515
import { c } from "../ui/colors.js";
16-
import { resolveProject } from "../utils/project.js";
16+
import { resolveProject, type ProjectDir } from "../utils/project.js";
17+
import type { Hono } from "hono";
18+
import { requestSubPath } from "@hyperframes/studio-server";
1719
import {
1820
openBrowser,
1921
parseRemoteDebuggingPort,
@@ -113,7 +115,6 @@ export default defineCommand({
113115

114116
const { Hono } = await import("hono");
115117
const { createAdaptorServer } = await import("@hono/node-server");
116-
const { isSafePath } = await import("@hyperframes/core/studio-api");
117118

118119
const app = new Hono();
119120

@@ -133,16 +134,7 @@ export default defineCommand({
133134
// timelines (no engine runtime injected) — the same model demo.html / the
134135
// standalone harness use; injecting a runtime would leave the composition
135136
// engine-paused and blank.
136-
app.get("/composition/*", (ctx) => {
137-
const reqPath = ctx.req.path.replace("/composition/", "");
138-
const filePath = resolve(project.dir, reqPath);
139-
// Security: canonicalizes symlinks + guards the trailing separator so neither
140-
// an in-project symlink nor a sibling dir sharing the prefix can escape.
141-
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
142-
if (!existsSync(filePath)) return ctx.text("Not found", 404);
143-
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
144-
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
145-
});
137+
await registerPresentCompositionRoute(app, project);
146138

147139
// Both the presenter window and the audience window (opened by present() with
148140
// ?mode=audience) load this same page; the component reads the mode from the URL.
@@ -309,3 +301,20 @@ function escHtml(value: string): string {
309301
.replace(/>/g, "&gt;")
310302
.replace(/"/g, "&quot;");
311303
}
304+
305+
export async function registerPresentCompositionRoute(
306+
app: Hono,
307+
project: ProjectDir,
308+
): Promise<void> {
309+
const { isSafePath } = await import("@hyperframes/core/studio-api");
310+
app.get("/composition/*", (ctx) => {
311+
const reqPath = requestSubPath(ctx.req.url, "composition");
312+
const filePath = resolve(project.dir, reqPath);
313+
// Security: canonicalizes symlinks + guards the trailing separator so neither
314+
// an in-project symlink nor a sibling dir sharing the prefix can escape.
315+
if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
316+
if (!existsSync(filePath)) return ctx.text("Not found", 404);
317+
if (filePath.endsWith(".html")) return ctx.html(readFileSync(filePath, "utf-8"));
318+
return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) });
319+
});
320+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// The decoded path after `route` ("projects/:id/preview", "composition"), cut by segment from the raw URL:
2+
// Hono's c.req.path leaves %40 %25 %23 %26 %3F encoded, so cutting a decoded prefix out of it misses.
3+
export function requestSubPath(url: string, route: string): string {
4+
const routeSegments = route.split("/");
5+
const segments = new URL(url).pathname.split("/");
6+
const start = segments.indexOf(routeSegments[0] ?? "");
7+
return decodeURIComponent(segments.slice(start + routeSegments.length).join("/"));
8+
}

‎packages/studio-server/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
3232
export { PREVIEW_BUNDLE_OPTIONS } from "./routes/preview.js";
3333
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
34+
export { requestSubPath } from "./helpers/requestSubPath.js";
3435
export {
3536
consumeFileWriteReceipt,
3637
identifyFileWrite,

‎packages/studio-server/src/routes/files.ts‎

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
insertCompositionIntoSource,
9090
} from "../helpers/compositionInsertion.js";
9191
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
92+
import { requestSubPath } from "../helpers/requestSubPath.js";
9293

9394
// ── Server cutover flag ─────────────────────────────────────────────────────
9495

@@ -114,7 +115,7 @@ async function loadGsapParser() {
114115
interface RouteContext {
115116
req: {
116117
param: (name: string) => string;
117-
path: string;
118+
url: string;
118119
query: (name: string) => string | undefined;
119120
header: (name: string) => string | undefined;
120121
};
@@ -172,7 +173,7 @@ function isDanglingSymlinkInProject(projectDir: string, lexicalPath: string): bo
172173
async function resolveProjectPath(
173174
c: RouteContext,
174175
adapter: StudioApiAdapter,
175-
pathPrefix: (projectId: string) => string,
176+
route: string,
176177
opts?: { mustExist?: boolean },
177178
) {
178179
const id = c.req.param("id");
@@ -195,7 +196,7 @@ async function resolveProjectPath(
195196
} as const;
196197
}
197198

198-
const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
199+
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
199200
if (filePath.includes("\0")) {
200201
return { error: c.json({ error: "forbidden", why: "nul" }, 403) } as const;
201202
}
@@ -220,11 +221,11 @@ function resolveProjectFile(
220221
adapter: StudioApiAdapter,
221222
opts?: { mustExist?: boolean },
222223
) {
223-
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
224+
return resolveProjectPath(c, adapter, "files", opts);
224225
}
225226

226227
function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) {
227-
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
228+
return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
228229
}
229230

230231
type MutationTarget = {
@@ -3222,7 +3223,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
32223223
// ── GSAP Animations (parse) ──
32233224

32243225
api.get("/projects/:id/gsap-animations/*", async (c) => {
3225-
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-animations/`, {
3226+
const res = await resolveProjectPath(c, adapter, "gsap-animations", {
32263227
mustExist: true,
32273228
});
32283229
if ("error" in res) return res.error;
@@ -3251,7 +3252,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
32513252
});
32523253

32533254
api.post("/projects/:id/gsap-mutations/*", async (c) => {
3254-
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
3255+
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
32553256
mustExist: true,
32563257
});
32573258
if ("error" in res) return res.error;
@@ -3264,12 +3265,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
32643265
});
32653266

32663267
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
3267-
const res = await resolveProjectPath(
3268-
c,
3269-
adapter,
3270-
(id) => `/projects/${id}/gsap-mutations-batch/`,
3271-
{ mustExist: true },
3272-
);
3268+
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
32733269
if ("error" in res) return res.error;
32743270

32753271
const body = (await c.req.json().catch(() => null)) as {
@@ -3289,12 +3285,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
32893285
// mutation wrote. Keep compare + write in this synchronous server section so
32903286
// another request cannot land between a client-side check and the restore.
32913287
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
3292-
const res = await resolveProjectPath(
3293-
c,
3294-
adapter,
3295-
(id) => `/projects/${id}/gsap-mutation-rollback/`,
3296-
{ mustExist: true },
3297-
);
3288+
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
32983289
if ("error" in res) return res.error;
32993290

33003291
const body = (await c.req.json().catch(() => null)) as {

‎packages/studio-server/src/routes/preview.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
resolvePreviewMediaCodecProbeCache,
4848
type PreviewApiAdapter,
4949
} from "../helpers/mediaProxyPreview.js";
50+
import { requestSubPath } from "../helpers/requestSubPath.js";
5051

5152
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
5253
const GSAP_CDN_VERSION = "3.15.0";
@@ -471,9 +472,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
471472
const vars = previewVariablesFromRequest(c.req.query("variables"));
472473
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
473474
const previewVariables = vars.values;
474-
const compPath = decodeURIComponent(
475-
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
476-
);
475+
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
477476
const compFile = resolveWithinProject(project.dir, compPath);
478477
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
479478
return c.text("not found", 404);
@@ -516,9 +515,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
516515
api.get("/projects/:id/preview/*", async (c) => {
517516
const project = await adapter.resolveProject(c.req.param("id"));
518517
if (!project) return c.json({ error: "not found" }, 404);
519-
const subPath = decodeURIComponent(
520-
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
521-
);
518+
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
522519
// Assets are read-only and should mirror the renderer: permit a path that
523520
// is lexically inside the project even if an explicit project symlink
524521
// targets a shared directory outside it. Composition source files still

0 commit comments

Comments
 (0)