feat: add dangerouslySetInnerHTML attr

This adds are new attribute to Elements, "dangerouslySetInnerHTML", which like the same attribute from React allows one to take a stirng of unescaped HTML and render it unconditionally. This is of course a potentially dangerous operation that can open your app up to XSS attacks, but for interoperating with existing content management systems and libraries that output HTML (e.g. markdown renderers).

Using "dangerouslySetInnerHTML" on an element with children will generate an error within createElement, since it doesn't make sense to have both children and inner HTML.
This commit is contained in:
M. George Hansen 2021-01-02 21:24:17 -08:00
parent 4cf35429b5
commit 2bf1125b83
Signed by: mgeorgehansen
SSH key fingerprint: SHA256:JlIGiQLPyQ2RHTH3a2oVlb20Xkh9Glr8DUF4YTXHJxM
4 changed files with 61 additions and 3 deletions

View file

@ -54,6 +54,15 @@ export function createElement(
}
const attrs: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(props || {})) {
if (key === "dangerouslySetInnerHTML") {
if (children.length > 0) {
throw new Error(
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
);
}
attrs[key] = value.__html;
continue;
}
if (
typeof value !== "string" &&
typeof value !== "number" &&

View file

@ -25,7 +25,14 @@ export interface MicrodataAttributes {
itemRef?: string;
}
export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
export interface SetInnerHtmlAttributes {
dangerouslySetInnerHTML?: { __html: string };
}
export interface HTMLAttributes
extends RdfaAttributes,
MicrodataAttributes,
SetInnerHtmlAttributes {
accept?: string;
acceptCharset?: string;
accessKey?: string;

View file

@ -34,8 +34,12 @@ const renderElement = (elem: Element): string => {
let output = "";
output += startTag(elem);
for (const child of elem.children) {
output += renderElement(child);
if (elem.attributes.dangerouslySetInnerHTML != null) {
output += elem.attributes.dangerouslySetInnerHTML;
} else {
for (const child of elem.children) {
output += renderElement(child);
}
}
output += endTag(elem);
return output;
@ -55,6 +59,11 @@ const startTag = (elem: HTMLElement): string => {
continue;
}
// Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") {
continue;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";

View file

@ -117,4 +117,37 @@ testSuite("renderPage", ({ test, expect }) => {
"<!DOCTYPE html><html><div>test of</div><div>fragments</div></html>",
);
});
test("renders unescaped HTML via dangerouslySetInnerHTML", () => {
const html = renderPage(
<html>
<div
dangerouslySetInnerHTML={{
__html: "<div>red alert!</div>",
}}
/>
</html>,
);
expect(html).toEqual(
"<!DOCTYPE html><html><div><div>red alert!</div></div></html>",
);
});
test("throws error when both dangerouslySetInnerHTML and children prop present", () => {
expect(() =>
renderPage(
<html>
<div
dangerouslySetInnerHTML={{
__html: "<div>set phasers to kill</div>",
}}
>
<div>set phasers to stun</div>
</div>
</html>,
),
).toThrowErrorMatching(
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
);
});
});