diff --git a/docs/packages/core.mdx b/docs/packages/core.mdx index 5e52b83d31..e2cd1a8237 100644 --- a/docs/packages/core.mdx +++ b/docs/packages/core.mdx @@ -75,6 +75,19 @@ const html = generateHyperframesHtml(elements, 6, { The second argument is the requested duration in seconds. Pass a stable `compositionId` when output must be reproducible. +Composition generators require trusted authors for code-bearing inputs. `styles` and +`generateHyperframesStyles` preserve authored CSS, which can load external resources. +`animations` may contain `__raw:` values that are emitted as JavaScript; +`includeScripts: true` includes executable timeline code. `serializeGsapAnimations` +also accepts raw `preamble`, `postamble`, and a code-bearing `timelineVar`. Never fill +these inputs with untrusted data. Attribute encoding and closing-tag containment +are not a sandbox; render untrusted compositions in an appropriately isolated +execution environment and never serve them on a privileged origin. + +Text content retains the supported inline-formatting sanitizer contract. The clip +parser intentionally flattens inner formatting to text, so parse/generate is not +a lossless replacement for editing the source HTML. + ## Read and validate variables Inside a composition script, `getVariables()` reads declared defaults plus the diff --git a/packages/core/README.md b/packages/core/README.md index 642eae16ea..a120a58ea3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -22,6 +22,21 @@ npm install @hyperframes/core | **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) | +## Generated composition trust + +Composition generators require trusted authors for code-bearing inputs. `styles` and +`generateHyperframesStyles` preserve authored CSS, which can load external resources. +`animations` may contain `__raw:` values that are emitted as JavaScript; +`includeScripts: true` includes executable timeline code. `serializeGsapAnimations` +also accepts raw `preamble`, `postamble`, and a code-bearing `timelineVar`. Never fill +these inputs with untrusted data. Attribute encoding and closing-tag containment +are not a sandbox; render untrusted compositions in an appropriately isolated +execution environment and never serve them on a privileged origin. + +Text content retains the supported inline-formatting sanitizer contract. The clip +parser intentionally flattens inner formatting to text, so parse/generate is not +a lossless replacement for editing the source HTML. + ## Frame Adapters A frame adapter tells the engine how to seek your animation to a specific frame: diff --git a/packages/core/src/generators/hyperframes.test.ts b/packages/core/src/generators/hyperframes.test.ts index d14dae9a5d..4ff940260e 100644 --- a/packages/core/src/generators/hyperframes.test.ts +++ b/packages/core/src/generators/hyperframes.test.ts @@ -7,8 +7,13 @@ import { generateGsapTimelineScript, generateHyperframesStyles, } from "./hyperframes.js"; +import { parseHtml } from "@hyperframes/parsers"; import { GSAP_CDN } from "../templates/constants.js"; -import type { TimelineTextElement, TimelineMediaElement } from "../core.types"; +import type { + TimelineTextElement, + TimelineMediaElement, + TimelineCompositionElement, +} from "../core.types"; function makeTextElement(overrides: Partial = {}): TimelineTextElement { return { @@ -37,6 +42,172 @@ function makeVideoElement(overrides: Partial = {}): Timeli } describe("generateHyperframesHtml", () => { + it("contains mixed-case style closing tags in authored CSS", () => { + const styles = '.label::after { content: ""; }'; + const doc = new DOMParser().parseFromString( + generateHyperframesHtml([], 1, { styles, includeStyles: true }), + "text/html", + ); + expect(doc.querySelectorAll("style")).toHaveLength(2); + expect(doc.querySelector("script")).toBeNull(); + expect(doc.querySelector("style[data-hf-custom]")?.textContent).toContain("StYlE"); + }); + + it("contains script closing tags while retaining JS string values and raw expressions", () => { + const targetSelector = '#x"'; + const position = 'label"; bad(); //'; + const doc = new DOMParser().parseFromString( + generateHyperframesHtml([], 1, { + includeScripts: true, + animations: [ + { targetSelector, method: "to", position, properties: { x: "__raw:1 < 2 ? 3 : 4" } }, + ], + }), + "text/html", + ); + expect(doc.querySelectorAll("script")).toHaveLength(2); + const script = doc.querySelector("script:not([src])")?.textContent ?? ""; + const calls: unknown[][] = []; + const gsap = { timeline: () => ({ to: (...args: unknown[]) => calls.push(args) }) }; + new Function("gsap", script)(gsap); + expect(calls).toEqual([[targetSelector, { x: 3 }, position]]); + }); + + it("round-trips element attribute values without creating event handlers", () => { + const marker = `x" onmouseover="bad()"<`; + const element = makeVideoElement({ id: marker, name: marker, src: marker }); + const doc = new DOMParser().parseFromString(generateHyperframesHtml([element], 1), "text/html"); + const video = doc.querySelector("video"); + for (const name of ["id", "data-hf-id", "data-name", "src"]) + expect(video?.getAttribute(name)).toBe(marker); + expect(video?.hasAttribute("onmouseover")).toBe(false); + expect(doc.querySelector("script")).toBeNull(); + }); + + it("keeps special IDs targeted by generated visibility animations", () => { + const element = makeTextElement({ id: '9 title"[x],#other', name: "Title" }); + const doc = new DOMParser().parseFromString(generateHyperframesHtml([element], 1), "text/html"); + const targets: string[] = []; + const gsap = { timeline: () => ({ set: (selector: string) => targets.push(selector) }) }; + new Function("gsap", generateGsapTimelineScript([element], 1))(gsap); + expect(targets.length).toBeGreaterThan(0); + for (const selector of targets) expect(doc.querySelector(selector)?.id).toBe(element.id); + }); + + it("preserves supported rich text while removing executable markup", () => { + const content = + 'AB
CD'; + const html = generateHyperframesHtml([makeTextElement({ content })], 1); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelector("strong span")?.getAttribute("style")).toContain("font-size: 32px"); + expect(doc.querySelector("strong span")?.hasAttribute("onclick")).toBe(false); + expect(doc.querySelector("br")).not.toBeNull(); + expect(doc.querySelector("script,svg,sup,[onclick]")).toBeNull(); + expect(doc.querySelector("#text-1")?.textContent).toBe("ABCD"); + expect(html).not.toContain("url(evil)"); + expect(parseHtml(html).elements[0]).toMatchObject({ content: "ABCD" }); + }); + + it.each(["", "&quot; &#39; &lt;"])( + "preserves empty and entity-looking caption content: %s", + (content) => { + const html = generateHyperframesHtml([makeTextElement({ content })], 1); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelector("#text-1")?.textContent).toBe(content ? "" ' <" : ""); + expect(parseHtml(html).elements[0]).toMatchObject({ + content: content ? "" ' <" : "", + }); + }, + ); + + it.each([ + "javascript:bad()", + "java\nscript:bad()", + "vbscript:bad()", + "data:text/html,", + ])("rejects executable source URLs: %s", (src) => { + expect(() => generateHyperframesHtml([makeVideoElement({ src })], 1)).toThrow( + "Unsafe media or composition source URL", + ); + }); + + it("rejects JavaScript supplied through a numeric element field", () => { + const element = { ...makeTextElement(), startTime: "0); bad(); //" }; + expect(() => Reflect.apply(generateGsapTimelineScript, undefined, [[element], 1])).toThrow( + "finite generator numeric value", + ); + }); + + it("rejects declaration breakouts in generated color values", () => { + expect(() => + generateHyperframesStyles( + [makeTextElement({ color: "red; } body { color: blue" })], + "landscape", + ), + ).toThrow("Invalid generated CSS value"); + }); + + it("keeps composition identifiers inside their attribute and round-trips entities", () => { + const compositionId = `x" autofocus onfocus="alert(1)'>"&`; + const doc = new DOMParser().parseFromString( + generateHyperframesHtml([], 1, { compositionId }), + "text/html", + ); + expect(doc.documentElement.getAttribute("data-composition-id")).toBe(compositionId); + expect(doc.documentElement.hasAttribute("autofocus")).toBe(false); + expect(doc.documentElement.hasAttribute("onfocus")).toBe(false); + expect(doc.querySelector("script")).toBeNull(); + }); + + it("contains resolution values supplied by JavaScript callers inside their attribute", () => { + const resolution = `x" autofocus onfocus="alert(1)'>"&`; + const html = Reflect.apply(generateHyperframesHtml, undefined, [ + [], + 1, + { resolution, includeStyles: false, includeScripts: false }, + ]); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.documentElement.getAttribute("data-resolution")).toBe(resolution); + expect(doc.documentElement.hasAttribute("autofocus")).toBe(false); + expect(doc.documentElement.hasAttribute("onfocus")).toBe(false); + expect(doc.querySelector("script")).toBeNull(); + }); + + it("round-trips entity-bearing CSS through the JSON metadata attribute", () => { + const styles = `.x::after { content: "" ' & < > '"; }`; + const doc = new DOMParser().parseFromString( + generateHyperframesHtml([], 1, { styles }), + "text/html", + ); + expect(JSON.parse(doc.documentElement.getAttribute("data-custom-styles")!)).toBe(styles); + expect(doc.querySelector("style")).toBeNull(); + expect(parseHtml(generateHyperframesHtml([], 1, { styles })).styles).toBe(styles); + }); + + it("round-trips composition variable metadata through the public parser", () => { + const variableValues = { label: `" ' & < > '`, count: 2, enabled: true }; + const element: TimelineCompositionElement = { + id: "nested", + type: "composition", + name: "Nested", + startTime: 0, + duration: 1, + zIndex: 0, + src: "nested.html", + compositionId: "nested-comp", + variableValues, + }; + const html = generateHyperframesHtml([element], 1); + const parsed = parseHtml(html).elements[0]; + expect(parsed?.type).toBe("composition"); + if (parsed?.type !== "composition") throw new Error("Expected composition"); + expect(parsed.variableValues).toEqual(variableValues); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(JSON.parse(doc.getElementById("nested")!.getAttribute("data-variable-values")!)).toEqual( + variableValues, + ); + }); + it("generates valid HTML with proper data attributes", () => { const elements = [makeTextElement()]; const html = generateHyperframesHtml(elements, 5); @@ -153,7 +324,7 @@ describe("generateHyperframesHtml", () => { const elements = [makeTextElement({ id: "text-kf" })]; const keyframes = { "text-kf": [ - { id: "kf1", time: 0, properties: { opacity: 0 } }, + { id: "kf1 " ' < >", time: 0, properties: { opacity: 0 } }, { id: "kf2", time: 1, properties: { opacity: 1 } }, ], }; @@ -162,17 +333,27 @@ describe("generateHyperframesHtml", () => { expect(html).toContain("data-keyframes="); expect(html).toContain("kf1"); expect(html).toContain("kf2"); + expect(parseHtml(html).keyframes["text-kf"]?.[0]?.id).toBe(keyframes["text-kf"][0]!.id); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(JSON.parse(doc.getElementById("text-kf")!.getAttribute("data-keyframes")!)).toEqual( + keyframes["text-kf"], + ); }); it("serializes zoom keyframes on zoom container", () => { const elements = [makeTextElement()]; const stageZoomKeyframes = [ - { id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } }, + { id: "z1 " ' < >", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } }, { id: "z2", time: 5, zoom: { scale: 2, focusX: 400, focusY: 300 } }, ]; const html = generateHyperframesHtml(elements, 10, { stageZoomKeyframes }); expect(html).toContain("data-zoom-keyframes="); + expect(parseHtml(html).stageZoomKeyframes?.[0]?.id).toBe(stageZoomKeyframes[0]!.id); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect( + JSON.parse(doc.getElementById("stage-zoom-container")!.getAttribute("data-zoom-keyframes")!), + ).toEqual(stageZoomKeyframes); }); it("includes x, y, scale data attributes for non-default values", () => { diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts index f6cd2eac4a..96580f70c8 100644 --- a/packages/core/src/generators/hyperframes.ts +++ b/packages/core/src/generators/hyperframes.ts @@ -1,3 +1,5 @@ +import { isSafeAttributeValue } from "../utils/htmlAttrSafety"; +import { richTextHtml } from "./richTextHtml"; import type { TimelineElement, CanvasResolution, Keyframe, StageZoomKeyframe } from "../core.types"; import { CANVAS_DIMENSIONS, @@ -10,6 +12,50 @@ import { serializeGsapAnimations, keyframesToGsapAnimations } from "@hyperframes import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants"; import { COMPOSITION_ATTRIBUTES } from "../compositionContract.js"; +function escapeHtmlAttributeValue(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function elementSelector(id: string): string { + // Hex escapes preserve ID identity without letting punctuation add selectors. + const escaped = id.replace(/[^a-zA-Z0-9_-]|^-?\d/g, (match) => + Array.from(match, (char) => `\\${char.codePointAt(0)?.toString(16)} `).join(""), + ); + return `#${escaped}`; +} + +function cssString(value: string): string { + return value.replace(/[\\'\r\n\f]/g, (char) => `\\${char.codePointAt(0)?.toString(16)} `); +} + +function sourceAttribute(src: string, composition = false): string { + // URL parsing ignores embedded ASCII tabs/newlines before detecting a scheme. + const normalized = Array.from(src) + .filter((char) => char.charCodeAt(0) > 32) + .join(""); + if (!isSafeAttributeValue("src", normalized) || (composition && /^data:/i.test(normalized))) { + throw new Error("Unsafe media or composition source URL"); + } + return escapeHtmlAttributeValue(src); +} + +function finiteNumber(value: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error("Expected a finite generator numeric value"); + } + return value; +} + +function cssValue(value: string): string { + if (/[;{}<>\r\n]/.test(value)) throw new Error("Invalid generated CSS value"); + return value; +} + const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2"; const FONT_WEIGHTS: Record = { Inter: "400;500;600;700;800;900", @@ -42,13 +88,16 @@ function generateGoogleFontsUrl(fontFamilies: string[]): string | null { } export interface SerializeOptions { + /** Trusted animations: __raw: values are emitted as executable JavaScript. */ animations?: GsapAnimation[]; + /** Trusted authored CSS, preserved as code; may load external resources. */ styles?: string; generateDefaultAnimations?: boolean; resolution?: CanvasResolution; compositionId?: string; keyframes?: Record; stageZoomKeyframes?: StageZoomKeyframe[]; + /** Emit executable timeline code; animations and __raw: expressions must be trusted. */ includeScripts?: boolean; includeStyles?: boolean; } @@ -82,6 +131,7 @@ function sortElements(elements: TimelineElement[]): TimelineElement[] { }); } +/** Generate CSS from trusted authors. customStyles is authored code, not sanitized CSS. */ export function generateHyperframesStyles( elements: TimelineElement[], resolution: CanvasResolution, @@ -131,39 +181,40 @@ function generateElementStyles(element: TimelineElement): string { // Text outline using -webkit-text-stroke const textOutline = element.textOutline - ? `-webkit-text-stroke: ${element.textOutlineWidth ?? 2}px ${ - element.textOutlineColor ?? "#000000" - }; paint-order: stroke fill;` + ? `-webkit-text-stroke: ${finiteNumber(element.textOutlineWidth ?? 2)}px ${cssValue( + element.textOutlineColor ?? "#000000", + )}; paint-order: stroke fill;` : ""; // Text highlight using background const textHighlight = element.textHighlight - ? `background-color: ${element.textHighlightColor ?? "yellow"}; padding: ${element.textHighlightPadding ?? 4}px ${ - (element.textHighlightPadding ?? 4) * 1.5 - }px; border-radius: ${ - element.textHighlightRadius ?? 4 - }px; box-decoration-break: clone; -webkit-box-decoration-break: clone;` + ? `background-color: ${cssValue(element.textHighlightColor ?? "yellow")}; padding: ${finiteNumber(element.textHighlightPadding ?? 4)}px ${finiteNumber( + (element.textHighlightPadding ?? 4) * 1.5, + )}px; border-radius: ${finiteNumber( + element.textHighlightRadius ?? 4, + )}px; box-decoration-break: clone; -webkit-box-decoration-break: clone;` : ""; - return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; } - #${element.id} > div { font-family: '${fontFamily}', sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; color: ${color}; ${textShadow} ${textOutline} ${textHighlight} pointer-events: auto; cursor: grab; white-space: pre-wrap; text-align: center; }`; + return ` ${elementSelector(element.id)} { ${baseStyles} width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; } + ${elementSelector(element.id)} > div { font-family: '${cssString(fontFamily)}', sans-serif; font-size: ${finiteNumber(fontSize)}px; font-weight: ${finiteNumber(fontWeight)}; color: ${cssValue(color)}; ${textShadow} ${textOutline} ${textHighlight} pointer-events: auto; cursor: grab; white-space: pre-wrap; text-align: center; }`; } switch (element.type) { case "video": // Videos fill the stage with standard CSS positioning (0,0 = top-left) - return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; object-fit: contain; transform-origin: center center; }`; + return ` ${elementSelector(element.id)} { ${baseStyles} width: 100%; height: 100%; object-fit: contain; transform-origin: center center; }`; case "image": // Images use standard CSS positioning (0,0 = top-left) - return ` #${element.id} { ${baseStyles} max-width: 100%; max-height: 100%; transform-origin: center center; }`; + return ` ${elementSelector(element.id)} { ${baseStyles} max-width: 100%; max-height: 100%; transform-origin: center center; }`; case "audio": - return ` #${element.id} { ${baseStyles} }`; + return ` ${elementSelector(element.id)} { ${baseStyles} }`; case "composition": // Compositions use standard CSS positioning (0,0 = top-left) - return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; position: absolute; }`; + return ` ${elementSelector(element.id)} { ${baseStyles} width: 100%; height: 100%; position: absolute; }`; } } +/** Generate executable JavaScript. Animation __raw: expressions require trusted authors. */ export function generateGsapTimelineScript( elements: TimelineElement[], totalDuration: number, @@ -200,7 +251,12 @@ export function generateGsapTimelineScript( scale: baseScale, }, ); - keyframeAnimations = keyframeAnimations.concat(converted); + keyframeAnimations = keyframeAnimations.concat( + converted.map((animation) => ({ + ...animation, + targetSelector: elementSelector(element.id), + })), + ); } } } @@ -268,7 +324,7 @@ export function generateGsapTimelineScript( } else { gsapScript = ` const tl = gsap.timeline({ paused: true }); -${initialPositionSets ? initialPositionSets + "\n" : ""} tl.to({}, { duration: ${totalDuration || 1} }); +${initialPositionSets ? initialPositionSets + "\n" : ""} tl.to({}, { duration: ${finiteNumber(totalDuration || 1)} }); `; // Append zoom animations if (zoomAnimations) { @@ -279,6 +335,7 @@ ${initialPositionSets ? initialPositionSets + "\n" : ""} tl.to({}, { duration return gsapScript; } +/** Generate a document for trusted authors. Authored CSS and animation __raw: expressions retain their executable capabilities. Context encoding is not a sandbox: do not supply untrusted code-bearing options or serve untrusted compositions in a privileged origin. Text uses the inline-formatting sanitizer; parseHtml flattens inner formatting to text. */ export function generateHyperframesHtml( elements: TimelineElement[], totalDuration: number, @@ -318,7 +375,7 @@ export function generateHyperframesHtml( // Serialize zoom keyframes to data attribute const zoomKeyframesAttr = stageZoomKeyframes && stageZoomKeyframes.length > 0 - ? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "'")}'` + ? ` data-zoom-keyframes='${escapeHtmlAttributeValue(JSON.stringify(stageZoomKeyframes))}'` : ""; let styleTags = ""; @@ -329,12 +386,18 @@ export function generateHyperframesHtml( styleTags = [ styles.coreCss ? ` ` : "", styles.customCss ? ` ` : "", ] @@ -356,18 +419,18 @@ export function generateHyperframesHtml( const gsapScriptTag = includeScripts ? ` ` : ""; const customStylesAttr = customStyles - ? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "'")}'` + ? ` data-custom-styles='${escapeHtmlAttributeValue(JSON.stringify(customStyles))}'` : ""; - const resolutionAttr = ` data-resolution="${resolution}"`; + const resolutionAttr = ` data-resolution="${escapeHtmlAttributeValue(resolution)}"`; return ` - + @@ -427,15 +490,15 @@ function generateZoomGsapAnimations( if (i === 0) { animations.push( - ` tl.set("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y} }, ${kf.time});`, + ` tl.set("#stage-zoom-container", { scale: ${finiteNumber(kf.zoom.scale)}, x: ${finiteNumber(x)}, y: ${finiteNumber(y)} }, ${finiteNumber(kf.time)});`, ); } else { const prevKf = sortedKeyframes[i - 1]; if (!prevKf) continue; const duration = kf.time - prevKf.time; - const ease = kf.ease ? `, ease: "${kf.ease}"` : ""; + const ease = kf.ease ? `, ease: ${JSON.stringify(kf.ease)}` : ""; animations.push( - ` tl.to("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y}, duration: ${duration}${ease} }, ${prevKf.time});`, + ` tl.to("#stage-zoom-container", { scale: ${finiteNumber(kf.zoom.scale)}, x: ${finiteNumber(x)}, y: ${finiteNumber(y)}, duration: ${finiteNumber(duration)}${ease} }, ${finiteNumber(prevKf.time)});`, ); } } @@ -445,47 +508,47 @@ function generateZoomGsapAnimations( function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): string { const baseAttrs = [ - `id="${element.id}"`, - `data-hf-id="${element.id}"`, - `${COMPOSITION_ATTRIBUTES.start}="${element.startTime}"`, - `${COMPOSITION_ATTRIBUTES.duration}="${element.duration}"`, - `${COMPOSITION_ATTRIBUTES.trackIndex}="${element.zIndex}"`, - `data-name="${element.name}"`, + `id="${escapeHtmlAttributeValue(String(element.id))}"`, + `data-hf-id="${escapeHtmlAttributeValue(String(element.id))}"`, + `${COMPOSITION_ATTRIBUTES.start}="${escapeHtmlAttributeValue(String(element.startTime))}"`, + `${COMPOSITION_ATTRIBUTES.duration}="${escapeHtmlAttributeValue(String(element.duration))}"`, + `${COMPOSITION_ATTRIBUTES.trackIndex}="${escapeHtmlAttributeValue(String(element.zIndex))}"`, + `data-name="${escapeHtmlAttributeValue(String(element.name))}"`, ]; // Serialize transform properties (x, y, scale, opacity) if non-default if (element.x !== undefined && element.x !== 0) { - baseAttrs.push(`data-x="${element.x}"`); + baseAttrs.push(`data-x="${escapeHtmlAttributeValue(String(element.x))}"`); } if (element.y !== undefined && element.y !== 0) { - baseAttrs.push(`data-y="${element.y}"`); + baseAttrs.push(`data-y="${escapeHtmlAttributeValue(String(element.y))}"`); } if (element.scale !== undefined && element.scale !== 1) { - baseAttrs.push(`data-scale="${element.scale}"`); + baseAttrs.push(`data-scale="${escapeHtmlAttributeValue(String(element.scale))}"`); } if (element.opacity !== undefined && element.opacity !== 1) { - baseAttrs.push(`data-opacity="${element.opacity}"`); + baseAttrs.push(`data-opacity="${escapeHtmlAttributeValue(String(element.opacity))}"`); } // Serialize keyframes to data attribute if present if (keyframes && keyframes.length > 0) { const kfJson = JSON.stringify(keyframes); - baseAttrs.push(`data-keyframes='${kfJson.replace(/'/g, "'")}'`); + baseAttrs.push(`data-keyframes='${escapeHtmlAttributeValue(kfJson)}'`); } if (isTextElement(element)) { const textAttrs = [...baseAttrs, `data-type="text"`]; if (element.color) { - textAttrs.push(`data-color="${element.color}"`); + textAttrs.push(`data-color="${escapeHtmlAttributeValue(String(element.color))}"`); } if (element.fontSize) { - textAttrs.push(`data-font-size="${element.fontSize}"`); + textAttrs.push(`data-font-size="${escapeHtmlAttributeValue(String(element.fontSize))}"`); } if (element.fontWeight) { - textAttrs.push(`data-font-weight="${element.fontWeight}"`); + textAttrs.push(`data-font-weight="${escapeHtmlAttributeValue(String(element.fontWeight))}"`); } if (element.fontFamily) { - textAttrs.push(`data-font-family="${element.fontFamily}"`); + textAttrs.push(`data-font-family="${escapeHtmlAttributeValue(String(element.fontFamily))}"`); } if (element.textShadow === false) { textAttrs.push(`data-text-shadow="false"`); @@ -493,25 +556,35 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): if (element.textOutline) { textAttrs.push(`data-text-outline="true"`); if (element.textOutlineColor) { - textAttrs.push(`data-text-outline-color="${element.textOutlineColor}"`); + textAttrs.push( + `data-text-outline-color="${escapeHtmlAttributeValue(String(element.textOutlineColor))}"`, + ); } if (element.textOutlineWidth) { - textAttrs.push(`data-text-outline-width="${element.textOutlineWidth}"`); + textAttrs.push( + `data-text-outline-width="${escapeHtmlAttributeValue(String(element.textOutlineWidth))}"`, + ); } } if (element.textHighlight) { textAttrs.push(`data-text-highlight="true"`); if (element.textHighlightColor) { - textAttrs.push(`data-text-highlight-color="${element.textHighlightColor}"`); + textAttrs.push( + `data-text-highlight-color="${escapeHtmlAttributeValue(String(element.textHighlightColor))}"`, + ); } if (element.textHighlightPadding) { - textAttrs.push(`data-text-highlight-padding="${element.textHighlightPadding}"`); + textAttrs.push( + `data-text-highlight-padding="${escapeHtmlAttributeValue(String(element.textHighlightPadding))}"`, + ); } if (element.textHighlightRadius) { - textAttrs.push(`data-text-highlight-radius="${element.textHighlightRadius}"`); + textAttrs.push( + `data-text-highlight-radius="${escapeHtmlAttributeValue(String(element.textHighlightRadius))}"`, + ); } } - const content = element.content || element.name; + const content = richTextHtml(element.content ?? element.name); return `
${content}
`; } @@ -519,25 +592,31 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): const compositionAttrs = [ ...baseAttrs, `data-type="composition"`, - `data-composition-id="${element.compositionId}"`, + `data-composition-id="${escapeHtmlAttributeValue(String(element.compositionId))}"`, ]; if (element.sourceDuration) { - compositionAttrs.push(`data-source-duration="${element.sourceDuration}"`); + compositionAttrs.push( + `data-source-duration="${escapeHtmlAttributeValue(String(element.sourceDuration))}"`, + ); } if (element.sourceWidth) { - compositionAttrs.push(`data-source-width="${element.sourceWidth}"`); + compositionAttrs.push( + `data-source-width="${escapeHtmlAttributeValue(String(element.sourceWidth))}"`, + ); } if (element.sourceHeight) { - compositionAttrs.push(`data-source-height="${element.sourceHeight}"`); + compositionAttrs.push( + `data-source-height="${escapeHtmlAttributeValue(String(element.sourceHeight))}"`, + ); } if (element.variableValues && Object.keys(element.variableValues).length > 0) { const varJson = JSON.stringify(element.variableValues); - compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "'")}'`); + compositionAttrs.push(`data-variable-values='${escapeHtmlAttributeValue(varJson)}'`); } const attrs = compositionAttrs.join(" "); // Build iframe src with variable values as query params if present // Strip any existing query params first to avoid duplication - let iframeSrc = element.src.split("?")[0]; + let iframeSrc = element.src.split("?")[0] ?? ""; if (element.variableValues && Object.keys(element.variableValues).length > 0) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(element.variableValues)) { @@ -549,23 +628,27 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): // The motion design HTML handles its own internal positioning // Wrap iframe in container with click overlay for selection return `
- +
`; } if (isMediaElement(element)) { if (element.mediaStartTime) { - baseAttrs.push(`data-media-start="${element.mediaStartTime}"`); + baseAttrs.push( + `data-media-start="${escapeHtmlAttributeValue(String(element.mediaStartTime))}"`, + ); } if (element.sourceDuration) { - baseAttrs.push(`data-source-duration="${element.sourceDuration}"`); + baseAttrs.push( + `data-source-duration="${escapeHtmlAttributeValue(String(element.sourceDuration))}"`, + ); } if (element.isAroll) { baseAttrs.push(`data-aroll="true"`); } if (element.volume !== undefined && element.volume !== 1) { - baseAttrs.push(`data-volume="${element.volume}"`); + baseAttrs.push(`data-volume="${escapeHtmlAttributeValue(String(element.volume))}"`); } if (element.type === "video" && element.hasAudio) { baseAttrs.push(`data-has-audio="true"`); @@ -576,11 +659,11 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): switch (element.type) { case "video": - return ``; + return ``; case "image": - return `${element.name}`; + return `${escapeHtmlAttributeValue(String(element.name))}`; case "audio": - return ``; + return ``; default: return ""; } @@ -633,9 +716,13 @@ function generateInitialPositionSets( // Set position and scale (xPercent/yPercent applied at player init) if (scaleVal !== 1) { - sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal}, scale: ${scaleVal} }, 0);`); + sets.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { x: ${finiteNumber(xVal)}, y: ${finiteNumber(yVal)}, scale: ${finiteNumber(scaleVal)} }, 0);`, + ); } else if (xVal !== 0 || yVal !== 0) { - sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal} }, 0);`); + sets.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { x: ${finiteNumber(xVal)}, y: ${finiteNumber(yVal)} }, 0);`, + ); } } @@ -662,9 +749,10 @@ function generateVisibilityForElementsWithoutKeyframes( const start = el.startTime; const end = el.startTime + el.duration; - const safeName = el.name.replace(/[\r\n]+/g, " "); - animations.push(` // ${safeName} (visibility)`); - animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`); + animations.push(" // Element visibility"); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "hidden" }, 0);`, + ); let elementOpacity = el.opacity ?? 1; if (opacityKeyframes.length > 0) { @@ -677,13 +765,17 @@ function generateVisibilityForElementsWithoutKeyframes( const needsOpacity = elementOpacity !== 1 || opacityKeyframes.length > 0; if (needsOpacity) { animations.push( - ` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`, + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "visible", opacity: ${finiteNumber(elementOpacity)} }, ${finiteNumber(start)});`, ); } else { - animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "visible" }, ${finiteNumber(start)});`, + ); } - animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "hidden" }, ${finiteNumber(end)});`, + ); } return animations.length > 0 ? animations.join("\n") : ""; @@ -699,7 +791,7 @@ function generateDefaultGsapAnimations( if (elements.length === 0 && (!stageZoomKeyframes || stageZoomKeyframes.length === 0)) { return ` const tl = gsap.timeline({ paused: true }); - tl.to({}, { duration: ${totalDuration || 1} }); + tl.to({}, { duration: ${finiteNumber(totalDuration || 1)} }); `; } @@ -715,19 +807,24 @@ function generateDefaultGsapAnimations( const start = el.startTime; const end = el.startTime + el.duration; - const safeName = el.name.replace(/[\r\n]+/g, " "); const elementOpacity = el.opacity ?? 1; - animations.push(` // ${safeName}`); - animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`); + animations.push(" // Element visibility"); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "hidden" }, 0);`, + ); // Only include opacity if non-default if (elementOpacity !== 1) { animations.push( - ` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`, + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "visible", opacity: ${finiteNumber(elementOpacity)} }, ${finiteNumber(start)});`, ); } else { - animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "visible" }, ${finiteNumber(start)});`, + ); } - animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`); + animations.push( + ` tl.set(${JSON.stringify(elementSelector(el.id))}, { visibility: "hidden" }, ${finiteNumber(end)});`, + ); } const mediaElements = elements.filter((el) => el.type === "video" || el.type === "audio"); diff --git a/packages/core/src/generators/richTextHtml.ts b/packages/core/src/generators/richTextHtml.ts new file mode 100644 index 0000000000..8561c96c26 --- /dev/null +++ b/packages/core/src/generators/richTextHtml.ts @@ -0,0 +1,43 @@ +import { parseHTML } from "linkedom"; +import { sanitizeRichTextChildren } from "../utils/richTextSanitize"; + +function escape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** Preserve the existing inline-formatting contract, with one safe serialization. */ +export function richTextHtml(content: string): string { + const { document } = parseHTML(""); + const host = document.createElement("div"); + host.innerHTML = content; + sanitizeRichTextChildren(host); + // Linkedom does not re-escape entity-looking ampersands in attributes. + // Serialize the sanitized tree explicitly so a browser parse preserves values. + const result: string[] = []; + const pending: Array = Array.from(host.childNodes).reverse(); + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (typeof node === "string") { + result.push(node); + continue; + } + if (node.nodeType === 3) { + result.push(escape(node.textContent ?? "")); + continue; + } + if (node.nodeType !== 1) continue; + const element = node as Element; + const tag = element.tagName.toLowerCase(); + const attrs = Array.from( + element.attributes, + (attr) => ` ${attr.name}="${escape(attr.value)}"`, + ).join(""); + result.push(`<${tag}${attrs}>`); + if (tag === "br") continue; + pending.push(``, ...Array.from(element.childNodes).reverse()); + } + return result.join(""); +} diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index b901366856..cb51b7a2f4 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -222,6 +222,12 @@ export interface SplitAnimationsResult { // ── Serialization ─────────────────────────────────────────────────────────── +/** + * Construct executable JavaScript from trusted composition-author inputs. + * __raw: values, preamble, postamble, and timelineVar are code-bearing inputs + * and are deliberately not sanitized. Never populate them from untrusted data. + * Quoting ordinary values does not sandbox authored code or its side effects. + */ export function serializeGsapAnimations( animations: GsapAnimation[], timelineVar = "tl", @@ -236,7 +242,7 @@ export function serializeGsapAnimations( }); // fallow-ignore-next-line complexity const lines = sorted.map((anim) => { - const selector = `"${anim.targetSelector}"`; + const selector = JSON.stringify(anim.targetSelector); const props: Record = { ...anim.properties }; if (anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; @@ -250,7 +256,7 @@ export function serializeGsapAnimations( propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`; } } - const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; + const posStr = JSON.stringify(anim.position); switch (anim.method) { case "set": // A global set is a base `gsap.set` — off the timeline, no position arg. diff --git a/packages/parsers/src/hfIdAssignment.ts b/packages/parsers/src/hfIdAssignment.ts new file mode 100644 index 0000000000..ebfabdc070 --- /dev/null +++ b/packages/parsers/src/hfIdAssignment.ts @@ -0,0 +1,166 @@ +// Non-editable / non-visual elements that should never receive a stable id. +export const EXCLUDED_TAGS = new Set([ + "script", + "style", + "template", + "meta", + "link", + "noscript", + "base", +]); + +// 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random. +function fnv1a(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +// 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision +// ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic +// clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId +// resolves the rare collision; width is deliberately small for readable ids. +function toHfId(hash: number): string { + const s = (hash >>> 0).toString(36); + // Use suffix (most-avalanched bits) for better distribution within the 4-char window. + const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0"); + return `hf-${four}`; +} + +// Element's own direct text (TEXT_NODE children), not descendants'. +function ownText(el: Element): string { + let text = ""; + el.childNodes.forEach((n) => { + if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; + }); + return text.trim(); +} + +function getContractAttribute(el: Element, name: string): string | null { + return Array.from(el.attributes).find((attr) => attr.name.toLowerCase() === name)?.value ?? null; +} + +function contentKey(el: Element): string { + // HTML parsers normalize foreign-content attribute names differently too. + // Canonicalize only the hash input; retain actual SVG attribute spelling. + // Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash. + // Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization. + const attrs = Array.from(el.attributes) + .filter((a) => !a.name.toLowerCase().startsWith("data-hf-")) + .map((a) => `${a.name.toLowerCase()}\x00${a.value}`) + .sort() + .join("\x01"); + return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`; +} + +/** + * Collision tiebreak for byte-identical siblings: document-order dup counter + * (`hash(key#N)`). This IS order-dependent — two identical `` + * get different ids based on which comes first in the DOM. This is unavoidable: + * unique ids for byte-identical elements require a positional signal. + * + * Why this is safe in practice: once `ensureHfIds` write-back persists + * `data-hf-id` to source the attribute is physically bound to its element. + * Reordering identical siblings carries the attribute along → zero + * order-dependence post-persist. `ensureHfIds` skips pinned elements + * (`if (getContractAttribute(el, "data-hf-id")) continue`), so normal operation + * never re-exposes the ordering after first persist. + */ +// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's +// preview route relies on mintHfId producing identical ids across mint contexts +// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts +// "bundle returning untagged HTML gets same ids as disk". Any change that adds +// positional, session, or random input to the hash breaks that invariant and +// makes hf- ids diverge between disk and served HTML, silently corrupting +// drag-to-edit targeting. +export function mintHfId(el: Element, assigned: Set): string { + const key = contentKey(el); + let id = toHfId(fnv1a(key)); + let dup = 0; + while (assigned.has(id)) { + dup += 1; + // Graceful fallback instead of a hard throw: rehashing only fails to find a + // free 4-char slot in a pathological document (~1.6M identical elements). + // Rather than crash the whole parse, widen the id with the dup counter — + // still deterministic and unique, just longer than the 4-char norm. + if (dup > 10000) { + id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`; + break; + } + id = toHfId(fnv1a(`${key}#${dup}`)); + } + assigned.add(id); + return id; +} + +/** + * True for a sub-composition authoring template whose content the studio preview + * unwraps into the served body. Two accepted forms: + * A) `