@postalsys/email-text-tools 2.4.19 → 2.4.21
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 +15 -0
- package/README.md +3 -1
- package/lib/html-guard.js +127 -0
- package/lib/inline-html.js +21 -5
- package/lib/mime-html.js +72 -111
- package/package.json +2 -1
- package/test/hardening.test.js +140 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.21](https://github.com/postalsys/email-text-tools/compare/v2.4.20...v2.4.21) (2026-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bump @postalsys/email-content to 0.1.14 ([c35da4b](https://github.com/postalsys/email-text-tools/commit/c35da4b5da09ea90ae45ea21c1c292ddba474fab))
|
|
9
|
+
|
|
10
|
+
## [2.4.20](https://github.com/postalsys/email-text-tools/compare/v2.4.19...v2.4.20) (2026-10-16)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* bound CSS inlining, guard inlineHtml like mimeHtml and drop style attributes the CSSOM could not parse ([13b2725](https://github.com/postalsys/email-text-tools/commit/13b27254ae31a2aeab56a3edd7dd84737fa016cd))
|
|
16
|
+
* bump @postalsys/email-content to 0.1.13 ([1a8738d](https://github.com/postalsys/email-text-tools/commit/1a8738da39f1adc404f871ab63c8dda5d3733dd2))
|
|
17
|
+
|
|
3
18
|
## [2.4.19](https://github.com/postalsys/email-text-tools/compare/v2.4.18...v2.4.19) (2026-10-14)
|
|
4
19
|
|
|
5
20
|
|
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ Email text tools
|
|
|
9
9
|
- elements: `title`, `link`, `meta`, `base`, `basefont`, `frame`, `iframe`, `frameset`, `template`, `script`, `noframes`, `noscript`, `object`, `embed`, `dialog`, `canvas`, `audio`, `video`, `applet`, and forms with their controls (`form`, `input`, `button`, `select`, `textarea`; their text content is kept)
|
|
10
10
|
- attributes: `action`, `formaction`, `popover`, `popovertarget`
|
|
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
|
+
- 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
|
+
- 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
|
|
12
14
|
|
|
13
|
-
Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing.
|
|
15
|
+
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.
|
|
14
16
|
|
|
15
17
|
Reply/forward date headers from `inlineHtml()` and `inlineText()` are formatted with `Intl.DateTimeFormat` (for example `Sat, Jun 15, 2024, 2:30 PM`). An unsupported locale falls back to English and an unknown time zone to UTC. A Node build without full ICU data only has English.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const he = require('he');
|
|
4
|
+
|
|
5
|
+
// jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
|
|
6
|
+
// its serializer overflows the stack a few thousand levels down; node-html-parser spends cubic time
|
|
7
|
+
// on the elements left open at the end of a document. Deeper input is not parsed at all. Real
|
|
8
|
+
// email nests a few dozen levels.
|
|
9
|
+
const MAX_NESTING_DEPTH = 1000;
|
|
10
|
+
|
|
11
|
+
// Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
|
|
12
|
+
// so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
|
|
13
|
+
// so there every start tag counts.
|
|
14
|
+
const NON_NESTING_TAGS = new Set(
|
|
15
|
+
'area,base,br,col,embed,hr,img,input,keygen,link,meta,param,source,track,wbr,p,li,dt,dd,td,th,tr,tbody,thead,tfoot,colgroup,caption,option,optgroup,rt,rp'.split(
|
|
16
|
+
','
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// A single linear pass estimating how deep the parsed tree would get. An end tag closes the
|
|
21
|
+
// nearest open element of its name together with everything opened inside it, and one that names
|
|
22
|
+
// no open element is ignored, as an HTML parser ignores it: counting every end tag down instead
|
|
23
|
+
// let "<b></i>" repeated nest as deep as the input allowed, and 10,000 copies took jsdom ten
|
|
24
|
+
// seconds. Otherwise the estimate only ever overestimates (tags inside comments, attribute values
|
|
25
|
+
// and raw text count too), which at worst means an extreme message is shown as plain text.
|
|
26
|
+
function exceedsNestingDepth(html, maxDepth) {
|
|
27
|
+
const tagName = /[a-z][a-z0-9:-]*/iy;
|
|
28
|
+
const stack = [];
|
|
29
|
+
const open = new Map();
|
|
30
|
+
let foreign = 0;
|
|
31
|
+
let pos = 0;
|
|
32
|
+
while (pos < html.length) {
|
|
33
|
+
const tagStart = html.indexOf('<', pos);
|
|
34
|
+
if (tagStart < 0) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const tagEnd = html.indexOf('>', tagStart + 1);
|
|
38
|
+
if (tagEnd < 0) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
// resume right after the first ">", so no tag the parser would see is ever skipped
|
|
42
|
+
pos = tagEnd + 1;
|
|
43
|
+
|
|
44
|
+
const closing = html.charAt(tagStart + 1) === '/';
|
|
45
|
+
tagName.lastIndex = tagStart + (closing ? 2 : 1);
|
|
46
|
+
const nameMatch = tagName.exec(html);
|
|
47
|
+
if (!nameMatch) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const name = nameMatch[0].toLowerCase();
|
|
51
|
+
const isForeignRoot = name === 'svg' || name === 'math';
|
|
52
|
+
|
|
53
|
+
if (closing) {
|
|
54
|
+
if (!open.get(name)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let popped;
|
|
58
|
+
do {
|
|
59
|
+
popped = stack.pop();
|
|
60
|
+
open.set(popped, open.get(popped) - 1);
|
|
61
|
+
if (popped === 'svg' || popped === 'math') {
|
|
62
|
+
foreign--;
|
|
63
|
+
}
|
|
64
|
+
} while (popped !== name);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!foreign && NON_NESTING_TAGS.has(name)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
|
|
73
|
+
if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isForeignRoot) {
|
|
78
|
+
foreign++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stack.push(name);
|
|
82
|
+
open.set(name, (open.get(name) || 0) + 1);
|
|
83
|
+
if (stack.length > maxDepth) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The text of an HTML body for a fallback rendering. The fallback runs when the HTML could not be
|
|
91
|
+
// processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a real HTML
|
|
92
|
+
// parser either: html-to-text gives up on a body nested past its own limit and returned nothing
|
|
93
|
+
// for exactly the input that lands here. One pass over the string: tags become a space, style and
|
|
94
|
+
// script blocks are dropped whole, entities are decoded once
|
|
95
|
+
function fallbackText(html) {
|
|
96
|
+
const lower = html.toLowerCase();
|
|
97
|
+
const parts = [];
|
|
98
|
+
let pos = 0;
|
|
99
|
+
while (pos < html.length) {
|
|
100
|
+
const lt = html.indexOf('<', pos);
|
|
101
|
+
if (lt < 0) {
|
|
102
|
+
parts.push(html.slice(pos));
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
parts.push(html.slice(pos, lt));
|
|
106
|
+
const gt = html.indexOf('>', lt + 1);
|
|
107
|
+
if (gt < 0) {
|
|
108
|
+
// an unterminated tag runs to the end of the input
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
|
|
112
|
+
if (name) {
|
|
113
|
+
const close = lower.indexOf('</' + name[1], gt + 1);
|
|
114
|
+
const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
|
|
115
|
+
if (closeEnd < 0) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
pos = closeEnd + 1;
|
|
119
|
+
} else {
|
|
120
|
+
pos = gt + 1;
|
|
121
|
+
}
|
|
122
|
+
parts.push(' ');
|
|
123
|
+
}
|
|
124
|
+
return he.decode(parts.join(''));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText };
|
package/lib/inline-html.js
CHANGED
|
@@ -4,6 +4,12 @@ const HTMLParser = require('node-html-parser');
|
|
|
4
4
|
const he = require('he');
|
|
5
5
|
const formatDate = require('./format-date');
|
|
6
6
|
const textToHtml = require('./text-to-html');
|
|
7
|
+
const { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } = require('./html-guard');
|
|
8
|
+
|
|
9
|
+
// Escaped text in place of a body that is not worth parsing
|
|
10
|
+
function fallbackBody(html) {
|
|
11
|
+
return `<div style="white-space: pre-wrap;">${he.encode(fallbackText(html), { useNamedReferences: true })}</div>`;
|
|
12
|
+
}
|
|
7
13
|
|
|
8
14
|
function getHtmlBody(html) {
|
|
9
15
|
html = ((html && html.toString()) || '').trim();
|
|
@@ -11,12 +17,22 @@ function getHtmlBody(html) {
|
|
|
11
17
|
return '';
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// node-html-parser spends cubic time removing the elements left open at the end of a document
|
|
21
|
+
// (16 KB of unclosed <b> tags took ten seconds), so the depth check that guards mimeHtml()
|
|
22
|
+
// refuses the obvious shapes before parsing. The parse option leaves whatever is still open in
|
|
23
|
+
// place instead of reparenting it, so a shape the check underestimates (it follows the HTML
|
|
24
|
+
// parsing rules, node-html-parser ignores more end tags) costs linear time and at worst a stack
|
|
25
|
+
// overflow in the serializer, caught below
|
|
26
|
+
if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
|
|
27
|
+
return fallbackBody(html);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const root = HTMLParser.parse(html, { parseNoneClosedTags: true });
|
|
31
|
+
const body = root.querySelector('body') || root.querySelector('html') || root;
|
|
32
|
+
return (body.innerHTML || '').trim();
|
|
33
|
+
} catch (_err) {
|
|
34
|
+
return fallbackBody(html);
|
|
18
35
|
}
|
|
19
|
-
return ((body && body.innerHTML) || '').trim();
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
function formatAddressHtml(addr) {
|
package/lib/mime-html.js
CHANGED
|
@@ -9,6 +9,7 @@ const he = require('he');
|
|
|
9
9
|
const { Worker } = require('worker_threads');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const WorkerPool = require('./worker-pool');
|
|
12
|
+
const { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } = require('./html-guard');
|
|
12
13
|
|
|
13
14
|
const textToHtml = require('./text-to-html');
|
|
14
15
|
|
|
@@ -74,6 +75,10 @@ const FORBID_STYLES = [
|
|
|
74
75
|
// Matches the style text of any element that could carry one of FORBID_STYLES
|
|
75
76
|
const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
|
|
76
77
|
|
|
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');
|
|
81
|
+
|
|
77
82
|
// Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
|
|
78
83
|
// name, so that a forbidden property cannot hide behind an escaped character
|
|
79
84
|
function decodeCssEscapes(value) {
|
|
@@ -198,119 +203,49 @@ blockquote blockquote blockquote blockquote{
|
|
|
198
203
|
// filter below scale with it, and no legitimate email needs more.
|
|
199
204
|
const MAX_STYLE_TEXT = 256 * 1024;
|
|
200
205
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
function exceedsNestingDepth(html, maxDepth) {
|
|
219
|
-
const tagName = /[a-z][a-z0-9:-]*/iy;
|
|
206
|
+
// juice matches every selector against every element, so its cost is the product of the two
|
|
207
|
+
// counts: 1,000 rules on 10,000 paragraphs took 2.7 s and 10,000 on 10,000 took 25 s, on the
|
|
208
|
+
// calling thread, and the style text cap alone leaves the body unbounded. Each bound sits well
|
|
209
|
+
// above real mail (an Outlook thread of fifty replies is a few hundred selectors on some fifteen
|
|
210
|
+
// thousand elements, a heavy newsletter a thousand on a few thousand) and the product bound keeps
|
|
211
|
+
// the worst case around a second: 0.25 us per pair measured when every selector matches every
|
|
212
|
+
// element, a quarter of that when none does. Past any of them the sanitized HTML is returned
|
|
213
|
+
// without inlined CSS, and the wrapper says so.
|
|
214
|
+
const MAX_INLINE_SELECTORS = 5000;
|
|
215
|
+
const MAX_INLINE_ELEMENTS = 50000;
|
|
216
|
+
const MAX_INLINE_WORK = 5000000;
|
|
217
|
+
|
|
218
|
+
// Selectors juice would match against the document: every selector of every rule at the top level
|
|
219
|
+
// of the style text. A rule nested in an at-rule is not inlined (juice keeps media queries as rules),
|
|
220
|
+
// so it only costs its parse and is not counted. One linear pass
|
|
221
|
+
function countSelectors(css) {
|
|
222
|
+
let count = 0;
|
|
220
223
|
let depth = 0;
|
|
221
|
-
let
|
|
222
|
-
let
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const tagEnd = html.indexOf('>', tagStart + 1);
|
|
229
|
-
if (tagEnd < 0) {
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
// resume right after the first ">", so no tag the parser would see is ever skipped
|
|
233
|
-
pos = tagEnd + 1;
|
|
234
|
-
|
|
235
|
-
const closing = html.charAt(tagStart + 1) === '/';
|
|
236
|
-
tagName.lastIndex = tagStart + (closing ? 2 : 1);
|
|
237
|
-
const nameMatch = tagName.exec(html);
|
|
238
|
-
if (!nameMatch) {
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
const name = nameMatch[0].toLowerCase();
|
|
242
|
-
const isForeignRoot = name === 'svg' || name === 'math';
|
|
243
|
-
|
|
244
|
-
if (closing) {
|
|
245
|
-
if (isForeignRoot && foreign > 0) {
|
|
246
|
-
foreign--;
|
|
224
|
+
let commas = 0;
|
|
225
|
+
for (let i = 0; i < css.length; i++) {
|
|
226
|
+
const c = css.charAt(i);
|
|
227
|
+
if (c === '{') {
|
|
228
|
+
if (depth === 0) {
|
|
229
|
+
count += commas + 1;
|
|
230
|
+
commas = 0;
|
|
247
231
|
}
|
|
248
|
-
|
|
232
|
+
depth++;
|
|
233
|
+
} else if (c === '}') {
|
|
234
|
+
if (depth > 0) {
|
|
249
235
|
depth--;
|
|
250
236
|
}
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
|
|
259
|
-
if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (isForeignRoot) {
|
|
264
|
-
foreign++;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
depth++;
|
|
268
|
-
if (depth > maxDepth) {
|
|
269
|
-
return true;
|
|
237
|
+
commas = 0;
|
|
238
|
+
} else if (c === ';' && depth === 0) {
|
|
239
|
+
commas = 0;
|
|
240
|
+
} else if (c === ',' && depth === 0) {
|
|
241
|
+
commas++;
|
|
270
242
|
}
|
|
271
243
|
}
|
|
272
|
-
return
|
|
244
|
+
return count;
|
|
273
245
|
}
|
|
274
246
|
|
|
275
247
|
// Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
|
|
276
248
|
// example when a hostile nesting depth overflows the stack of the parser or the serializer.
|
|
277
|
-
// The text of an HTML body for the fallback rendering. The fallback runs when the HTML could
|
|
278
|
-
// not be processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a
|
|
279
|
-
// real HTML parser either: html-to-text gives up on a body nested past its own limit and
|
|
280
|
-
// returned nothing for exactly the input that lands here. One pass over the string: tags become
|
|
281
|
-
// a space, style and script blocks are dropped whole, entities are decoded once
|
|
282
|
-
function fallbackText(html) {
|
|
283
|
-
const lower = html.toLowerCase();
|
|
284
|
-
const parts = [];
|
|
285
|
-
let pos = 0;
|
|
286
|
-
while (pos < html.length) {
|
|
287
|
-
const lt = html.indexOf('<', pos);
|
|
288
|
-
if (lt < 0) {
|
|
289
|
-
parts.push(html.slice(pos));
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
parts.push(html.slice(pos, lt));
|
|
293
|
-
const gt = html.indexOf('>', lt + 1);
|
|
294
|
-
if (gt < 0) {
|
|
295
|
-
// an unterminated tag runs to the end of the input
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
|
|
299
|
-
if (name) {
|
|
300
|
-
const close = lower.indexOf('</' + name[1], gt + 1);
|
|
301
|
-
const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
|
|
302
|
-
if (closeEnd < 0) {
|
|
303
|
-
break;
|
|
304
|
-
}
|
|
305
|
-
pos = closeEnd + 1;
|
|
306
|
-
} else {
|
|
307
|
-
pos = gt + 1;
|
|
308
|
-
}
|
|
309
|
-
parts.push(' ');
|
|
310
|
-
}
|
|
311
|
-
return he.decode(parts.join(''));
|
|
312
|
-
}
|
|
313
|
-
|
|
314
249
|
function fallbackHtml(message) {
|
|
315
250
|
let text = message.text;
|
|
316
251
|
if (!text && message.html) {
|
|
@@ -405,6 +340,7 @@ function prepareHtml(message) {
|
|
|
405
340
|
// Process remaining style tags to remove known problematic selectors
|
|
406
341
|
let styleBudget = MAX_STYLE_TEXT;
|
|
407
342
|
let hasStyle = false;
|
|
343
|
+
let selectors = 0;
|
|
408
344
|
root.querySelectorAll('style').forEach(styleTag => {
|
|
409
345
|
let cssContent = styleTag.textContent || '';
|
|
410
346
|
if (cssContent.length > styleBudget) {
|
|
@@ -416,14 +352,24 @@ function prepareHtml(message) {
|
|
|
416
352
|
styleTag.textContent = cssContent;
|
|
417
353
|
if (cssContent.trim()) {
|
|
418
354
|
hasStyle = true;
|
|
355
|
+
selectors += countSelectors(cssContent);
|
|
419
356
|
}
|
|
420
357
|
});
|
|
421
358
|
|
|
422
|
-
|
|
359
|
+
let inliningSkipped = false;
|
|
360
|
+
if (hasStyle) {
|
|
361
|
+
const elements = root.getElementsByTagName('*').length;
|
|
362
|
+
if (selectors > MAX_INLINE_SELECTORS || elements > MAX_INLINE_ELEMENTS || selectors * elements > MAX_INLINE_WORK) {
|
|
363
|
+
hasStyle = false;
|
|
364
|
+
inliningSkipped = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { html: root.outerHTML, hasStyle, inliningSkipped };
|
|
423
369
|
}
|
|
424
370
|
|
|
425
371
|
// Second sanitization pass and post-processing of the juice output.
|
|
426
|
-
function finishHtml(html) {
|
|
372
|
+
function finishHtml(html, inliningSkipped) {
|
|
427
373
|
// second pass, outputs a BODY DOM element
|
|
428
374
|
const dom = DOMPurify.sanitize(html, {
|
|
429
375
|
RETURN_DOM: true,
|
|
@@ -465,13 +411,28 @@ function finishHtml(html) {
|
|
|
465
411
|
for (let disallowedStyle of FORBID_STYLES) {
|
|
466
412
|
node.style.removeProperty(disallowedStyle);
|
|
467
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
|
+
}
|
|
468
428
|
}
|
|
469
429
|
}
|
|
470
430
|
|
|
471
431
|
let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
|
|
472
432
|
|
|
473
|
-
// embed into a styled container
|
|
474
|
-
|
|
433
|
+
// embed into a styled container; the attribute tells the caller the CSS inlining caps were hit
|
|
434
|
+
const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
|
|
435
|
+
return `<div style="${he.encode(bodyStyles)}"${marker}>${dom.innerHTML.trim()}</div>`;
|
|
475
436
|
}
|
|
476
437
|
|
|
477
438
|
// Runs the pipeline and renders the plain text fallback when it fails, for example when a
|
|
@@ -494,7 +455,7 @@ function guarded(fn, message, rethrow) {
|
|
|
494
455
|
function mimeHtmlSync(message) {
|
|
495
456
|
return guarded(
|
|
496
457
|
() => {
|
|
497
|
-
let { html, hasStyle } = prepareHtml(message);
|
|
458
|
+
let { html, hasStyle, inliningSkipped } = prepareHtml(message);
|
|
498
459
|
if (hasStyle) {
|
|
499
460
|
try {
|
|
500
461
|
// Run juice synchronously - risky but backwards compatible
|
|
@@ -503,7 +464,7 @@ function mimeHtmlSync(message) {
|
|
|
503
464
|
// not so important, so we'll ignore if style inlining fails
|
|
504
465
|
}
|
|
505
466
|
}
|
|
506
|
-
return finishHtml(html);
|
|
467
|
+
return finishHtml(html, inliningSkipped);
|
|
507
468
|
},
|
|
508
469
|
message,
|
|
509
470
|
false
|
|
@@ -513,7 +474,7 @@ function mimeHtmlSync(message) {
|
|
|
513
474
|
async function mimeHtmlAsync(message, options = {}) {
|
|
514
475
|
return guarded(
|
|
515
476
|
async () => {
|
|
516
|
-
let { html, hasStyle } = prepareHtml(message);
|
|
477
|
+
let { html, hasStyle, inliningSkipped } = prepareHtml(message);
|
|
517
478
|
if (hasStyle) {
|
|
518
479
|
try {
|
|
519
480
|
// Use worker thread with timeout for juice - provides protection against ANY
|
|
@@ -526,7 +487,7 @@ async function mimeHtmlAsync(message, options = {}) {
|
|
|
526
487
|
}
|
|
527
488
|
}
|
|
528
489
|
}
|
|
529
|
-
return finishHtml(html);
|
|
490
|
+
return finishHtml(html, inliningSkipped);
|
|
530
491
|
},
|
|
531
492
|
message,
|
|
532
493
|
options.fallbackOnError === false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postalsys/email-text-tools",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.21",
|
|
4
4
|
"description": "Email text tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"prettier": "3.9.10"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@postalsys/email-content": "0.1.14",
|
|
37
38
|
"dompurify": "3.4.17",
|
|
38
39
|
"he": "1.2.0",
|
|
39
40
|
"html-to-text": "10.0.1",
|
package/test/hardening.test.js
CHANGED
|
@@ -231,6 +231,19 @@ describe('hostile input hardening', () => {
|
|
|
231
231
|
assert.equal(mimeHtml.getWorkerPoolStats(), null);
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
it('mimeHtml depth check is not bypassed by end tags that close nothing', () => {
|
|
235
|
+
// the check counted every end tag down, so "<b></i>" repeated passed it while the
|
|
236
|
+
// parser, which ignores the </i>, nested the <b> tags: 10,000 copies took 9.7 s
|
|
237
|
+
const { result, ms } = timed(() => mimeHtml({ html: '<b></i>'.repeat(10000) + 'deep' }));
|
|
238
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
239
|
+
assert.ok(result.includes('deep'));
|
|
240
|
+
assert.ok(ms < 1500, `took ${ms} ms`);
|
|
241
|
+
// an end tag still closes what it names, including what was opened inside it
|
|
242
|
+
const nested = mimeHtml({ html: '<div><span><b>x</div>'.repeat(2000) + 'shallow' });
|
|
243
|
+
assert.ok(!nested.startsWith(FALLBACK_PREFIX));
|
|
244
|
+
assert.ok(nested.includes('shallow'));
|
|
245
|
+
});
|
|
246
|
+
|
|
234
247
|
it('htmlToText does not overflow the stack on deep nesting', () => {
|
|
235
248
|
for (const tag of ['div', 'b', 'blockquote']) {
|
|
236
249
|
const html = 'hello & <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
|
|
@@ -243,6 +256,104 @@ describe('hostile input hardening', () => {
|
|
|
243
256
|
});
|
|
244
257
|
});
|
|
245
258
|
|
|
259
|
+
describe('CSS inlining caps', () => {
|
|
260
|
+
const SKIPPED_PREFIX = '<div style="overflow: auto;" data-css-inlining="skipped">';
|
|
261
|
+
|
|
262
|
+
function styled(css, body) {
|
|
263
|
+
return `<html><head><style>${css}</style></head><body>${body}</body></html>`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function rules(count, selector) {
|
|
267
|
+
let css = '';
|
|
268
|
+
for (let i = 0; i < count; i++) {
|
|
269
|
+
css += `${selector || '.c' + i} { color: #${(i % 4096).toString(16).padStart(3, '0')}; }\n`;
|
|
270
|
+
}
|
|
271
|
+
return css;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it('skips inlining when the selectors times the elements is too much work', () => {
|
|
275
|
+
// 10,000 rules on 10,000 paragraphs took 25 s in juice, on the calling thread
|
|
276
|
+
const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
|
|
277
|
+
const { result, ms } = timed(() => mimeHtml({ html }));
|
|
278
|
+
assert.ok(ms < 3000, `took ${ms} ms`);
|
|
279
|
+
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
280
|
+
assert.ok(result.includes('<p>x</p>'));
|
|
281
|
+
assert.doesNotMatch(result, /color:/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('counts every selector of a rule, not every rule', () => {
|
|
285
|
+
const html = styled(`${'p, '.repeat(9999)}p { color: red; }`, '<p>x</p>'.repeat(10000));
|
|
286
|
+
const result = mimeHtml({ html });
|
|
287
|
+
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('skips inlining past the selector cap or the element cap on their own', () => {
|
|
291
|
+
const manyRules = mimeHtml({ html: styled(rules(5001), '<p class="c1">x</p>') });
|
|
292
|
+
assert.ok(manyRules.startsWith(SKIPPED_PREFIX), manyRules.slice(0, 80));
|
|
293
|
+
const manyElements = mimeHtml({ html: styled('p { color: red; }', '<i></i>'.repeat(50001)) });
|
|
294
|
+
assert.ok(manyElements.startsWith(SKIPPED_PREFIX), manyElements.slice(0, 80));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('inlines a large but ordinary message', () => {
|
|
298
|
+
// rules nested in a media query are not matched against elements and do not count
|
|
299
|
+
const css = `${rules(2000)}p.x { color: blue; }\n@media screen { ${rules(4000)} }`;
|
|
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));
|
|
302
|
+
assert.match(result, /color: blue/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('mimeHtml.async skips the same way', async () => {
|
|
306
|
+
const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
|
|
307
|
+
const result = await mimeHtml.async({ html });
|
|
308
|
+
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
309
|
+
assert.ok(result.includes('<p>x</p>'));
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('inlineHtml parse guard', () => {
|
|
314
|
+
const FALLBACK_BODY = '<div style="white-space: pre-wrap;">';
|
|
315
|
+
|
|
316
|
+
it('does not spend seconds on tags left open in the quoted message', () => {
|
|
317
|
+
// 16 KB of "<b>x" took node-html-parser 10.6 s to clean up
|
|
318
|
+
const { result, ms } = timed(() => inlineHtml('reply', '<p>my reply</p>', { html: '<b>x'.repeat(4000) }));
|
|
319
|
+
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
320
|
+
assert.ok(result.includes('my reply'));
|
|
321
|
+
assert.ok(result.includes(FALLBACK_BODY));
|
|
322
|
+
assert.ok(!result.includes('<b>'));
|
|
323
|
+
assert.ok(/x x x/.test(result));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
for (const [label, html] of [
|
|
327
|
+
['end tags that close nothing', '<b></i>'.repeat(5000) + 'deep'],
|
|
328
|
+
['end tags the parser ignores', '<td><font>x</td>'.repeat(3000)],
|
|
329
|
+
['padded end tags', '<a><b><c>y</a></b>'.repeat(3000)],
|
|
330
|
+
['rows left open', '<table>' + '<tr><td>cell'.repeat(5000)]
|
|
331
|
+
]) {
|
|
332
|
+
it(`stays fast on ${label}`, () => {
|
|
333
|
+
const { result, ms } = timed(() => inlineHtml('forward', '<p>fyi</p>', { html }));
|
|
334
|
+
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
335
|
+
assert.ok(result.includes('fyi'));
|
|
336
|
+
assert.ok(result.includes('<blockquote'));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
it('guards the new content the same way', () => {
|
|
341
|
+
const { result, ms } = timed(() => inlineHtml('reply', '<b>x'.repeat(4000), { html: '<p>Original</p>' }));
|
|
342
|
+
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
343
|
+
assert.ok(result.includes('<p>Original</p>'));
|
|
344
|
+
assert.ok(result.includes(FALLBACK_BODY));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('keeps the quoted body of ordinary and sloppy markup', () => {
|
|
348
|
+
const result = inlineHtml('reply', '<p>r</p>', { html: '<html><body><div><b>bold text<p>para</body></html>' });
|
|
349
|
+
assert.ok(result.includes('bold text'));
|
|
350
|
+
assert.ok(result.includes('<b>'));
|
|
351
|
+
// only the reply template's own body element is left
|
|
352
|
+
assert.equal((result.match(/<body/g) || []).length, 1);
|
|
353
|
+
assert.ok(!result.includes(FALLBACK_BODY));
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
246
357
|
describe('sanitizer policy', () => {
|
|
247
358
|
it('removes forms and form controls but keeps their text', () => {
|
|
248
359
|
const html =
|
|
@@ -362,3 +473,32 @@ describe('forbidden style properties behind CSS escapes', () => {
|
|
|
362
473
|
assert.ok(/color:\s*red/i.test(out), out);
|
|
363
474
|
});
|
|
364
475
|
});
|
|
476
|
+
|
|
477
|
+
describe('forbidden style properties the CSSOM could not parse', () => {
|
|
478
|
+
it('drops a style attribute cssstyle rejected that names a forbidden property', () => {
|
|
479
|
+
// cssstyle 5 parses nothing from an attribute whose background shorthand with a url() is
|
|
480
|
+
// followed by more declarations, so the raw text went out verbatim and a browser, which
|
|
481
|
+
// parses it fine, honored the position
|
|
482
|
+
const out = mimeHtml({ html: '<div style="background:url(https://example.com/x.gif) no-repeat; position:fixed; top:0">x</div>' });
|
|
483
|
+
assert.ok(!/position|fixed|top:/i.test(out), out);
|
|
484
|
+
assert.ok(out.includes('<div>x</div>'), out);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('keeps the declarations the CSSOM parsed around one it could not', () => {
|
|
488
|
+
const out = mimeHtml({ html: '<div style="color:red;}position:fixed;top:0">x</div>' });
|
|
489
|
+
assert.ok(!/position|fixed/i.test(out), out);
|
|
490
|
+
assert.match(out, /color: red/);
|
|
491
|
+
assert.match(out, /top: 0/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('leaves an attribute that names no forbidden property as it was, parsed or not', () => {
|
|
495
|
+
const out = mimeHtml({ html: '<div style="background:url(clear.gif); color:red">x</div>' });
|
|
496
|
+
assert.ok(out.includes('style="background:url(clear.gif); color:red"'), out);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('does not take a longer property name for a forbidden one', () => {
|
|
500
|
+
const out = mimeHtml({ html: '<div style="background-position: center top; text-underline-offset: 2px; color:red">x</div>' });
|
|
501
|
+
assert.ok(out.includes('background-position: center top'), out);
|
|
502
|
+
assert.ok(out.includes('text-underline-offset: 2px'), out);
|
|
503
|
+
});
|
|
504
|
+
});
|