Repository: exadel-inc/esl
Pull request #1057 (2022-06-24 17:36:27)

Feat(esl-utils, esl-image): sanitize html to prevent DOM XSS vulnarabilities
In scope of this PR:
 - create esl-utils dom helper to sanitize html
 - modify esl-image to prevent DOM XSS vulnerabilities

Closes: #1058


Patch:
diff --git a/src/modules/esl-image/core/esl-image-strategies.ts b/src/modules/esl-image/core/esl-image-strategies.ts
index 9d82b763c..798f6bdfa 100644
--- a/src/modules/esl-image/core/esl-image-strategies.ts
+++ b/src/modules/esl-image/core/esl-image-strategies.ts
@@ -1,3 +1,4 @@
+import {sanitize} from '../../esl-utils/dom/sanitize';
 import {ESLImage} from './esl-image';
 
 /**
@@ -67,11 +68,7 @@ export const STRATEGIES: ESLImageStrategyMap = {
       request.open('GET', shadowImg.src, true);
       request.onreadystatechange = (): void => {
         if (request.readyState !== 4 || request.status !== 200) return;
-        const tmp = document.createElement('div');
-        tmp.innerHTML = request.responseText;
-        Array.from(tmp.querySelectorAll('script') || [])
-          .forEach((node: Element) => node.remove());
-        img.innerHTML = tmp.innerHTML;
+        img.innerHTML = sanitize(request.responseText, ['svg']);
       };
       request.send();
     },
diff --git a/src/modules/esl-utils/all.ts b/src/modules/esl-utils/all.ts
index 2cc0c2822..56eb5f6df 100644
--- a/src/modules/esl-utils/all.ts
+++ b/src/modules/esl-utils/all.ts
@@ -38,6 +38,7 @@ export * from './dom/focus';
 export * from './dom/keys';
 export * from './dom/ready';
 export * from './dom/rtl';
+export * from './dom/sanitize';
 export * from './dom/script';
 export * from './dom/scroll';
 export * from './dom/traversing';
diff --git a/src/modules/esl-utils/dom/sanitize.ts b/src/modules/esl-utils/dom/sanitize.ts
new file mode 100644
index 000000000..c1da8ced4
--- /dev/null
+++ b/src/modules/esl-utils/dom/sanitize.ts
@@ -0,0 +1,58 @@
+/** checks if the attribute is dangerous */
+const isDangerousAttribute = (name: string, value: string): boolean => {
+  const val = value.replace(/\s+/g, '').toLowerCase();
+  if (['src', 'data', 'href', 'xlink:href'].includes(name)
+    && (val.includes('javascript:') || val.includes('data:text/html'))) return true;
+  if (name.indexOf('on') === 0) return true;
+  return false;
+};
+
+/** loops through each attribute, if it's dangerous, remove it */
+const removeDangerousAttributes = (el: Element): void => {
+  Array.from(el.attributes).forEach(({name, value}) => {
+    if (isDangerousAttribute(name, value)) {
+      el.removeAttribute(name);
+    }
+  });
+};
+
+/** removes malicious attributes from element and its children */
+const sanitizeElAttributes = (body: Element): void => {
+  Array.from(body.children).forEach((el) => {
+    removeDangerousAttributes(el);
+    sanitizeElAttributes(el);
+  });
+};
+
+/** removes script elements inside element */
+const removeScripts = (body: Element): void => {
+  const scripts = body.querySelectorAll('script');
+  Array.from(scripts).forEach((script) => script.remove());
+};
+
+const filterElements = (body: Element, allowedTopLevelTags: string[]): void => {
+  if (!allowedTopLevelTags.length) return;
+  Array.from(body.childNodes).forEach((el: Element) => {
+    if (!allowedTopLevelTags.includes(el.tagName)) body.removeChild(el);
+  });
+};
+
+/**
+ * Sanitizes html string from malicious attributes, values, and scripts
+ * Can also filter elements at the top nesting level by tag names.
+ * @param html - html string to sanitize
+ * @param allowedTopLevelTags - array of allowed tag names
+ */
+export function sanitize(html: string, allowedTopLevelTags: string[] = []): string {
+  // converts the string to an HTML document
+  const parser = new DOMParser();
+  const doc = parser.parseFromString(html, 'text/html');
+  const body = doc.body || document.createElement('body');
+  // sanitizes html
+  removeScripts(body);
+  sanitizeElAttributes(body);
+  // filter allowed tags
+  filterElements(body, allowedTopLevelTags);
+
+  return body.innerHTML;
+}


