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