@kubex/zinc 1.1.171 → 1.1.173
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 +1 -1
- package/dist/web-types.json +1 -1
- package/dist/zn.d.ts +11 -0
- package/dist/zn.min.js +283 -283
- package/package.json +1 -1
- package/src/components/content-block/content-block.component.ts +12 -1
- package/src/components/content-block/content-block.test.ts +34 -2
- package/src/components/content-block/sanitize-html.ts +85 -0
- package/src/components/split-pane/split-pane.scss +4 -0
package/package.json
CHANGED
|
@@ -2,8 +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
6
|
import {MutationController} from '@lit-labs/observers/mutation-controller.js';
|
|
6
7
|
import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js';
|
|
8
|
+
import {unsafeHTML} from 'lit/directives/unsafe-html.js';
|
|
7
9
|
import ZincElement from "../../internal/zinc-element";
|
|
8
10
|
import type {PropertyValues} from 'lit';
|
|
9
11
|
import type ZnTile from "../tile";
|
|
@@ -13,6 +15,8 @@ import styles from './content-block.scss';
|
|
|
13
15
|
interface TextRow {
|
|
14
16
|
lines: string[];
|
|
15
17
|
type: 'reply' | 'text';
|
|
18
|
+
/** `lines` holds a single sanitized HTML string. */
|
|
19
|
+
html?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -264,7 +268,9 @@ export default class ContentBlock extends ZincElement {
|
|
|
264
268
|
'text-section--text': section.type === 'text',
|
|
265
269
|
'hidden': section.type === 'reply'
|
|
266
270
|
})}>
|
|
267
|
-
${section.
|
|
271
|
+
${section.html
|
|
272
|
+
? unsafeHTML(section.lines[0])
|
|
273
|
+
: section.lines.map((line) => html`${line}<br>`)}
|
|
268
274
|
</div>
|
|
269
275
|
`)}
|
|
270
276
|
</div>
|
|
@@ -358,6 +364,11 @@ export default class ContentBlock extends ZincElement {
|
|
|
358
364
|
const textContent = this.querySelector('[slot="text"]') as HTMLDivElement | null;
|
|
359
365
|
const textRows: TextRow[] = [];
|
|
360
366
|
|
|
367
|
+
if (textContent && looksLikeHtml(textContent.innerText)) {
|
|
368
|
+
this._textRows = [{lines: [sanitizeHtml(textContent.innerText)], type: 'text', html: true}];
|
|
369
|
+
return this._textRows;
|
|
370
|
+
}
|
|
371
|
+
|
|
361
372
|
if (textContent) {
|
|
362
373
|
const text = textContent.innerText.replace(/<br\s*\/?>/gi, '\n');
|
|
363
374
|
const rows = text.split('\n');
|
|
@@ -17,11 +17,43 @@ describe('<zn-content-block>', () => {
|
|
|
17
17
|
expect(el).to.exist;
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
it('should render
|
|
20
|
+
it('should render plain text in the text body as text', async () => {
|
|
21
|
+
const content = await textSection('1 < 2 && 3 > 2');
|
|
22
|
+
|
|
23
|
+
expect(content.textContent).to.contain('1 < 2 && 3 > 2');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should drop images and handlers from markup in the text body', async () => {
|
|
21
27
|
const content = await textSection('<img src="x" onerror="window.__xss = true"> hello');
|
|
22
28
|
|
|
23
29
|
expect(content.querySelector('img')).to.be.null;
|
|
24
|
-
expect(content.textContent).to.contain('
|
|
30
|
+
expect(content.textContent).to.contain('hello');
|
|
31
|
+
expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should render allowed formatting from markup in the text body', async () => {
|
|
35
|
+
const content = await textSection(
|
|
36
|
+
'<p><em><strong>Need help?</strong> Visit our '
|
|
37
|
+
+ '<a href="http://help.example.com" onclick="x()">Help Center</a></em></p>'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(content.querySelector('p em strong')?.textContent).to.equal('Need help?');
|
|
41
|
+
const link = content.querySelector('a')!;
|
|
42
|
+
expect(link.getAttribute('href')).to.equal('http://help.example.com/');
|
|
43
|
+
expect(link.getAttribute('rel')).to.equal('noopener noreferrer');
|
|
44
|
+
expect(link.hasAttribute('onclick')).to.be.false;
|
|
45
|
+
expect(content.textContent).to.not.contain('<p>');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should strip unsafe links and dropped elements from markup in the text body', async () => {
|
|
49
|
+
const content = await textSection(
|
|
50
|
+
'<p><a href="JavaScript:alert(1)">click</a></p>'
|
|
51
|
+
+ '<style>p{color:red}</style><script>window.__xss = true</script>'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(content.querySelector('a')!.hasAttribute('href')).to.be.false;
|
|
55
|
+
expect(content.querySelector('style, script')).to.be.null;
|
|
56
|
+
expect(content.textContent).to.not.contain('color:red');
|
|
25
57
|
expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
|
|
26
58
|
});
|
|
27
59
|
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
}
|
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
gap: 0 calc(var(--zn-base-gap) / 2);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Slotted panes are light DOM, which ::-webkit-scrollbar rules here cannot reach, so they take the standard properties.
|
|
9
10
|
::slotted(*) {
|
|
10
11
|
display: flex;
|
|
11
12
|
flex-direction: column;
|
|
12
13
|
height: 100%;
|
|
13
14
|
overflow-y: auto;
|
|
15
|
+
scrollbar-width: thin;
|
|
16
|
+
scrollbar-color: mixins.$scrollbar-thumb transparent;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
:host {
|
|
20
|
+
@include wc.scrollbars;
|
|
17
21
|
display: flex;
|
|
18
22
|
height: 100%;
|
|
19
23
|
max-height: 100%;
|