Test patch:
diff --git a/src/modules/esl-image/test/esl-image.test.ts b/src/modules/esl-image/test/esl-image.test.ts
new file mode 100644
index 000000000..237bec019
--- /dev/null
+++ b/src/modules/esl-image/test/esl-image.test.ts
@@ -0,0 +1,67 @@
+import {ESLImage} from '../core';
+import {mockXMLHttpRequest} from './xmlHttpRequest.mock';
+
+describe('ESLImageElement', () => {
+  describe('DOM XSS vulnerabilities in mode="inner-svg"', () => {
+
+    ESLImage.register('esl-image');
+    const el = new ESLImage();
+    el.src = 'http:/localhost/test.svg';
+    el.mode = 'inner-svg';
+    let mock: any;
+
+    beforeAll(() => {
+      mock = mockXMLHttpRequest();
+    });
+    afterAll(() => {
+      mock.cleanUp();
+    });
+
+    afterEach(() => {
+      if (!el.parentElement) return;
+      document.body.removeChild(el);
+    });
+
+    const SVG = '<svg><circle cx="5" cy="5" r="5"></circle></svg>';
+    const DANGER_ATTRS = '<svg onload="alert(document.cookie)"><circle cx="5" cy="5" r="5"></circle></svg>';
+    const DANGER_CONTENT_SCRIPT = '<svg><circle cx="5" cy="5" r="5"></circle><script>alert(\'xss\')</script></svg>';
+    const DANGER_CONTENT_DATA_URI = '<svg><a xlink:href="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K"><circle cx="5" cy="5" r="5"></circle></a></svg>';
+    const DANGER_CONTENT_LINK_HREF = '<svg><a href="javascript:alert(document.cookie)"><circle cx="5" cy="5" r="5"></circle></a></svg>';
+
+    test('load content from src and put it into innerHTML', () => {
+      mock.setData(SVG);
+      document.body.append(el);
+      expect(el.innerHTML).toBe(SVG);
+    });
+
+    test('SVG with dangerous on event attribute', () => {
+      mock.setData(DANGER_ATTRS);
+      document.body.append(el);
+      expect(el.innerHTML).toBe(SVG);
+    });
+
+    test('SVG with dangerous script in content', () => {
+      mock.setData(DANGER_CONTENT_SCRIPT);
+      document.body.append(el);
+      expect(el.innerHTML).toBe(SVG);
+    });
+
+    test('SVG with the link with malicious attr in content', () => {
+      mock.setData(DANGER_CONTENT_DATA_URI);
+      document.body.append(el);
+      expect(el.innerHTML).toBe('<svg><a><circle cx="5" cy="5" r="5"></circle></a></svg>');
+    });
+
+    test('SVG with the link with malicious href attr in content', () => {
+      mock.setData(DANGER_CONTENT_LINK_HREF);
+      document.body.append(el);
+      expect(el.innerHTML).toBe('<svg><a><circle cx="5" cy="5" r="5"></circle></a></svg>');
+    });
+
+    test('should load only SVG', () => {
+      mock.setData(`<a href="javascript:alert('xss')">Click me!</a>${SVG}`);
+      document.body.append(el);
+      expect(el.innerHTML).toBe(SVG);
+    });
+  });
+});
diff --git a/src/modules/esl-image/test/xmlHttpRequest.mock.ts b/src/modules/esl-image/test/xmlHttpRequest.mock.ts
new file mode 100644
index 000000000..aa322fdbb
--- /dev/null
+++ b/src/modules/esl-image/test/xmlHttpRequest.mock.ts
@@ -0,0 +1,70 @@
+interface mockXMLHttpRequestController {
+  open: jest.Mock;
+  onreadystatechange: jest.Mock;
+  cleanUp: () => void;
+  setData: (newData: string, newStatus?: number, newReadyState?: number) => void;
+  send: jest.Mock;
+}
+
+interface mockedXMLHttpRequest {
+  open: jest.Mock;
+  onreadystatechange: jest.Mock;
+  send: jest.Mock;
+  readyState: number;
+  status: number;
+  responseText: string;
+}
+
+export function mockXMLHttpRequest(): mockXMLHttpRequestController  {
+  // mocked properties
+  let readyState = 4;
+  let status = 200;
+  let responseText: string = 'responseText from mocked XMLHttpRequest';
+
+  // mocked functions
+  const open = jest.fn();
+  const onreadystatechange = jest.fn();
+  const send = jest.fn(function send() {
+    this.onreadystatechange();
+  });
+
+  // set data to return on next request
+  function setData(newData: string, newStatus: number = 200, newReadyState: number = 4): void {
+    responseText = newData;
+    status = newStatus;
+    readyState = newReadyState;
+  }
+
+  // clean up mocked implementation
+  function cleanUp(): void {
+    if (window.XMLHttpRequest) {
+      // @ts-ignore
+      delete window.XMLHttpRequest;
+    }
+  }
+
+  // mock constructor that will replace window XMLHttpRequest
+  function mockConstructor(): mockedXMLHttpRequest {
+    return {
+      readyState,
+      status,
+      responseText,
+      open,
+      onreadystatechange,
+      send
+    };
+  }
+
+  // override/define XMLHttpRequest
+  // @ts-ignore
+  window.XMLHttpRequest = mockConstructor;
+
+  // returning necessary methods for further testing and functionality
+  return {
+    open,
+    onreadystatechange,
+    cleanUp,
+    setData,
+    send
+  };
+}
diff --git a/src/modules/esl-media-query/test/esl-media-rule-list.test.ts b/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
index bf89f6abf..1c0f11f87 100644
--- a/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
+++ b/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
@@ -6,176 +6,167 @@ describe('ESLMediaRuleList', () => {
   const mockSmMatchMedia = getMatchMediaMock(ESLScreenBreakpoints.get('sm')!.mediaQuery);
   const mockMdMatchMedia = getMatchMediaMock(ESLScreenBreakpoints.get('md')!.mediaQuery);
 
-  describe('Integration cases:', () => {
-    test('Basic case: "1 | @sm => 2 | @md => 3" parsed correctly', () => {
-      const mrl = ESLMediaRuleList.parse('1 | @sm => 2 | @md => 3');
-      expect(mrl.rules.length).toBe(3);
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.active).toBeDefined();
-      expect(mrl.value).toBe('1');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('2');
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('3');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('3');
-    });
-
-    test('Extended media case parsed correctly: "1 | @sm and @md => 2"', () => {
-      const mrl = ESLMediaRuleList.parse('1 | @sm and @md => 2');
-      const listener = jest.fn();
-
-      expect(mrl.rules.length).toBe(2);
-
-      mrl.addEventListener(listener);
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('1');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('1');
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('1');
-      expect(listener).not.toBeCalled();
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('2');
-      expect(listener).toBeCalled();
-    });
-
-    test('Extended media case parsed correctly: "1 | @sm or @md => 2"', () => {
-      const mrl = ESLMediaRuleList.parse('1 | @sm or @md => 2');
-      const listener = jest.fn();
-
-      expect(mrl.rules.length).toBe(2);
-
-      mrl.addEventListener(listener);
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('1');
-      expect(listener).not.toBeCalled();
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('2');
-      expect(listener).toBeCalled();
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('2');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('2');
-    });
-
-    test('Tuple media query "all|@sm|@md ==> 1|2|3" correctly parsed', () => {
-      const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1|2|3');
-      expect(mrl.rules.length).toBe(3);
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('1');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = false;
-      expect(mrl.value).toBe('2');
-
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('3');
-
-      mockSmMatchMedia.matches = true;
-      mockMdMatchMedia.matches = true;
-      expect(mrl.value).toBe('3');
-    });
-
-    afterEach(() => {
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-    });
+  test('123 (default)', () => {
+    const mrl = ESLMediaRuleList.parse('123');
+    expect(mrl.rules.length).toBe(1);
+    expect(mrl.active.length).toBeGreaterThan(0);
+    expect(mrl.value).toBe('123');
+    expect(mrl.activeValue).toBe('123');
   });
 
-  describe('Basic cases:', () => {
-    test('Single value parsed to the single "all" rule', () => {
-      const mrl = ESLMediaRuleList.parse('123');
-      expect(mrl.rules.length).toBe(1);
-      expect(mrl.active.length).toBeGreaterThan(0);
-      expect(mrl.value).toBe('123');
-      expect(mrl.activeValue).toBe('123');
-    });
-
-    test('Single rule with media query "@sm => 1"', () => {
-      const mrl = ESLMediaRuleList.parse('@sm => 1');
-      expect(mrl.rules.length).toBe(1);
-    });
-
-    test('Single rule "@sm => 1" response to the matcher correctly', () => {
-      const mrl = ESLMediaRuleList.parse('@sm => 1');
-
-      mockSmMatchMedia.matches = false;
-      expect(mrl.value).toBe(undefined);
-
-      mockSmMatchMedia.matches = true;
-      expect(mrl.value).toBe('1');
-
-      mockSmMatchMedia.matches = false;
-      expect(mrl.value).toBe(undefined);
-    });
-
-    afterEach(() => {
-      mockSmMatchMedia.matches = false;
-      mockMdMatchMedia.matches = false;
-    });
+  test('@sm => 1', () => {
+    const mrl = ESLMediaRuleList.parse('@sm => 1');
+    expect(mrl.rules.length).toBe(1);
+
+    mockSmMatchMedia.matches = false;
+    expect(mrl.value).toBe(undefined);
+
+    mockSmMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = false;
+    expect(mrl.value).toBe(undefined);
+  });
+
+  test('1 | @sm => 2 | @md => 3', () => {
+    const mrl = ESLMediaRuleList.parse('1 | @sm => 2 | @md => 3');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.active).toBeDefined();
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('3');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('3');
+  });
+
+  test('1 | @sm and @md => 2', () => {
+    const mrl = ESLMediaRuleList.parse('1 | @sm and @md => 2');
+    const listener = jest.fn();
+
+    expect(mrl.rules.length).toBe(2);
+
+    mrl.addEventListener(listener);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
+    expect(listener).not.toBeCalled();
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+    expect(listener).toBeCalled();
+  });
+
+  test('1 | @sm or @md => 2', () => {
+    const mrl = ESLMediaRuleList.parse('1 | @sm or @md => 2');
+    const listener = jest.fn();
+
+    expect(mrl.rules.length).toBe(2);
+
+    mrl.addEventListener(listener);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+    expect(listener).not.toBeCalled();
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('2');
+    expect(listener).toBeCalled();
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+  });
+
+  test('all|@sm|@md ==> 1|2|3', () => {
+    const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1|2|3');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('3');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('3');
+  });
+  test('all|@sm|@md ==> 1|2', () => {
+    const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1|2');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+  });
+  test('all|@sm|@md ==> 1', () => {
+    const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
   });
 
-  describe('Partial tuple parsing', () => {
-    test('Single value is accepted and copied for each condition', () => {
-      const ruleList = ESLMediaRuleList.parseTuple('@sm|@md|@lg', '123');
-      expect(ruleList.rules.length).toBe(3);
-      for (const rule of ruleList.rules) {
-        expect(rule.payload).toBe('123');
-      }
-    });
-
-    test('Partial value parsing leads to autocomplete values with the last one', () => {
-      const ruleList = ESLMediaRuleList.parseTuple('@sm|@md|@lg', '1 | 12');
-      expect(ruleList.rules.length).toBe(3);
-      expect(ruleList.rules[0].payload).toBe('1');
-      expect(ruleList.rules[1].payload).toBe('12');
-      expect(ruleList.rules[2].payload).toBe('12');
-    });
-
-    test('Empty value to be parsed aas empty sting', () => {
-      const ruleList = ESLMediaRuleList.parseTuple('(min-width: 100px)', '');
-      expect(ruleList.rules.length).toBe(1);
-      expect(ruleList.rules[0].payload).toBe('');
-      expect(ruleList.rules[0].query.toString()).toBe('(min-width: 100px)');
-    });
-
-    test('Empty mask leads to all query', () => {
-      const ruleList = ESLMediaRuleList.parseTuple('', '');
-      expect(ruleList.rules.length).toBe(1);
-      expect(ruleList.rules[0].payload).toBe('');
-      expect(ruleList.rules[0].query.toString()).toBe('all');
-    });
-
-    test('Values cortege longer then mask cortege is not allowed', () => {
-      expect(() => ESLMediaRuleList.parseTuple('@xs', '1|2|3')).toThrowError();
-    });
+  afterEach(() => {
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
   });
 });
diff --git a/src/modules/esl-media-query/test/esl-screen-breakpoint.test.ts b/src/modules/esl-media-query/test/esl-screen-breakpoint.test.ts
index 9351f81ab..9f595eb68 100644
--- a/src/modules/esl-media-query/test/esl-screen-breakpoint.test.ts
+++ b/src/modules/esl-media-query/test/esl-screen-breakpoint.test.ts
@@ -1,80 +1,34 @@
 import {ESLScreenBreakpoints} from '../core';
 
 describe('ESLScreenBreakpoint tests', () => {
-  const {add, get} = ESLScreenBreakpoints;
-
-  test(
-    'Get un-existing shortcut returs undefined',
-    () => expect(get('some')).toBeUndefined()
-  );
-
   test('Get all breakpoints (default)', () => {
     expect(new Set(ESLScreenBreakpoints.names)).toEqual(new Set(['xs', 'sm', 'md', 'lg', 'xl']));
   });
 
-  test(
-    'Add custom breakpoint "SMALL"',
-    () => expect(add('SMALL', 300, 400)).toBeUndefined()
-  );
-  test(
-    'Get custom breakpoint "SMALL" regular query',
-    () => expect(get('SMALL')?.mediaQuery).toBe('(min-width: 300px) and (max-width: 400px)')
-  );
-
-  test(
-    'Get custom breakpoint "SMALL" ge query',
-    () => expect(get('SMALL')?.mediaQueryGE).toBe('(min-width: 300px)')
-  );
-
-  test(
-    'Get custom breakpoint "SMALL" le query',
-    () => expect(get('SMALL')?.mediaQueryLE).toBe('(max-width: 400px)')
-  );
-
-  test(
-    'Replace custom breakpoint "SMALL"',
-    () => expect(add('Small', 400, 500)?.mediaQuery)
-      .toBe('(min-width: 300px) and (max-width: 400px)')
-  );
-  test(
-    'Get updated custom breakpoint "SMALL" regular query',
-    () => expect(get('small')?.mediaQuery).toBe('(min-width: 400px) and (max-width: 500px)')
-  );
-
-  test(
-    'Add default custom breakpoint "xs"',
-    () => expect(add('xs', 11, 22)).not.toBeUndefined()
-  );
-
-  test(
-    'Check that default custom breakpoint "xs" updated',
-    () => expect(get('xs')?.mediaQuery).toBe('(min-width: 11px) and (max-width: 22px)')
-  );
+  test('add and get custom breakpoints tests', () => {
+    expect(ESLScreenBreakpoints.add('SMALL', 300, 400)).toBeUndefined();
+    expect(ESLScreenBreakpoints.get('SMALL')?.mediaQuery).toBe('(min-width: 300px) and (max-width: 400px)');
+    expect(ESLScreenBreakpoints.get('SMALL')?.mediaQueryGE).toBe('(min-width: 300px)');
+    expect(ESLScreenBreakpoints.get('SMALL')?.mediaQueryLE).toBe('(max-width: 400px)');
+    expect(ESLScreenBreakpoints.add('Small', 400, 500)?.mediaQuery)
+      .toBe('(min-width: 300px) and (max-width: 400px)');
+    expect(ESLScreenBreakpoints.get('small')?.mediaQuery).toBe('(min-width: 400px) and (max-width: 500px)');
+    ESLScreenBreakpoints.add('xs', 11, 22);
+    expect(ESLScreenBreakpoints.get('xs')?.mediaQuery).toBe('(min-width: 11px) and (max-width: 22px)');
+  });
 
   test('Get all breakpoints after adding custom', () => {
     expect(new Set(ESLScreenBreakpoints.names))
       .toEqual(new Set(['xs', 'sm', 'md', 'lg', 'xl', 'small']));
   });
 
-  test(
-    'Empty keys is not allowed in custom breakpoint name',
-    () => expect(() => add('', 200, 300)).toThrowError()
-  );
-  test(
-    'Non-Latin keys are not allowed in custom breakpoint name',
-    () => expect(() => add('небольшой', 200, 300)).toThrowError()
-  );
-  test(
-    'Extra "@" is not allowed in custom breakpoint name',
-    () => expect(() => add('@XS', 200, 300)).toThrowError()
-  );
-  test(
-    'Numeric key is not allowed in custom breakpoint name',
-    () => expect(() => add('123', 200, 300)).toThrowError()
-  );
-
-  test(
-    'Digits allowed as part of regular custom breakpoint name',
-    () => expect(() => add('xs123', 200, 300)).not.toThrowError()
-  );
+  test('Error tests', () => {
+    const testFn = ESLScreenBreakpoints.add;
+    expect(() => testFn('небольшой', 200, 300)).toThrowError();
+    expect(() => testFn('@XS', 200, 300)).toThrowError();
+    expect(() => testFn('', 200, 300)).toThrowError();
+    expect(() => testFn('234', 200, 300)).toThrowError();
+    expect(() => testFn('XS2', 200, 300)).not.toThrowError();
+    expect(ESLScreenBreakpoints.get('some')).toBeUndefined();
+  });
 });
diff --git a/src/modules/esl-utils/dom/test/sanitize.test.ts b/src/modules/esl-utils/dom/test/sanitize.test.ts
new file mode 100644
index 000000000..c8d8c0371
--- /dev/null
+++ b/src/modules/esl-utils/dom/test/sanitize.test.ts
@@ -0,0 +1,58 @@
+import {sanitize} from '../sanitize';
+
+describe('sanitize', () => {
+  test('should return the original markup', () => {
+    const markup = '<div><p>Text</p></div>';
+    const html = sanitize(markup);
+    expect(html).toBe(markup);
+  });
+
+  test('should clean dangerous script', () => {
+    const markup = '<div><p>Text</p></div>';
+    const html = sanitize(`<script>alert(document.cookie)</script>${markup}`);
+    expect(html).toBe(markup);
+  });
+
+  test('should clean dangerous script inside another elements', () => {
+    const html = sanitize('<div><div><script>alert(document.cookie)</script></div></div>');
+    expect(html).toBe('<div><div></div></div>');
+  });
+
+  test('should clean dangerous on event attributes', () => {
+    const markup = '<div><p>Text</p></div>';
+    const html = sanitize(`${markup}<img src="x" onerror="alert(document.cookie)">`);
+    expect(html).toBe(`${markup}<img src="x">`);
+  });
+
+  test('should clean dangerous href attribute with javascript content', () => {
+    const markup = '<div><p>Text</p></div>';
+    const html = sanitize(`<a href="javascript:alert(document.cookie)">${markup}</a>`);
+    expect(html).toBe(`<a>${markup}</a>`);
+  });
+
+  test('should clean dangerous src attribute with data-uri text/html content', () => {
+    const html = sanitize('<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K"></iframe>');
+    expect(html).toBe('<iframe></iframe>');
+  });
+
+  test('should clean dangerous data attribute with data-uri text/html content', () => {
+    const html = sanitize('<object data="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K"></object>');
+    expect(html).toBe('<object></object>');
+  });
+
+  test('should clean dangerous xlink:href attributes with data-uri text/html content', () => {
+    const markup = '<rect width="100" height="100"></rect>';
+    const html = sanitize(`<a xlink:href="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K">${markup}</a>`);
+    expect(html).toBe(`<a>${markup}</a>`);
+  });
+
+  test('should filter first level elements by tags', () => {
+    const html = sanitize('<h1>Title</h1><h2>Header</h2><div><p>Text</p></div>', ['H1']);
+    expect(html).toBe('<h1>Title</h1>');
+  });
+
+  test('should filter elements only at first level', () => {
+    const html = sanitize('<div><p>Text</p></div><p>Another text</p>', ['P']);
+    expect(html).toBe('<p>Another text</p>');
+  });
+});