Merge pull request #666 from uchar/fix/dangerouslySetInnerHTML

Fix/dangerously set inner html
This commit is contained in:
Joseph Garrone 2024-09-22 18:27:25 +02:00 committed by GitHub
commit 2a6b14adc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 2909 additions and 2162 deletions

View File

@ -75,7 +75,6 @@
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"eslint-plugin-storybook": "^0.6.7",
"@types/babel__generator": "^7.6.4",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
@ -88,6 +87,9 @@
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"isomorphic-dompurify": "^2.15.0",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.7",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
@ -103,12 +105,11 @@
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"tsx": "^4.15.5",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"yauzl": "^2.10.0",
"zod": "^3.17.10",
"evt": "^2.5.7",
"tsx": "^4.15.5"
"zod": "^3.17.10"
}
}

View File

@ -0,0 +1,220 @@
import DOMPurify from "isomorphic-dompurify";
type TagType = {
name: string;
attributes: AttributeType[];
};
type AttributeType = {
name: string;
matchRegex?: RegExp;
matchFunction?: (value: string) => boolean;
};
// implementation for org.owasp.html.HtmlPolicyBuilder
// https://www.javadoc.io/static/com.googlecode.owasp-java-html-sanitizer/owasp-java-html-sanitizer/20160628.1/index.html?org/owasp/html/HtmlPolicyBuilder.html
// It supports the methods that KCSanitizerPolicy needs and nothing more
export class HtmlPolicyBuilder {
private globalAttributesAllowed: Set<AttributeType> = new Set();
private tagsAllowed: Map<string, TagType> = new Map();
private tagsAllowedWithNoAttribute: Set<string> = new Set();
private currentAttribute: AttributeType | null = null;
private isStylingAllowed: boolean = false;
private allowedProtocols: Set<string> = new Set();
private enforceRelNofollow: boolean = false;
allowWithoutAttributes(tag: string): this {
this.tagsAllowedWithNoAttribute.add(tag);
return this;
}
// Adds the attributes for validation
allowAttributes(...args: string[]): this {
if (args.length) {
const attr = args[0];
this.currentAttribute = { name: attr }; // Default regex, will be set later
}
return this;
}
// Matching regex for value of allowed attributes
matching(matchingPattern: RegExp | ((value: string) => boolean)): this {
if (this.currentAttribute) {
if (matchingPattern instanceof RegExp) {
this.currentAttribute.matchRegex = matchingPattern;
} else {
this.currentAttribute.matchFunction = matchingPattern;
}
}
return this;
}
// Make attributes in prev call global
globally(): this {
if (this.currentAttribute) {
this.currentAttribute.matchRegex = /.*/;
this.globalAttributesAllowed.add(this.currentAttribute);
this.currentAttribute = null; // Reset after global application
}
return this;
}
// Allow styling globally
allowStyling(): this {
this.isStylingAllowed = true;
return this;
}
// Save attributes for specific tag
onElements(...tags: string[]): this {
if (this.currentAttribute) {
tags.forEach(tag => {
const element = this.tagsAllowed.get(tag) || { name: tag, attributes: [] };
element.attributes.push(this.currentAttribute!);
this.tagsAllowed.set(tag, element);
});
this.currentAttribute = null; // Reset after applying to elements
}
return this;
}
// Make specific tag allowed
allowElements(...tags: string[]): this {
tags.forEach(tag => {
if (!this.tagsAllowed.has(tag)) {
this.tagsAllowed.set(tag, { name: tag, attributes: [] });
}
});
return this;
}
// Handle rel=nofollow on links
requireRelNofollowOnLinks(): this {
this.enforceRelNofollow = true;
return this;
}
// Allow standard URL protocols (could include further implementation)
allowStandardUrlProtocols(): this {
this.allowedProtocols.add("http");
this.allowedProtocols.add("https");
this.allowedProtocols.add("mailto");
return this;
}
apply(html: string): string {
//Clear all previous configs first ( in case we used DOMPurify somewhere else )
DOMPurify.clearConfig();
DOMPurify.removeAllHooks();
this.setupHooks();
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()),
ALLOWED_ATTR: this.getAllowedAttributes(),
ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(),
ADD_TAGS: this.isStylingAllowed ? ["style"] : [],
ADD_ATTR: this.isStylingAllowed ? ["style"] : []
});
}
private setupHooks(): void {
// Check allowed attribute and global attributes and it doesnt exist in them remove it
DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => {
if (!hookEvent) return;
const tagName = currentNode.tagName.toLowerCase();
const allowedAttributes = this.tagsAllowed.get(tagName)?.attributes || [];
//Add global attributes to allowed attributes
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.push(attribute);
});
//Add style attribute to allowed attributes
if (this.isStylingAllowed) {
let styleAttribute: AttributeType = { name: "style", matchRegex: /.*/ };
allowedAttributes.push(styleAttribute);
}
// Check if the attribute is allowed
if (!allowedAttributes.some(attr => attr.name === hookEvent.attrName)) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
} else {
const attributeType = allowedAttributes.find(attr => attr.name === hookEvent.attrName);
if (attributeType) {
//Check if attribute value is allowed
if (attributeType.matchRegex && !attributeType.matchRegex.test(hookEvent.attrValue)) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
if (attributeType.matchFunction && !attributeType.matchFunction(hookEvent.attrValue)) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
}
}
// both attribute and value already checked so they should be ok
// set forceKeep to true to make sure next hooks won't delete them
// except for href that we will check later
if (hookEvent.attrName !== "href") {
hookEvent.keepAttr = true;
hookEvent.forceKeepAttr = true;
}
});
DOMPurify.addHook("afterSanitizeAttributes", currentNode => {
// if tag is not allowed to have no attribute then remove it completely
if (currentNode.attributes.length == 0 && currentNode.childNodes.length == 0) {
if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) {
currentNode.remove();
}
} else {
//in case of <a> or <img> if we have no attribute we need to remove them even if they have child
if (currentNode.tagName === "A" || currentNode.tagName === "IMG") {
if (currentNode.attributes.length == 0) {
//add currentNode children to parent node
while (currentNode.firstChild) {
currentNode?.parentNode?.insertBefore(currentNode.firstChild, currentNode);
}
// Remove the currentNode itself
currentNode.remove();
}
}
//
if (currentNode.tagName === "A") {
if (this.enforceRelNofollow) {
if (!currentNode.hasAttribute("rel")) {
currentNode.setAttribute("rel", "nofollow");
} else if (!currentNode.getAttribute("rel")?.includes("nofollow")) {
currentNode.setAttribute("rel", currentNode.getAttribute("rel") + " nofollow");
}
}
}
}
});
}
private getAllowedAttributes(): string[] {
const allowedAttributes: Set<string> = new Set();
this.tagsAllowed.forEach(element => {
element.attributes.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
});
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
return Array.from(allowedAttributes);
}
private getAllowedUriRegexp(): RegExp {
const protocols = Array.from(this.allowedProtocols).join("|");
return new RegExp(`^(?:${protocols})://`, "i");
}
}

