diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index 9ff53c1a73..f59b43585b 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -111,7 +111,7 @@ export async function injectMediaCodecMapIntoHtml( }); } // <-escape prevents a src path containing "" from breaking out of - // the injected tag, mirroring injectPreviewVariables in routes/preview.ts. + // the injected tag, mirroring injectPreviewVariables in helpers/previewVariables.ts. const json = JSON.stringify(map) .replace(/ { + it.each([ + [ + "x", + "", + "x", + ], + [ + "x", + "", + "x", + ], + ["x", "", "x"], + [" \nx", " \n", "x"], + ["fragment", "", "fragment"], + ["xy", "", "xy"], + ["x", "", "x"], + ["x", "x"], + ["", "x", "", "x"], + ])("preserves insertion boundaries (case %#)", (html, before, after) => { + expect(injectPreviewVariables(html, { a: 1 })).toBe(before + SCRIPT + after); + }); + + it.each([" { + const malformed = prefix.repeat(20_000); + expect(injectPreviewVariables(malformed, { a: 1 })).toBe(SCRIPT + malformed); + expect(injectPreviewVariables(malformed + ">tail", { a: 1 })).toBe( + malformed + ">" + SCRIPT + "tail", + ); + }); + + it("escapes script-breaking input before inserting it", () => { + expect(injectPreviewVariables("", { a: "" })).toBe( + '', + ); + }); +}); diff --git a/packages/studio-server/src/helpers/previewVariables.ts b/packages/studio-server/src/helpers/previewVariables.ts new file mode 100644 index 0000000000..79574e09b0 --- /dev/null +++ b/packages/studio-server/src/helpers/previewVariables.ts @@ -0,0 +1,28 @@ +/** + * Inject preview variable overrides: `?variables=` becomes + * `window.__hfVariables` set before any composition script runs — the exact + * global the engine sets via evaluateOnNewDocument at render time + * (engine/src/services/frameCapture.ts), so preview-with-values cannot + * diverge from render behavior. The runtime's getVariables() merges these + * overrides over the declared defaults. + */ +export function injectPreviewVariables(html: string, values: Record): string { + // <-escape prevents a string value containing "" from + // breaking out of the injected tag. + const json = JSON.stringify(values).replace(/window.__hfVariables=${json};`; + // Insert as early as possible without ever landing before the doctype — + // content before flips the document into quirks mode, so the + // fallback chain is → after the doctype → prepend. + for (const pattern of [/, no later prefix can close either. + const end = html.indexOf(">", match.index + match[0].length); + if (end < 0) continue; + const at = end + 1; + return html.slice(0, at) + tag + html.slice(at); + } + } + return tag + html; +} diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index f23b8c1130..ef807bf1c5 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -19,6 +19,7 @@ import { import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; +import { injectPreviewVariables } from "../helpers/previewVariables.js"; import { resolveProxy, ProxyCapacityError, @@ -203,32 +204,6 @@ function injectGsapCdnFallback(html: string): string { return GSAP_CDN_FALLBACK_SCRIPT + html; } -/** - * Inject preview variable overrides: `?variables=` becomes - * `window.__hfVariables` set before any composition script runs — the exact - * global the engine sets via evaluateOnNewDocument at render time - * (engine/src/services/frameCapture.ts), so preview-with-values cannot - * diverge from render behavior. The runtime's getVariables() merges these - * overrides over the declared defaults. - */ -function injectPreviewVariables(html: string, values: Record): string { - // <-escape prevents a string value containing "" from - // breaking out of the injected tag. - const json = JSON.stringify(values).replace(/window.__hfVariables=${json};`; - // Insert as early as possible without ever landing before the doctype — - // content before flips the document into quirks mode, so the - // fallback chain is → after the doctype → prepend. - for (const pattern of [/]*>/i, /]*>/i, /^\s*]*>/i]) { - const match = pattern.exec(html); - if (match) { - const at = match.index + match[0].length; - return html.slice(0, at) + tag + html.slice(at); - } - } - return tag + html; -} - /** * Parse the `?variables=` query param. Absent/empty → null (no injection). * Invalid JSON or a non-object payload is a caller error — surfaced as a 400