fix(studio-server): bound preview variable insertion scans (#3715)

* fix(studio-server): bound preview variable insertion scans

* docs(studio-server): update preview variables helper reference
This commit is contained in:
James Russo
2026-09-05 15:32:34 -04:00
committed by GitHub
parent 7d7003aa64
commit fe2cc92050
4 changed files with 73 additions and 27 deletions
@@ -111,7 +111,7 @@ export async function injectMediaCodecMapIntoHtml(
});
}
// <-escape prevents a src path containing "</script>" 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(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { injectPreviewVariables } from "./previewVariables";
const SCRIPT = '<script data-hf-preview-variables>window.__hfVariables={"a":1};</script>';
describe("injectPreviewVariables", () => {
it.each([
[
"<!doctype html><html><head>x</head></html>",
"<!doctype html><html><head>",
"x</head></html>",
],
[
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>x",
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>",
"x",
],
["<!doctype html><html lang='en'>x", "<!doctype html><html lang='en'>", "x"],
[" \n<!DOCTYPE html>x", " \n<!DOCTYPE html>", "x"],
["fragment", "", "fragment"],
["x<!doctype html>y", "", "x<!doctype html>y"],
["<headless>x", "<headless>", "x"],
["<head attr='>'>x", "<head attr='>", "'>x"],
["<html><head", "<html>", "<head"],
["<head<head>x", "<head<head>", "x"],
])("preserves insertion boundaries (case %#)", (html, before, after) => {
expect(injectPreviewVariables(html, { a: 1 })).toBe(before + SCRIPT + after);
});
it.each(["<head", "<html"])("handles repeated unterminated %s prefixes", (prefix) => {
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("<head>", { a: "</script>" })).toBe(
'<head><script data-hf-preview-variables>window.__hfVariables={"a":"\\u003c/script>"};</script>',
);
});
});
@@ -0,0 +1,28 @@
/**
* Inject preview variable overrides: `?variables=<json>` 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, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head/i, /<html/i, /^\s*<!doctype/i]) {
const match = pattern.exec(html);
if (match) {
// If the first prefix has no closing >, 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;
}
+1 -26
View File
@@ -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=<json>` 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, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head[^>]*>/i, /<html[^>]*>/i, /^\s*<!doctype[^>]*>/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