@kubex/zinc 1.1.175 → 1.1.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.175",
3
+ "version": "1.1.177",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -50,6 +50,7 @@
50
50
  "@shoelace-style/localize": "^3.2.1",
51
51
  "air-datepicker": "^3.5.3",
52
52
  "composed-offset-position": "^0.0.6",
53
+ "dompurify": "^3.4.16",
53
54
  "echarts": "^6.0.0",
54
55
  "emoji-mart": "^5.6.0",
55
56
  "lit": "^3.3.3",
@@ -59,8 +59,8 @@ export default class ZnChatMessage extends ZincElement {
59
59
  @property() sender: string = '';
60
60
 
61
61
  /**
62
- * The message body as an HTML string. When set, it is sanitized (scripts and event
63
- * handlers stripped), newlines become line breaks and bare URLs become links. When
62
+ * The message body as an HTML string. When set, it is sanitized down to formatting tags and
63
+ * http(s) or mailto links, newlines become line breaks and bare URLs become links. When
64
64
  * omitted the default slot is rendered instead.
65
65
  */
66
66
  @property() message: string = '';
@@ -217,11 +217,7 @@ export default class ZnChatMessage extends ZincElement {
217
217
  <slot></slot>`;
218
218
  }
219
219
 
220
- let content = cleanHTML(this.message);
221
- content = content.replace(/\r\n|\r|\n/g, '<br>');
222
- content = content.replace(/\b(((https?|ftp|dict):\/\/|www\.)[^'">\s]+\.[^'">\s]+)(?=\s|$)(?!["<>])/g,
223
- '<a href="$1" target="_blank">$1</a>');
224
- return unsafeHTML(content);
220
+ return unsafeHTML(cleanHTML(this.message));
225
221
  }
226
222
 
227
223
  /** HH:MM only — used on the system card. */
@@ -0,0 +1,72 @@
1
+ import '../../../dist/zn.min.js';
2
+ import { expect, fixture } from '@open-wc/testing';
3
+ import type { LitElement } from 'lit';
4
+
5
+ const messageContent = async (message: string) => {
6
+ const el = await fixture<LitElement>('<zn-chat-message sender="Sam"></zn-chat-message>');
7
+ el.setAttribute('message', message);
8
+ await el.updateComplete;
9
+ return el.shadowRoot!.querySelector<HTMLDivElement>('.message__content')!;
10
+ };
11
+
12
+ describe('<zn-chat-message>', () => {
13
+ it('should render a component', async () => {
14
+ const el = await fixture('<zn-chat-message></zn-chat-message>');
15
+
16
+ expect(el).to.exist;
17
+ });
18
+
19
+ it('should render formatting and blocks chat styles', async () => {
20
+ const content = await messageContent('<p><strong>Hi</strong></p><pre>code</pre><blockquote>quoted</blockquote>');
21
+
22
+ expect(content.querySelector('p strong')?.textContent).to.equal('Hi');
23
+ expect(content.querySelector('pre')?.textContent).to.equal('code');
24
+ expect(content.querySelector('blockquote')?.textContent).to.equal('quoted');
25
+ });
26
+
27
+ it('should strip scripts, handlers and links to unsafe schemes in any case', async () => {
28
+ const content = await messageContent(
29
+ '<a href="JAVASCRIPT:window.__xss = true">a</a><a href="data:text/html,x">b</a>'
30
+ + '<span onclick="window.__xss = true">c</span><script>window.__xss = true</script>'
31
+ );
32
+
33
+ content.querySelectorAll('a').forEach(link => expect(link.hasAttribute('href')).to.be.false);
34
+ expect(content.querySelector('[onclick], script')).to.be.null;
35
+ expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
36
+ });
37
+
38
+ it('should drop embedded content and styles with their contents', async () => {
39
+ const content = await messageContent(
40
+ '<iframe src="https://example.com"></iframe><object data="x"></object><embed src="x">'
41
+ + '<style>p{color:red}</style><img src="https://tracker.example.com/pixel.gif"><form><input></form>ok'
42
+ );
43
+
44
+ expect(content.querySelector('iframe, object, embed, style, img, form, input')).to.be.null;
45
+ expect(content.textContent).to.equal('ok');
46
+ });
47
+
48
+ it('should open links in a new tab without the opener', async () => {
49
+ const content = await messageContent('<a href="https://example.com/help">help</a>');
50
+ const link = content.querySelector('a')!;
51
+
52
+ expect(link.getAttribute('href')).to.equal('https://example.com/help');
53
+ expect(link.getAttribute('target')).to.equal('_blank');
54
+ expect(link.getAttribute('rel')).to.equal('noopener noreferrer');
55
+ });
56
+
57
+ it('should turn newlines into breaks and bare URLs into links', async () => {
58
+ const content = await messageContent('See https://example.com/a\nor www.example.com');
59
+ const links = content.querySelectorAll('a');
60
+
61
+ expect(content.querySelectorAll('br').length).to.equal(1);
62
+ expect(links[0].getAttribute('href')).to.equal('https://example.com/a');
63
+ expect(links[1].getAttribute('href')).to.equal('https://www.example.com/');
64
+ expect(links[1].textContent).to.equal('www.example.com');
65
+ });
66
+
67
+ it('should show plain text as text', async () => {
68
+ const content = await messageContent('1 &lt; 2 &amp;&amp; 3 &gt; 2');
69
+
70
+ expect(content.textContent).to.equal('1 < 2 && 3 > 2');
71
+ });
72
+ });
@@ -1,46 +1,14 @@
1
- function stringToHTML(str: string): HTMLElement {
2
- const parser = new DOMParser();
3
- const doc = parser.parseFromString(str, 'text/html');
4
- return doc.body;
5
- }
6
-
7
- function removeScripts(node: HTMLElement) {
8
- node.querySelectorAll('script').forEach((script) => {
9
- script.remove();
10
- });
11
- }
1
+ import {sanitizeHtml} from '../../utilities/sanitize-html';
12
2
 
13
- function isPossiblyDangerousAttribute(name: string, value: string): boolean {
14
- if (['src', 'href', 'xlink:href'].includes(name)) {
15
- // eslint-disable-next-line no-script-url -- defensively matching the scheme we want to strip
16
- if (value.includes('javascript:') || value.includes('data:text/html')) return true;
17
- }
18
-
19
- return name.startsWith('on');
20
- }
21
-
22
- function removeAttributes(elem: Element) {
23
- const attributes = elem.attributes;
24
- for (let i = attributes.length - 1; i >= 0; i--) {
25
- if (isPossiblyDangerousAttribute(attributes[i].name, attributes[i].value)) {
26
- elem.removeAttributeNode(attributes[i]);
27
- }
28
- }
29
- }
30
-
31
- function clean(html: Element) {
32
- const nodes = html.children;
33
- for (let i = nodes.length - 1; i >= 0; i--) {
34
- const node = nodes[i];
35
- removeAttributes(node);
36
- clean(node);
37
- }
38
- }
3
+ const BARE_URL = /\b((https?:\/\/|www\.)[^'">\s]+\.[^'">\s]+)(?=\s|$)(?!["<>])/g;
39
4
 
40
- /** Strip scripts and event/handler attributes from an untrusted HTML string. */
5
+ /**
6
+ * Sanitizes an untrusted chat message, turning newlines into breaks and bare URLs into links first so the
7
+ * links are vetted too.
8
+ */
41
9
  export function cleanHTML(message: string): string {
42
- const html = stringToHTML(message);
43
- removeScripts(html);
44
- clean(html);
45
- return html.innerHTML;
10
+ const linked = message
11
+ .replace(BARE_URL, url => `<a href="${url.startsWith('www.') ? 'https://' + url : url}">${url}</a>`)
12
+ .replace(/\r\n|\r|\n/g, '<br>');
13
+ return sanitizeHtml(linked, ['pre', 'code', 'blockquote']);
46
14
  }
@@ -2,9 +2,10 @@ import {classMap} from "lit/directives/class-map.js";
2
2
  import {deepQuerySelectorAll} from "../../utilities/query";
3
3
  import {HasSlotController} from "../../internal/slot";
4
4
  import {html, unsafeCSS} from 'lit';
5
- import {looksLikeHtml, sanitizeHtml} from './sanitize-html';
5
+ import {htmlToText, looksLikeHtml} from './html-text';
6
6
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
7
7
  import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js';
8
+ import {sanitizeHtml} from '../../utilities/sanitize-html';
8
9
  import {unsafeHTML} from 'lit/directives/unsafe-html.js';
9
10
  import ZincElement from "../../internal/zinc-element";
10
11
  import type {PropertyValues} from 'lit';
@@ -292,7 +293,7 @@ export default class ContentBlock extends ZincElement {
292
293
  protected truncateText() {
293
294
  const textContent = this.querySelector('[slot="text"]') as HTMLDivElement | null;
294
295
  if (!textContent) return '';
295
- const trimmed = textContent.innerText.replace(/<[^>]*>/g, '').trim();
296
+ const trimmed = htmlToText(textContent.innerText);
296
297
  return trimmed.length > 32 ? trimmed.substring(0, 32) + '...' : trimmed;
297
298
  }
298
299
 
@@ -63,4 +63,18 @@ describe('<zn-content-block>', () => {
63
63
  expect(content.textContent).to.not.contain('<br');
64
64
  expect(content.querySelectorAll('br').length).to.be.greaterThan(1);
65
65
  });
66
+
67
+ it('should decode entities and strip markup in the header preview', async () => {
68
+ const preview = async (body: string) => {
69
+ const el = await fixture<LitElement>(
70
+ `<zn-content-block><div slot="text">${body}</div></zn-content-block>`
71
+ );
72
+ await el.updateComplete;
73
+ return el.shadowRoot!.querySelector('.content-block-header__description')!.textContent;
74
+ };
75
+
76
+ expect(await preview('test&amp;nbsp;send')).to.equal('test send');
77
+ expect(await preview('&lt;p&gt;Hello&lt;/p&gt;&lt;p&gt;there&lt;/p&gt;')).to.equal('Hello there');
78
+ expect(await preview('From: Bob &lt;bob@example.com&gt;')).to.equal('From: Bob <bob@example.com>');
79
+ });
66
80
  });
@@ -0,0 +1,19 @@
1
+ import {sanitizeHtml} from '../../utilities/sanitize-html';
2
+
3
+ const HTML_TAG = /<\/?(p|div|span|strong|b|em|i|u|a|img|ul|ol|li|table|tr|td|h[1-6]|blockquote|font|center)\b[^>]*>/i;
4
+
5
+ /** Whether a plain-text body is really HTML source. `<br>` alone doesn't count. */
6
+ export function looksLikeHtml(text: string): boolean {
7
+ return HTML_TAG.test(text);
8
+ }
9
+
10
+ /** Plain text of a body that may be HTML source or text holding encoded entities like `&nbsp;`. */
11
+ export function htmlToText(source: string): string {
12
+ // Plain text is escaped so an address like `<bob@example.com>` isn't parsed as a tag
13
+ const markup = looksLikeHtml(source)
14
+ ? sanitizeHtml(source)
15
+ : source.replace(/<br\s*\/?>/gi, ' ').replace(/</g, '&lt;');
16
+ const parsed = new DOMParser().parseFromString(markup, 'text/html');
17
+ parsed.body.querySelectorAll('br, p, li').forEach((el) => el.after(' '));
18
+ return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim();
19
+ }
@@ -0,0 +1,52 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ const FORMATTING = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'a'];
4
+
5
+ // Allowed through so their text doesn't run together, then turned into paragraphs
6
+ const BLOCKS = ['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'tr', 'center', 'section'];
7
+
8
+ // Removed with their contents; any other disallowed element is unwrapped to its children.
9
+ // img is dropped because a remote src in an email is a tracking pixel.
10
+ const DROPPED = [
11
+ 'script', 'style', 'template', 'iframe', 'frame', 'object', 'embed', 'noscript', 'noembed', 'noframes', 'xmp',
12
+ 'plaintext', 'title', 'head', 'meta', 'link', 'base', 'svg', 'math', 'img', 'picture', 'video', 'audio', 'form',
13
+ 'input', 'button', 'select', 'textarea',
14
+ ];
15
+
16
+ /**
17
+ * Sanitizes untrusted HTML down to formatting tags, keeping only a link's href when it is http(s) or mailto.
18
+ * Tags in `keep` are allowed as they are, so a block such as `pre` isn't turned into a paragraph.
19
+ */
20
+ export function sanitizeHtml(source: string, keep: string[] = []): string {
21
+ const fragment = DOMPurify.sanitize(source, {
22
+ ALLOWED_TAGS: [...FORMATTING, ...BLOCKS, ...keep],
23
+ ALLOWED_ATTR: ['href'],
24
+ ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
25
+ FORBID_CONTENTS: DROPPED,
26
+ RETURN_DOM_FRAGMENT: true,
27
+ });
28
+
29
+ const blocks = BLOCKS.filter(tag => !keep.includes(tag));
30
+ fragment.querySelectorAll(blocks.join(',')).forEach(block => {
31
+ const p = document.createElement('p');
32
+ p.append(...block.childNodes);
33
+ block.replaceWith(p);
34
+ });
35
+ // Spacer paragraphs, and ones that only held an image, would leave gaps
36
+ fragment.querySelectorAll('p').forEach(p => {
37
+ if (!p.textContent?.trim()) p.remove();
38
+ });
39
+ fragment.querySelectorAll('a[href]').forEach(link => {
40
+ try {
41
+ link.setAttribute('href', new URL(link.getAttribute('href')!.trim()).href);
42
+ link.setAttribute('target', '_blank');
43
+ link.setAttribute('rel', 'noopener noreferrer');
44
+ } catch {
45
+ link.removeAttribute('href');
46
+ }
47
+ });
48
+
49
+ const container = document.createElement('div');
50
+ container.append(fragment);
51
+ return container.innerHTML;
52
+ }
@@ -1,85 +0,0 @@
1
- const ALLOWED = new Set(['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'a']);
2
-
3
- // Removed with their contents; any other disallowed element is unwrapped to its children.
4
- // img is dropped because a remote src in an email is a tracking pixel.
5
- const DROPPED = new Set([
6
- 'script', 'style', 'template', 'iframe', 'frame', 'object', 'embed', 'noscript', 'title', 'head',
7
- 'meta', 'link', 'base', 'svg', 'math', 'img', 'picture', 'video', 'audio', 'form', 'input', 'button',
8
- 'select', 'textarea',
9
- ]);
10
-
11
- // Unwrapped like any disallowed element, but kept as a paragraph so their text doesn't run together
12
- const BLOCKS = new Set(['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'tr', 'center', 'section']);
13
-
14
- const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
15
-
16
- const HTML_TAG = /<\/?(p|div|span|strong|b|em|i|u|a|img|ul|ol|li|table|tr|td|h[1-6]|blockquote|font|center)\b[^>]*>/i;
17
-
18
- /** Whether a plain-text body is really HTML source. `<br>` alone doesn't count. */
19
- export function looksLikeHtml(text: string): boolean {
20
- return HTML_TAG.test(text);
21
- }
22
-
23
- function safeHref(href: string | null): string | null {
24
- if (!href) return null;
25
- try {
26
- const url = new URL(href.trim());
27
- return SAFE_PROTOCOLS.has(url.protocol) ? url.href : null;
28
- } catch {
29
- return null;
30
- }
31
- }
32
-
33
- function copyChildren(from: Node, to: Node, doc: Document) {
34
- from.childNodes.forEach((child) => {
35
- if (child.nodeType === Node.TEXT_NODE) {
36
- to.appendChild(doc.createTextNode(child.textContent ?? ''));
37
- return;
38
- }
39
- if (child.nodeType !== Node.ELEMENT_NODE) return;
40
-
41
- const tag = (child as Element).localName;
42
- if (DROPPED.has(tag)) return;
43
- if (tag === 'p' || BLOCKS.has(tag)) {
44
- appendParagraph(child, to, doc);
45
- return;
46
- }
47
- if (!ALLOWED.has(tag)) {
48
- copyChildren(child, to, doc);
49
- return;
50
- }
51
-
52
- const el = doc.createElement(tag);
53
- if (tag === 'a') {
54
- const href = safeHref((child as Element).getAttribute('href'));
55
- if (href) {
56
- el.setAttribute('href', href);
57
- el.setAttribute('target', '_blank');
58
- el.setAttribute('rel', 'noopener noreferrer');
59
- }
60
- }
61
- copyChildren(child, el, doc);
62
- to.appendChild(el);
63
- });
64
- }
65
-
66
- // Skips paragraphs left empty, e.g. spacer paragraphs or ones that only held an image
67
- function appendParagraph(from: Node, to: Node, doc: Document) {
68
- const p = doc.createElement('p');
69
- copyChildren(from, p, doc);
70
- if (p.textContent?.trim()) {
71
- to.appendChild(p);
72
- }
73
- }
74
-
75
- /**
76
- * Rebuilds untrusted HTML from an allowlist of formatting tags. Attributes are never copied,
77
- * except a link's href when it is http(s) or mailto.
78
- */
79
- export function sanitizeHtml(source: string): string {
80
- const parsed = new DOMParser().parseFromString(source, 'text/html');
81
- const out = document.implementation.createHTMLDocument('');
82
- const root = out.createElement('div');
83
- copyChildren(parsed.body, root, out);
84
- return root.innerHTML;
85
- }