Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { parseHTML } from "linkedom";
import {
buildVariablesByCompScript,
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
Expand Down Expand Up @@ -886,3 +887,60 @@ window.__timelines['intro'] = tl;
expect(gsapTargets).toEqual([["HELLO"]]);
});
});

/**
* The emitted statement is placed inside a `<script>` element, and `<script>` is a
* RAW TEXT element: HTML serialization does not escape its content and the tokenizer
* closes it at the first `</script`. `JSON.stringify` escapes `"` and `\` but not `/`,
* so an unescaped variable value could close the element and have the remainder parsed
* as markup — turning composition data into executable script.
*/
describe("buildVariablesByCompScript — <script> breakout", () => {
/** Serialize into a document the way the compilers do, then re-parse it. */
function scriptsAfterRoundTrip(body: string): string[] {
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>");
const el = document.createElement("script");
el.textContent = body;
document.body.appendChild(el);
const { document: reparsed } = parseHTML(document.toString());
return [...reparsed.querySelectorAll("script")].map((s) => s.textContent ?? "");
}

it("does not let a variable VALUE close the script element", () => {
const body = buildVariablesByCompScript({
"comp-a": { greeting: "</script><script>window.__pwned=1//" },
});
expect(body).not.toBeNull();
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
});

it("does not let a variable KEY close the script element", () => {
const body = buildVariablesByCompScript({
"comp-a": { "</script><script>window.__pwned=1//": "x" },
});
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
});

it("does not let a COMP ID close the script element", () => {
const body = buildVariablesByCompScript({
"</script><script>window.__pwned=1//": { a: "x" },
});
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
});

it("keeps the value byte-identical once executed — the escape is transparent", () => {
// Run the statement the way the browser does rather than string-slicing it.
const variables = { "comp-a": { greeting: "a </script> b <em>c</em>" } };
const body = buildVariablesByCompScript(variables) ?? "";
const fakeWindow: Record<string, unknown> = {};
new Function("window", body)(fakeWindow);
expect(fakeWindow.__hfVariablesByComp).toEqual(variables);
});

it("returns null when there are no per-instance values", () => {
expect(buildVariablesByCompScript({})).toBeNull();
});
});
11 changes: 10 additions & 1 deletion packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,10 +601,19 @@ export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: st
* `getVariables()` returned `{}` only during render — parametrized sub-comps
* silently shipped blank/default text in the final MP4 while snapshot QA passed
* (issue #2064). Both callers now share this one builder so they can't drift.
*
* Every `<` is rewritten to its JSON unicode escape because this body is
* emitted into a `<script>` element, and `<script>` is a RAW TEXT element:
* HTML serialization does not escape its content, and the tokenizer ends it at
* the first `</script`. `JSON.stringify` escapes `"` and `\` but NOT `/`, so a
* variable value or key containing `</script>` would otherwise close the
* element early and the remainder would parse as markup. The escape is
* transparent to `JSON.parse`, so the value the runtime reads is unchanged.
*/
export function buildVariablesByCompScript(
variablesByComp: Record<string, Record<string, unknown>>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
const json = JSON.stringify(variablesByComp).replace(/</g, "\\u003c");
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
}