Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ export {
} from "./compiler/rewriteSubCompPaths";
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export { decodeUrlPathVariants } from "./utils/urlPath";
export {
ALLOWED_HTML_ATTRS,
URI_BEARING_ATTRS,
DANGEROUS_URI_SCHEMES,
DANGEROUS_DATA_URI,
isAllowedHtmlAttribute,
isSafeAttributeValue,
} from "./utils/htmlAttrSafety";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
export {
HF_COLOR_GRADING_ATTR,
Expand Down
91 changes: 1 addition & 90 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "../../utils/htmlAttrSafety";

export interface SourceMutationTarget {
id?: string | null;
Expand Down Expand Up @@ -133,96 +134,6 @@ export interface PatchOperation {
value: string | null;
}

const ALLOWED_HTML_ATTRS = new Set([
// Identity & structure
"id",
"class",
"style",
"title",
"name",
"for",
"type",
// Internationalization
"lang",
"dir",
"translate",
// Interaction
"hidden",
"tabindex",
"draggable",
"contenteditable",
// Accessibility
"role",
"slot",
// Links & navigation
"href",
"target",
"rel",
// Media
"src",
"srcset",
"sizes",
"alt",
"poster",
"loading",
"decoding",
"crossorigin",
"preload",
"autoplay",
"loop",
"muted",
"controls",
"playsinline",
// Layout
"width",
"height",
"colspan",
"rowspan",
"scope",
// Form
"placeholder",
"value",
"min",
"max",
"step",
"pattern",
"required",
"disabled",
"readonly",
"checked",
"selected",
"multiple",
"accept",
"maxlength",
"minlength",
"rows",
"cols",
"wrap",
]);

const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;

function isAllowedHtmlAttribute(name: string): boolean {
const lower = name.toLowerCase();
if (lower.startsWith("on")) return false;
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
if (lower.startsWith("data-")) return true;
if (lower.startsWith("aria-")) return true;
return false;
}

const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]);

function isSafeAttributeValue(name: string, value: string): boolean {
if (URI_ATTRS.has(name.toLowerCase())) {
const trimmed = value.trim();
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
}
return true;
}

// fallow-ignore-next-line complexity
function patchStyleAttrString(style: string, property: string, value: string | null): string {
const props = new Map<string, string>();
Expand Down
96 changes: 96 additions & 0 deletions packages/core/src/utils/htmlAttrSafety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Shared HTML attribute safety constants.
*
* Single source of truth for attribute allowlists and dangerous-URI patterns
* used by sourceMutation (core), sdkCutover (studio), and mutate (sdk).
*/

export const ALLOWED_HTML_ATTRS = new Set([
"id",
"class",
"style",
"title",
"name",
"for",
"type",
"lang",
"dir",
"translate",
"hidden",
"tabindex",
"draggable",
"contenteditable",
"role",
"slot",
"href",
"target",
"rel",
"src",
"srcset",
"sizes",
"alt",
"poster",
"loading",
"decoding",
"crossorigin",
"preload",
"autoplay",
"loop",
"muted",
"controls",
"playsinline",
"width",
"height",
"colspan",
"rowspan",
"scope",
"placeholder",
"value",
"min",
"max",
"step",
"pattern",
"required",
"disabled",
"readonly",
"checked",
"selected",
"multiple",
"accept",
"maxlength",
"minlength",
"rows",
"cols",
"wrap",
]);

export const URI_BEARING_ATTRS = new Set([
"src",
"href",
"action",
"formaction",
"poster",
"srcset",
"xlink:href",
]);

export const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
export const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;

export function isAllowedHtmlAttribute(name: string): boolean {
const lower = name.toLowerCase();
if (lower.startsWith("on")) return false;
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
if (lower.startsWith("data-")) return true;
if (lower.startsWith("aria-")) return true;
return false;
}

export function isSafeAttributeValue(name: string, value: string): boolean {
if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
const trimmed = value.trim();
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
}
return true;
}
16 changes: 6 additions & 10 deletions packages/sdk/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";

