diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 7985c9f809..b9d7e72d79 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { parseHTML } from "linkedom";
import {
+ buildVariablesByCompScript,
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
@@ -699,13 +700,18 @@ window.__afterTimeline = window.__timelines.scene;
});
it("wraps unscoped composition script source as a string literal", () => {
+ const source = 'window.payload = "";';
const wrapped = wrapInlineScriptWithErrorBoundary(
- 'window.payload = "";',
+ source,
"[HyperFrames] composition script error:",
);
expect(wrapped).toContain("Function(");
- expect(wrapped).toContain('\\"\\"');
+ // The literal carries the source verbatim, with `<` escaped so it cannot end the
+ // raw-text ` {
@@ -886,3 +892,132 @@ window.__timelines['intro'] = tl;
expect(gsapTargets).toEqual([["HELLO"]]);
});
});
+
+/**
+ * The emitted statement is placed inside a ``),
+ * so a payload whose breakout is not the first `<` is the realistic one.
+ */
+const SCRIPT_BREAKOUT = "x {
+ const body = buildVariablesByCompScript({
+ "comp-a": { [SCRIPT_BREAKOUT]: "x" },
+ });
+ expect(body).not.toContain(" {
+ const body = buildVariablesByCompScript({
+ [SCRIPT_BREAKOUT]: { a: "x" },
+ });
+ expect(body).not.toContain(" {
+ // Run the statement the way the browser does rather than string-slicing it.
+ const variables = { "comp-a": { greeting: "a b c" } };
+ const body = buildVariablesByCompScript(variables) ?? "";
+ const fakeWindow: Record = {};
+ new Function("window", body)(fakeWindow);
+ expect(fakeWindow.__hfVariablesByComp).toEqual(variables);
+ });
+
+ it("returns null when there are no per-instance values", () => {
+ expect(buildVariablesByCompScript({})).toBeNull();
+ });
+});
+
+/**
+ * The variables table is not the only attacker-reachable literal emitted into a
+ * ` {
+ const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT);
+ const literal = /var __hfCompId = (.*);/.exec(body)?.[1];
+ expect(literal).toBeDefined();
+ expect(JSON.parse(literal ?? "")).toBe(SCRIPT_BREAKOUT);
+ });
+
+ it("does not let the AUTHORED ROOT ID close the script element", () => {
+ const body = wrapScopedCompositionScript(
+ "console.log(1);",
+ "comp-a",
+ LABEL,
+ undefined,
+ "comp-a",
+ SCRIPT_BREAKOUT,
+ );
+ expect(body).not.toContain(" {
+ const body = wrapScopedCompositionScript("console.log(1);", "comp-a", LABEL, SCRIPT_BREAKOUT);
+ expect(body).not.toContain(" {
+ const body = wrapScopedCompositionScript("console.log(1);", "comp-a", SCRIPT_BREAKOUT);
+ expect(body).not.toContain(" breakout", () => {
+ it("does not let the wrapped SOURCE close the script element", () => {
+ const body = wrapInlineScriptWithErrorBoundary(`var a = "${SCRIPT_BREAKOUT}";`, "[err]");
+ expect(body).not.toContain(" {
+ const body = wrapInlineScriptWithErrorBoundary("var a = 1;", SCRIPT_BREAKOUT);
+ expect(body).not.toContain("` element.
+ *
+ * `` would close
+ * the element early and have the remainder parsed as markup. Rewriting every
+ * `<` to `<` removes the only byte that can start a closing tag, and is
+ * transparent to both `JSON.parse` and the JS string grammar, so the value the
+ * runtime reads is unchanged.
+ *
+ * Every dynamic literal in an emitted script body must go through here: a
+ * per-value guard on this surface has already been missed once, since the
+ * composition id reaches the emitted script through four separate literals.
+ */
+function jsonScriptLiteral(value: unknown): string {
+ return JSON.stringify(value).replace(/>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
- return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
+ const json = jsonScriptLiteral(variablesByComp);
+ return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
}