mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c324a40a87
commit
17dd4ee347
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user