Skip to content

Commit d620fe5

Browse files
jrusso1020claude
andcommitted
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>
1 parent e7127cd commit d620fe5

6 files changed

Lines changed: 113 additions & 55 deletions

File tree

packages/core/src/compiler/htmlBundler.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @vitest-environment node
2-
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
2+
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { parseHTML } from "linkedom";
@@ -50,6 +50,47 @@ describe("bundleToSingleHtml", () => {
5050
expect(bundled).toContain('document.getElementById("scene")');
5151
});
5252

53+
it("inlines an in-project sub-composition script but not one reached through a symlink escaping the project root", async () => {
54+
// Security: a shared/cloned project may carry a symlink pointing outside the
55+
// root (e.g. ext -> /etc). The bundler reads+inlines local assets, so it must
56+
// refuse to follow such a symlink and leak external file contents.
57+
const dir = makeTempProject({
58+
"index.html": `<!doctype html>
59+
<html><head>
60+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
61+
</head><body>
62+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
63+
<div id="scene-host"
64+
data-composition-id="scene"
65+
data-composition-src="compositions/scene.html"
66+
data-start="0" data-duration="5"></div>
67+
</div>
68+
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
69+
</body></html>`,
70+
"compositions/scene.html": `<template id="scene-template">
71+
<div data-composition-id="scene" data-width="1920" data-height="1080">
72+
<script src="assets/local.js"></script>
73+
<script src="ext/secret.js"></script>
74+
<script>
75+
window.__timelines = window.__timelines || {};
76+
window.__timelines["scene"] = gsap.timeline({ paused: true });
77+
</script>
78+
</div>
79+
</template>`,
80+
"assets/local.js": `window.__HF_LOCAL__ = "LOCAL_MARKER_INLINED";`,
81+
});
82+
const external = mkdtempSync(join(tmpdir(), "hf-bundler-external-"));
83+
writeFileSync(join(external, "secret.js"), `window.__HF_SECRET__ = "SECRET_MARKER_LEAKED";`);
84+
symlinkSync(external, join(dir, "ext"), "dir");
85+
86+
const bundled = await bundleToSingleHtml(dir);
87+
88+
// Positive control: the in-project sub-comp script IS inlined, so the bundler
89+
// would have inlined the symlinked one too had isSafePath not rejected it.
90+
expect(bundled).toContain("LOCAL_MARKER_INLINED");
91+
expect(bundled).not.toContain("SECRET_MARKER_LEAKED");
92+
});
93+
5394
it("produces a self-contained runtime script when no HYPERFRAME_RUNTIME_URL is set", async () => {
5495
// Regression guard: hf#XXX. The bundler used to emit
5596
// <script ... src=""></script> when no runtime URL was configured. An

packages/core/src/compiler/htmlBundler.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ import { validateHyperframeHtmlContract } from "./staticGuard";
1717
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
1818
import { readDeclaredDefaults } from "../runtime/getVariables";
1919
import { inlineSubCompositions } from "./inlineSubCompositions";
20+
import { isSafePath } from "../safePath.js";
2021

21-
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
22+
/**
23+
* Resolve a relative path within projectDir, rejecting traversal outside it.
24+
* Uses isSafePath so an in-project symlink pointing outside the root can't
25+
* smuggle an external file into the bundle (this fn's result is read+inlined).
26+
*/
2227
function safePath(projectDir: string, relativePath: string): string | null {
2328
const resolved = resolve(projectDir, relativePath);
24-
const normalizedBase = resolve(projectDir) + sep;
25-
if (!resolved.startsWith(normalizedBase) && resolved !== resolve(projectDir)) return null;
26-
return resolved;
29+
return isSafePath(projectDir, resolved) ? resolved : null;
2730
}
2831

2932
const DEFAULT_RUNTIME_SCRIPT_URL = "";
@@ -155,8 +158,9 @@ function inlineCssFile(
155158
const importPath = urlPath ?? barePath;
156159
if (!importPath || !isRelativeUrl(importPath)) return full;
157160
const resolved = resolve(cssFileDir, importPath);
158-
const normalizedBase = resolve(projectDir) + sep;
159-
if (!resolved.startsWith(normalizedBase)) return full;
161+
// @import is resolved relative to the CSS file, but must stay within the
162+
// project root; isSafePath also blocks symlink escapes (content is inlined).
163+
if (!isSafePath(projectDir, resolved)) return full;
160164
if (visited.has(resolved)) return "";
161165
const content = safeReadFile(resolved);
162166
if (content == null) return full;

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export {
163163
type MediaVisualStyleProperty,
164164
} from "./inline-scripts/parityContract";
165165
export { redactTelemetryString } from "./telemetryRedaction";
166+
export { isSafePath } from "./safePath";
166167
export type {
167168
HyperframePickerApi,
168169
HyperframePickerBoundingBox,
File renamed without changes.

packages/core/src/safePath.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { resolve, sep, join, dirname, basename } from "node:path";
2+
import { realpathSync } from "node:fs";
3+
4+
/**
5+
* Reject paths that escape the `base` directory — including via symlinks.
6+
*
7+
* `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
8+
* plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
9+
* symlink that lives *inside* `base` but points outside it (e.g.
10+
* `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
11+
* then follows that link to a file outside `base`. To close this we canonicalize
12+
* both sides with `realpathSync` before comparing.
13+
*
14+
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
15+
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
16+
* segments. Segments that don't exist cannot be symlinks at check time, so they
17+
* can't redirect the path outside `base` right now. (A symlink swapped in between
18+
* this check and the subsequent fs call is an inherent TOCTOU race this helper
19+
* does not, and cannot by itself, defend against.)
20+
*
21+
* Lives at the package root rather than under `studio-api/` because callers span
22+
* layers — `studio-api` routes, the `compiler`, the CLI, and the engine — and
23+
* `compiler` sits below `studio-api` in the dependency graph, so it cannot import
24+
* from there without a backwards edge.
25+
*/
26+
export function isSafePath(base: string, resolved: string): boolean {
27+
let baseReal: string;
28+
try {
29+
baseReal = realpathSync(resolve(base));
30+
} catch {
31+
// Base must exist and be resolvable; fail closed if not.
32+
return false;
33+
}
34+
35+
const target = resolve(resolved);
36+
const trailing: string[] = [];
37+
let probe = target;
38+
39+
for (;;) {
40+
let ancestorReal: string;
41+
try {
42+
ancestorReal = realpathSync(probe);
43+
} catch {
44+
const parent = dirname(probe);
45+
if (parent === probe) return false; // walked past the filesystem root
46+
trailing.push(basename(probe));
47+
probe = parent;
48+
continue;
49+
}
50+
51+
const targetReal = trailing.length ? join(ancestorReal, ...trailing.reverse()) : ancestorReal;
52+
return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
53+
}
54+
}

packages/core/src/studio-api/helpers/safePath.ts

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,10 @@
1-
import { resolve, sep, join, dirname, basename } from "node:path";
2-
import { readdirSync, realpathSync } from "node:fs";
1+
import { join } from "node:path";
2+
import { readdirSync } from "node:fs";
33

4-
/**
5-
* Reject paths that escape the project directory — including via symlinks.
6-
*
7-
* `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
8-
* plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
9-
* symlink that lives *inside* the project dir but points outside it (e.g.
10-
* `project/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
11-
* then follows that link to a file outside `base`. To close this we canonicalize
12-
* both sides with `realpathSync` before comparing.
13-
*
14-
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
15-
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
16-
* segments. Segments that don't exist cannot be symlinks at check time, so they
17-
* can't redirect the path outside `base` right now. (A symlink swapped in between
18-
* this check and the subsequent fs call is an inherent TOCTOU race this helper
19-
* does not, and cannot by itself, defend against.)
20-
*/
21-
export function isSafePath(base: string, resolved: string): boolean {
22-
let baseReal: string;
23-
try {
24-
baseReal = realpathSync(resolve(base));
25-
} catch {
26-
// Base must exist and be resolvable; fail closed if not.
27-
return false;
28-
}
29-
30-
const target = resolve(resolved);
31-
const trailing: string[] = [];
32-
let probe = target;
33-
34-
for (;;) {
35-
let ancestorReal: string;
36-
try {
37-
ancestorReal = realpathSync(probe);
38-
} catch {
39-
const parent = dirname(probe);
40-
if (parent === probe) return false; // walked past the filesystem root
41-
trailing.push(basename(probe));
42-
probe = parent;
43-
continue;
44-
}
45-
46-
const targetReal = trailing.length ? join(ancestorReal, ...trailing.reverse()) : ancestorReal;
47-
return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
48-
}
49-
}
4+
// `isSafePath` lives at the package root so non-studio-api layers (compiler,
5+
// CLI, engine) can share it without a backwards dependency on studio-api.
6+
// Re-exported here for back-compat with existing `../helpers/safePath.js` imports.
7+
export { isSafePath } from "../../safePath.js";
508

519
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
5210

0 commit comments

Comments
 (0)