mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
feat(core): sanitize rich text on the way into a composition (#3141)
* feat(core): sanitize rich text on the way into a composition Studio's patch vocabulary was inline-style, attribute, html-attribute and text-content. text-content assigns textContent, and the text-field model escapes markup on the way out and refuses a change in child structure, so a styled span had no route into a composition file. Adds a rich-text operation with one, guarded by a single sanitizer called on both ends of the trip: in the browser so the preview shows what will be saved, and on the server because that is where the file is written. Tags and style properties are a small allowlist, and an unexpected tag loses its formatting rather than its words. Spans an edit adds get their ids in the same write, so a follow-up write cannot race it. No UI yet — this is the persistence contract the editor is built on. * fix(core): document and test the sanitizer boundary * fix(core): harden rich text sanitizer traversal
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { isRichTextFormattingTag, sanitizeRichTextChildren } from "./richTextSanitize";
|
||||
|
||||
function parseWithLinkedom(html: string): Element {
|
||||
const { document: doc } = parseHTML(
|
||||
`<!DOCTYPE html><html><body><div id="test-host">${html}</div></body></html>`,
|
||||
);
|
||||
const host = doc.getElementById("test-host");
|
||||
if (!host) throw new Error("test host was not parsed");
|
||||
return host as unknown as Element;
|
||||
}
|
||||
|
||||
// Both DOM implementations, every case. The contract must not depend on which
|
||||
// parser constructs the inert tree at a trust boundary.
|
||||
const PARSERS: Array<[string, (html: string) => Element]> = [
|
||||
[
|
||||
"jsdom",
|
||||
(html) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
return host;
|
||||
},
|
||||
],
|
||||
["linkedom", parseWithLinkedom],
|
||||
];
|
||||
|
||||
function clean(html: string, parse: (html: string) => Element): string {
|
||||
const host = parse(html);
|
||||
sanitizeRichTextChildren(host);
|
||||
return host.innerHTML;
|
||||
}
|
||||
|
||||
describe.each(PARSERS)("sanitizeRichTextChildren (%s)", (_name, parse) => {
|
||||
it("keeps a styled span, which is the whole point", () => {
|
||||
expect(clean('<span style="color: red">hi</span>', parse)).toBe(
|
||||
'<span style="color: red">hi</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps plain text untouched", () => {
|
||||
expect(clean("just words", parse)).toBe("just words");
|
||||
});
|
||||
|
||||
it("keeps nested formatting and its nesting", () => {
|
||||
expect(clean('<b><span style="color: red">x</span></b>', parse)).toBe(
|
||||
'<b><span style="color: red">x</span></b>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a line break", () => {
|
||||
expect(clean("a<br>b", parse)).toContain("<br>");
|
||||
});
|
||||
|
||||
it("removes a script and does not leave its source as visible text", () => {
|
||||
const out = clean("<script>alert(1)</script>keep", parse);
|
||||
expect(out).not.toContain("script");
|
||||
expect(out).not.toContain("alert");
|
||||
expect(out).toContain("keep");
|
||||
});
|
||||
|
||||
it("strips an event handler from a tag it otherwise keeps", () => {
|
||||
const out = clean('<span onclick="steal()" style="color: red">x</span>', parse);
|
||||
expect(out).not.toContain("onclick");
|
||||
expect(out).toContain("color: red");
|
||||
});
|
||||
|
||||
it("strips every attribute that is neither style nor an identity", () => {
|
||||
const out = clean('<span id="a" class="b" data-x="c" style="color: red">x</span>', parse);
|
||||
expect(out).not.toContain("id=");
|
||||
expect(out).not.toContain("class=");
|
||||
expect(out).not.toContain("data-x");
|
||||
expect(out).toContain("color: red");
|
||||
});
|
||||
|
||||
// The design panel tracks each text layer by this. Stripping it left the
|
||||
// panel unable to match a layer to its source after any inline style edit.
|
||||
it("keeps the attributes a text layer is tracked by", () => {
|
||||
const out = clean(
|
||||
'<span data-hf-text-key="child:1" data-hf-id="hf-abc" style="color: red">x</span>',
|
||||
parse,
|
||||
);
|
||||
expect(out).toContain('data-hf-text-key="child:1"');
|
||||
expect(out).toContain('data-hf-id="hf-abc"');
|
||||
});
|
||||
|
||||
it("drops an identity attribute whose value is not a bare token", () => {
|
||||
const out = clean(`<span data-hf-text-key='a" onload="alert(1)'>x</span>`, parse);
|
||||
expect(out).not.toContain("onload");
|
||||
expect(out).not.toContain("data-hf-text-key");
|
||||
});
|
||||
|
||||
// These are what the design panel writes onto those same spans. Sanitizing
|
||||
// them away did not stop a text edit changing layout, it deleted the layout
|
||||
// the user had already set: colouring one word dropped a sibling's size.
|
||||
it("keeps the typography the design panel authors on a text layer", () => {
|
||||
const out = clean(
|
||||
'<span style="font-family: Inter; font-size: 48px; letter-spacing: -1px; line-height: 1.2">x</span>',
|
||||
parse,
|
||||
);
|
||||
expect(out).toContain("font-family: Inter");
|
||||
expect(out).toContain("font-size: 48px");
|
||||
expect(out).toContain("letter-spacing: -1px");
|
||||
expect(out).toContain("line-height: 1.2");
|
||||
});
|
||||
|
||||
it("still refuses a value that reaches outside the stylesheet", () => {
|
||||
const out = clean(`<span style="font-family: url(http://x/f.woff)">x</span>`, parse);
|
||||
expect(out).not.toContain("url(");
|
||||
});
|
||||
|
||||
it("unwraps a tag that is not formatting, keeping its words in place", () => {
|
||||
expect(clean("before<div>middle</div>after", parse)).toBe("beforemiddleafter");
|
||||
});
|
||||
|
||||
it("unwraps deeply and keeps the formatting found inside", () => {
|
||||
const out = clean('<div><p><span style="color: red">deep</span></p></div>', parse);
|
||||
expect(out).toBe('<span style="color: red">deep</span>');
|
||||
});
|
||||
|
||||
it("keeps only the allowlisted style properties", () => {
|
||||
const out = clean('<span style="color: red; position: fixed; z-index: 99">x</span>', parse);
|
||||
expect(out).toContain("color: red");
|
||||
expect(out).not.toContain("position");
|
||||
expect(out).not.toContain("z-index");
|
||||
});
|
||||
|
||||
it("keeps every property the allowlist names", () => {
|
||||
const style =
|
||||
"color: red; background-color: blue; font-weight: 700; font-style: italic; text-decoration-line: underline";
|
||||
const out = clean(`<span style="${style}">x</span>`, parse);
|
||||
for (const property of [
|
||||
"color",
|
||||
"background-color",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"text-decoration-line",
|
||||
]) {
|
||||
expect(out).toContain(property);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a value that smuggles a url or a script in", () => {
|
||||
const out = clean(
|
||||
'<span style="background-color: url(javascript:alert(1)); color: red">x</span>',
|
||||
parse,
|
||||
);
|
||||
expect(out).not.toContain("javascript");
|
||||
expect(out).not.toContain("url(");
|
||||
expect(out).toContain("color: red");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an image event handler", "<img src=x onerror=alert(1)>safe", "safe"],
|
||||
["a script URL", '<a href="javascript:alert(1)">safe</a>', "safe"],
|
||||
["an SVG script", "<svg><script>alert(1)</script></svg>safe", "safe"],
|
||||
[
|
||||
"a legacy CSS expression",
|
||||
'<span style="color: expression(alert(1))">safe</span>',
|
||||
"<span>safe</span>",
|
||||
],
|
||||
[
|
||||
"an entity-encoded script scheme",
|
||||
'<span style="color: javascript:alert(1)">safe</span>',
|
||||
"<span>safe</span>",
|
||||
],
|
||||
[
|
||||
"a case-folded script scheme",
|
||||
'<span style="color: JAVASCRIPT:alert(1)">safe</span>',
|
||||
"<span>safe</span>",
|
||||
],
|
||||
])("rejects %s", (_case, html, expected) => {
|
||||
expect(clean(html, parse)).toBe(expected);
|
||||
});
|
||||
|
||||
it("drops the style attribute entirely when nothing in it survives", () => {
|
||||
expect(clean('<span style="position: fixed">x</span>', parse)).toBe("<span>x</span>");
|
||||
});
|
||||
|
||||
it("keeps a value carrying a function with its own separators", () => {
|
||||
const out = clean('<span style="color: rgb(1, 2, 3); font-style: italic">x</span>', parse);
|
||||
expect(out).toContain("rgb(1, 2, 3)");
|
||||
expect(out).toContain("font-style: italic");
|
||||
});
|
||||
|
||||
it("keeps a quoted semicolon inside a style value", () => {
|
||||
const out = clean(`<span style='font-family: "Roboto Mono; a"; color: red'>x</span>`, parse);
|
||||
expect(out).toContain("Roboto Mono; a");
|
||||
expect(out).toContain("color: red");
|
||||
});
|
||||
|
||||
it("removes a comment, which is neither text nor formatting", () => {
|
||||
expect(clean("a<!-- note -->b", parse)).toBe("ab");
|
||||
});
|
||||
|
||||
it("leaves an empty element alone", () => {
|
||||
expect(clean("", parse)).toBe("");
|
||||
});
|
||||
|
||||
it("does not produce unbalanced markup from an unclosed tag", () => {
|
||||
const out = clean('<span style="color: red">open', parse);
|
||||
expect(out).toBe('<span style="color: red">open</span>');
|
||||
});
|
||||
|
||||
it("is a fixed point", () => {
|
||||
const host = parse('<div><span onclick="steal()" style="color: red">x</span></div>');
|
||||
sanitizeRichTextChildren(host);
|
||||
const once = host.innerHTML;
|
||||
sanitizeRichTextChildren(host);
|
||||
expect(host.innerHTML).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes adversarially deep markup without recursive stack growth", () => {
|
||||
const depth = 15_000;
|
||||
const host = parseWithLinkedom(`${"<b>".repeat(depth)}x${"</b>".repeat(depth)}`);
|
||||
|
||||
expect(() => sanitizeRichTextChildren(host)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("isRichTextFormattingTag", () => {
|
||||
it("names the tags an inline edit may contain", () => {
|
||||
for (const tag of ["SPAN", "B", "STRONG", "I", "EM", "U", "BR"]) {
|
||||
expect(isRichTextFormattingTag(tag)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive, since the two parsers disagree about case", () => {
|
||||
expect(isRichTextFormattingTag("span")).toBe(true);
|
||||
});
|
||||
|
||||
it("says no to anything structural", () => {
|
||||
for (const tag of ["DIV", "P", "H1", "IMG", "SCRIPT", "A"]) {
|
||||
expect(isRichTextFormattingTag(tag)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* What inline formatting a composition file is allowed to receive.
|
||||
*
|
||||
* Editing text in the Studio preview can style a run of characters, which means
|
||||
* markup now travels from a contenteditable element into a file on disk. This
|
||||
* module is the only thing deciding what may make that trip. The server write
|
||||
* boundary applies it unconditionally before returning composition bytes.
|
||||
*
|
||||
* One module rather than two implementations. Two would drift, and the drift
|
||||
* would be a security bug rather than an inconsistency.
|
||||
*
|
||||
* It works on an element's subtree in place. Untrusted markup must be parsed in
|
||||
* an inert document before this function receives it.
|
||||
*/
|
||||
|
||||
/** Tags an inline text edit may contain. Everything else is not text styling. */
|
||||
const FORMATTING_TAGS = new Set(["SPAN", "B", "STRONG", "I", "EM", "U", "BR"]);
|
||||
|
||||
/**
|
||||
* Style properties a formatting tag may carry.
|
||||
*
|
||||
* This was paint-only, on the reasoning that a property which moves or resizes
|
||||
* text would let an edit inside one element change the composition's layout,
|
||||
* and layout is the design panel's job. The reasoning was wrong about who was
|
||||
* being restricted: the design panel writes exactly these typography
|
||||
* properties onto exactly these spans, as its text layers. Sanitizing them
|
||||
* away did not stop text from changing layout, it deleted the layout the user
|
||||
* had already set — colouring one word silently dropped a sibling layer's font
|
||||
* size. The line that matters is the one below, values that reach outside the
|
||||
* stylesheet, not which of its own properties the editor is allowed to keep.
|
||||
*/
|
||||
const FORMATTING_STYLE_PROPS = new Set([
|
||||
"color",
|
||||
"background-color",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"text-decoration-line",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"letter-spacing",
|
||||
"line-height",
|
||||
// Paints the glyph fill and inherits, so an ancestor that sets it wins over
|
||||
// any `color` below. The editor mirrors a run's colour into it when that is
|
||||
// happening, and stripping it here would put the colour back to invisible.
|
||||
"-webkit-text-fill-color",
|
||||
]);
|
||||
|
||||
// Keep this list limited to properties whose grammar cannot fetch a resource.
|
||||
// Adding a URL-consuming property also requires decoding CSS escapes before
|
||||
// UNSAFE_VALUE can be a sufficient guard.
|
||||
|
||||
/**
|
||||
* Attributes a formatting tag may carry.
|
||||
*
|
||||
* The identity a text layer is tracked by. Everything else is dropped: a
|
||||
* contenteditable is a paste target, and an event handler or an id that
|
||||
* shadows a composition's own is not formatting.
|
||||
*/
|
||||
const FORMATTING_ATTRS = new Set(["data-hf-text-key", "data-hf-id"]);
|
||||
|
||||
/**
|
||||
* What those attributes are allowed to look like: a bare token, nothing else.
|
||||
* `:` is deliberate because text keys use selector-like tokens such as
|
||||
* `child:1`; neither allowed attribute is interpreted as a URL.
|
||||
*/
|
||||
const SAFE_ATTR_VALUE = /^[A-Za-z0-9_:-]+$/;
|
||||
|
||||
/**
|
||||
* Tags dropped whole rather than unwrapped.
|
||||
*
|
||||
* Everything else is unwrapped, so an unexpected tag costs the user its
|
||||
* formatting and not their words. These are the ones whose contents are not
|
||||
* words: unwrapping a script would turn its source into visible text.
|
||||
*/
|
||||
const OPAQUE_TAGS = new Set([
|
||||
"SCRIPT",
|
||||
"STYLE",
|
||||
"TEMPLATE",
|
||||
"NOSCRIPT",
|
||||
"IFRAME",
|
||||
"OBJECT",
|
||||
"EMBED",
|
||||
"SVG",
|
||||
"MATH",
|
||||
]);
|
||||
|
||||
/** Anything that reaches out of the stylesheet, in a property that should not. */
|
||||
const UNSAFE_VALUE = /url\(|expression\(|javascript:|vbscript:|@import|<\//i;
|
||||
|
||||
const ELEMENT_NODE = 1;
|
||||
const TEXT_NODE = 3;
|
||||
|
||||
type SanitizerFrame =
|
||||
| { phase: "visit"; node: Node }
|
||||
| { phase: "sanitize"; element: Element; tag: string };
|
||||
|
||||
export function isRichTextFormattingTag(tagName: string): boolean {
|
||||
return FORMATTING_TAGS.has(tagName.toUpperCase());
|
||||
}
|
||||
|
||||
function isElementNode(node: Node): node is Element {
|
||||
return node.nodeType === ELEMENT_NODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip everything but allowed formatting from an element's contents, in place.
|
||||
*
|
||||
* The element itself is never touched, only what is inside it. Callers own the
|
||||
* element, and it is the composition's, not the editor's, to rewrite.
|
||||
*
|
||||
* When the children came from untrusted markup, callers must parse that markup
|
||||
* into an inert document (for example linkedom or a detached DOMParser document)
|
||||
* first. Never assign untrusted HTML to a live DOM element and then call this
|
||||
* function: active content can run before sanitization begins.
|
||||
*/
|
||||
export function sanitizeRichTextChildren(parent: Element): void {
|
||||
const pending: SanitizerFrame[] = Array.from(
|
||||
parent.childNodes,
|
||||
(node): SanitizerFrame => ({ phase: "visit", node }),
|
||||
).reverse();
|
||||
|
||||
// Post-order without recursion: adversarially deep pasted markup must not
|
||||
// exhaust either the server or browser call stack.
|
||||
for (let frame = pending.pop(); frame; frame = pending.pop()) {
|
||||
if (frame.phase === "sanitize") {
|
||||
if (!FORMATTING_TAGS.has(frame.tag)) unwrap(frame.element);
|
||||
else stripAttributes(frame.element);
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = frame.node;
|
||||
if (child.nodeType === TEXT_NODE) continue;
|
||||
|
||||
if (!isElementNode(child)) {
|
||||
// Comments and processing instructions are neither words nor formatting.
|
||||
child.parentNode?.removeChild(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = child;
|
||||
const tag = element.tagName.toUpperCase();
|
||||
|
||||
if (OPAQUE_TAGS.has(tag)) {
|
||||
element.parentNode?.removeChild(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.push({ phase: "sanitize", element, tag });
|
||||
for (const descendant of Array.from(element.childNodes).reverse()) {
|
||||
pending.push({ phase: "visit", node: descendant });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace an element with its own children, keeping their order and place. */
|
||||
function unwrap(element: Element): void {
|
||||
const parent = element.parentNode;
|
||||
if (!parent) return;
|
||||
while (element.firstChild) parent.insertBefore(element.firstChild, element);
|
||||
parent.removeChild(element);
|
||||
}
|
||||
|
||||
/** Leave a kept tag with a filtered style attribute and its identity, no more. */
|
||||
function stripAttributes(element: Element): void {
|
||||
const style = element.getAttribute("style");
|
||||
for (const name of Array.from(element.getAttributeNames())) {
|
||||
const value = element.getAttribute(name) ?? "";
|
||||
if (FORMATTING_ATTRS.has(name.toLowerCase()) && SAFE_ATTR_VALUE.test(value)) continue;
|
||||
element.removeAttribute(name);
|
||||
}
|
||||
if (style === null) return;
|
||||
const safe = filterStyle(style);
|
||||
if (safe) element.setAttribute("style", safe);
|
||||
else element.removeAttribute("style");
|
||||
}
|
||||
|
||||
/** Keep only the allowlisted declarations, and only if their values are inert. */
|
||||
function filterStyle(style: string): string {
|
||||
return splitDeclarations(style)
|
||||
.map((declaration) => {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon === -1) return null;
|
||||
const property = declaration.slice(0, colon).trim().toLowerCase();
|
||||
const value = declaration.slice(colon + 1).trim();
|
||||
if (!FORMATTING_STYLE_PROPS.has(property)) return null;
|
||||
if (!value || UNSAFE_VALUE.test(value)) return null;
|
||||
return `${property}: ${value}`;
|
||||
})
|
||||
.filter((declaration): declaration is string => declaration !== null)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function isQuoteDelimiter(char: string): char is "'" | '"' {
|
||||
return char === "'" || char === '"';
|
||||
}
|
||||
|
||||
function nextParenthesisDepth(depth: number, char: string): number {
|
||||
if (char === "(") return depth + 1;
|
||||
if (char === ")") return Math.max(0, depth - 1);
|
||||
return depth;
|
||||
}
|
||||
|
||||
function isDeclarationSeparator(char: string, depth: number, quote: "'" | '"' | null): boolean {
|
||||
return char === ";" && depth === 0 && quote === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split on the semicolons that separate declarations, not the ones inside a
|
||||
* value. `color: rgb(1, 2, 3)` is one declaration however many separators its
|
||||
* value contains.
|
||||
*/
|
||||
function splitDeclarations(style: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
let current = "";
|
||||
let depth = 0;
|
||||
let quote: "'" | '"' | null = null;
|
||||
for (const char of style) {
|
||||
if (char === quote) quote = null;
|
||||
else if (quote === null && isQuoteDelimiter(char)) quote = char;
|
||||
else if (isDeclarationSeparator(char, depth, quote)) {
|
||||
declarations.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
} else if (quote === null) {
|
||||
depth = nextParenthesisDepth(depth, char);
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current.trim()) declarations.push(current);
|
||||
return declarations;
|
||||
}
|
||||
Reference in New Issue
Block a user