From 8db9768c36949c0b2b5c30648e4bf1de5ff4c0b9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 6 Aug 2026 13:25:17 -0700 Subject: [PATCH 1/2] fix(core): escape `<` in the compiler-emitted variables script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` 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 --- .../src/compiler/compositionScoping.test.ts | 58 +++++++++++++++++++ .../core/src/compiler/compositionScoping.ts | 11 +++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 7985c9f809..75cde782ba 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, @@ -886,3 +887,60 @@ window.__timelines['intro'] = tl; expect(gsapTargets).toEqual([["HELLO"]]); }); }); + +/** + * The emitted statement is placed inside 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(); + }); +}); diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index cbe75b0f87..2566660b66 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -601,10 +601,19 @@ 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. + * + * Every `<` is rewritten to its JSON unicode escape because this body is + * emitted into a `` would otherwise close the + * element early and the remainder would parse as markup. The escape is + * transparent to `JSON.parse`, so the value the runtime reads is unchanged. */ export function buildVariablesByCompScript( variablesByComp: Record>, ): string | null { if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null; - return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`; + const json = JSON.stringify(variablesByComp).replace(/ Date: Wed, 12 Aug 2026 17:45:53 -0700 Subject: [PATCH 2/2] fix(core): centralize the JSON-in-script escape for every emitted literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ";'; 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 `` 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(/` element, and `` would otherwise close the - * element early and the remainder would parse as markup. The escape is - * transparent to `JSON.parse`, so the value the runtime reads is unchanged. + * 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 | null { if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null; - const json = JSON.stringify(variablesByComp).replace(/