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 ` 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 + * `` 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});`; } diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 3a4f86da70..d28f7622fc 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -4,8 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseHTML } from "linkedom"; import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; -import { bundleToSingleHtml } from "./htmlBundler"; +import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler"; import { resetUnknownEnumWarnings } from "../runtime/getVariables"; +import { sanitizeCssValue } from "../runtime/applyVariableBindings"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; function makeTempProject(files: Record): string { @@ -1510,3 +1511,75 @@ describe("bundleToSingleHtml unknown enum values", () => { ]); }); }); + +/** + * Composition variable values are emitted as CSS declarations inside a `