Files
hyperframes/packages/core
James RussoandClaude Opus 4.8 953bab319b 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>
2026-06-12 20:08:35 -07:00
..
2026-03-21 22:43:56 -07:00

@hyperframes/core

Types, parsers, generators, compiler, linter, runtime, and frame adapters for the Hyperframes video framework.

Install

npm install @hyperframes/core

Most users don't need to install core directly — the CLI, producer, and studio packages depend on it internally.

What's inside

Module Description
Types TimelineElement, CompositionSpec, Asset, canvas dimensions, defaults
Parsers parseHtml — extract timeline elements from HTML; parseGsapScript — parse GSAP animations
Generators generateHyperframesHtml — produce valid Hyperframes HTML from a composition spec
Compiler compileTimingAttrs — resolve data-start / data-duration into absolute times
Linter lintHyperframeHtml — validate Hyperframes HTML (missing attributes, overlapping tracks, etc.)
Runtime IIFE script injected into the browser — manages seek, media playback, and the window.__hf protocol
Frame Adapters Pluggable animation drivers (GSAP, Lottie, CSS, or custom)

Frame Adapters

A frame adapter tells the engine how to seek your animation to a specific frame:

import { createGSAPFrameAdapter } from "@hyperframes/core";

const adapter = createGSAPFrameAdapter({
  getTimeline: () => gsap.timeline(),
  compositionId: "my-video",
});

Implement FrameAdapter for custom animation runtimes:

import type { FrameAdapter } from "@hyperframes/core";

const myAdapter: FrameAdapter = {
  id: "my-adapter",
  getDurationFrames: () => 300,
  seekFrame: (frame) => {
    /* seek your animation */
  },
};

Parsing and generating HTML

import { parseHtml, generateHyperframesHtml } from "@hyperframes/core";

const { elements, metadata } = parseHtml(htmlString);
const html = generateHyperframesHtml(spec);

Linting

import { lintHyperframeHtml } from "@hyperframes/core/lint";

const result = lintHyperframeHtml(htmlString);
// result.findings: { severity, message, elementId }[]

Documentation

Full documentation: hyperframes.heygen.com/packages/core