mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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,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", ".hyperframes", "node_modules", ".git"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { VALID_CANVAS_RESOLUTIONS } from "../../core.types";
|
||||
@@ -13,7 +13,9 @@ function createAdapter(
|
||||
): { adapter: StudioApiAdapter; rendersDir: string } {
|
||||
const adapter: StudioApiAdapter = {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: "/tmp/proj" }),
|
||||
// Use a real, existing dir: isSafePath() canonicalizes the project dir with
|
||||
// realpath and fails closed if it doesn't exist (real projects always do).
|
||||
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
@@ -261,3 +263,94 @@ describe("POST /projects/:id/render — fps wire format", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /projects/:id/render — composition path safety", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
function buildAppWithProjectDir(spy: ReturnType<typeof vi.fn>): {
|
||||
app: Hono;
|
||||
projectDir: string;
|
||||
} {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
|
||||
const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
|
||||
tmpDirs.push(projectDir, rendersDir);
|
||||
const adapter: StudioApiAdapter = {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => rendersDir,
|
||||
startRender: (opts) => {
|
||||
spy(opts);
|
||||
return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
|
||||
},
|
||||
};
|
||||
const app = new Hono();
|
||||
registerRenderRoutes(app, adapter);
|
||||
return { app, projectDir };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
async function postComposition(app: Hono, composition: string): Promise<Response> {
|
||||
return app.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
it("accepts a composition path inside the project directory", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app } = buildAppWithProjectDir(spy);
|
||||
const res = await postComposition(app, "scenes/intro.html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a `..` traversal in the composition path", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app } = buildAppWithProjectDir(spy);
|
||||
const res = await postComposition(app, "../../etc/passwd");
|
||||
expect(res.status).toBe(400);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app, projectDir } = buildAppWithProjectDir(spy);
|
||||
const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
|
||||
tmpDirs.push(external);
|
||||
writeFileSync(join(external, "secret.html"), "<html></html>");
|
||||
if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
|
||||
const res = await postComposition(app, "link/secret.html");
|
||||
expect(res.status).toBe(400);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app, projectDir } = buildAppWithProjectDir(spy);
|
||||
mkdirSync(join(projectDir, "real"));
|
||||
writeFileSync(join(projectDir, "real", "scene.html"), "<html></html>");
|
||||
if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
|
||||
const res = await postComposition(app, "alias/scene.html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +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, sep } from "node:path";
|
||||
import { join, resolve } 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";
|
||||
|
||||
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
|
||||
|
||||
@@ -80,7 +81,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
let composition: string | undefined;
|
||||
if (typeof body.composition === "string" && body.composition.length > 0) {
|
||||
const resolved = resolve(project.dir, body.composition);
|
||||
if (!resolved.startsWith(resolve(project.dir) + sep)) {
|
||||
// `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)) {
|
||||
return c.json({ error: "composition path must be within the project directory" }, 400);
|
||||
}
|
||||
composition = body.composition;
|
||||
|
||||
Reference in New Issue
Block a user