Skip to content

Commit a6c12ff

Browse files
vanceingallsclaude
andcommitted
fix(studio-server): keep the freeze still inside assets/freeze
The clip id from the project HTML went straight into ffmpeg's -y output path, so an id with ../ segments wrote outside the project. The id is now reduced to one safe filename component, the output is pinned inside assets/freeze before extraction, and the still's src is built from that name rather than from a filesystem path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 368775b commit a6c12ff

4 files changed

Lines changed: 56 additions & 8 deletions

File tree

‎packages/studio-server/src/helpers/freezeFrame.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
applyFreezeFrameToHtml,
55
freezeExtractArgs,
66
freezeFrameMediaTime,
7+
freezeStillFileName,
78
readFreezeSource,
89
} from "./freezeFrame.js";
910

@@ -152,3 +153,12 @@ describe("applyFreezeFrameToHtml", () => {
152153
).toBeNull();
153154
});
154155
});
156+
157+
describe("freezeStillFileName", () => {
158+
it("reduces a clip id to one safe filename component", () => {
159+
expect(freezeStillFileName("talk", 2.5)).toBe("talk-2500.png");
160+
expect(freezeStillFileName("../../etc/x", 1)).toBe("______etc_x-1000.png");
161+
expect(freezeStillFileName("a\\b:c", 1)).toBe("a_b_c-1000.png");
162+
expect(freezeStillFileName("", 1)).toBe("clip-1000.png");
163+
});
164+
});

‎packages/studio-server/src/helpers/freezeFrame.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export function freezeExtractArgs(src: string, mediaTime: number, output: string
4141
return ["-y", "-ss", String(round3(mediaTime)), "-i", src, "-frames:v", "1", output];
4242
}
4343

44+
export function freezeStillFileName(clipId: string, playhead: number): string {
45+
const stem = clipId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80) || "clip";
46+
return `${stem}-${Math.round(playhead * 1000)}.png`;
47+
}
48+
4449
export interface FreezeSource {
4550
id: string;
4651
src: string;

‎packages/studio-server/src/routes/freezeFrame.test.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { afterEach, describe, expect, it } from "vitest";
22
import { Hono } from "hono";
3-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
5-
import { join } from "node:path";
5+
import { dirname, join } from "node:path";
66
import { registerFreezeFrameRoutes, type FrameExtractor } from "./freezeFrame";
77
import { fileContentVersion } from "../helpers/fileVersion";
88
import type { StudioApiAdapter } from "../types";
@@ -16,10 +16,11 @@ const html = `<div data-composition-id="main" data-start="0" data-duration="6">
1616
<video id="talk" class="clip" src="media/talk.mp4" data-start="0" data-duration="6" data-track-index="0"></video>
1717
</div>`;
1818

19-
function setup(extract: FrameExtractor) {
19+
function setup(extract: FrameExtractor, file = { path: "index.html", html }) {
2020
const dir = mkdtempSync(join(tmpdir(), "hf-freeze-"));
2121
tempDirs.push(dir);
22-
writeFileSync(join(dir, "index.html"), html);
22+
mkdirSync(dirname(join(dir, file.path)), { recursive: true });
23+
writeFileSync(join(dir, file.path), file.html);
2324
const adapter: StudioApiAdapter = {
2425
listProjects: () => [],
2526
resolveProject: async (id: string) => ({ id, dir }),
@@ -98,4 +99,30 @@ describe("freeze-frame route", () => {
9899
expect(res.status).toBe(500);
99100
expect(readFileSync(join(dir, "index.html"), "utf-8")).toBe(html);
100101
});
102+
103+
it("keeps a traversal clip id inside assets/freeze, for the ffmpeg output and the still's src", async () => {
104+
const evil = html.replace('id="talk"', 'id="../../../../outside/frame"');
105+
const calls: string[][] = [];
106+
const { dir, post } = setup(
107+
async (args) => {
108+
calls.push(args);
109+
return { ok: true };
110+
},
111+
{ path: "scenes/a.html", html: evil.replace('src="media/', 'src="../media/') },
112+
);
113+
const res = await post({
114+
path: "scenes/a.html",
115+
expectedVersion: fileContentVersion(evil.replace('src="media/', 'src="../media/')),
116+
target: { id: "../../../../outside/frame" },
117+
playhead: 2.5,
118+
});
119+
const body: { imageSrc?: string; after?: string } = await res.json();
120+
expect(res.status).toBe(200);
121+
const output = calls[0]?.at(-1);
122+
expect(output).toBe(join(dir, "assets/freeze/____________outside_frame-2500.png"));
123+
expect(dirname(output ?? "")).toBe(join(dir, "assets/freeze"));
124+
expect(body.imageSrc).toBe("../assets/freeze/____________outside_frame-2500.png");
125+
expect(body.after).toContain('src="../assets/freeze/____________outside_frame-2500.png"');
126+
expect(existsSync(join(dir, "..", "outside"))).toBe(false);
127+
});
101128
});

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import {
1616
applyFreezeFrameToHtml,
1717
freezeExtractArgs,
18+
freezeStillFileName,
1819
readFreezeSource,
1920
type FreezeSource,
2021
} from "../helpers/freezeFrame.js";
@@ -62,7 +63,7 @@ function isFreezeFrameRequest(value: unknown): value is FreezeFrameRequest {
6263
);
6364
}
6465

65-
const toPosix = (path: string) => path.split(sep).join("/");
66+
const FREEZE_DIR = ["assets", "freeze"];
6667

6768
type Failure = { error: string; status: 403 | 404 | 409 | 500 };
6869

@@ -88,17 +89,22 @@ async function extractStill(
8889
const fileDir = dirname(absPath);
8990
const mediaPath = pinWithinProject(projectDir, relative(projectDir, join(fileDir, source.src)));
9091
if (!mediaPath) return { error: `forbidden media path: ${source.src}`, status: 403 };
91-
const freezeDir = join(projectDir, "assets", "freeze");
92-
const imagePath = join(freezeDir, `${source.id}-${Math.round(playhead * 1000)}.png`);
92+
const freezeDir = join(projectDir, ...FREEZE_DIR);
93+
const fileName = freezeStillFileName(source.id, playhead);
9394
mkdirWithinProject(projectDir, freezeDir);
95+
const imagePath = pinWithinProject(projectDir, join(...FREEZE_DIR, fileName));
96+
if (!imagePath || dirname(imagePath) !== freezeDir) {
97+
return { error: `forbidden freeze path: ${fileName}`, status: 403 };
98+
}
9499
const extracted = await extract(freezeExtractArgs(mediaPath, source.mediaTime, imagePath));
95100
if (!extracted.ok) {
96101
return {
97102
error: `Could not extract the frame: ${extracted.error ?? "ffmpeg failed"}`,
98103
status: 500,
99104
};
100105
}
101-
return { imageSrc: toPosix(relative(fileDir, imagePath)) };
106+
const depth = relative(projectDir, fileDir).split(sep).filter(Boolean).length;
107+
return { imageSrc: `${"../".repeat(depth)}${FREEZE_DIR.join("/")}/${fileName}` };
102108
}
103109

104110
function writeFolded(

0 commit comments

Comments
 (0)