mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(core): block symlink-based path escape in studio-api isSafePath (#1397)
* fix(core): block symlink-based path escape in studio-api isSafePath path.resolve() collapses ./.. but does not dereference symlinks, so a symlink living inside the project dir but pointing outside it (e.g. project/link -> /etc) passed the prefix check, letting a downstream read/write/stat follow it to a file outside the project root. The `..` traversal case was already blocked; symlink traversal was the gap. Canonicalize both base and target with realpathSync before comparing. The target may not exist yet (new-file writes), so canonicalize the deepest existing ancestor and re-attach the trailing not-yet-existing segments, which cannot be symlinks at check time. Fail closed if base is unresolvable. Adds safePath.test.ts covering: in-base allow, not-yet-existing write target, `..` escape, existing-file-through-symlink escape, write-target under a symlinked parent, file-symlink escape, in-base symlink allow, symlinked-base canonicalization, and base-missing fail-closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core,cli): route render + play composition paths through isSafePath Review on #1397 found a third call site with the same vulnerable startsWith pattern. Apply Rule 2: fix every site sharing the contract (gate an attacker-influenced path before a symlink-following fs op). - studio-api routes/render.ts: body.composition (from c.req.json()) was checked with `resolved.startsWith(resolve(project.dir) + sep)`, which doesn't dereference symlinks — an in-project symlink to an external target escaped the project root. Now uses isSafePath(). - cli commands/play.ts: the `/composition/*` server route used `filePath.startsWith(project.dir)` with no trailing-separator guard, so both a sibling dir sharing the prefix (`<dir>-evil`) and symlink escapes passed. Now uses isSafePath() via @hyperframes/core/studio-api (the same lazy-import pattern commands/validate.ts already uses). Tests: render.test.ts gains a "composition path safety" block (in-base allow, `..` reject, in-project-symlink-to-outside reject, in-project symlink staying inside allow). The shared render test adapter now points at a real dir since isSafePath fails closed on an unresolvable base (production project dirs always exist on disk). Not in this change: compiler/htmlBundler.ts has the same class at two sites (safePath helper + inline CSS @import check), but the compiler sits below studio-api in the dependency graph and can't import isSafePath without a backwards edge; that fix needs the helper promoted to a neutral module and is tracked as a follow-up. renderArgs.ts / videoFrameExtractor.ts carry the trailing-sep guard and a local-CLI/engine-internal threat model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): promote isSafePath to a shared module + harden htmlBundler Per review on #1397: extend the symlink-escape fix to the compiler, and remove the duplicated path-safety logic. - Move isSafePath to packages/core/src/safePath.ts (a neutral package-root module). studio-api/helpers/safePath.ts re-exports it for back-compat (keeping walkDir), and it's now exported from the core entrypoint so non-studio-api layers can use it. compiler/ sits below studio-api in the dep graph, so it could not import the helper from its old home without a backwards edge — the promotion removes that constraint. - compiler/htmlBundler.ts: route both containment checks (the safePath helper and the inline CSS @import check) through isSafePath. The bundler reads+inlines these files, so an in-project symlink pointing outside the root would otherwise bake external content into the output. All callers already skip on a null/false result, so nothing is read on rejection. Tests: safePath.test.ts moves with the impl; htmlBundler.test.ts gains a case proving an in-project sub-composition script is inlined while a script reached through an escaping symlink is not (positive control + leak assertion). Deferred (tracked for a dedicated follow-up, see PR thread): the relative()-based isPathInside family (core/compiler/assetPaths, producer/services/fileServer, producer/utils/paths and their callers in the render pipeline) is symlink-blind in the same way, and engine videoFrameExtractor's asset resolver needs a caller-side gate (its http downloads land outside the project root, so a single-root check is wrong). Both are regression-sensitive render-pipeline surfaces that warrant their own focused, well-tested pass. renderArgs.ts is intentionally left: it is filesystem-free by design (injected stat) and its threat model is the user's own --composition CLI arg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(core): hedge symlink tests for Windows + copy before reverse (review nits) Addresses Via's non-blocking review notes on #1397: - Wrap every symlinkSync in the new tests with a tryCreateSymlink helper that returns false (and the test early-returns) when creation throws, mirroring the preview.test.ts convention. Non-symlink-privileged Windows runners no longer risk crashing the suite on EPERM. - safePath.ts: `[...trailing].reverse()` instead of mutating `trailing` in place — harmless today (single return) but future-proof against a looping edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3bcab3dc29
commit
953bab319b
@@ -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, 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({
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user