Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/cli/src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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. `<dir>-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");
Expand Down
54 changes: 53 additions & 1 deletion packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +17,17 @@ function makeTempProject(files: Record<string, string>): 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({
Expand Down Expand Up @@ -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": `<!doctype html>
<html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="scene-host"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0" data-duration="5"></div>
</div>
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<script src="assets/local.js"></script>
<script src="ext/secret.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`,
"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
// <script ... src=""></script> when no runtime URL was configured. An
Expand Down
16 changes: 10 additions & 6 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export {
type MediaVisualStyleProperty,
} from "./inline-scripts/parityContract";
export { redactTelemetryString } from "./telemetryRedaction";
export { isSafePath } from "./safePath";
export type {
HyperframePickerApi,
HyperframePickerBoundingBox,
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/safePath.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions packages/core/src/safePath.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 5 additions & 6 deletions packages/core/src/studio-api/helpers/safePath.ts
Original file line number Diff line number Diff line change
@@ -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"]);

Expand Down
Loading
Loading