diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts
index d237c555f..e9f254976 100644
--- a/packages/cli/src/commands/play.ts
+++ b/packages/cli/src/commands/play.ts
@@ -100,6 +100,7 @@ export default defineCommand({
const { Hono } = await import("hono");
const { createAdaptorServer } = await import("@hono/node-server");
+ const { isSafePath } = await import("@hyperframes/core/studio-api");
const app = new Hono();
@@ -124,8 +125,11 @@ export default defineCommand({
const reqPath = ctx.req.path.replace("/composition/", "");
const filePath = resolve(project.dir, reqPath);
- // Security: don't allow path traversal outside project dir
- if (!filePath.startsWith(project.dir)) return ctx.text("Forbidden", 403);
+ // Security: don't allow path traversal outside project dir. isSafePath
+ // canonicalizes symlinks and applies a trailing-separator guard, so neither
+ // an in-project symlink to an external target nor a sibling dir whose name
+ // shares the project-dir prefix (e.g. `
-evil`) can escape.
+ if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403);
if (!existsSync(filePath)) return ctx.text("Not found", 404);
const content = readFileSync(filePath, "utf-8");
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index bbf5bfef6..235b8a61a 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment node
-import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
+import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
@@ -17,6 +17,17 @@ function makeTempProject(files: Record): string {
return dir;
}
+// Mirror the repo convention (preview.test.ts): skip symlink cases on
+// non-symlink-privileged Windows runners rather than crash the suite.
+function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
+ try {
+ symlinkSync(target, path, type);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
describe("bundleToSingleHtml", () => {
it("does not merge author scripts into the runtime bootstrap placeholder", async () => {
const dir = makeTempProject({
@@ -50,6 +61,47 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('document.getElementById("scene")');
});
+ it("inlines an in-project sub-composition script but not one reached through a symlink escaping the project root", async () => {
+ // Security: a shared/cloned project may carry a symlink pointing outside the
+ // root (e.g. ext -> /etc). The bundler reads+inlines local assets, so it must
+ // refuse to follow such a symlink and leak external file contents.
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+`,
+ "compositions/scene.html": `
+
+
+
+
+
+`,
+ "assets/local.js": `window.__HF_LOCAL__ = "LOCAL_MARKER_INLINED";`,
+ });
+ const external = mkdtempSync(join(tmpdir(), "hf-bundler-external-"));
+ writeFileSync(join(external, "secret.js"), `window.__HF_SECRET__ = "SECRET_MARKER_LEAKED";`);
+ if (!tryCreateSymlink(external, join(dir, "ext"), "dir")) return;
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // Positive control: the in-project sub-comp script IS inlined, so the bundler
+ // would have inlined the symlinked one too had isSafePath not rejected it.
+ expect(bundled).toContain("LOCAL_MARKER_INLINED");
+ expect(bundled).not.toContain("SECRET_MARKER_LEAKED");
+ });
+
it("produces a self-contained runtime script when no HYPERFRAME_RUNTIME_URL is set", async () => {
// Regression guard: hf#XXX. The bundler used to emit
// when no runtime URL was configured. An
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 121b89336..c0f47d76c 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -17,13 +17,16 @@ import { validateHyperframeHtmlContract } from "./staticGuard";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { readDeclaredDefaults } from "../runtime/getVariables";
import { inlineSubCompositions } from "./inlineSubCompositions";
+import { isSafePath } from "../safePath.js";
-/** Resolve a relative path within projectDir, rejecting traversal outside it. */
+/**
+ * Resolve a relative path within projectDir, rejecting traversal outside it.
+ * Uses isSafePath so an in-project symlink pointing outside the root can't
+ * smuggle an external file into the bundle (this fn's result is read+inlined).
+ */
function safePath(projectDir: string, relativePath: string): string | null {
const resolved = resolve(projectDir, relativePath);
- const normalizedBase = resolve(projectDir) + sep;
- if (!resolved.startsWith(normalizedBase) && resolved !== resolve(projectDir)) return null;
- return resolved;
+ return isSafePath(projectDir, resolved) ? resolved : null;
}
const DEFAULT_RUNTIME_SCRIPT_URL = "";
@@ -155,8 +158,9 @@ function inlineCssFile(
const importPath = urlPath ?? barePath;
if (!importPath || !isRelativeUrl(importPath)) return full;
const resolved = resolve(cssFileDir, importPath);
- const normalizedBase = resolve(projectDir) + sep;
- if (!resolved.startsWith(normalizedBase)) return full;
+ // @import is resolved relative to the CSS file, but must stay within the
+ // project root; isSafePath also blocks symlink escapes (content is inlined).
+ if (!isSafePath(projectDir, resolved)) return full;
if (visited.has(resolved)) return "";
const content = safeReadFile(resolved);
if (content == null) return full;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 747e32abf..69a70818a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -163,6 +163,7 @@ export {
type MediaVisualStyleProperty,
} from "./inline-scripts/parityContract";
export { redactTelemetryString } from "./telemetryRedaction";
+export { isSafePath } from "./safePath";
export type {
HyperframePickerApi,
HyperframePickerBoundingBox,
diff --git a/packages/core/src/safePath.test.ts b/packages/core/src/safePath.test.ts
new file mode 100644
index 000000000..47455ed79
--- /dev/null
+++ b/packages/core/src/safePath.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { isSafePath } from "./safePath.js";
+
+describe("isSafePath", () => {
+ const tmpDirs: string[] = [];
+
+ afterEach(() => {
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
+ tmpDirs.length = 0;
+ });
+
+ function tmpDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ tmpDirs.push(dir);
+ return dir;
+ }
+
+ // Mirror the repo convention (preview.test.ts): non-symlink-privileged Windows
+ // runners can't create symlinks — skip those cases rather than crash the suite.
+ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
+ try {
+ symlinkSync(target, path, type);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ it("allows the base directory itself", () => {
+ const base = tmpDir("safepath-base-");
+ expect(isSafePath(base, base)).toBe(true);
+ });
+
+ it("allows an existing nested path inside base", () => {
+ const base = tmpDir("safepath-base-");
+ const file = join(base, "assets", "logo.png");
+ mkdirSync(join(base, "assets"));
+ writeFileSync(file, "x");
+ expect(isSafePath(base, file)).toBe(true);
+ });
+
+ it("allows a not-yet-existing write target inside base", () => {
+ const base = tmpDir("safepath-base-");
+ // Neither the dir nor the file exist yet — the create/write case.
+ expect(isSafePath(base, join(base, "new", "deep", "file.txt"))).toBe(true);
+ });
+
+ it("rejects a `..` traversal that escapes base", () => {
+ const base = tmpDir("safepath-base-");
+ expect(isSafePath(base, join(base, "..", "..", "etc", "passwd"))).toBe(false);
+ });
+
+ it("rejects an existing file reached through a symlink that points outside base", () => {
+ const base = tmpDir("safepath-base-");
+ const external = tmpDir("safepath-external-");
+ const secret = join(external, "secret.txt");
+ writeFileSync(secret, "top secret");
+ // project/link -> external/ (the classic in-project symlink escape)
+ if (!tryCreateSymlink(external, join(base, "link"), "dir")) return;
+ expect(isSafePath(base, join(base, "link", "secret.txt"))).toBe(false);
+ });
+
+ it("rejects a not-yet-existing write target whose parent is a symlink to outside base", () => {
+ const base = tmpDir("safepath-base-");
+ const external = tmpDir("safepath-external-");
+ // base/link -> external; writing base/link/evil.txt would land in external.
+ if (!tryCreateSymlink(external, join(base, "link"), "dir")) return;
+ expect(isSafePath(base, join(base, "link", "evil.txt"))).toBe(false);
+ });
+
+ it("rejects a file symlink inside base that targets a file outside base", () => {
+ const base = tmpDir("safepath-base-");
+ const external = tmpDir("safepath-external-");
+ const secret = join(external, "secret.txt");
+ writeFileSync(secret, "top secret");
+ if (!tryCreateSymlink(secret, join(base, "passwd"), "file")) return;
+ expect(isSafePath(base, join(base, "passwd"))).toBe(false);
+ });
+
+ it("allows a symlink inside base that points to another location inside base", () => {
+ const base = tmpDir("safepath-base-");
+ const realDir = join(base, "real");
+ mkdirSync(realDir);
+ writeFileSync(join(realDir, "in.txt"), "x");
+ if (!tryCreateSymlink(realDir, join(base, "alias"), "dir")) return;
+ expect(isSafePath(base, join(base, "alias", "in.txt"))).toBe(true);
+ });
+
+ it("canonicalizes base too: a symlinked base path still admits in-base targets", () => {
+ // Guards against one-sided realpath: when base is reached via a symlink
+ // (as on macOS where tmpdir lives under /var -> /private/var), an in-base
+ // target must still be accepted.
+ const realBase = tmpDir("safepath-realbase-");
+ const linkParent = tmpDir("safepath-linkparent-");
+ const baseLink = join(linkParent, "baseLink");
+ if (!tryCreateSymlink(realBase, baseLink, "dir")) return;
+ writeFileSync(join(realBase, "file.txt"), "x");
+ expect(isSafePath(baseLink, join(baseLink, "file.txt"))).toBe(true);
+ });
+
+ it("fails closed when the base directory does not exist", () => {
+ const base = join(tmpdir(), "safepath-does-not-exist-zzz", "nope");
+ expect(isSafePath(base, join(base, "file.txt"))).toBe(false);
+ });
+});
diff --git a/packages/core/src/safePath.ts b/packages/core/src/safePath.ts
new file mode 100644
index 000000000..bf6517224
--- /dev/null
+++ b/packages/core/src/safePath.ts
@@ -0,0 +1,58 @@
+import { resolve, sep, join, dirname, basename } from "node:path";
+import { realpathSync } from "node:fs";
+
+/**
+ * Reject paths that escape the `base` directory — including via symlinks.
+ *
+ * `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
+ * plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
+ * symlink that lives *inside* `base` but points outside it (e.g.
+ * `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
+ * then follows that link to a file outside `base`. To close this we canonicalize
+ * both sides with `realpathSync` before comparing.
+ *
+ * The target may not exist yet (e.g. creating a new file), so we canonicalize the
+ * deepest *existing* ancestor and re-attach the trailing not-yet-existing
+ * segments. Segments that don't exist cannot be symlinks at check time, so they
+ * can't redirect the path outside `base` right now. (A symlink swapped in between
+ * this check and the subsequent fs call is an inherent TOCTOU race this helper
+ * does not, and cannot by itself, defend against.)
+ *
+ * Lives at the package root rather than under `studio-api/` because callers span
+ * layers — `studio-api` routes, the `compiler`, the CLI, and the engine — and
+ * `compiler` sits below `studio-api` in the dependency graph, so it cannot import
+ * from there without a backwards edge.
+ */
+export function isSafePath(base: string, resolved: string): boolean {
+ let baseReal: string;
+ try {
+ baseReal = realpathSync(resolve(base));
+ } catch {
+ // Base must exist and be resolvable; fail closed if not.
+ return false;
+ }
+
+ const target = resolve(resolved);
+ const trailing: string[] = [];
+ let probe = target;
+
+ for (;;) {
+ let ancestorReal: string;
+ try {
+ ancestorReal = realpathSync(probe);
+ } catch {
+ const parent = dirname(probe);
+ if (parent === probe) return false; // walked past the filesystem root
+ trailing.push(basename(probe));
+ probe = parent;
+ continue;
+ }
+
+ // Copy before reverse(): the array is only consumed once today, but a future
+ // edit that loops would otherwise silently misorder the rebuilt segments.
+ const targetReal = trailing.length
+ ? join(ancestorReal, ...[...trailing].reverse())
+ : ancestorReal;
+ return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
+ }
+}
diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts
index 25edab21f..2f9a18e56 100644
--- a/packages/core/src/studio-api/helpers/safePath.ts
+++ b/packages/core/src/studio-api/helpers/safePath.ts
@@ -1,11 +1,10 @@
-import { resolve, sep, join } from "node:path";
+import { join } from "node:path";
import { readdirSync } from "node:fs";
-/** Reject paths that escape the project directory. */
-export function isSafePath(base: string, resolved: string): boolean {
- const norm = resolve(base) + sep;
- return resolved.startsWith(norm) || resolved === resolve(base);
-}
+// `isSafePath` lives at the package root so non-studio-api layers (compiler,
+// CLI, engine) can share it without a backwards dependency on studio-api.
+// Re-exported here for back-compat with existing `../helpers/safePath.js` imports.
+export { isSafePath } from "../../safePath.js";
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/core/src/studio-api/routes/render.test.ts
index b7a168747..0d92e49c9 100644
--- a/packages/core/src/studio-api/routes/render.test.ts
+++ b/packages/core/src/studio-api/routes/render.test.ts
@@ -1,6 +1,6 @@
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
-import { mkdtempSync, rmSync } from "node:fs";
+import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "../../core.types";
@@ -13,7 +13,9 @@ function createAdapter(
): { adapter: StudioApiAdapter; rendersDir: string } {
const adapter: StudioApiAdapter = {
listProjects: () => [],
- resolveProject: async (id: string) => ({ id, dir: "/tmp/proj" }),
+ // Use a real, existing dir: isSafePath() canonicalizes the project dir with
+ // realpath and fails closed if it doesn't exist (real projects always do).
+ resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
@@ -261,3 +263,94 @@ describe("POST /projects/:id/render — fps wire format", () => {
}
});
});
+
+describe("POST /projects/:id/render — composition path safety", () => {
+ const tmpDirs: string[] = [];
+
+ function buildAppWithProjectDir(spy: ReturnType): {
+ app: Hono;
+ projectDir: string;
+ } {
+ const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
+ const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
+ tmpDirs.push(projectDir, rendersDir);
+ const adapter: StudioApiAdapter = {
+ listProjects: () => [],
+ resolveProject: async (id: string) => ({ id, dir: projectDir }),
+ bundle: async () => null,
+ lint: async () => ({ findings: [] }),
+ runtimeUrl: "/api/runtime.js",
+ rendersDir: () => rendersDir,
+ startRender: (opts) => {
+ spy(opts);
+ return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
+ },
+ };
+ const app = new Hono();
+ registerRenderRoutes(app, adapter);
+ return { app, projectDir };
+ }
+
+ afterEach(() => {
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
+ tmpDirs.length = 0;
+ });
+
+ async function postComposition(app: Hono, composition: string): Promise {
+ return app.request("http://localhost/projects/demo/render", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
+ });
+ }
+
+ // Mirror the repo convention (preview.test.ts): skip symlink cases on
+ // non-symlink-privileged Windows runners rather than crash the suite.
+ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
+ try {
+ symlinkSync(target, path, type);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ it("accepts a composition path inside the project directory", async () => {
+ const spy = vi.fn();
+ const { app } = buildAppWithProjectDir(spy);
+ const res = await postComposition(app, "scenes/intro.html");
+ expect(res.status).toBe(200);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it("rejects a `..` traversal in the composition path", async () => {
+ const spy = vi.fn();
+ const { app } = buildAppWithProjectDir(spy);
+ const res = await postComposition(app, "../../etc/passwd");
+ expect(res.status).toBe(400);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
+ const spy = vi.fn();
+ const { app, projectDir } = buildAppWithProjectDir(spy);
+ const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
+ tmpDirs.push(external);
+ writeFileSync(join(external, "secret.html"), "");
+ if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
+ const res = await postComposition(app, "link/secret.html");
+ expect(res.status).toBe(400);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
+ const spy = vi.fn();
+ const { app, projectDir } = buildAppWithProjectDir(spy);
+ mkdirSync(join(projectDir, "real"));
+ writeFileSync(join(projectDir, "real", "scene.html"), "");
+ if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
+ const res = await postComposition(app, "alias/scene.html");
+ expect(res.status).toBe(200);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts
index 18caaafa5..70c34a3ab 100644
--- a/packages/core/src/studio-api/routes/render.ts
+++ b/packages/core/src/studio-api/routes/render.ts
@@ -1,9 +1,10 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
-import { join, resolve, sep } from "node:path";
+import { join, resolve } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js";
+import { isSafePath } from "../helpers/safePath.js";
const VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -80,7 +81,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
const resolved = resolve(project.dir, body.composition);
- if (!resolved.startsWith(resolve(project.dir) + sep)) {
+ // `body.composition` is attacker-controlled (from c.req.json()). isSafePath
+ // dereferences symlinks so an in-project symlink pointing outside the root
+ // can't smuggle the render target out of the project dir.
+ if (!isSafePath(project.dir, resolved)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;