Repository: nhn/tui.editor (ts)

Issue:
Empty tagBlacklist causes the editor to crash
## Describe the bug
Creating editor instance multiple times causes the editor to crash.

Because `tagBlacklist` is empty so that `querySelectorAll` in [`findNodes`](https://github.com/nhn/tui.editor/blob/vue-editor%403.0.1/apps/editor/src/sanitizer/htmlSanitizer.ts#L86) get an empty string.

And empty `tagBlacklist` comes from [`splice`](https://github.com/nhn/tui.editor/blob/vue-editor%403.0.1/apps/editor/src/sanitizer/htmlSanitizer.ts#L67).
If `indexOf` return -1, `splice` remove the last item of `tagBlacklist`. But in fact, nothing should be removed.

## To Reproduce
Steps to reproduce the behavior:
1. Define a custom plugin that has toHTMLRenderers with htmlBlock, while the htmlBlock tag is in `CAN_BE_TAG_WHITE_LIST`.
2. Creating editor instance multiple times with the custom plugin.
3. The editor will crash.

## Expected behavior
The editor should not crash.

## Additional context
Version 3.0.1


Pull request description:
fix: empty tagBlacklist cause the editor to crash
fix #1678 

### Please check if the PR fulfills these requirements
- [x] It's the right issue type on the title
- [x] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] The commit message follows our guidelines
- [ ] Tests for the changes have been added (for bug fixes/features)
- [ ] Docs have been added/updated (for bug fixes/features)
- [ ] It does not introduce a breaking change or has a description of the breaking change

### Description

Fixed wrong tagBlacklist item remove.

Patch:
diff --git a/apps/editor/src/css/editor.css b/apps/editor/src/css/editor.css
index 1b39db4c..aa02db62 100644
--- a/apps/editor/src/css/editor.css
+++ b/apps/editor/src/css/editor.css
@@ -179,7 +179,7 @@
 }
 
 .toastui-editor-defaultUI .toastui-editor-ok-button {
-  width: 63px;
+  min-width: 63px;
   height: 32px;
   background-color: #00a9ff;
   color: #fff;
@@ -191,7 +191,7 @@
 }
 
 .toastui-editor-defaultUI .toastui-editor-close-button {
-  width: 63px;
+  min-width: 63px;
   height: 32px;
   background-color: #f7f9fc;
   border: 1px solid #dadde6;
@@ -508,7 +508,7 @@
 }
 
 .toastui-editor-popup-add-image .toastui-editor-file-name {
-  width: 62%;
+  width: 58%;
   display: inline-block;
   border-radius: 2px;
   border: 1px solid #e1e3e9;
@@ -527,7 +527,7 @@
 }
 
 .toastui-editor-popup-add-image .toastui-editor-file-select-button {
-  width: 29%;
+  width: 33%;
   margin-left: 5px;
   height: 32px;
   border-radius: 2px;
diff --git a/apps/editor/src/sanitizer/htmlSanitizer.ts b/apps/editor/src/sanitizer/htmlSanitizer.ts
index 777b3545..d44a1ac5 100644
--- a/apps/editor/src/sanitizer/htmlSanitizer.ts
+++ b/apps/editor/src/sanitizer/htmlSanitizer.ts
@@ -3,13 +3,14 @@ import isString from 'tui-code-snippet/type/isString';
 
 import { finalizeHtml, findNodes, removeNode } from '@/utils/dom';
 import { includes } from '@/utils/common';
+import { TAG_NAME } from '@/utils/constants';
 
 type GlobalEventTypes = keyof Omit<
   Omit<GlobalEventHandlers, 'addEventListener'>,
   'removeEventListener'
 >;
 
-const reXSSHtmlAttr = new RegExp(
+const reHTMLAttr = new RegExp(
   '^(abbr|align|alt|axis|bgcolor|border|cellpadding|cellspacing|class|clear|' +
     'color|cols|compact|coords|dir|face|headers|height|hreflang|hspace|' +
     'ismap|lang|language|nohref|nowrap|rel|rev|rows|rules|' +
@@ -19,7 +20,7 @@ const reXSSHtmlAttr = new RegExp(
   'g'
 );
 
-const reXSSSvgAttr = new RegExp(
+const reSvgAttr = new RegExp(
   '^(accent-height|accumulate|additive|alphabetic|arabic-form|ascent|' +
     'baseProfile|bbox|begin|by|calcMode|cap-height|class|color|color-rendering|content|' +
     'cx|cy|d|dx|dy|descent|display|dur|end|fill|fill-rule|font-family|font-size|font-stretch|' +
@@ -41,7 +42,9 @@ const reXSSSvgAttr = new RegExp(
 const reXSSAttr = /href|src|background/i;
 const reXSSAttrValue = /((java|vb|live)script|x):/i;
 const reOnEvent = /^on\S+/i;
+const reXSSOnload = new RegExp(`(<${TAG_NAME}[^>]*)(onload\\s*=)`, 'ig');
 const reComment = /<!--[\s\S]*?-->/g;
+const reWhitespace = /[ \t\r\n]/g;
 const DEFAULT_TAG_BLACK_LIST = [
   'script',
   'iframe',
@@ -58,13 +61,18 @@ const DEFAULT_TAG_BLACK_LIST = [
   'object',
   'details',
   'summary',
+  'base',
 ];
 const CAN_BE_TAG_WHITE_LIST = ['iframe', 'embed', 'details', 'summary'];
 const tagBlacklist = [...DEFAULT_TAG_BLACK_LIST];
 
 export function registerTagWhitelistIfPossible(tagName: string) {
   if (includes(CAN_BE_TAG_WHITE_LIST, tagName)) {
-    tagBlacklist.splice(tagBlacklist.indexOf(tagName), 1);
+    const index = tagBlacklist.indexOf(tagName);
+
+    if (index > -1) {
+      tagBlacklist.splice(index, 1);
+    }
   }
 }
 
@@ -72,7 +80,7 @@ export function sanitizeHTML(html: string) {
   const root = document.createElement('div');
 
   if (isString(html)) {
-    html = html.replace(reComment, '');
+    html = html.replace(reComment, '').replace(reXSSOnload, '$1');
     root.innerHTML = html;
   }
 
@@ -91,7 +99,7 @@ function removeUnnecessaryTags(html: HTMLElement) {
 }
 
 function isXSSAttribute(attrName: string, attrValue: string) {
-  return attrName.match(reXSSAttr) && attrValue.match(reXSSAttrValue);
+  return attrName.match(reXSSAttr) && attrValue.replace(reWhitespace, '').match(reXSSAttrValue);
 }
 
 function removeBlacklistAttributes(node: HTMLElement, blacklistAttrs: Attr[]) {
@@ -99,20 +107,18 @@ function removeBlacklistAttributes(node: HTMLElement, blacklistAttrs: Attr[]) {
     if (reOnEvent.test(name)) {
       node[name as GlobalEventTypes] = null;
     }
-
-    if (node.getAttribute(name)) {
-      node.removeAttribute(name);
-    }
+    node.removeAttribute(name);
   });
 }
 
 function leaveOnlyWhitelistAttribute(html: HTMLElement) {
   findNodes(html, '*').forEach((node) => {
     const { attributes } = node as HTMLElement;
+
     const blacklist = toArray(attributes).filter((attr) => {
       const { name, value } = attr;
-      const htmlAttr = name.match(reXSSHtmlAttr);
-      const svgAttr = name.match(reXSSSvgAttr);
+      const htmlAttr = name.match(reHTMLAttr);
+      const svgAttr = name.match(reSvgAttr);
       const xssAttr = htmlAttr && isXSSAttribute(name, value);
 
       return (!htmlAttr && !svgAttr) || xssAttr;
diff --git a/apps/editor/src/utils/constants.ts b/apps/editor/src/utils/constants.ts
index 9f733db6..7a119719 100644
--- a/apps/editor/src/utils/constants.ts
+++ b/apps/editor/src/utils/constants.ts
@@ -1,4 +1,4 @@
-const TAG_NAME = '[A-Za-z][A-Za-z0-9-]*';
+export const TAG_NAME = '[A-Za-z][A-Za-z0-9-]*';
 const ATTRIBUTE_NAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*';
 const UNQUOTED_VALUE = '[^"\'=<>`\\x00-\\x20]+';
 


Test patch:
diff --git a/apps/editor/src/__test__/unit/sanitizer.spec.ts b/apps/editor/src/__test__/unit/sanitizer.spec.ts
index 586aa58e..53102ee4 100644
--- a/apps/editor/src/__test__/unit/sanitizer.spec.ts
+++ b/apps/editor/src/__test__/unit/sanitizer.spec.ts
@@ -7,6 +7,7 @@ describe('sanitizeHTML', () => {
     expect(sanitizeHTML('<object>child die</object>')).toBe('');
     expect(sanitizeHTML('<details><summary>foo</summary></details>')).toBe('');
     expect(sanitizeHTML('<input type="image" />')).toBe('');
+    expect(sanitizeHTML('<base href=https://avocadot0ast.free.beeceptor.com>')).toBe('');
   });
 
   describe('attributes', () => {
@@ -29,6 +30,7 @@ describe('sanitizeHTML', () => {
         expect(sanitizeHTML('<a href="  LIVEScript: alert() ;">xss</a>')).toBe('<a>xss</a>');
         expect(sanitizeHTML(`123<a href=' javascript:alert();'>xss</a>`)).toBe('123<a>xss</a>');
         expect(sanitizeHTML(`<a href='javas<!-- -->cript:alert()'>xss</a>`)).toBe('<a>xss</a>');
+        expect(sanitizeHTML(`<a href='javas cript:alert()'>xss</a>`)).toBe('<a>xss</a>');
       });
 
       it('src attribute with img tag', () => {
@@ -38,6 +40,7 @@ describe('sanitizeHTML', () => {
         expect(sanitizeHTML('<img src="  VBscript: alert(); ">')).toBe('<img>');
         expect(sanitizeHTML('<img src="  LIVEScript: alert() ;">')).toBe('<img>');
         expect(sanitizeHTML('<img src="java<!-- -->script:alert();">')).toBe('<img>');
+        expect(sanitizeHTML('<img src="java script:alert();">')).toBe('<img>');
       });
 
       it('src and onerror attribute with img tag', () => {
@@ -49,6 +52,21 @@ describe('sanitizeHTML', () => {
           '<img alt="0">'
         );
       });
+
+      it('should remove onload attribute in svg', () => {
+        expect(sanitizeHTML('<svg><svg onload=alert(111)> </svg></svg>')).toBe(
+          '<svg><svg> </svg></svg>'
+        );
+        expect(sanitizeHTML('<svg><svg onLOad=alert(111)> </svg></svg>')).toBe(
+          '<svg><svg> </svg></svg>'
+        );
+        expect(sanitizeHTML('<svg><svg onLOad="alert(111)"> </svg></svg>')).toBe(
+          '<svg><svg> </svg></svg>'
+        );
+        expect(sanitizeHTML(`<svg><svg onLOad='alert(111)'> </svg></svg>`)).toBe(
+          '<svg><svg> </svg></svg>'
+        );
+      });
     });
 
     describe('registerTagWhitelistIfPossible', () => {