@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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.4.22"
2
+ ".": "2.4.24"
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
- // Matches the style text of any element that could carry one of FORBID_STYLES
76
- const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
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
- // A forbidden property in declaration position: at the start, or after any character that cannot
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
- // Second sanitization pass and post-processing of the juice output.
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
- // second pass, outputs a BODY DOM element
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
- // mark all links to be opened in a new window
383
- for (let elm of dom.querySelectorAll('a')) {
384
- elm.setAttribute('target', '_blank');
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
- let styleText = node.getAttribute('style') || '';
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 check below would miss them. Decode first and write 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
- styleText = decodeCssEscapes(styleText);
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
- let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
432
-
433
- // embed into a styled container; the attribute tells the caller the CSS inlining caps were hit
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
- return `<div style="${he.encode(bodyStyles)}"${marker}>${dom.innerHTML.trim()}</div>`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.22",
3
+ "version": "2.4.24",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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 &lt;style&gt; to add rules</p>' });
126
+ assert.equal(out, '<div style="overflow: auto; contain: paint;"><div><p>write &lt;style&gt; to add rules</p></div></div>');
127
+ });
128
+ });
@@ -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', () => {