Skip to content
Merged
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
139 changes: 137 additions & 2 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 @@ -699,13 +700,18 @@ window.__afterTimeline = window.__timelines.scene;
});

it("wraps unscoped composition script source as a string literal", () => {
const source = 'window.payload = "</script><script>window.pwned = true;</script>";';
const wrapped = wrapInlineScriptWithErrorBoundary(
'window.payload = "</script><script>window.pwned = true;</script>";',
source,
"[HyperFrames] composition script error:",
);

expect(wrapped).toContain("Function(");
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
// The literal carries the source verbatim, with `<` escaped so it cannot end the
// raw-text `<script>` this is emitted into.
expect(wrapped).not.toContain("</script");
const literal = /Function\((".*")\)/.exec(wrapped)?.[1];
expect(JSON.parse(literal ?? "")).toBe(source);
});

it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
Expand Down Expand Up @@ -886,3 +892,132 @@ 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.
*/
/**
* Every payload leads with a benign `<` before its `</script`, so escaping only the
* first `<` is not enough to pass: that pins the `/g` flag on the escape rather than
* merely "an escape ran". A lone `<` in a value is the common case (`a < b`, `<em>`),
* so a payload whose breakout is not the first `<` is the realistic one.
*/
const SCRIPT_BREAKOUT = "x<y</script><script>window.__pwned=1//";

/** 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 ?? "");
}

describe("buildVariablesByCompScript — <script> breakout", () => {
it("does not let a variable VALUE close the script element", () => {
const body = buildVariablesByCompScript({
"comp-a": { greeting: SCRIPT_BREAKOUT },
});
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_BREAKOUT]: "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_BREAKOUT]: { 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();
});
});

/**
* The variables table is not the only attacker-reachable literal emitted into a
* `<script>`: the wrapper the sub-composition scripts run inside embeds the
* composition id four times over (directly, as the timeline id, and inside two
* derived selector patterns), plus the authored root id, the scope-selector
* override and the error label. All of them are emitted into the same raw-text
* element, so each has to survive a serialize/reparse round trip.
*/
describe("wrapScopedCompositionScript — <script> breakout via the wrapper literals", () => {
const LABEL = "[HyperFrames] composition script error:";

it("does not let a COMP ID close the script element", () => {
const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT);
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});

it("keeps the comp id byte-identical — the escape is transparent", () => {
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("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});

it("does not let the SCOPE SELECTOR override close the script element", () => {
const body = wrapScopedCompositionScript("console.log(1);", "comp-a", LABEL, SCRIPT_BREAKOUT);
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});

it("does not let the ERROR LABEL close the script element", () => {
const body = wrapScopedCompositionScript("console.log(1);", "comp-a", SCRIPT_BREAKOUT);
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});
});

describe("wrapInlineScriptWithErrorBoundary — <script> breakout", () => {
it("does not let the wrapped SOURCE close the script element", () => {
const body = wrapInlineScriptWithErrorBoundary(`var a = "${SCRIPT_BREAKOUT}";`, "[err]");
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});

it("does not let the ERROR LABEL close the script element", () => {
const body = wrapInlineScriptWithErrorBoundary("var a = 1;", SCRIPT_BREAKOUT);
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});
});
46 changes: 35 additions & 11 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ export function scopeCssToComposition(
return root.toResult({ map: false }).css;
}

/**
* Serialize a value as a JS literal safe to emit inside a `<script>` element.
*
* `<script>` is a RAW TEXT element: HTML serialization does not escape its
* content, and the tokenizer ends the element at the first `</script` — in any
* string, comment or regex context. `JSON.stringify` escapes `"` and `\` but
* neither `<` nor `/`, so any dynamic literal carrying `</script>` 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(/</g, "\\u003c");
}

export function wrapScopedCompositionScript(
source: string,
compositionId: string,
Expand All @@ -258,27 +278,27 @@ export function wrapScopedCompositionScript(
timelineCompositionId = compositionId,
authoredRootId?: string | null,
): string {
const compositionIdLiteral = JSON.stringify(compositionId);
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
const errorLabelLiteral = JSON.stringify(errorLabel);
const compositionIdLiteral = jsonScriptLiteral(compositionId);
const timelineCompositionIdLiteral = jsonScriptLiteral(timelineCompositionId);
const errorLabelLiteral = jsonScriptLiteral(errorLabel);
const escapedCompositionId = escapeRegExp(compositionId);
const authoredRootIdLiteral = JSON.stringify(authoredRootId?.trim() || null);
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = JSON.stringify(
const authoredRootIdLiteral = jsonScriptLiteral(authoredRootId?.trim() || null);
const scopeSelectorLiteral = jsonScriptLiteral(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = jsonScriptLiteral(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
);
const timingSelectorPatternLiteral = JSON.stringify(
const timingSelectorPatternLiteral = jsonScriptLiteral(
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
);
const authoredRootIdFormsLiteral = JSON.stringify(
const authoredRootIdFormsLiteral = jsonScriptLiteral(
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
);
return `(function(){
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfAuthoredRootId = ${authoredRootIdLiteral};
var __hfAuthoredRootAttr = ${JSON.stringify(AUTHORED_ROOT_ID_ATTR)};
var __hfAuthoredRootAttr = ${jsonScriptLiteral(AUTHORED_ROOT_ID_ATTR)};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
};
Expand Down Expand Up @@ -585,7 +605,7 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
}

export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
return `(function(){ try { Function(${jsonScriptLiteral(source)}).call(window); } catch (_err) { console.error(${jsonScriptLiteral(errorLabel)}, _err); } })();`;
}

/**
Expand All @@ -601,10 +621,14 @@ 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.
*
* Values, keys and composition ids are all attacker-reachable, so the whole
* table goes through `jsonScriptLiteral` — see there for why.
*/
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 = jsonScriptLiteral(variablesByComp);
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
}
Loading