@postalsys/email-text-tools 2.4.18 → 2.4.20

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.
@@ -0,0 +1,75 @@
1
+ name: '@postalsys/email-text-tools security vulnerability report'
2
+ description: 'Report an exploitable security vulnerability in @postalsys/email-text-tools'
3
+
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Before reporting, read our
9
+ [Security Policy](https://github.com/postalsys/email-text-tools/security/policy).
10
+
11
+ Reports must demonstrate a credible security impact.
12
+ Technically valid bugs without a realistic attack
13
+ scenario belong in the regular issue tracker.
14
+
15
+ - type: textarea
16
+ id: summary
17
+ attributes:
18
+ label: Vulnerability summary
19
+ description: Describe the vulnerability and affected versions.
20
+ validations:
21
+ required: true
22
+
23
+ - type: textarea
24
+ id: attacker
25
+ attributes:
26
+ label: Attacker capabilities
27
+ description: |
28
+ What can an attacker control without already having
29
+ privileged access to the application or its configuration?
30
+ validations:
31
+ required: true
32
+ min_length: 100
33
+
34
+ - type: textarea
35
+ id: exploit
36
+ attributes:
37
+ label: Realistic exploitation scenario
38
+ description: |
39
+ Describe how this can be exploited in a real application.
40
+ Explain the prerequisites and security boundary crossed.
41
+ validations:
42
+ required: true
43
+ min_length: 150
44
+
45
+ - type: textarea
46
+ id: poc
47
+ attributes:
48
+ label: Proof of concept
49
+ description: Provide reproducible steps or working code.
50
+ validations:
51
+ required: true
52
+
53
+ - type: textarea
54
+ id: impact
55
+ attributes:
56
+ label: Security impact
57
+ description: |
58
+ What can the attacker achieve? Who is affected?
59
+ Explain why this is a security vulnerability rather
60
+ than an ordinary bug.
61
+ validations:
62
+ required: true
63
+ min_length: 100
64
+
65
+ - type: checkboxes
66
+ id: confirmation
67
+ attributes:
68
+ label: Reporting requirements
69
+ options:
70
+ - label: I have read the Security Policy.
71
+ required: true
72
+ - label: I have verified the finding against a supported version.
73
+ required: true
74
+ - label: I have described a credible attacker-controlled scenario.
75
+ required: true
package/.ncurc.js CHANGED
@@ -13,6 +13,10 @@ module.exports = {
13
13
  // 6.0.0 changed the CommonJS export shape (named bindings instead of the constructor) and,
14
14
  // more importantly, stopped linkifying bare domains such as 'example.com' or 'www.example.org'
15
15
  // by default, which silently breaks autolinking in plain text emails. Stay on 5.x.
16
- 'linkify-it'
16
+ 'linkify-it',
17
+ // 2.0.0 is ESM-only (exports only src/he.mjs) and requires Node >=22, so require('he') throws
18
+ // and pkg cannot bundle it. 1.2.0 is the last CommonJS release; EmailEngine and mailparser
19
+ // hold it there too, and a different copy here would be a second he in their trees
20
+ 'he'
17
21
  ]
18
22
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.4.18"
2
+ ".": "2.4.20"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.20](https://github.com/postalsys/email-text-tools/compare/v2.4.19...v2.4.20) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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))
9
+ * bump @postalsys/email-content to 0.1.13 ([1a8738d](https://github.com/postalsys/email-text-tools/commit/1a8738da39f1adc404f871ab63c8dda5d3733dd2))
10
+
11
+ ## [2.4.19](https://github.com/postalsys/email-text-tools/compare/v2.4.18...v2.4.19) (2026-10-14)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **deps:** update dompurify to 3.4.17 and hold he on 1.x ([7a900b0](https://github.com/postalsys/email-text-tools/commit/7a900b03f4fbf4001bb21dffa62c659a89917e74))
17
+
3
18
  ## [2.4.18](https://github.com/postalsys/email-text-tools/compare/v2.4.17...v2.4.18) (2026-09-28)
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.
package/SECURITY.md CHANGED
@@ -1,81 +1,156 @@
1
1
  # Security Policy
2
2
 
3
- `@postalsys/email-text-tools` converts and sanitizes email content for display -
4
- turning plain text into HTML, HTML into plain text, and sanitizing/CSS-inlining
5
- arbitrary (and often untrusted) email HTML into markup that is safe to render.
6
- Because it processes attacker-controlled input and its output is shown to end
7
- users, we take security reports seriously and aim to respond quickly.
3
+ `@postalsys/email-text-tools` converts and sanitizes email content for display:
4
+ it turns plain text into HTML, HTML into plain text, and sanitizes and
5
+ CSS-inlines arbitrary (often untrusted) email HTML into markup that is safe to
6
+ render. We welcome responsible reports of vulnerabilities that present a
7
+ credible security risk to users. Please distinguish security vulnerabilities
8
+ from ordinary bugs, unusual edge cases, and hardening suggestions.
8
9
 
9
10
  ## Supported Versions
10
11
 
11
- Security fixes are released only against the latest version. We do not backport
12
- patches to older releases - upgrading to the current release line is the
13
- supported way to receive security updates.
12
+ Security fixes are released only against the latest major version. We do not
13
+ backport patches to older majors, so upgrading to the current release line is
14
+ the supported way to receive security updates.
14
15
 
15
16
  | Version | Supported |
16
17
  | ------- | ------------------ |
17
18
  | 2.x | :white_check_mark: |
18
19
  | < 2.0 | :x: |
19
20
 
20
- If you are on an older version, please upgrade. See the release notes at
21
+ If you are on an older major, please upgrade. See the release notes at
21
22
  <https://github.com/postalsys/email-text-tools/releases> before updating.
22
23
 
23
- ## Reporting a Vulnerability
24
+ ## What Qualifies as a Security Vulnerability
24
25
 
25
- **Please do not report security vulnerabilities through public GitHub issues,
26
- pull requests, or discussions.**
26
+ A security report should identify a realistic way for an attacker to cross a
27
+ security boundary in a supported version of `@postalsys/email-text-tools`.
28
+ Unexpected behavior, a failing test, or a technically correct proof of concept
29
+ is not sufficient on its own to establish security impact.
27
30
 
28
- Report privately through one of the following channels:
31
+ In particular, explain:
29
32
 
30
- 1. **GitHub Security Advisories (preferred).** Open a private report at
31
- <https://github.com/postalsys/email-text-tools/security/advisories/new>. This
32
- keeps the discussion private until a fix is published and lets us credit you.
33
- 2. **Email.** Send details to **andris@postalsys.com**. Encrypt sensitive details
34
- if possible.
33
+ - **Attacker capabilities:** Who is the attacker, and what inputs, systems, or
34
+ responses can they control _without_ already having the privileges or secrets
35
+ they are trying to gain?
36
+ - **Attack path:** How does attacker-controlled input reach the affected code
37
+ through a supported public API or a realistic application integration?
38
+ - **Prerequisites:** What configuration and user actions are required? If the
39
+ attack depends on unusual options or deliberately malformed input, explain why
40
+ those conditions could arise in a real application.
41
+ - **Impact:** What confidentiality, integrity, or availability boundary is
42
+ crossed, and what can the attacker achieve as a result?
35
43
 
36
- When reporting, please include as much of the following as you can:
44
+ A vulnerability involving an optional or less common feature can still qualify
45
+ if these conditions are met. Conversely, the existence of a defensive check in
46
+ the code does not, by itself, establish that every bypass of that check has a
47
+ realistic security impact.
37
48
 
38
- - The affected version(s) and environment (package version, Node.js version, OS).
39
- - The function involved (`mimeHtml`, `textToHtml`, `htmlToText`, `inlineHtml`,
40
- `inlineText`).
41
- - A clear description of the issue and its impact (e.g. XSS / sanitizer bypass,
42
- denial of service via catastrophic regex backtracking or CSS that hangs
43
- processing, prototype pollution, content or information disclosure).
44
- - A minimal proof of concept: the input that triggers it and the unsafe output
45
- or behavior it produces.
46
- - Any suggested remediation, if you have one.
49
+ ## Reporting a Vulnerability
47
50
 
48
- We are a small team, so there is no guaranteed response time - sometimes reports
49
- are handled within hours, sometimes they take longer. Accepted issues are fixed
50
- in a new release and coordinated through a GitHub Security Advisory, and
51
- reporters who wish to be named are credited.
51
+ **Do not disclose suspected exploitable vulnerabilities through public GitHub
52
+ issues, pull requests, or discussions before coordinating with the
53
+ maintainers.**
52
54
 
53
- ## CVEs
55
+ Report privately through one of the following channels:
54
56
 
55
- We track and disclose vulnerabilities through GitHub Security Advisories. We do
56
- not request or manage CVE identifiers ourselves. If you need a CVE assigned for a
57
- reported issue, please request one yourself - for example, through GitHub's own
58
- CVE request flow on the published advisory, or another CNA.
57
+ 1. **GitHub Security Advisories (preferred):**
58
+ <https://github.com/postalsys/email-text-tools/security/advisories/new>.
59
+ 2. **Email:** **andris@postalsys.com**. Encrypt sensitive details if possible
60
+ (PGP key fingerprint `5D952A46E1D8C931F6364E01DC6C83F4D584D364`).
61
+
62
+ ### Required Information
63
+
64
+ Please provide enough information to evaluate both the defect and its security
65
+ impact. Reports should include:
66
+
67
+ 1. The affected `@postalsys/email-text-tools` version or commit, Node.js
68
+ version, and relevant configuration.
69
+ 2. A concise description of the issue and the affected public API or code path
70
+ (for example `mimeHtml`, `textToHtml`, `htmlToText`, `inlineHtml`, or
71
+ `inlineText`, with the input that triggers the issue and the unsafe output or
72
+ behavior it produces).
73
+ 3. A minimal, tested reproduction, including steps to run it, expected behavior,
74
+ and observed behavior.
75
+ 4. A concrete threat model: attacker control, prerequisites, trust boundary, and
76
+ resulting impact. Clearly distinguish what your reproduction proves from what
77
+ you believe might be possible.
78
+ 5. An explanation of why the issue affects real deployments beyond a
79
+ deliberately constructed test setup, where this is not obvious.
80
+
81
+ A suggested fix is welcome but does not replace an explanation of
82
+ exploitability. A complete real-world exploit is not always necessary, but the
83
+ report must make a credible security impact clear.
84
+
85
+ ### Reports That May Be Treated as Ordinary Bugs
86
+
87
+ The following generally do **not** qualify for private security handling unless
88
+ you can demonstrate an additional, meaningful security boundary crossing:
89
+
90
+ - Issues that require the attacker to control trusted application code,
91
+ administrator-only configuration, or the secret they supposedly extract.
92
+ - Problems caused solely by application-level misuse, exposed credentials, or
93
+ insecure deployment choices outside `@postalsys/email-text-tools`'s control.
94
+ - Contrived proof-of-concept setups where the tester controls both sides of a
95
+ request, without explaining how a separate attacker could trigger the issue.
96
+ - Parser quirks, unexpected behavior, and bypasses of internal checks with no
97
+ demonstrated security impact.
98
+ - Speculative findings based only on static analysis or an automated tool's
99
+ assessment, without a verified reproduction and credible attack scenario.
100
+
101
+ These may still be valid bugs and may be considered through the normal issue
102
+ tracker. Please coordinate before making any potentially exploitable details
103
+ public. Classification as a bug does not guarantee a fix or a particular
104
+ priority.
105
+
106
+ ### Automated and AI-Assisted Reports
107
+
108
+ Automated analysis and AI tools are welcome as research aids, but the person
109
+ submitting a report is responsible for verifying its claims. **Do not submit
110
+ bulk, unverified, or speculative AI-generated findings.** Test each finding
111
+ against a supported release and establish the actual security impact. Reports
112
+ that do not meet the requirements above may be closed without further
113
+ investigation or individual feedback; they can be resubmitted with stronger
114
+ evidence.
115
+
116
+ ## Triage and Disclosure
117
+
118
+ `@postalsys/email-text-tools` is maintained by a small team, and there is **no
119
+ guaranteed response or resolution time**. Reports are prioritized based on
120
+ demonstrated impact, exploitability, affected usage, and available maintenance
121
+ resources.
122
+
123
+ A private report does not automatically become a security advisory. Confirmed
124
+ vulnerabilities may be fixed and disclosed through GitHub Security Advisories;
125
+ other findings may be closed, deferred, or handled as ordinary bugs. Please do
126
+ not disclose details of an unresolved, potentially exploitable issue publicly
127
+ without coordinating with the maintainers.
128
+
129
+ ## CVEs and Credit
130
+
131
+ We do not request or manage CVE identifiers. A report being acknowledged, a bug
132
+ being fixed, or a pull request being merged does **not** mean that
133
+ `@postalsys/email-text-tools` has confirmed a security vulnerability or endorsed
134
+ a CVE assignment. We do not guarantee advisories, CVE identifiers, or public
135
+ credit for reports. For confirmed vulnerabilities disclosed through an advisory,
136
+ substantive reporters may be credited if they wish.
59
137
 
60
138
  ## Scope
61
139
 
62
- In scope: the library source in this repository - in particular:
63
-
64
- - Sanitizer bypasses in `mimeHtml` that let active content (scripts, event
65
- handler attributes, dangerous URL schemes, or other executable markup) survive
66
- into the sanitized output.
67
- - Denial of service in any conversion path - input that causes catastrophic
68
- regex backtracking or makes CSS inlining hang or exhaust resources.
69
- - Prototype pollution or similar object-integrity issues reachable through the
70
- public API.
140
+ In scope: the library source in this repository, in particular sanitizer
141
+ bypasses in `mimeHtml` that let active content (scripts, event handler
142
+ attributes, dangerous URL schemes, or other executable markup) survive into the
143
+ sanitized output, denial of service in any conversion path from catastrophic
144
+ regex backtracking or CSS inlining that hangs or exhausts resources, and
145
+ prototype pollution reachable through the public API.
71
146
 
72
147
  Out of scope:
73
148
 
74
- - Vulnerabilities in your own application that consumes this library - for
75
- example, rendering the output without a Content Security Policy, or trusting
149
+ - Vulnerabilities in your own application that consumes this library, for
150
+ example rendering the output without a Content Security Policy, or trusting
76
151
  the output in a non-HTML context.
77
152
  - Issues in third-party dependencies that are already publicly known; report
78
153
  those upstream (we will still pick up the dependency fix in a release).
79
- - Missing security hardening without a demonstrated, concrete impact.
154
+ - Social-engineering reports.
80
155
 
81
- Thank you for helping keep email-text-tools and its users safe.
156
+ Thank you for helping keep `@postalsys/email-text-tools` and its users safe.
@@ -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 };
@@ -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
- const root = HTMLParser.parse(html);
15
- let body = root.querySelector('body');
16
- if (!body) {
17
- body = root.querySelector('html') || root;
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
- // jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
202
- // its serializer overflows the stack a few thousand levels down, so deeper input is not parsed at all.
203
- // Real email nests a few dozen levels.
204
- const MAX_NESTING_DEPTH = 1000;
205
-
206
- // Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
207
- // so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
208
- // so there every start tag counts.
209
- const NON_NESTING_TAGS = new Set(
210
- '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(
211
- ','
212
- )
213
- );
214
-
215
- // A single linear pass estimating how deep the parsed tree would get. It only ever overestimates
216
- // (tags inside comments, attribute values and raw text count too), which at worst means an
217
- // extreme message is shown as plain text.
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 foreign = 0;
222
- let pos = 0;
223
- while (pos < html.length) {
224
- const tagStart = html.indexOf('<', pos);
225
- if (tagStart < 0) {
226
- break;
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
- if (depth > 0 && (foreign || !NON_NESTING_TAGS.has(name))) {
232
+ depth++;
233
+ } else if (c === '}') {
234
+ if (depth > 0) {
249
235
  depth--;
250
236
  }
251
- continue;
252
- }
253
-
254
- if (!foreign && NON_NESTING_TAGS.has(name)) {
255
- continue;
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 false;
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
- return { html: root.outerHTML, hasStyle };
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
- return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
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.18",
3
+ "version": "2.4.20",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,12 +29,13 @@
29
29
  "homepage": "https://github.com/postalsys/email-text-tools#readme",
30
30
  "devDependencies": {
31
31
  "@eslint/js": "10.0.1",
32
- "eslint": "10.11.0",
32
+ "eslint": "10.12.0",
33
33
  "eslint-config-prettier": "10.1.8",
34
- "prettier": "3.9.9"
34
+ "prettier": "3.9.10"
35
35
  },
36
36
  "dependencies": {
37
- "dompurify": "3.4.16",
37
+ "@postalsys/email-content": "0.1.13",
38
+ "dompurify": "3.4.17",
38
39
  "he": "1.2.0",
39
40
  "html-to-text": "10.0.1",
40
41
  "jsdom": "27.0.0",
@@ -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 &amp; <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
+ });