diff --git a/packages/cli/src/commands/beats.test.ts b/packages/cli/src/commands/beats.test.ts index 87904df066..138b17b096 100644 --- a/packages/cli/src/commands/beats.test.ts +++ b/packages/cli/src/commands/beats.test.ts @@ -1,4 +1,13 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -55,6 +64,7 @@ async function runBeats(dir: string): Promise<{ exitCode: number; out: string }> return { exitCode, out: lines.join("\n") }; } +const dirLink = process.platform === "win32" ? "junction" : "dir"; const ffmpeg = findFfBinary("ffmpeg"); const canDecode = ffmpeg && findFfBinary("ffprobe"); @@ -87,6 +97,59 @@ describe.skipIf(!canDecode)("hyperframes beats", () => { expect(out).toMatch(/No beats detected in assets\/song\.wav/); expect(existsSync(join(dir, "beats"))).toBe(false); }); + + it("refuses a music src that resolves outside the project", async () => { + // An encoded `../` survives URL normalization and decodes to a parent path, so + // without a containment check the command reads ../../secret.wav and writes + // beats/../../secret.wav.json, both outside the project. + const dir = project(join("escape", "nested", "proj"), 120); + const base = join(root, "escape"); + writeFileSync(join(base, "secret.wav"), readFileSync(join(dir, "assets", "song.wav"))); + writeFileSync( + join(dir, "index.html"), + `
`, + ); + const { exitCode, out } = await runBeats(dir); + expect(exitCode).toBe(1); + expect(out).toMatch(/outside the project/); + expect(existsSync(join(base, "nested", "secret.wav.json"))).toBe(false); + expect(existsSync(join(dir, "beats"))).toBe(false); + }); + + it("refuses music read through a symlinked folder that points outside the project", async () => { + const dir = project(join("link-read", "proj"), 120); + const outside = join(root, "link-read", "outside"); + renameSync(join(dir, "assets"), outside); + symlinkSync(outside, join(dir, "assets"), dirLink); + const { exitCode, out } = await runBeats(dir); + expect(exitCode).toBe(1); + expect(out).toMatch(/outside the project/); + expect(existsSync(join(dir, "beats"))).toBe(false); + }); + + it("refuses to write a beat file through a symlinked folder that points outside the project", async () => { + const dir = project(join("link-write", "proj"), 120); + const outside = join(root, "link-write", "outside"); + mkdirSync(outside, { recursive: true }); + mkdirSync(join(dir, "beats")); + symlinkSync(outside, join(dir, "beats", "assets"), dirLink); + const { exitCode, out } = await runBeats(dir); + expect(exitCode).toBe(1); + expect(out).toMatch(/outside the project/); + expect(existsSync(join(outside, "song.wav.json"))).toBe(false); + }); + + it("accepts an in-project file whose name starts with two dots", async () => { + const dir = project("dotted", 120); + renameSync(join(dir, "assets", "song.wav"), join(dir, "..music.wav")); + writeFileSync( + join(dir, "index.html"), + ``, + ); + const { exitCode } = await runBeats(dir); + expect(exitCode).toBe(0); + expect(existsSync(join(dir, "beats", "..music.wav.json"))).toBe(true); + }); }); describe.skipIf(!canDecode)("beats source discovery", () => { diff --git a/packages/cli/src/commands/beats.ts b/packages/cli/src/commands/beats.ts index 6bf7598a0c..77c0e1933b 100644 --- a/packages/cli/src/commands/beats.ts +++ b/packages/cli/src/commands/beats.ts @@ -1,7 +1,7 @@ import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { resolve, join, dirname } from "node:path"; +import { join, dirname } from "node:path"; import { analyzeMusicFromSignal, audioRelPathForSrc, @@ -11,6 +11,7 @@ import { } from "@hyperframes/core/beats"; import { parseHTMLContent } from "@hyperframes/core/compiler"; import { walkCompositionDescendants } from "@hyperframes/parsers"; +import { isSafePath, resolveWithinProject } from "@hyperframes/core"; import type { Example } from "./_examples.js"; import { resolveProject, type ProjectDir } from "../utils/project.js"; import type { AudioSignal } from "@hyperframes/core/audio-analysis"; @@ -60,7 +61,8 @@ function resolveMusicTarget(project: ProjectDir): { rel: string; audioPath: stri } const rel = audioRelPathForSrc(src); // same derivation the Studio uses if (!rel) fail(`Cannot derive a beat-file path for music src: ${src}`); - const audioPath = resolve(project.dir, rel); + const audioPath = resolveWithinProject(project.dir, rel); + if (!audioPath) fail(`Music src resolves outside the project: ${src}`); if (!existsSync(audioPath)) fail(`Audio file not found: ${rel}`); return { rel, audioPath }; } @@ -110,6 +112,8 @@ export default defineCommand({ } const outPath = join(project.dir, "beats", `${rel}.json`); + if (!isSafePath(project.dir, outPath)) + fail(`Beat file path resolves outside the project: ${rel}`); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, serializeBeats(result.beatTimes, result.beatStrengths, rel)); report(`beats/${rel}.json`, result, Boolean(args.json));