View File

@ -0,0 +1,68 @@
import { KcSanitizerPolicy } from "keycloakify/tools/kcSanitize/KcSanitizerPolicy";
// implementation of keycloak java sanitize method ( KeycloakSanitizerMethod )
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33
export class KcSanitizer {
private static HREF_PATTERN = /\s+href="([^"]*)"/g;
private static textarea: HTMLTextAreaElement | null = null;
public static sanitize(html: string | null): string {
if (html == null) {
throw new Error("Cannot escape null value.");
}
if (html === "") return "";
html = this.decodeHtmlFull(html);
const sanitized = KcSanitizerPolicy.sanitize(html);
return this.fixURLs(sanitized);
}
private static decodeHtmlFull(html: string): string {
if (typeof window !== "undefined" && typeof document !== "undefined") {
return KcSanitizer.decodeHtmlOnClient(html);
} else {
throw new Error("not implemented");
// return await KcSanitizer.decodeHtmlOnServer(html);
}
}
// private static async decodeHtmlOnServer(html: string): Promise<string> {
// // Dynamically import html-entities only on the server side
// const { decode } = await import("html-entities");
// return decode(html);
// }
private static decodeHtmlOnClient(html: string): string {
if (!KcSanitizer.textarea) {
KcSanitizer.textarea = document.createElement("textarea");
}
KcSanitizer.textarea.innerHTML = html;
return KcSanitizer.textarea.value;
}
// This will remove unwanted characters from url
private static fixURLs(msg: string): string {
const HREF_PATTERN = this.HREF_PATTERN;
const result = [];
let last = 0;
let match: RegExpExecArray | null;
do {
match = HREF_PATTERN.exec(msg);
if (match) {
const href = match[0]
.replace(/&#61;/g, "=")
.replace(/\.\./g, ".")
.replace(/&amp;/g, "&");
result.push(msg.substring(last, match.index!));
result.push(href);
last = HREF_PATTERN.lastIndex;
}
} while (match);
result.push(msg.substring(last));
return result.join("");
}
}

