mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
refactor(core): route project paths through a single resolveWithinProject chokepoint (#1398)
Structural follow-up to the symlink-escape fix. The recurring miss (#465 fixed isSafePath but left render.ts; the sweep then turned up play.ts, htmlBundler, ...) is because containment was enforced by convention — "remember to call isSafePath after every resolve()" — which a new call site can silently skip. Add resolveWithinProject(base, relativePath) -> string | null (resolve + containment in one call) and route the studio-api + bundler sites through it, so a caller cannot resolve a project-relative path without the guard: - studio-api routes/files.ts (read, rename, duplicate, upload-dir), preview.ts (sub-comp + static asset), render.ts (composition) — all the resolve()+isSafePath() pairs collapse to a single call. - compiler/htmlBundler.ts: its local safePath helper was exactly this; drop it for the shared one. Left intentionally on isSafePath: files.ts upload (resolves a name against a validated sub-dir but contains against the project root) and htmlBundler's CSS @import (resolves against the CSS file's dir, contains against the root) — these resolve and contain against *different* bases, which the single-base chokepoint doesn't model. Exported from @hyperframes/core and re-exported from studio-api/helpers for back-compat. Adds resolveWithinProject unit tests; all existing studio-api route tests pass unchanged (behavior is identical — same resolve, same containment, same reject paths). 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
b9f8a30ee6
commit
1c47ba9981
@@ -17,17 +17,7 @@ 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.
|
||||
* 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);
|
||||
return isSafePath(projectDir, resolved) ? resolved : null;
|
||||
}
|
||||
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
||||
|
||||
const DEFAULT_RUNTIME_SCRIPT_URL = "";
|
||||
|
||||
@@ -233,7 +223,7 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
|
||||
if (!urlValue || !isRelativeUrl(urlValue)) return null;
|
||||
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
|
||||
if (!basePath) return null;
|
||||
const filePath = safePath(projectDir, basePath);
|
||||
const filePath = resolveWithinProject(projectDir, basePath);
|
||||
if (!filePath || !shouldInlineAsDataUrl(filePath)) return null;
|
||||
const content = safeReadFileBuffer(filePath);
|
||||
if (content == null) return null;
|
||||
@@ -643,7 +633,7 @@ export async function bundleToSingleHtml(
|
||||
for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) {
|
||||
const href = el.getAttribute("href");
|
||||
if (!href || !isRelativeUrl(href)) continue;
|
||||
const cssPath = safePath(projectDir, href);
|
||||
const cssPath = resolveWithinProject(projectDir, href);
|
||||
if (!cssPath) continue;
|
||||
const css = safeReadFile(cssPath);
|
||||
if (css == null) continue;
|
||||
@@ -675,7 +665,7 @@ export async function bundleToSingleHtml(
|
||||
for (const el of [...document.querySelectorAll("script[src]")]) {
|
||||
const src = el.getAttribute("src");
|
||||
if (!src || !isRelativeUrl(src)) continue;
|
||||
const jsPath = safePath(projectDir, src);
|
||||
const jsPath = resolveWithinProject(projectDir, src);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js == null) continue;
|
||||
localJsChunks.push(js);
|
||||
@@ -710,7 +700,7 @@ export async function bundleToSingleHtml(
|
||||
const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
|
||||
resolveHtml: (srcPath: string) => {
|
||||
if (!isRelativeUrl(srcPath)) return null;
|
||||
const compPath = safePath(projectDir, srcPath);
|
||||
const compPath = resolveWithinProject(projectDir, srcPath);
|
||||
return compPath ? safeReadFile(compPath) : null;
|
||||
},
|
||||
parseHtml: parseHTMLContent,
|
||||
@@ -741,7 +731,7 @@ export async function bundleToSingleHtml(
|
||||
if (seenCompScriptSrcs.has(extSrc)) continue;
|
||||
seenCompScriptSrcs.add(extSrc);
|
||||
if (isRelativeUrl(extSrc)) {
|
||||
const jsPath = safePath(projectDir, extSrc);
|
||||
const jsPath = resolveWithinProject(projectDir, extSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
@@ -806,7 +796,7 @@ export async function bundleToSingleHtml(
|
||||
if (!seenCompScriptSrcs.has(externalSrc)) {
|
||||
seenCompScriptSrcs.add(externalSrc);
|
||||
if (isRelativeUrl(externalSrc)) {
|
||||
const jsPath = safePath(projectDir, externalSrc);
|
||||
const jsPath = resolveWithinProject(projectDir, externalSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
@@ -861,7 +851,7 @@ export async function bundleToSingleHtml(
|
||||
if (!seenCompScriptSrcs.has(externalSrc)) {
|
||||
seenCompScriptSrcs.add(externalSrc);
|
||||
if (isRelativeUrl(externalSrc)) {
|
||||
const jsPath = safePath(projectDir, externalSrc);
|
||||
const jsPath = resolveWithinProject(projectDir, externalSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
|
||||
Reference in New Issue
Block a user