mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
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:
co-authored by
Claude Fable 5
parent
120ea37c2f
commit
91db93aa34
@@ -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});`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user