@kubex/zinc 1.1.176 → 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/dist/custom-elements.json +76 -76
- package/dist/vscode.html-custom-data.json +12 -12
- package/dist/web-types.json +25 -25
- package/dist/zn.d.ts +14 -9
- package/dist/zn.min.js +766 -764
- package/package.json +2 -1
- package/src/components/chat-message/chat-message.component.ts +3 -7
- package/src/components/chat-message/chat-message.test.ts +72 -0
- package/src/components/chat-message/clean-message.ts +10 -42
- package/src/components/content-block/content-block.component.ts +2 -1
- package/src/components/content-block/html-text.ts +19 -0
- package/src/utilities/sanitize-html.ts +52 -0
- package/src/components/content-block/sanitize-html.ts +0 -96
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubex/zinc",
|
|
3
|
-
"version": "1.1.
|
|
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
|
|
63
|
-
*
|
|
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
|
-
|
|
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 < 2 && 3 > 2');
|
|
69
|
+
|
|
70
|
+
expect(content.textContent).to.equal('1 < 2 && 3 > 2');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,46 +1,14 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
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 {htmlToText, looksLikeHtml
|
|
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';
|
|
@@ -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 ` `. */
|
|
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, '<');
|
|
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,96 +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
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Plain text of a body that may be HTML source or text holding encoded entities like ` `. */
|
|
88
|
-
export function htmlToText(source: string): string {
|
|
89
|
-
// Plain text is escaped so an address like `<bob@example.com>` isn't parsed as a tag
|
|
90
|
-
const markup = looksLikeHtml(source)
|
|
91
|
-
? sanitizeHtml(source)
|
|
92
|
-
: source.replace(/<br\s*\/?>/gi, ' ').replace(/</g, '<');
|
|
93
|
-
const parsed = new DOMParser().parseFromString(markup, 'text/html');
|
|
94
|
-
parsed.body.querySelectorAll('br, p, li').forEach((el) => el.after(' '));
|
|
95
|
-
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
96
|
-
}
|