@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.
- package/.github/VULNERABILITY_REPORT.yml +75 -0
- package/.ncurc.js +5 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +3 -1
- package/SECURITY.md +125 -50
- package/lib/html-guard.js +127 -0
- package/lib/inline-html.js +21 -5
- package/lib/mime-html.js +72 -111
- package/package.json +5 -4
- package/test/hardening.test.js +140 -0
|
@@ -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
|
};
|
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
|
-
|
|
5
|
-
arbitrary (
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
12
|
-
patches to older
|
|
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
|
|
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
|
-
##
|
|
24
|
+
## What Qualifies as a Security Vulnerability
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
+
In particular, explain:
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
+
Report privately through one of the following channels:
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
75
|
-
example
|
|
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
|
-
-
|
|
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 };
|
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.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.
|
|
32
|
+
"eslint": "10.12.0",
|
|
33
33
|
"eslint-config-prettier": "10.1.8",
|
|
34
|
-
"prettier": "3.9.
|
|
34
|
+
"prettier": "3.9.10"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"
|
|
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",
|
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
|
+
});
|