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:
James Russo
2026-06-12 21:10:36 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b9f8a30ee6
commit 1c47ba9981
8 changed files with 100 additions and 49 deletions
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs";
// `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";
export { isSafePath, resolveWithinProject } from "../../safePath.js";
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
+11 -11
View File
@@ -16,7 +16,7 @@ import type { StudioApiAdapter } from "../types.js";
import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js";
import { isSafePath, resolveWithinProject } from "../helpers/safePath.js";
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
import {
findUnsafeDomPatchValues,
@@ -66,8 +66,8 @@ async function resolveProjectPath(
return { error: c.json({ error: "forbidden" }, 403) } as const;
}
const absPath = resolve(project.dir, filePath);
if (!isSafePath(project.dir, absPath)) {
const absPath = resolveWithinProject(project.dir, filePath);
if (!absPath) {
return { error: c.json({ error: "forbidden" }, 403) } as const;
}
@@ -1037,8 +1037,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ error: "newPath required" }, 400);
}
const newAbs = resolve(res.project.dir, body.newPath);
if (!isSafePath(res.project.dir, newAbs)) {
const newAbs = resolveWithinProject(res.project.dir, body.newPath);
if (!newAbs) {
return c.json({ error: "forbidden" }, 403);
}
if (existsSync(newAbs)) {
@@ -1065,14 +1065,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ error: "path required" }, 400);
}
const srcAbs = resolve(project.dir, body.path);
if (!isSafePath(project.dir, srcAbs) || !existsSync(srcAbs)) {
const srcAbs = resolveWithinProject(project.dir, body.path);
if (!srcAbs || !existsSync(srcAbs)) {
return c.json({ error: "not found" }, 404);
}
const copyPath = generateCopyPath(project.dir, body.path);
const destAbs = resolve(project.dir, copyPath);
if (!isSafePath(project.dir, destAbs)) {
const destAbs = resolveWithinProject(project.dir, copyPath);
if (!destAbs) {
return c.json({ error: "forbidden" }, 403);
}
@@ -1098,8 +1098,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Optional subdirectory within the project (e.g. "assets/audio")
const subDir = c.req.query("dir") ?? "";
const targetDir = subDir ? resolve(project.dir, subDir) : project.dir;
if (!isSafePath(project.dir, targetDir)) return c.json({ error: "forbidden" }, 403);
const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
if (!targetDir) return c.json({ error: "forbidden" }, 403);
if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
const formData = await c.req.formData();
+9 -10
View File
@@ -1,9 +1,9 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { injectScriptsIntoHtml } from "../../compiler/htmlDocument.js";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
import { createProjectSignature } from "../helpers/projectSignature.js";
@@ -287,12 +287,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compFile = resolve(project.dir, compPath);
if (
!isSafePath(project.dir, compFile) ||
!existsSync(compFile) ||
!statSync(compFile).isFile()
) {
const compFile = resolveWithinProject(project.dir, compPath);
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
return c.text("not found", 404);
}
@@ -321,9 +317,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolve(project.dir, subPath);
const file = resolveWithinProject(project.dir, subPath);
if (!file) {
return c.text("not found", 404);
}
const stat = existsSync(file) ? statSync(file) : null;
if (!isSafePath(project.dir, file) || !stat?.isFile()) {
if (!stat?.isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
@@ -1,10 +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 } from "node:path";
import { join } 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";
import { resolveWithinProject } from "../helpers/safePath.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
@@ -80,11 +80,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
const resolved = resolve(project.dir, body.composition);
// `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)) {
// `body.composition` is attacker-controlled (from c.req.json()).
// resolveWithinProject dereferences symlinks, so an in-project symlink
// pointing outside the root can't smuggle the render target out.
if (!resolveWithinProject(project.dir, body.composition)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;