View File

@ -0,0 +1,288 @@
import { HtmlPolicyBuilder } from "keycloakify/tools/kcSanitize/HtmlPolicyBuilder";
//implementation of java Sanitizer policy ( KeycloakSanitizerPolicy )
// All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL)
// Also replaced ?i with "i" tag as second parameter of RegExp
//https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerPolicy.java#L29
export class KcSanitizerPolicy {
public static readonly COLOR_NAME = new RegExp(
"(?:aqua|black|blue|fuchsia|gray|grey|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)"
);
public static readonly COLOR_CODE = new RegExp(
"(?:#(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?))"
);
public static readonly NUMBER_OR_PERCENT = new RegExp("[0-9]+%?");
public static readonly PARAGRAPH = new RegExp(
"(?:[\\p{L}\\p{N},'\\.\\s\\-_\\(\\)]|&[0-9]{2};)*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_ID = new RegExp("[a-zA-Z0-9\\:\\-_\\.]+");
public static readonly HTML_TITLE = new RegExp(
"[\\p{L}\\p{N}\\s\\-_',:\\[\\]!\\./\\\\\\(\\)&]*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_CLASS = new RegExp("[a-zA-Z0-9\\s,\\-_]+");
public static readonly ONSITE_URL = new RegExp(
"(?:[\\p{L}\\p{N}.#@\\$%+&;\\-_~,?=/!]+|#(\\w)+)",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly OFFSITE_URL = new RegExp(
"\\s*(?:(?:ht|f)tps?://|mailto:)[\\p{L}\\p{N}]+" +
"[\\p{L}\\p{N}\\p{Zs}.#@\\$%+&;:\\-_~,?=/!()]*\\s*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly NUMBER = new RegExp(
"[+-]?(?:(?:[0-9]+(?:\\.[0-9]*)?)|\\.[0-9]+)"
);
public static readonly NAME = new RegExp("[a-zA-Z0-9\\-_\\$]+");
public static readonly ALIGN = new RegExp(
"\\b(center|left|right|justify|char)\\b",
"i" // Case-insensitive flag
);
public static readonly VALIGN = new RegExp(
"\\b(baseline|bottom|middle|top)\\b",
"i" // Case-insensitive flag
);
public static readonly HISTORY_BACK = new RegExp(
"(?:javascript:)?\\Qhistory.go(-1)\\E"
);
public static readonly ONE_CHAR = new RegExp(
".?",
"s" // Dotall flag for . to match newlines
);
private static COLOR_NAME_OR_COLOR_CODE(s: string): boolean {
return (
KcSanitizerPolicy.COLOR_NAME.test(s) || KcSanitizerPolicy.COLOR_CODE.test(s)
);
}
private static ONSITE_OR_OFFSITE_URL(s: string): boolean {
return (
KcSanitizerPolicy.ONSITE_URL.test(s) || KcSanitizerPolicy.OFFSITE_URL.test(s)
);
}
public static sanitize(html: string) {
return new HtmlPolicyBuilder()
.allowWithoutAttributes("span")
.allowAttributes("id")
.matching(this.HTML_ID)
.globally()
.allowAttributes("class")
.matching(this.HTML_CLASS)
.globally()
.allowAttributes("lang")
.matching(/[a-zA-Z]{2,20}/)
.globally()
.allowAttributes("title")
.matching(this.HTML_TITLE)
.globally()
.allowStyling()
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("p")
.allowAttributes("for")
.matching(this.HTML_ID)
.onElements("label")
.allowAttributes("color")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("font")
.allowAttributes("face")
.matching(/[\w;, \-]+/)
.onElements("font")
.allowAttributes("size")
.matching(this.NUMBER)
.onElements("font")
.allowAttributes("href")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("a")
.allowStandardUrlProtocols()
.allowAttributes("nohref")
.onElements("a")
.allowAttributes("name")
.matching(this.NAME)
.onElements("a")
.allowAttributes("onfocus", "onblur", "onclick", "onmousedown", "onmouseup")
.matching(this.HISTORY_BACK)
.onElements("a")
.requireRelNofollowOnLinks()
.allowAttributes("src")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("img")
.allowAttributes("name")
.matching(this.NAME)
.onElements("img")
.allowAttributes("alt")
.matching(this.PARAGRAPH)
.onElements("img")
.allowAttributes("border", "hspace", "vspace")
.matching(this.NUMBER)
.onElements("img")
.allowAttributes("border", "cellpadding", "cellspacing")
.matching(this.NUMBER)
.onElements("table")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("table")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("table")
.allowAttributes("noresize")
.matching(new RegExp("noresize", "i"))
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("td", "th", "tr")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("td", "th")
.allowAttributes("abbr")
.matching(this.PARAGRAPH)
.onElements("td", "th")
.allowAttributes("axis", "headers")
.matching(this.NAME)
.onElements("td", "th")
.allowAttributes("scope")
.matching(new RegExp("(?:row|col)(?:group)?", "i"))
.onElements("td", "th")
.allowAttributes("nowrap")
.onElements("td", "th")
.allowAttributes("height", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("table", "td", "th", "tr", "img")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements(
"thead",
"tbody",
"tfoot",
"img",
"td",
"th",
"tr",
"colgroup",
"col"
)
.allowAttributes("valign")
.matching(this.VALIGN)
.onElements("thead", "tbody", "tfoot", "td", "th", "tr", "colgroup", "col")
.allowAttributes("charoff")
.matching(this.NUMBER_OR_PERCENT)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("char")
.matching(this.ONE_CHAR)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("colspan", "rowspan")
.matching(this.NUMBER)
.onElements("td", "th")
.allowAttributes("span", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("colgroup", "col")
.allowElements(
"a",
"label",
"noscript",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"i",
"b",
"u",
"strong",
"em",
"small",
"big",
"pre",
"code",
"cite",
"samp",
"sub",
"sup",
"strike",
"center",
"blockquote",
"hr",
"br",
"col",
"font",
"map",
"span",
"div",
"img",
"ul",
"ol",
"li",
"dd",
"dt",
"dl",
"tbody",
"thead",
"tfoot",
"table",
"td",
"th",
"tr",
"colgroup",
"fieldset",
"legend"
)
.apply(html);
}
}

View File

@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { KcSanitizer } from "keycloakify/tools/kcSanitize/KcSanitizer";
// Implementation of Keycloak Java method KeycloakSanitizerTest with bunch of more test for p tag styling
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/test/java/org/keycloak/theme/KeycloakSanitizerTest.java#L32
const testCases = [
{
description: "should handle escapes correctly",
cases: [
{
html: "<div class=\"kc-logo-text\"><script>alert('foo');</script><span>Keycloak</span></div>",
expectedResult: '<div class="kc-logo-text"><span>Keycloak</span></div>'
},
{
html: "<h1>Foo</h1>",
expectedResult: "<h1>Foo</h1>"
},
{
html: '<div class="kc-logo-text"><span>Keycloak</span></div><svg onload=alert(document.cookie);>',
expectedResult: '<div class="kc-logo-text"><span>Keycloak</span></div>'
},
{
html: null,
expectedResult: null
},
{
html: "",
expectedResult: ""
}
]
},
{
description: "should handle URLs correctly",
cases: [
{
html: "<p><a href='https://localhost'>link</a></p>",
expectedResult:
'<p><a href="https://localhost" rel="nofollow">link</a></p>'
},
{
html: '<p><a href="">link</a></p>',
expectedResult: "<p>link</p>"
},
{
html: "<p><a href=\"javascript:alert('hello!');\">link</a></p>",
expectedResult: "<p>link</p>"
},
{
html: '<p><a href="javascript:alert(document.domain);">link</a></p>',
expectedResult: "<p>link</p>"
},
{
html: '<p><a href="javascript&colon;alert(document.domain);">link</a></p>',
expectedResult: "<p>link</p>"
},
{
html: '<p><a href="javascript&\\0colon;alert(document.domain);">link</a></p>',
expectedResult: "<p>link</p>"
},
{
html: '<p><a href="javascript&amp;amp;\\0colon;alert(document.domain);">link</a></p>',
expectedResult: "<p>link</p>"
},
{
html: '<p><a href="https://localhost?key=123&msg=abc">link</a></p>',
expectedResult:
'<p><a href="https://localhost?key=123&msg=abc" rel="nofollow">link</a></p>'
},
{
html: '<p><a href="https://localhost?key=abc&msg=123">link2</a></p>',
expectedResult:
'<p><a href="https://localhost?key=abc&msg=123" rel="nofollow">link2</a></p>'
}
]
},
{
description: "should handle ordinary texts correctly",
cases: [
{
html: "Some text",
expectedResult: "Some text"
},
{
html: `text with "double quotation"`,
expectedResult: `text with "double quotation"`
},
{
html: `text with 'single quotation'`,
expectedResult: `text with 'single quotation'`
}
]
},
{
description: "should handle text styles correctly",
cases: [
{
html: "<p><strong>text</strong></p>",
expectedResult: "<p><strong>text</strong></p>"
},
{
html: "<p><b>text</b></p>",
expectedResult: "<p><b>text</b></p>"
},
{
html: '<p class="red"> red text </p>',
expectedResult: '<p class="red"> red text </p>'
},
{
html: '<p align="center"> <b>red text </b></p>',
expectedResult: '<p align="center"> <b>red text </b></p>'
},
{
html: '<p align="CenTer"> <b> Case-insensitive</b></p>',
expectedResult: '<p align="CenTer"> <b> Case-insensitive</b></p>'
},
{
html: '<p align="xyz"> <b>wrong value for align</b></p>',
expectedResult: "<p> <b>wrong value for align</b></p>"
},
{
html: '<p align="centercenter"> <b>wrong value for align</b></p>',
expectedResult: "<p> <b>wrong value for align</b></p>"
},
{
html: '<p style="font-size: 20px;">This is a paragraph with larger text.</p>',
expectedResult:
'<p style="font-size: 20px;">This is a paragraph with larger text.</p>'
},
{
html: "<h3> או נושא שתבחר</h3>",
expectedResult: "<h3> או נושא שתבחר</h3>"
}
]
},
{
description: "should handle styles correctly",
cases: [
{
html: '<table border="5"> </table>',
expectedResult: '<table border="5"> </table>'
},
{
html: '<table border="xyz"> </table>',
expectedResult: "<table> </table>"
},
{
html: '<font color="red"> Content </font>',
expectedResult: '<font color="red"> Content </font>'
}
]
}
];
const assertResult = (expectedResult: string | null, html: string | null): void => {
if (html === null) {
expect(KcSanitizer.sanitize(html)).toThrow("Cannot escape null value.");
} else {
const result = KcSanitizer.sanitize(html);
expect(result).toBe(expectedResult);
}
};
// Server-side tests
// describe("KcSanitizer - Server Side", () => {
// for (const group of testCases) {
// describe(group.description, () => {
// for (const test of group.cases) {
// it(`should handle ${test.html}`, async () => {
// await assertResult(test.expectedResult, test.html);
// });
// }
// });
// }
// });
describe("KcSanitizer - Client Side", () => {
const decodeHtmlEntities = (html: string): string => {
const entitiesMap: { [key: string]: string } = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#039;": "'"
};
return html.replace(
/&amp;|&lt;|&gt;|&quot;|&#039;/g,
entity => entitiesMap[entity] || entity
);
};
beforeAll(() => {
vi.stubGlobal("window", {});
// Mocking the `document.createElement` to simulate textarea behavior
vi.stubGlobal("document", {
createElement: (tagName: string) => {
if (tagName === "textarea") {
let _innerHTML = "";
return {
get innerHTML() {
return _innerHTML;
},
set innerHTML(html) {
_innerHTML = html;
this.value = decodeHtmlEntities(html); // Simulate decoding
},
value: "" // Mimic the textarea behavior where innerHTML -> value
};
}
throw new Error("Unsupported element");
}
});
});
for (const group of testCases) {
describe(group.description, () => {
for (const test of group.cases) {
it(`should handle ${test.html}`, () => {
if (test.html == null)
expect(() =>
assertResult(test.expectedResult, test.html)
).toThrow("Cannot escape null value.");
else assertResult(test.expectedResult, test.html);
});
}
});
}
});

4259
yarn.lock

File diff suppressed because it is too large Load Diff