fix(core): escape < in the compiler-emitted variables script (#3071)

* fix(core): escape `<` in the compiler-emitted variables script

`<script>` is a raw-text element: HTML serialization does not escape its
content, and the tokenizer ends it at the first `</script`. The statement
`buildVariablesByCompScript` emits embeds composition variables via
`JSON.stringify`, which escapes `"` and `\` but not `/` — so a variable
value, key, or composition id containing `</script>` terminated the element
early and the remainder was parsed as markup, corrupting the compiled
document.

Rewrite `<` to its JSON unicode escape. This is transparent to JSON.parse,
so the table the runtime reads is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): centralize the JSON-in-script escape for every emitted literal

Escaping only the variables table left the composition id exploitable through
the wrapper it is emitted beside: wrapScopedCompositionScript serializes the
comp id, timeline comp id, authored root id, scope-selector override, error
label and two derived selector patterns with a bare JSON.stringify, and
wrapInlineScriptWithErrorBoundary does the same for the composition's own
source. All land in the same raw-text <script>, so any one of them could close
the element and have the remainder parsed as markup.

Route every literal through one jsonScriptLiteral helper instead of guarding
per value — the comp id alone reaches the emitted script through four separate
literals, which is how the first pass missed it.

Tests cover each wrapper literal via a serialize/reparse round trip, and every
payload now leads with a benign `<` ahead of its `</script`, so escaping only
the first match no longer passes.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-12 23:55:50 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 120ea37c2f
commit 91db93aa34
2 changed files with 172 additions and 13 deletions
@@ -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 = "</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", () => {
@@ -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);
});
});
@@ -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,
@@ -258,19 +278,19 @@ 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(){
@@ -278,7 +298,7 @@ export function wrapScopedCompositionScript(
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, "\\\\\\"");
};
@@ -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); } })();`;
}
/**
@@ -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});`;
}