// Tags that carry no editable content and must not enter the element tree.
Expand All @@ -27,14 +27,10 @@ const EXCLUDED_TAGS = new Set([
]);

// Snapshot text is TRIMMED for display (markup indentation produces noisy
// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText
// operate on raw text. el.text is a display value, not a round-trip identity.
function ownText(el: Element): string | null {
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
const trimmed = text.trim();
// whitespace text nodes). The raw text target is shared with setText so shadow
// value checks and dispatch serialization use the same DOM target.
function snapshotText(el: Element): string | null {
const trimmed = getOwnText(el).trim();
return trimmed.length > 0 ? trimmed : null;
}

Expand Down Expand Up @@ -147,7 +143,7 @@ function buildElement(
inlineStyles,
classNames,
attributes,
text: ownText(el),
text: snapshotText(el),
start,
duration,
trackIndex,
Expand Down
71 changes: 67 additions & 4 deletions packages/sdk/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,58 @@ function toKebab(prop: string): string {
}

/** Parse style attribute string → camelCase map (custom props kept as-is). */
interface StyleDeclarationScan {
depth: number;
quote: "'" | '"' | null;
skip: boolean;
}

function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void {
if (scan.quote) {
if (ch === "\\" && next) {
scan.skip = true;
return;
}
if (ch === scan.quote) scan.quote = null;
return;
}
if (ch === "'" || ch === '"') {
scan.quote = ch;
return;
}
if (ch === "(") scan.depth++;
else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1);
}

function splitStyleDeclarations(style: string): string[] {
const declarations: string[] = [];
const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false };
let start = 0;
for (let i = 0; i < style.length; i++) {
if (scan.skip) {
scan.skip = false;
continue;
}
const ch = style[i] ?? "";
if (ch === ";" && scan.depth === 0 && scan.quote === null) {
declarations.push(style.slice(start, i));
start = i + 1;
} else {
advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? "");
}
}
declarations.push(style.slice(start));
return declarations;
}

function parseStyleAttr(styleAttr: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of styleAttr.split(";")) {
for (const decl of splitStyleDeclarations(styleAttr)) {
const idx = decl.indexOf(":");
if (idx === -1) continue;
const rawProp = decl.slice(0, idx).trim();
const value = decl.slice(idx + 1).trim();
if (!rawProp || !value) continue;
if (!rawProp) continue;
result[toCamel(rawProp)] = value;
}
return result;
Expand Down Expand Up @@ -185,17 +229,36 @@ export function setElementStyles(el: Element, updates: Record<string, string | n

// ─── Text helpers ─────────────────────────────────────────────────────────────

/** Read only direct (non-descendant) text node content. */
function isHTMLElementTarget(el: Element): boolean {
const HTMLElementCtor = el.ownerDocument.defaultView?.HTMLElement;
if (HTMLElementCtor) return el instanceof HTMLElementCtor;
return "style" in el;
}

function resolveSingleChildTextTarget(el: Element): Element | null {
const inner = el.children.length === 1 ? el.firstElementChild : null;
return inner && isHTMLElementTarget(inner) ? inner : null;
}

/** Read the text target used by SDK setText. */
export function getOwnText(el: Element): string {
const singleChild = resolveSingleChildTextTarget(el);
if (singleChild) return singleChild.textContent ?? "";
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
return text;
}

/** Replace only direct text nodes — preserves child elements. */
/** Replace the SDK text target without destroying multi-child element structure. */
export function setOwnText(el: Element, text: string): void {
const singleChild = resolveSingleChildTextTarget(el);
if (singleChild) {
singleChild.textContent = text;
return;
}

const doc = el.ownerDocument;
const children = Array.from(el.childNodes);
// Track original position of the first text node so we restore there, not at firstChild.
Expand Down
Loading
Loading