From be2b464650ab4b083138822d3ebbc98df0224d32 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 20:44:52 -0700 Subject: [PATCH 1/3] fix(studio): open projects whose folder name has a colon --- .../studio/src/utils/projectRouting.test.ts | 9 +++++++++ packages/studio/src/utils/projectRouting.ts | 4 +++- packages/studio/vite.adapter.projects.test.ts | 18 ++++++++++++++++++ packages/studio/vite.adapter.ts | 4 +++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts index 9f022719e3..4f62929c90 100644 --- a/packages/studio/src/utils/projectRouting.test.ts +++ b/packages/studio/src/utils/projectRouting.test.ts @@ -18,6 +18,15 @@ describe("project routing utilities", () => { }, ); + it("opens a folder whose name has a colon, encoded once both ways", () => { + const id = "Customer story: Northwind"; + expect(encodeProjectId(id)).toBe("Customer%20story%3A%20Northwind"); + expect(buildProjectApiPath(id, "/preview")).toBe( + "/api/projects/Customer%20story%3A%20Northwind/preview", + ); + expect(parseProjectIdFromHash(buildProjectHash(id))).toBe(id); + }); + it("decodes project ids from hash routes before building capture URLs", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-01T12:00:00Z")); diff --git a/packages/studio/src/utils/projectRouting.ts b/packages/studio/src/utils/projectRouting.ts index 6291d665c8..1b991beecc 100644 --- a/packages/studio/src/utils/projectRouting.ts +++ b/packages/studio/src/utils/projectRouting.ts @@ -5,13 +5,15 @@ export interface ProjectHashRoute { params: URLSearchParams; } +const WINDOWS_DRIVE_PREFIX = /^[a-z]:/i; + /** Project names are single path segments, including when received from a hash or server. */ export function isValidProjectId(value: string): boolean { return ( value.length > 0 && value !== "." && value !== ".." && - !value.includes(":") && + !WINDOWS_DRIVE_PREFIX.test(value) && !value.includes("/") && !value.includes("\\") && !Array.from(value).some((char) => char.charCodeAt(0) < 32) diff --git a/packages/studio/vite.adapter.projects.test.ts b/packages/studio/vite.adapter.projects.test.ts index 2f844da539..cc901b145e 100644 --- a/packages/studio/vite.adapter.projects.test.ts +++ b/packages/studio/vite.adapter.projects.test.ts @@ -171,6 +171,24 @@ describe("Vite project resolution boundary", () => { }, ); + it("opens a folder named with a colon, which Windows would read as a file stream and refuses", () => { + const { data, adapter } = fixture(); + const id = "Customer story: Northwind"; + mkdirSync(join(data, "demo")); + if (process.platform !== "win32") { + mkdirSync(join(data, id)); + expect(adapter.resolveProject(id)?.id).toBe(id); + } + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + expect(adapter.resolveProject(id)).toBeNull(); + expect(adapter.resolveProject("demo::$INDEX_ALLOCATION")).toBeNull(); + } finally { + Object.defineProperty(process, "platform", platform); + } + }); + it("rejects traversal in a session's project mapping", () => { const { sessions, adapter } = fixture(); writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: "../sessions" })); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index b99a69970e..ca189ea97a 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -102,6 +102,8 @@ export function createProjectSignatureCache({ }; } +const isWindowsStreamId = (id: string) => process.platform === "win32" && id.includes(":"); + export function createViteAdapter( dataDir: string, server: ViteDevServer, @@ -233,7 +235,7 @@ export function createViteAdapter( // fallow-ignore-next-line complexity resolveProject(id: string) { - if (!isValidProjectId(id)) return null; + if (!isValidProjectId(id) || isWindowsStreamId(id)) return null; let projectDir = resolve(dataDir, id); if (!isPathWithin(dataDir, projectDir)) return null; if (!existsSync(projectDir)) { From 51c1ec219232742e85438160ff84d52882cef9f8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 21:58:29 -0700 Subject: [PATCH 2/3] fix(studio): open projects named with a colon or '#' in Studio and its preview --- .../studio-server/src/routes/preview.test.ts | 27 +++++++++++++++++++ packages/studio-server/src/routes/preview.ts | 17 +++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 753cb0b8d6..ce1933bac1 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -116,6 +116,33 @@ describe("registerPreviewRoutes", () => { expect(authored).not.toContain(''); }); + it("encodes the project name in , so a '#' or '\"' in it keeps assets in its own folder", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "scene.html"), ""); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + const baseOf = async (path: string) => + //.exec( + await (await app.request(`http://localhost${path}`)).text(), + )?.[1]; + const take = "/api/projects/Take%20%232/preview/"; + expect(await baseOf("/projects/Take%20%232/preview")).toBe(take); + expect(await baseOf("/projects/Take%20%232/preview/comp/scene.html")).toBe(take); + expect(new URL("logo.png", `http://localhost${take}`).pathname).toBe(`${take}logo.png`); + expect(await baseOf("/projects/a%22b/preview")).toBe("/api/projects/a%22b/preview/"); + }); + + it("keeps the encoded when the bundler fails and the page is read from disk", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + const bundle = async () => { + throw new Error("bundler unavailable"); + }; + registerPreviewRoutes(app, createAdapter(projectDir, { bundle })); + const html = await (await app.request("http://localhost/projects/Take%20%232/preview")).text(); + expect(html).toContain(''); + }); + it("serves the mark the runtime keys preview-only work on, ahead of the runtime script", async () => { const projectDir = createProjectDir(); const app = new Hono(); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 7fd3d460cd..d040aaed40 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -344,6 +344,14 @@ export const PREVIEW_BUNDLE_OPTIONS = { sceneParts: true, } as const satisfies BundleOptions; +const previewBaseHref = (projectId: string) => + `/api/projects/${encodeURIComponent(projectId)}/preview/`; + +const withPreviewBase = (html: string, projectId: string) => + hasBaseElement(html) + ? html + : html.replace(//i, ``); + export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void { const previewCacheHeaders = (etag: string) => ({ "Cache-Control": "private, no-cache", @@ -406,10 +414,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo } // Inject for relative asset resolution - const baseHref = `/api/projects/${project.id}/preview/`; - if (!hasBaseElement(bundled)) { - bundled = bundled.replace(//i, ``); - } + bundled = withPreviewBase(bundled, project.id); // Also covers elements the adapter injected; ids already present are kept. bundled = injectStudioPreviewAugmentations( @@ -438,7 +443,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo // not the pre-request snapshot that may have been saved over. const fallback = resolveProjectMainHtml(project.dir, project.id); if (fallback) { - const fallbackHtml = ensureHfIds(fallback.html); + const fallbackHtml = withPreviewBase(ensureHfIds(fallback.html), project.id); let fallbackAugmented = injectStudioPreviewAugmentations( await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath), adapter, @@ -549,7 +554,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo const stamped = pinSubCompHfIds(compFile, compPath); if (stamped === null) return c.text("not found", 404); // file removed between stat and read - const baseHref = `/api/projects/${project.id}/preview/`; + const baseHref = previewBaseHref(project.id); let html = buildSubCompositionHtml( project.dir, compPath, From e279accc27e666e87fb47477e1c37c664cd2e446 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 22:57:27 -0700 Subject: [PATCH 3/3] fix(studio): refuse a Windows stream id reached through a session alias One adapter rule, isServableProjectId, now guards the requested id, a session's projectId and the folder listing. The bundler-failure preview places its inside an attributed , and the media metadata cache keys on the encoded URL so ids with ':' can't collide. --- .../studio-server/src/routes/preview.test.ts | 1 + packages/studio-server/src/routes/preview.ts | 2 +- .../editor/useColorGradingController.test.ts | 37 ++++++++++++++++++- .../editor/useColorGradingController.ts | 4 +- packages/studio/vite.adapter.projects.test.ts | 9 ++++- packages/studio/vite.adapter.ts | 9 +++-- 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index ce1933bac1..8b5f779dd9 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -139,6 +139,7 @@ describe("registerPreviewRoutes", () => { throw new Error("bundler unavailable"); }; registerPreviewRoutes(app, createAdapter(projectDir, { bundle })); + writeFileSync(join(projectDir, "index.html"), ''); const html = await (await app.request("http://localhost/projects/Take%20%232/preview")).text(); expect(html).toContain(''); }); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index d040aaed40..1d20ced7fb 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -350,7 +350,7 @@ const previewBaseHref = (projectId: string) => const withPreviewBase = (html: string, projectId: string) => hasBaseElement(html) ? html - : html.replace(//i, ``); + : injectTagsAtHeadStart(html, ``); export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void { const previewCacheHeaders = (etag: string) => ({ diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts index d86796dbde..61baabe6fd 100644 --- a/packages/studio/src/components/editor/useColorGradingController.test.ts +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -71,7 +71,9 @@ function HookHost({ element, previewIframeRef, onApplyScope, + projectId = "proj", }: { + projectId?: string; onState: (state: ReturnType) => void; onSetAttributeLive: (attr: string, value: string | null) => void; element: DomEditSelection; @@ -79,7 +81,7 @@ function HookHost({ onApplyScope?: ApplyScope; }) { const state = useColorGradingController({ - projectId: "proj", + projectId, element, previewIframeRef, onSetAttributeLive, @@ -94,6 +96,7 @@ function renderHook( initialElement: DomEditSelection = makeElement(), previewIframeRef?: React.RefObject, onApplyScope?: ApplyScope, + projectId?: string, ) { const host = document.createElement("div"); document.body.append(host); @@ -108,6 +111,7 @@ function renderHook( element, previewIframeRef, onApplyScope, + projectId, }), ); }); @@ -763,6 +767,37 @@ describe("useColorGradingController", () => { act(() => second.root.unmount()); vi.unstubAllGlobals(); }); + + it("keeps media metadata apart for 'p:a' + 'b.mp4' and 'p' + 'a:b.mp4'", async () => { + const video = (src: string) => { + const el = document.createElement("video"); + el.setAttribute("src", src); + return el; + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ metadata: { kind: "video", color: { dynamicRange: "hdr" } } }), + } as unknown as Response); + vi.stubGlobal("fetch", fetchMock); + for (const [projectId, src] of [ + ["p:a", "b.mp4"], + ["p", "a:b.mp4"], + ]) { + const { root } = renderHook( + vi.fn(), + makeElement({ element: video(src) }), + undefined, + undefined, + projectId, + ); + await act(async () => { + await Promise.resolve(); + }); + act(() => root.unmount()); + } + expect(fetchMock).toHaveBeenCalledTimes(2); + vi.unstubAllGlobals(); + }); }); describe("useColorGradingController after the preview iframe is replaced", () => { diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index 3d69c99250..b1a30f2975 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -284,13 +284,13 @@ export function useColorGradingController({ useEffect(() => { setMediaMetadata(null); if (!selectedAssetPath) return; - const cacheKey = `${projectId}:${selectedAssetPath}`; + const cacheKey = mediaMetadataUrl(projectId, selectedAssetPath); if (MEDIA_METADATA_CACHE.has(cacheKey)) { setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null); return; } const controller = new AbortController(); - fetch(mediaMetadataUrl(projectId, selectedAssetPath), { signal: controller.signal }) + fetch(cacheKey, { signal: controller.signal }) .then(async (response) => { if (!response.ok) return { ok: false as const }; const data: MediaMetadataResponse | null = await response.json(); diff --git a/packages/studio/vite.adapter.projects.test.ts b/packages/studio/vite.adapter.projects.test.ts index cc901b145e..ecf3574344 100644 --- a/packages/studio/vite.adapter.projects.test.ts +++ b/packages/studio/vite.adapter.projects.test.ts @@ -172,18 +172,25 @@ describe("Vite project resolution boundary", () => { ); it("opens a folder named with a colon, which Windows would read as a file stream and refuses", () => { - const { data, adapter } = fixture(); + const { data, sessions, adapter } = fixture(); const id = "Customer story: Northwind"; mkdirSync(join(data, "demo")); if (process.platform !== "win32") { mkdirSync(join(data, id)); expect(adapter.resolveProject(id)?.id).toBe(id); } + writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: id })); + writeFileSync( + join(sessions, "stream.json"), + JSON.stringify({ projectId: "demo::$INDEX_ALLOCATION" }), + ); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { value: "win32" }); try { expect(adapter.resolveProject(id)).toBeNull(); expect(adapter.resolveProject("demo::$INDEX_ALLOCATION")).toBeNull(); + expect(adapter.resolveProject("alias")).toBeNull(); + expect(adapter.resolveProject("stream")).toBeNull(); } finally { Object.defineProperty(process, "platform", platform); } diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index ca189ea97a..939f33aec7 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -102,7 +102,8 @@ export function createProjectSignatureCache({ }; } -const isWindowsStreamId = (id: string) => process.platform === "win32" && id.includes(":"); +const isServableProjectId = (id: string) => + isValidProjectId(id) && !(process.platform === "win32" && id.includes(":")); export function createViteAdapter( dataDir: string, @@ -210,7 +211,7 @@ export function createViteAdapter( return readdirSync(dataDir, { withFileTypes: true }) .filter( (d) => - isValidProjectId(d.name) && + isServableProjectId(d.name) && (d.isDirectory() || d.isSymbolicLink()) && (existsSync(join(dataDir, d.name, "index.html")) || existsSync(join(dataDir, d.name, `${d.name}.html`))), @@ -235,7 +236,7 @@ export function createViteAdapter( // fallow-ignore-next-line complexity resolveProject(id: string) { - if (!isValidProjectId(id) || isWindowsStreamId(id)) return null; + if (!isServableProjectId(id)) return null; let projectDir = resolve(dataDir, id); if (!isPathWithin(dataDir, projectDir)) return null; if (!existsSync(projectDir)) { @@ -245,7 +246,7 @@ export function createViteAdapter( if (existsSync(sessionFile)) { try { const session = JSON.parse(readFileSync(sessionFile, "utf-8")); - if (typeof session.projectId === "string" && isValidProjectId(session.projectId)) { + if (typeof session.projectId === "string" && isServableProjectId(session.projectId)) { projectDir = resolve(dataDir, session.projectId); if (!isPathWithin(dataDir, projectDir)) return null; if (existsSync(projectDir)) {