-
Notifications
You must be signed in to change notification settings - Fork 5.1k
fix(core): contain generated HTML CSS and script contexts #3800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
3425364
331fd03
9eb94be
dd9d68f
9a34b5b
6b7e59d
3bb18ca
3ccbcf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,15 @@ | |
| import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants"; | ||
| import { COMPOSITION_ATTRIBUTES } from "../compositionContract.js"; | ||
|
|
||
| function escapeHtmlAttributeValue(value: string): string { | ||
| return value | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """) | ||
| .replace(/'/g, "'"); | ||
| } | ||
|
|
||
| const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2"; | ||
| const FONT_WEIGHTS: Record<string, string> = { | ||
| Inter: "400;500;600;700;800;900", | ||
|
|
@@ -318,7 +327,7 @@ | |
| // Serialize zoom keyframes to data attribute | ||
| const zoomKeyframesAttr = | ||
| stageZoomKeyframes && stageZoomKeyframes.length > 0 | ||
| ? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "'")}'` | ||
| ? ` data-zoom-keyframes='${escapeHtmlAttributeValue(JSON.stringify(stageZoomKeyframes))}'` | ||
| : ""; | ||
|
|
||
| let styleTags = ""; | ||
|
|
@@ -361,27 +370,27 @@ | |
| : ""; | ||
|
|
||
| const customStylesAttr = customStyles | ||
| ? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "'")}'` | ||
| ? ` data-custom-styles='${escapeHtmlAttributeValue(JSON.stringify(customStyles))}'` | ||
| : ""; | ||
|
|
||
| const resolutionAttr = ` data-resolution="${resolution}"`; | ||
|
|
||
| return `<!DOCTYPE html> | ||
| <html data-composition-id="${compositionId}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}> | ||
| <html data-composition-id="${escapeHtmlAttributeValue(compositionId)}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| ${googleFontsLink} | ||
| ${gsapCdnTag} | ||
| ${styleTags ? ` ${styleTags}` : ""} | ||
Check warningCode scanning / CodeQL Unsafe HTML constructed from library input Medium
This HTML construction which depends on
library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting Error loading related location Loading This HTML construction which depends on library input might later allow cross-site scripting. This HTML construction which depends on library input might later allow cross-site scripting. This HTML construction which depends on library input might later allow cross-site scripting. This HTML construction which depends on library input might later allow cross-site scripting. This HTML construction which depends on library input might later allow cross-site scripting. |
||
| </head> | ||
| <body> | ||
| <div id="stage"> | ||
| <div id="stage-zoom-container"${zoomKeyframesAttr}> | ||
| ${elementsHtml} | ||
Check warningCode scanning / CodeQL Unsafe HTML constructed from library input Medium
This HTML construction which depends on
library input Error loading related location Loading cross-site scripting Error loading related location Loading |
||
| </div> | ||
| </div> | ||
| ${gsapScriptTag} | ||
Check warningCode scanning / CodeQL Unsafe HTML constructed from library input Medium
This HTML construction which depends on
library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading cross-site scripting Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading This HTML construction which depends on library input Error loading related location Loading |
||
| </body> | ||
| </html>`; | ||
| } | ||
|
|
@@ -470,7 +479,7 @@ | |
| // Serialize keyframes to data attribute if present | ||
| if (keyframes && keyframes.length > 0) { | ||
| const kfJson = JSON.stringify(keyframes); | ||
| baseAttrs.push(`data-keyframes='${kfJson.replace(/'/g, "'")}'`); | ||
| baseAttrs.push(`data-keyframes='${escapeHtmlAttributeValue(kfJson)}'`); | ||
| } | ||
|
|
||
| if (isTextElement(element)) { | ||
|
|
@@ -532,7 +541,7 @@ | |
| } | ||
| if (element.variableValues && Object.keys(element.variableValues).length > 0) { | ||
| const varJson = JSON.stringify(element.variableValues); | ||
| compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "'")}'`); | ||
| compositionAttrs.push(`data-variable-values='${escapeHtmlAttributeValue(varJson)}'`); | ||
| } | ||
| const attrs = compositionAttrs.join(" "); | ||
| // Build iframe src with variable values as query params if present | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| // Non-editable / non-visual elements that should never receive a stable id. | ||
| export const EXCLUDED_TAGS = new Set([ | ||
| "script", | ||
| "style", | ||
| "template", | ||
| "meta", | ||
| "link", | ||
| "noscript", | ||
| "base", | ||
| ]); | ||
|
|
||
| // 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random. | ||
| function fnv1a(str: string): number { | ||
| let h = 0x811c9dc5; | ||
| for (let i = 0; i < str.length; i++) { | ||
| h ^= str.charCodeAt(i); | ||
| h = Math.imul(h, 0x01000193); | ||
| } | ||
| return h >>> 0; | ||
| } | ||
|
|
||
| // 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision | ||
| // ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic | ||
| // clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId | ||
| // resolves the rare collision; width is deliberately small for readable ids. | ||
| function toHfId(hash: number): string { | ||
| const s = (hash >>> 0).toString(36); | ||
| // Use suffix (most-avalanched bits) for better distribution within the 4-char window. | ||
| const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0"); | ||
| return `hf-${four}`; | ||
| } | ||
|
|
||
| // Element's own direct text (TEXT_NODE children), not descendants'. | ||
| function ownText(el: Element): string { | ||
| let text = ""; | ||
| el.childNodes.forEach((n) => { | ||
| if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; | ||
| }); | ||
| return text.trim(); | ||
| } | ||
|
|
||
| function contentKey(el: Element): string { | ||
| // Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash. | ||
| // Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization. | ||
| const attrs = Array.from(el.attributes) | ||
| .filter((a) => !a.name.startsWith("data-hf-")) | ||
| .map((a) => `${a.name}\x00${a.value}`) | ||
| .sort() | ||
| .join("\x01"); | ||
| return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`; | ||
| } | ||
|
|
||
| /** | ||
| * Collision tiebreak for byte-identical siblings: document-order dup counter | ||
| * (`hash(key#N)`). This IS order-dependent — two identical `<span></span>` | ||
| * get different ids based on which comes first in the DOM. This is unavoidable: | ||
| * unique ids for byte-identical elements require a positional signal. | ||
| * | ||
| * Why this is safe in practice: once `ensureHfIds` write-back persists | ||
| * `data-hf-id` to source the attribute is physically bound to its element. | ||
| * Reordering identical siblings carries the attribute along → zero | ||
| * order-dependence post-persist. `ensureHfIds` skips pinned elements | ||
| * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation | ||
| * never re-exposes the ordering after first persist. | ||
| */ | ||
| // WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's | ||
| // preview route relies on mintHfId producing identical ids across mint contexts | ||
| // (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts | ||
| // "bundle returning untagged HTML gets same ids as disk". Any change that adds | ||
| // positional, session, or random input to the hash breaks that invariant and | ||
| // makes hf- ids diverge between disk and served HTML, silently corrupting | ||
| // drag-to-edit targeting. | ||
| export function mintHfId(el: Element, assigned: Set<string>): string { | ||
| const key = contentKey(el); | ||
| let id = toHfId(fnv1a(key)); | ||
| let dup = 0; | ||
| while (assigned.has(id)) { | ||
| dup += 1; | ||
| // Graceful fallback instead of a hard throw: rehashing only fails to find a | ||
| // free 4-char slot in a pathological document (~1.6M identical elements). | ||
| // Rather than crash the whole parse, widen the id with the dup counter — | ||
| // still deterministic and unique, just longer than the 4-char norm. | ||
| if (dup > 10000) { | ||
| id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`; | ||
| break; | ||
| } | ||
| id = toHfId(fnv1a(`${key}#${dup}`)); | ||
| } | ||
| assigned.add(id); | ||
| return id; | ||
| } | ||
|
|
||
| /** | ||
| * True for a sub-composition authoring template whose content the studio preview | ||
| * unwraps into the served body. Two accepted forms: | ||
| * A) `<template data-composition-id="X">…` — the id on the template itself. | ||
| * B) `<template id="X-template"><div data-composition-id="X">…` — the id on the | ||
| * wrapped root div (the form `hyperframes add` scaffolds and registry blocks use). | ||
| * Only these are treated as transparent containers for hf-id purposes. A plain | ||
| * `<template>` (runtime clone-source: list item, particle, etc.) must NOT get | ||
| * inner ids — its content is cloned N times into the live DOM, so a persisted | ||
| * inner id would be duplicated across every clone. Form B is distinguished from | ||
| * a clone-source by the presence of a direct `[data-composition-id]` child. | ||
| */ | ||
| function getChildElements(parent: Element): Element[] { | ||
| const directChildren = Array.from(parent.children); | ||
| if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren; | ||
| const content = (parent as HTMLTemplateElement).content; | ||
| if (content?.children.length) return Array.from(content.children); | ||
| return directChildren; | ||
| } | ||
|
|
||
| export function isCompositionTemplate(el: Element): boolean { | ||
| if (el.tagName.toLowerCase() !== "template") return false; | ||
| if (el.getAttribute("data-composition-id") !== null) return true; | ||
| for (const child of getChildElements(el)) { | ||
| if (child.getAttribute("data-composition-id") !== null) return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Walk document-order descendants, descending through composition templates | ||
| * while keeping plain templates inert. linkedom's querySelectorAll does not | ||
| * expose template contents, so callers that model the served composition use | ||
| * this traversal instead. | ||
| */ | ||
| export function walkCompositionDescendants( | ||
| root: Document | Element, | ||
| visit: (el: Element) => void, | ||
| ): void { | ||
| const rootElement: Element | null = | ||
| root.nodeType === 9 ? (root as Document).documentElement : (root as Element); | ||
| if (!rootElement) return; | ||
|
|
||
| const walk = (parent: Element): void => { | ||
| for (const child of getChildElements(parent)) { | ||
| const isTemplate = child.tagName.toLowerCase() === "template"; | ||
| if (isTemplate && !isCompositionTemplate(child)) continue; | ||
| visit(child); | ||
| walk(child); | ||
| } | ||
| }; | ||
|
|
||
| walk(rootElement); | ||
| } | ||
|
|
||
| // Internal DOM-only assignment: callers retain their parser and document identity. | ||
| export function assignHfIds(body: Element): void { | ||
| const assigned = new Set<string>(); | ||
| walkCompositionDescendants(body, (el) => { | ||
| const existing = el.getAttribute("data-hf-id"); | ||
| if (existing) assigned.add(existing); | ||
| }); | ||
| walkCompositionDescendants(body, (el) => { | ||
| if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) return; | ||
| if (el.getAttribute("data-hf-id")) return; | ||
| el.setAttribute("data-hf-id", mintHfId(el, assigned)); | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.