@postalsys/email-text-tools 2.4.17 → 2.4.18
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/lib/mime-html.js +38 -2
- package/package.json +1 -1
- package/test/hardening.test.js +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.18](https://github.com/postalsys/email-text-tools/compare/v2.4.17...v2.4.18) (2026-09-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* keep the text of an over-deep HTML body in the fallback rendering ([a2e06b7](https://github.com/postalsys/email-text-tools/commit/a2e06b75878d3763bb35d225a93225aa6a3f18ab))
|
|
9
|
+
|
|
3
10
|
## [2.4.17](https://github.com/postalsys/email-text-tools/compare/v2.4.16...v2.4.17) (2026-09-28)
|
|
4
11
|
|
|
5
12
|
|
package/lib/mime-html.js
CHANGED
|
@@ -11,7 +11,6 @@ const path = require('path');
|
|
|
11
11
|
const WorkerPool = require('./worker-pool');
|
|
12
12
|
|
|
13
13
|
const textToHtml = require('./text-to-html');
|
|
14
|
-
const htmlToText = require('./html-to-text');
|
|
15
14
|
|
|
16
15
|
const FORBID_TAGS = [
|
|
17
16
|
'title',
|
|
@@ -275,10 +274,47 @@ function exceedsNestingDepth(html, maxDepth) {
|
|
|
275
274
|
|
|
276
275
|
// Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
|
|
277
276
|
// 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
|
+
|
|
278
314
|
function fallbackHtml(message) {
|
|
279
315
|
let text = message.text;
|
|
280
316
|
if (!text && message.html) {
|
|
281
|
-
text =
|
|
317
|
+
text = fallbackText(message.html.toString());
|
|
282
318
|
}
|
|
283
319
|
return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
|
|
284
320
|
}
|
package/package.json
CHANGED
package/test/hardening.test.js
CHANGED
|
@@ -146,8 +146,8 @@ describe('hostile input hardening', () => {
|
|
|
146
146
|
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
147
147
|
assert.ok(result.includes('hello <img'));
|
|
148
148
|
assert.doesNotMatch(result, /<img/);
|
|
149
|
-
// text below
|
|
150
|
-
assert.
|
|
149
|
+
// the fallback strips tags itself, so text below any parser depth limit survives
|
|
150
|
+
assert.ok(result.includes('deep'));
|
|
151
151
|
assert.ok(ms < 10000, `took ${ms} ms`);
|
|
152
152
|
});
|
|
153
153
|
|
|
@@ -155,6 +155,7 @@ describe('hostile input hardening', () => {
|
|
|
155
155
|
const { result, ms } = timed(() => mimeHtml({ html: 'shallow' + '<b>'.repeat(50000) + 'bold' }));
|
|
156
156
|
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
157
157
|
assert.ok(result.includes('shallow'));
|
|
158
|
+
assert.ok(result.includes('bold'));
|
|
158
159
|
assert.ok(ms < 15000, `took ${ms} ms`);
|
|
159
160
|
});
|
|
160
161
|
|
|
@@ -198,6 +199,25 @@ describe('hostile input hardening', () => {
|
|
|
198
199
|
const result = await mimeHtml.async({ html: 'async shallow' + '<div>'.repeat(4000) + 'async deep' });
|
|
199
200
|
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
200
201
|
assert.ok(result.includes('async shallow'));
|
|
202
|
+
assert.ok(result.includes('async deep'));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('the fallback keeps the text of an over-deep body that has no plain part', async () => {
|
|
206
|
+
const html = '<style>.x{color:red}</style><script>alert(1)</script>' + '<div>'.repeat(4000) + 'only <html> deep' + '</div>'.repeat(4000);
|
|
207
|
+
const result = await mimeHtml.async({ html });
|
|
208
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
209
|
+
assert.ok(result.includes('only <html> deep'));
|
|
210
|
+
assert.ok(!result.includes('<div>'));
|
|
211
|
+
assert.ok(!result.includes('color:red'));
|
|
212
|
+
assert.ok(!result.includes('alert(1)'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('the fallback drops an unterminated style block and tag instead of rendering them', async () => {
|
|
216
|
+
const result = await mimeHtml.async({ html: 'kept <style>.x{}' + '<div>'.repeat(4000) });
|
|
217
|
+
assert.ok(result.includes('kept'));
|
|
218
|
+
assert.ok(!result.includes('.x{}'));
|
|
219
|
+
const unterminated = await mimeHtml.async({ html: 'kept <div' + '<div>'.repeat(4000) });
|
|
220
|
+
assert.ok(unterminated.includes('kept'));
|
|
201
221
|
});
|
|
202
222
|
|
|
203
223
|
it('mimeHtml.async rejects on hostile nesting with fallbackOnError: false', async () => {
|