Skip to content

Commit fe2cc92

Browse files
authored
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
1 parent 7d7003a commit fe2cc92

4 files changed

Lines changed: 73 additions & 27 deletions

File tree

‎packages/studio-server/src/helpers/mediaProxyPreview.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export async function injectMediaCodecMapIntoHtml(
111111
});
112112
}
113113
// <-escape prevents a src path containing "</script>" from breaking out of
114-
// the injected tag, mirroring injectPreviewVariables in routes/preview.ts.
114+
// the injected tag, mirroring injectPreviewVariables in helpers/previewVariables.ts.
115115
const json = JSON.stringify(map)
116116
.replace(/</g, "\\u003c")
117117
.replace(/\u2028/g, "\\u2028")
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import { injectPreviewVariables } from "./previewVariables";
3+
4+
const SCRIPT = '<script data-hf-preview-variables>window.__hfVariables={"a":1};</script>';
5+
6+
describe("injectPreviewVariables", () => {
7+
it.each([
8+
[
9+
"<!doctype html><html><head>x</head></html>",
10+
"<!doctype html><html><head>",
11+
"x</head></html>",
12+
],
13+
[
14+
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>x",
15+
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>",
16+
"x",
17+
],
18+
["<!doctype html><html lang='en'>x", "<!doctype html><html lang='en'>", "x"],
19+
[" \n<!DOCTYPE html>x", " \n<!DOCTYPE html>", "x"],
20+
["fragment", "", "fragment"],
21+
["x<!doctype html>y", "", "x<!doctype html>y"],
22+
["<headless>x", "<headless>", "x"],
23+
["<head attr='>'>x", "<head attr='>", "'>x"],
24+
["<html><head", "<html>", "<head"],
25+
["<head<head>x", "<head<head>", "x"],
26+
])("preserves insertion boundaries (case %#)", (html, before, after) => {
27+
expect(injectPreviewVariables(html, { a: 1 })).toBe(before + SCRIPT + after);
28+
});
29+
30+
it.each(["<head", "<html"])("handles repeated unterminated %s prefixes", (prefix) => {
31+
const malformed = prefix.repeat(20_000);
32+
expect(injectPreviewVariables(malformed, { a: 1 })).toBe(SCRIPT + malformed);
33+
expect(injectPreviewVariables(malformed + ">tail", { a: 1 })).toBe(
34+
malformed + ">" + SCRIPT + "tail",
35+
);
36+
});
37+
38+
it("escapes script-breaking input before inserting it", () => {
39+
expect(injectPreviewVariables("<head>", { a: "</script>" })).toBe(
40+
'<head><script data-hf-preview-variables>window.__hfVariables={"a":"\\u003c/script>"};</script>',
41+
);
42+
});
43+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Inject preview variable overrides: `?variables=<json>` becomes
3+
* `window.__hfVariables` set before any composition script runs — the exact
4+
* global the engine sets via evaluateOnNewDocument at render time
5+
* (engine/src/services/frameCapture.ts), so preview-with-values cannot
6+
* diverge from render behavior. The runtime's getVariables() merges these
7+
* overrides over the declared defaults.
8+
*/
9+
export function injectPreviewVariables(html: string, values: Record<string, unknown>): string {
10+
// <-escape prevents a string value containing "</script>" from
11+
// breaking out of the injected tag.
12+
const json = JSON.stringify(values).replace(/</g, "\\u003c");
13+
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
14+
// Insert as early as possible without ever landing before the doctype —
15+
// content before <!doctype> flips the document into quirks mode, so the
16+
// fallback chain is <head…> → <html…> → after the doctype → prepend.
17+
for (const pattern of [/<head/i, /<html/i, /^\s*<!doctype/i]) {
18+
const match = pattern.exec(html);
19+
if (match) {
20+
// If the first prefix has no closing >, no later prefix can close either.
21+
const end = html.indexOf(">", match.index + match[0].length);
22+
if (end < 0) continue;
23+
const at = end + 1;
24+
return html.slice(0, at) + tag + html.slice(at);
25+
}
26+
}
27+
return tag + html;
28+
}

‎packages/studio-server/src/routes/preview.ts‎

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
2020
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
2121
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
22+
import { injectPreviewVariables } from "../helpers/previewVariables.js";
2223
import {
2324
resolveProxy,
2425
ProxyCapacityError,
@@ -203,32 +204,6 @@ function injectGsapCdnFallback(html: string): string {
203204
return GSAP_CDN_FALLBACK_SCRIPT + html;
204205
}
205206

206-
/**
207-
* Inject preview variable overrides: `?variables=<json>` becomes
208-
* `window.__hfVariables` set before any composition script runs — the exact
209-
* global the engine sets via evaluateOnNewDocument at render time
210-
* (engine/src/services/frameCapture.ts), so preview-with-values cannot
211-
* diverge from render behavior. The runtime's getVariables() merges these
212-
* overrides over the declared defaults.
213-
*/
214-
function injectPreviewVariables(html: string, values: Record<string, unknown>): string {
215-
// <-escape prevents a string value containing "</script>" from
216-
// breaking out of the injected tag.
217-
const json = JSON.stringify(values).replace(/</g, "\\u003c");
218-
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
219-
// Insert as early as possible without ever landing before the doctype —
220-
// content before <!doctype> flips the document into quirks mode, so the
221-
// fallback chain is <head…> → <html…> → after the doctype → prepend.
222-
for (const pattern of [/<head[^>]*>/i, /<html[^>]*>/i, /^\s*<!doctype[^>]*>/i]) {
223-
const match = pattern.exec(html);
224-
if (match) {
225-
const at = match.index + match[0].length;
226-
return html.slice(0, at) + tag + html.slice(at);
227-
}
228-
}
229-
return tag + html;
230-
}
231-
232207
/**
233208
* Parse the `?variables=` query param. Absent/empty → null (no injection).
234209
* Invalid JSON or a non-object payload is a caller error — surfaced as a 400

0 commit comments

Comments
 (0)