fix(cli): keep beats audio paths inside the project (#6188)

* fix(cli): keep beats audio paths inside the project

An encoded ../ in the music src decoded to a path outside the project, so
hyperframes beats read that file and wrote beats/<rel>.json outside the
project too. Fail the command when either path escapes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): drop added comment in beats containment check

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): resolve symlinks in beats path containment

The lexical check let a symlinked folder inside the project send the read
or the beat-file write outside it, and refused an in-project file named
..music.wav. Use the realpath-aware resolveWithinProject and isSafePath
from core for both paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-24 08:23:32 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c324a40a87
commit 17dd4ee347
2 changed files with 70 additions and 3 deletions
+64 -1
View File
@@ -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"),
`<div data-composition-id="main"><audio id="music" src="..%2F..%2Fsecret.wav"></audio></div>`,
);
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"),
`<div data-composition-id="main"><audio id="music" src="..music.wav"></audio></div>`,
);
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", () => {
+6 -2
View File
@@ -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));