mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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>
73 lines
3.0 KiB
TypeScript
73 lines
3.0 KiB
TypeScript
import { resolve, sep, join, dirname, basename } from "node:path";
|
|
import { realpathSync } from "node:fs";
|
|
|
|
/**
|
|
* Reject paths that escape the `base` directory — including via symlinks.
|
|
*
|
|
* `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
|
|
* plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
|
|
* symlink that lives *inside* `base` but points outside it (e.g.
|
|
* `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
|
|
* then follows that link to a file outside `base`. To close this we canonicalize
|
|
* both sides with `realpathSync` before comparing.
|
|
*
|
|
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
|
|
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
|
|
* segments. Segments that don't exist cannot be symlinks at check time, so they
|
|
* can't redirect the path outside `base` right now. (A symlink swapped in between
|
|
* this check and the subsequent fs call is an inherent TOCTOU race this helper
|
|
* does not, and cannot by itself, defend against.)
|
|
*
|
|
* Lives at the package root rather than under `studio-api/` because callers span
|
|
* layers — `studio-api` routes, the `compiler`, the CLI, and the engine — and
|
|
* `compiler` sits below `studio-api` in the dependency graph, so it cannot import
|
|
* from there without a backwards edge.
|
|
*/
|
|
export function isSafePath(base: string, resolved: string): boolean {
|
|
let baseReal: string;
|
|
try {
|
|
baseReal = realpathSync(resolve(base));
|
|
} catch {
|
|
// Base must exist and be resolvable; fail closed if not.
|
|
return false;
|
|
}
|
|
|
|
const target = resolve(resolved);
|
|
const trailing: string[] = [];
|
|
let probe = target;
|
|
|
|
for (;;) {
|
|
let ancestorReal: string;
|
|
try {
|
|
ancestorReal = realpathSync(probe);
|
|
} catch {
|
|
const parent = dirname(probe);
|
|
if (parent === probe) return false; // walked past the filesystem root
|
|
trailing.push(basename(probe));
|
|
probe = parent;
|
|
continue;
|
|
}
|
|
|
|
// Copy before reverse(): the array is only consumed once today, but a future
|
|
// edit that loops would otherwise silently misorder the rebuilt segments.
|
|
const targetReal = trailing.length
|
|
? join(ancestorReal, ...[...trailing].reverse())
|
|
: ancestorReal;
|
|
return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve `relativePath` against `base` and return the absolute path only if it
|
|
* stays within `base` (after symlink resolution); otherwise return `null`.
|
|
*
|
|
* Prefer this over a bare `resolve()` followed by a separate `isSafePath()`
|
|
* check: collapsing the two into one call means a caller cannot resolve a
|
|
* project-relative path and then forget the containment guard — the gap that
|
|
* let the symlink-escape slip past several call sites historically.
|
|
*/
|
|
export function resolveWithinProject(base: string, relativePath: string): string | null {
|
|
const resolved = resolve(base, relativePath);
|
|
return isSafePath(base, resolved) ? resolved : null;
|
|
}
|