@postalsys/email-text-tools 2.4.22 → 2.4.23
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +6 -0
- package/lib/mime-html.js +81 -44
- package/package.json +1 -1
- package/test/containment.test.js +130 -0
- package/test/hardening.test.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.23](https://github.com/postalsys/email-text-tools/compare/v2.4.22...v2.4.23) (2026-10-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* contain the message in a wrapper the sender cannot style, drop SVG, MathML and link openers ([7d79698](https://github.com/postalsys/email-text-tools/commit/7d79698f224c2bd4412d4dcd5d261d17c74022c1))
|
|
9
|
+
|
|
3
10
|
## [2.4.22](https://github.com/postalsys/email-text-tools/compare/v2.4.21...v2.4.22) (2026-10-18)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -11,6 +11,12 @@ Email text tools
|
|
|
11
11
|
- CSS rules whose selector uses `:is()`, `:where()`, `:has()` or a `:not()` containing another pseudo-class, and any `<style>` text beyond 256 KB per message
|
|
12
12
|
- CSS inlining altogether when the style text holds more than 5,000 selectors, the document more than 50,000 elements, or the product of the two exceeds 5,000,000 (inlining costs one selector match per element). The sanitized HTML is then returned with its `<style>` rules dropped and `data-css-inlining="skipped"` on the wrapper `<div>`
|
|
13
13
|
- a `style` attribute that still names a forbidden property after the CSSOM has filtered it, which means the CSSOM could not parse that declaration: the attribute is reduced to the declarations it did parse, or removed
|
|
14
|
+
- SVG and MathML (DOMPurify runs with the `html` profile only): the namespace confusion between the three is where most sanitizer bypasses have come from, and mail has no use for either
|
|
15
|
+
- `width`, `min-width`, `max-width`, `height`, `min-height` and `max-height` from the body's own style, which would otherwise size the container the host gave the message
|
|
16
|
+
|
|
17
|
+
The output is two wrappers: an outer `<div style="overflow: auto;">` that is never given anything else, and inside it a `<div>` carrying the sanitized styles of the message body. Everything the body does (its margins, a `display: contents`, a box-shadow) is clipped by the outer element, and `position` is forbidden on every element because it is the one property that escapes an overflow clip. Every `a` and `area` gets `target="_blank" rel="noopener noreferrer"`, whatever `rel` it came with.
|
|
18
|
+
|
|
19
|
+
Markup without a `<style>` start tag is sanitized once; the first pass exists to find and filter the stylesheets before they are inlined.
|
|
14
20
|
|
|
15
21
|
Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing. `inlineHtml()` applies the same depth limit to the quoted message and to the new content, and quotes escaped text instead when it is exceeded.
|
|
16
22
|
|
package/lib/mime-html.js
CHANGED
|
@@ -94,6 +94,23 @@ function decodeCssEscapes(value) {
|
|
|
94
94
|
// keep the style at first
|
|
95
95
|
const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
|
|
96
96
|
|
|
97
|
+
// HTML only. The default profiles admit SVG and MathML too, and the namespace confusion between
|
|
98
|
+
// the three is where most sanitizer bypasses have come from: markup one parser closes inside an
|
|
99
|
+
// <svg> or <mglyph> and another reads as HTML. Mail has no use for either, the major webmail
|
|
100
|
+
// clients strip inline SVG and MathML never reached a mail client, so both go.
|
|
101
|
+
const USE_PROFILES = { html: true };
|
|
102
|
+
|
|
103
|
+
// A start tag the HTML parser reads as a style element: the name followed by whitespace, "/" or
|
|
104
|
+
// ">". Any other character inside the name makes it some other element, and a "<style" at the
|
|
105
|
+
// very end of the input is dropped by the parser, so markup this does not match has no stylesheet.
|
|
106
|
+
const STYLE_TAG = /<style[\s/>]/i;
|
|
107
|
+
|
|
108
|
+
// The dimensions a body may carry. They end up on the element the message is wrapped in, where
|
|
109
|
+
// a width or a height would size the box the host gave the message rather than the message.
|
|
110
|
+
const BODY_FORBID_STYLES = ['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height'];
|
|
111
|
+
const BODY_FORBID_STYLES_HINT = /width|height/i;
|
|
112
|
+
const BODY_FORBID_STYLES_DECLARATION = new RegExp('(?:^|[^a-z0-9-])(?:' + BODY_FORBID_STYLES.join('|') + ')\\s*:', 'i');
|
|
113
|
+
|
|
97
114
|
// One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
|
|
98
115
|
// leak settings into each other.
|
|
99
116
|
const DOMPurify = createDOMPurify(new JSDOM('').window);
|
|
@@ -329,8 +346,15 @@ function prepareHtml(message) {
|
|
|
329
346
|
throw new Error('HTML is nested too deep to process');
|
|
330
347
|
}
|
|
331
348
|
|
|
349
|
+
if (!STYLE_TAG.test(html)) {
|
|
350
|
+
// No style element can come out of this markup, so there is nothing to inline. This pass
|
|
351
|
+
// exists to find and filter the stylesheets before juice sees them, and without one it
|
|
352
|
+
// would only add a parse and a serialization to the pass finishHtml() makes anyway.
|
|
353
|
+
return { html, hasStyle: false, inliningSkipped: false };
|
|
354
|
+
}
|
|
355
|
+
|
|
332
356
|
// first pass, returns the <html> element of the fixed HTML page
|
|
333
|
-
const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
|
|
357
|
+
const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, USE_PROFILES, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
|
|
334
358
|
|
|
335
359
|
// Remove any style tags from within body content
|
|
336
360
|
const body = root.querySelector('body');
|
|
@@ -368,71 +392,84 @@ function prepareHtml(message) {
|
|
|
368
392
|
return { html: root.outerHTML, hasStyle, inliningSkipped };
|
|
369
393
|
}
|
|
370
394
|
|
|
371
|
-
//
|
|
395
|
+
// Removes the named style properties from an element the way a browser will read them, by the
|
|
396
|
+
// CSSOM first and then by the attribute text the CSSOM did not parse.
|
|
397
|
+
function removeStyleProperties(node, properties, hint, declaration) {
|
|
398
|
+
if (!hint.test(node.getAttribute('style') || '')) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const property of properties) {
|
|
402
|
+
node.style.removeProperty(property);
|
|
403
|
+
}
|
|
404
|
+
// removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
|
|
405
|
+
// cssstyle managed to parse. A forbidden property still named in the attribute after that was
|
|
406
|
+
// never parsed and would go out verbatim for the browser to read its own way: cssstyle 5 parses
|
|
407
|
+
// nothing from an attribute whose "background: url()" shorthand is followed by more
|
|
408
|
+
// declarations, a browser reads every one of them. Keep only what the CSSOM parsed, which may
|
|
409
|
+
// be nothing
|
|
410
|
+
const remaining = node.getAttribute('style') || '';
|
|
411
|
+
if (declaration.test(remaining)) {
|
|
412
|
+
const parsed = node.style.cssText;
|
|
413
|
+
if (parsed) {
|
|
414
|
+
node.setAttribute('style', parsed);
|
|
415
|
+
} else {
|
|
416
|
+
node.removeAttribute('style');
|
|
417
|
+
}
|
|
418
|
+
} else if (!remaining.trim()) {
|
|
419
|
+
node.removeAttribute('style');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// The sanitization pass that produces the output (the second one when there was a stylesheet to
|
|
424
|
+
// inline, the only one otherwise) and the post-processing of its result.
|
|
372
425
|
function finishHtml(html, inliningSkipped) {
|
|
373
|
-
//
|
|
426
|
+
// outputs a BODY DOM element
|
|
374
427
|
const dom = DOMPurify.sanitize(html, {
|
|
375
428
|
RETURN_DOM: true,
|
|
376
429
|
WHOLE_DOCUMENT: false,
|
|
377
430
|
FORCE_BODY: true,
|
|
431
|
+
USE_PROFILES,
|
|
378
432
|
FORBID_TAGS: FORBID_TAGS_ALL,
|
|
379
433
|
FORBID_ATTR
|
|
380
434
|
});
|
|
381
435
|
|
|
382
|
-
//
|
|
383
|
-
for
|
|
384
|
-
|
|
436
|
+
// Every link opens a new browsing context that gets neither an opener nor a referrer. Browsers
|
|
437
|
+
// imply noopener for target="_blank", but rel="opener" opts back in and the sender writes rel,
|
|
438
|
+
// and an opener can navigate the page the message is shown in. An <area> is a link too.
|
|
439
|
+
for (const link of dom.querySelectorAll('a, area')) {
|
|
440
|
+
link.setAttribute('target', '_blank');
|
|
441
|
+
link.setAttribute('rel', 'noopener noreferrer');
|
|
385
442
|
}
|
|
386
443
|
|
|
387
|
-
dom.style.overflow = 'auto';
|
|
388
|
-
|
|
389
|
-
// return <BODY> HTML
|
|
390
|
-
dom.style.removeProperty('width');
|
|
391
|
-
dom.style.removeProperty('min-width');
|
|
392
|
-
dom.style.removeProperty('max-width');
|
|
393
|
-
dom.style.removeProperty('height');
|
|
394
|
-
dom.style.removeProperty('min-height');
|
|
395
|
-
dom.style.removeProperty('max-height');
|
|
396
|
-
|
|
397
444
|
// remove specific style properties, touching the CSSOM only where the style text could name one
|
|
398
445
|
for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
|
|
399
446
|
if (!node.style) {
|
|
400
447
|
continue;
|
|
401
448
|
}
|
|
402
|
-
|
|
449
|
+
const styleText = node.getAttribute('style') || '';
|
|
403
450
|
if (styleText.indexOf('\\') >= 0) {
|
|
404
451
|
// A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
|
|
405
|
-
// CSSOM does not, so the
|
|
452
|
+
// CSSOM does not, so the checks below would miss them. Decode first and write the
|
|
406
453
|
// decoded text back so the CSSOM sees the same properties the browser will
|
|
407
|
-
|
|
408
|
-
node.setAttribute('style', styleText);
|
|
409
|
-
}
|
|
410
|
-
if (FORBID_STYLES_HINT.test(styleText)) {
|
|
411
|
-
for (let disallowedStyle of FORBID_STYLES) {
|
|
412
|
-
node.style.removeProperty(disallowedStyle);
|
|
413
|
-
}
|
|
414
|
-
// removeProperty() rewrites the attribute from the CSSOM, which only holds the
|
|
415
|
-
// declarations cssstyle managed to parse. A forbidden property still named in the
|
|
416
|
-
// attribute after that was never parsed and would go out verbatim for the browser to
|
|
417
|
-
// read its own way: cssstyle 5 parses nothing from an attribute whose "background:
|
|
418
|
-
// url()" shorthand is followed by more declarations, a browser reads every one of
|
|
419
|
-
// them. Keep only what the CSSOM parsed, which may be nothing
|
|
420
|
-
if (FORBID_STYLES_DECLARATION.test(node.getAttribute('style') || '')) {
|
|
421
|
-
const parsed = node.style.cssText;
|
|
422
|
-
if (parsed) {
|
|
423
|
-
node.setAttribute('style', parsed);
|
|
424
|
-
} else {
|
|
425
|
-
node.removeAttribute('style');
|
|
426
|
-
}
|
|
427
|
-
}
|
|
454
|
+
node.setAttribute('style', decodeCssEscapes(styleText));
|
|
428
455
|
}
|
|
456
|
+
removeStyleProperties(node, FORBID_STYLES, FORBID_STYLES_HINT, FORBID_STYLES_DECLARATION);
|
|
429
457
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
458
|
+
removeStyleProperties(dom, BODY_FORBID_STYLES, BODY_FORBID_STYLES_HINT, BODY_FORBID_STYLES_DECLARATION);
|
|
459
|
+
|
|
460
|
+
const bodyStyles = (dom.getAttribute('style') || '').toString().trim();
|
|
461
|
+
|
|
462
|
+
// Two wrappers. The outer one is ours alone and clips everything inside it; the inner one is
|
|
463
|
+
// the message body with the styles it came with. While the body's styles sat on the clipping
|
|
464
|
+
// element, a sender could undo the clipping (display: contents or inline, an overflow-x:
|
|
465
|
+
// visible written after our overflow) or move the element itself over the page around it (a
|
|
466
|
+
// negative margin, a box-shadow or an outline a few thousand pixels wide). Position is the
|
|
467
|
+
// one property that escapes an overflow clip, and it is forbidden above; whatever else a
|
|
468
|
+
// descendant does stays inside the outer box. The attribute tells the caller the CSS inlining
|
|
469
|
+
// caps were hit
|
|
434
470
|
const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
|
|
435
|
-
|
|
471
|
+
const body = bodyStyles ? `<div style="${he.encode(bodyStyles)}">` : '<div>';
|
|
472
|
+
return `<div style="overflow: auto;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
|
|
436
473
|
}
|
|
437
474
|
|
|
438
475
|
// Runs the pipeline and renders the plain text fallback when it fails, for example when a
|
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const { mimeHtml } = require('..');
|
|
6
|
+
|
|
7
|
+
// The message is shown inside a page that is not the sender's. These tests pin what keeps it there:
|
|
8
|
+
// a clipping wrapper the sender cannot style, links that cannot reach back, no foreign content.
|
|
9
|
+
|
|
10
|
+
describe('container', () => {
|
|
11
|
+
it('wraps a message with no body styles in a bare inner element', () => {
|
|
12
|
+
assert.equal(mimeHtml({ html: '<p>x</p>' }), '<div style="overflow: auto;"><div><p>x</p></div></div>');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps the body styles off the clipping wrapper', () => {
|
|
16
|
+
const html =
|
|
17
|
+
'<html><body style="display: contents; margin: -500px; overflow: visible; overflow-x: visible; box-shadow: 0 0 0 9999px red; outline: 9999px solid red; color: red">' +
|
|
18
|
+
'<div style="transform: translateY(-300px)">x</div></body></html>';
|
|
19
|
+
const out = mimeHtml({ html });
|
|
20
|
+
assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
|
|
21
|
+
assert.ok(out.endsWith('</div></div>'), out);
|
|
22
|
+
const inner = /^<div style="overflow: auto;"><div style="([^"]*)">/.exec(out)[1];
|
|
23
|
+
assert.match(inner, /display: contents/);
|
|
24
|
+
assert.match(inner, /margin: -500px/);
|
|
25
|
+
assert.match(inner, /color: red/);
|
|
26
|
+
assert.match(out, /transform: translateY\(-300px\)/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('puts a body rule from the stylesheet on the inner element too', () => {
|
|
30
|
+
const html = '<html><head><style>body { display: contents; margin: -200px; } p { color: red; }</style></head><body><p>x</p></body></html>';
|
|
31
|
+
const out = mimeHtml({ html });
|
|
32
|
+
assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
|
|
33
|
+
assert.match(out, /display: contents/);
|
|
34
|
+
assert.match(out, /color: red/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('drops the dimensions of the body and keeps its other styles', () => {
|
|
38
|
+
const out = mimeHtml({
|
|
39
|
+
html: '<html><body style="width: 600px; min-height: 100vh; max-width: 50%; background-color: #fff; color: red">x</body></html>'
|
|
40
|
+
});
|
|
41
|
+
assert.doesNotMatch(out, /width|height/);
|
|
42
|
+
assert.match(out, /background-color: (?:#fff|rgb\(255, 255, 255\))/);
|
|
43
|
+
assert.match(out, /color: red/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('drops a body width the CSSOM could not parse', () => {
|
|
47
|
+
// cssstyle parses nothing from this attribute, a browser reads the width
|
|
48
|
+
const out = mimeHtml({ html: '<html><body style="background:url(https://example.com/x.gif) no-repeat; width: 5000px">x</body></html>' });
|
|
49
|
+
assert.doesNotMatch(out, /width/);
|
|
50
|
+
assert.equal(out, '<div style="overflow: auto;"><div>x</div></div>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps body styles the CSSOM could not parse when none of them is forbidden', () => {
|
|
54
|
+
// the old wrapper lost every body style here, because setting its overflow rewrote the
|
|
55
|
+
// attribute from a CSSOM that held nothing
|
|
56
|
+
const out = mimeHtml({ html: '<html><body style="background:url(https://example.com/x.gif) no-repeat; color:red; font:12px serif">x</body></html>' });
|
|
57
|
+
assert.ok(out.includes('<div style="background:url(https://example.com/x.gif) no-repeat; color:red; font:12px serif">x</div>'), out);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('still removes position everywhere, including the body', () => {
|
|
61
|
+
const out = mimeHtml({ html: '<html><body style="position: fixed; inset: 0; color: red"><div style="position: sticky; top: 0">x</div></body></html>' });
|
|
62
|
+
assert.doesNotMatch(out, /position/);
|
|
63
|
+
assert.match(out, /color: red/);
|
|
64
|
+
assert.match(out, /top: 0/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('marks skipped inlining on the outer wrapper', () => {
|
|
68
|
+
let css = '';
|
|
69
|
+
for (let i = 0; i <= 5000; i++) {
|
|
70
|
+
css += `.c${i} { color: red; }\n`;
|
|
71
|
+
}
|
|
72
|
+
const out = mimeHtml({ html: `<html><head><style>${css}</style></head><body style="color: blue"><p class="c1">x</p></body></html>` });
|
|
73
|
+
assert.ok(out.startsWith('<div style="overflow: auto;" data-css-inlining="skipped"><div style="color: blue">'), out.slice(0, 120));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('links', () => {
|
|
78
|
+
it('opens every link in a new context that gets no opener and no referrer', () => {
|
|
79
|
+
const out = mimeHtml({ html: '<a href="https://example.com/" rel="opener">x</a><a href="https://example.com/" target="_top" rel="nofollow">y</a>' });
|
|
80
|
+
assert.equal((out.match(/<a /g) || []).length, 2);
|
|
81
|
+
assert.doesNotMatch(out, /rel="opener"|rel="nofollow"|_top/);
|
|
82
|
+
assert.equal((out.match(/target="_blank"/g) || []).length, 2);
|
|
83
|
+
assert.equal((out.match(/rel="noopener noreferrer"/g) || []).length, 2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('treats an image map area as a link', () => {
|
|
87
|
+
const out = mimeHtml({
|
|
88
|
+
html: '<img src="https://example.com/i.png" usemap="#m"><map name="m"><area shape="rect" coords="0,0,9,9" href="https://example.com/"></map>'
|
|
89
|
+
});
|
|
90
|
+
assert.match(out, /<area [^>]*target="_blank"/);
|
|
91
|
+
assert.match(out, /<area [^>]*rel="noopener noreferrer"/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('foreign content', () => {
|
|
96
|
+
it('removes SVG and MathML', () => {
|
|
97
|
+
const out = mimeHtml({
|
|
98
|
+
html:
|
|
99
|
+
'<p>a</p><svg width="20" height="20"><circle cx="10" cy="10" r="8"/></svg><math><mi>x</mi></math>' +
|
|
100
|
+
'<math><mtext><table><mglyph><style><!--</style><img title="--><img src=1 onerror=alert(1)>"><p>b</p>'
|
|
101
|
+
});
|
|
102
|
+
assert.doesNotMatch(out, /<svg|<math|<circle|<mi|onerror/);
|
|
103
|
+
assert.ok(out.includes('<p>a</p>'), out);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('single sanitization pass for markup without a stylesheet', () => {
|
|
108
|
+
it('sanitizes the same way', () => {
|
|
109
|
+
const out = mimeHtml({
|
|
110
|
+
html: '<p onclick="x()">a</p><script>alert(1)</script><a href="javascript:alert(1)">b</a><iframe src="https://example.com/"></iframe>'
|
|
111
|
+
});
|
|
112
|
+
assert.equal(out, '<div style="overflow: auto;"><div><p>a</p><a target="_blank" rel="noopener noreferrer">b</a></div></div>');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
for (const [label, tag, close] of [
|
|
116
|
+
['upper case', '<STYLE>', '</STYLE>'],
|
|
117
|
+
['whitespace after the name', '<style\n>', '</style>'],
|
|
118
|
+
['a self-closing slash', '<style/>', '</style>']
|
|
119
|
+
]) {
|
|
120
|
+
it(`still inlines a stylesheet whose tag uses ${label}`, () => {
|
|
121
|
+
const out = mimeHtml({ html: `<html><head>${tag}p { color: red; }${close}</head><body><p>x</p></body></html>` });
|
|
122
|
+
assert.match(out, /color: red/, out);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('leaves a style tag that is only text alone', () => {
|
|
127
|
+
const out = mimeHtml({ html: '<p>write <style> to add rules</p>' });
|
|
128
|
+
assert.equal(out, '<div style="overflow: auto;"><div><p>write <style> to add rules</p></div></div>');
|
|
129
|
+
});
|
|
130
|
+
});
|
package/test/hardening.test.js
CHANGED
|
@@ -298,7 +298,7 @@ describe('hostile input hardening', () => {
|
|
|
298
298
|
// rules nested in a media query are not matched against elements and do not count
|
|
299
299
|
const css = `${rules(2000)}p.x { color: blue; }\n@media screen { ${rules(4000)} }`;
|
|
300
300
|
const result = mimeHtml({ html: styled(css, '<p class="x">y</p>'.repeat(2000)) });
|
|
301
|
-
assert.ok(result.startsWith('<div style="overflow: auto;"><p'), result.slice(0, 80));
|
|
301
|
+
assert.ok(result.startsWith('<div style="overflow: auto;"><div><p'), result.slice(0, 80));
|
|
302
302
|
assert.match(result, /color: blue/);
|
|
303
303
|
});
|
|
304
304
|
|
|
@@ -451,7 +451,7 @@ describe('hostile input hardening', () => {
|
|
|
451
451
|
assert.equal(afterText, false);
|
|
452
452
|
assert.equal(afterMime, true);
|
|
453
453
|
assert.equal(asyncType, 'function');
|
|
454
|
-
assert.equal(out, '<div style="overflow: auto;"><p>x</p></div>');
|
|
454
|
+
assert.equal(out, '<div style="overflow: auto;"><div><p>x</p></div></div>');
|
|
455
455
|
});
|
|
456
456
|
|
|
457
457
|
it('keeps mimeHtml enumerable and destructurable', () => {
|