postal-mime 2.4.5 → 2.4.7
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/.prettierignore +2 -1
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/package.json +3 -2
- package/postal-mime.d.ts +20 -7
- package/src/address-parser.js +69 -15
- package/src/mime-node.js +25 -6
- package/src/postal-mime.js +11 -2
package/.prettierignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.7](https://github.com/postalsys/postal-mime/compare/v2.4.6...v2.4.7) (2025-10-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* prevent email extraction from quoted strings in addressParser ([837d679](https://github.com/postalsys/postal-mime/commit/837d679b48cde95a111b8508a7aea23a28cbc12a))
|
|
9
|
+
|
|
10
|
+
## [2.4.6](https://github.com/postalsys/postal-mime/compare/v2.4.5...v2.4.6) (2025-10-01)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* add security limits for MIME parsing ([defbf11](https://github.com/postalsys/postal-mime/commit/defbf11e85c8233e6ff01a3d6fc10534b784c499))
|
|
16
|
+
|
|
3
17
|
## [2.4.5](https://github.com/postalsys/postal-mime/compare/v2.4.4...v2.4.5) (2025-09-29)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -111,6 +111,8 @@ PostalMime.parse(email, options) -> Promise<ParsedEmail>
|
|
|
111
111
|
- `"base64"`
|
|
112
112
|
- `"utf8"`
|
|
113
113
|
- `"arraybuffer"` (no decoding, returns `ArrayBuffer`)
|
|
114
|
+
- **maxNestingDepth** (number, default: `256`): Maximum allowed MIME part nesting depth. Throws an error if exceeded.
|
|
115
|
+
- **maxHeadersSize** (number, default: `2097152`): Maximum allowed total header size in bytes (default 2MB). Throws an error if exceeded.
|
|
114
116
|
|
|
115
117
|
**Returns**: A Promise that resolves to a structured object with the following properties:
|
|
116
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postal-mime",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.7",
|
|
4
4
|
"description": "Email parser for browser environments",
|
|
5
5
|
"main": "./src/postal-mime.js",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"type": "module",
|
|
12
12
|
"types": "postal-mime.d.ts",
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "npm run lint && node --test",
|
|
15
|
+
"lint": "eslint",
|
|
15
16
|
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
|
|
16
17
|
"format": "prettier --write \"**/*.{js,mjs,json}\" --ignore-path .prettierignore",
|
|
17
18
|
"format:check": "prettier --check \"**/*.{js,mjs,json}\" --ignore-path .prettierignore"
|
package/postal-mime.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
export type RawEmail = string | ArrayBuffer | Uint8Array | Blob | Buffer | ReadableStream;
|
|
2
2
|
|
|
3
|
-
export type Header =
|
|
3
|
+
export type Header = {
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
};
|
|
4
7
|
|
|
5
|
-
export type
|
|
8
|
+
export type Mailbox = {
|
|
6
9
|
name: string;
|
|
7
|
-
address
|
|
8
|
-
group?:
|
|
10
|
+
address: string;
|
|
11
|
+
group?: undefined;
|
|
9
12
|
};
|
|
10
13
|
|
|
14
|
+
export type Address =
|
|
15
|
+
| Mailbox
|
|
16
|
+
| {
|
|
17
|
+
name: string;
|
|
18
|
+
address?: undefined;
|
|
19
|
+
group: Mailbox[];
|
|
20
|
+
};
|
|
21
|
+
|
|
11
22
|
export type Attachment = {
|
|
12
23
|
filename: string | null;
|
|
13
24
|
mimeType: string;
|
|
@@ -22,7 +33,7 @@ export type Attachment = {
|
|
|
22
33
|
|
|
23
34
|
export type Email = {
|
|
24
35
|
headers: Header[];
|
|
25
|
-
from
|
|
36
|
+
from?: Address;
|
|
26
37
|
sender?: Address;
|
|
27
38
|
replyTo?: Address[];
|
|
28
39
|
deliveredTo?: string;
|
|
@@ -31,7 +42,7 @@ export type Email = {
|
|
|
31
42
|
cc?: Address[];
|
|
32
43
|
bcc?: Address[];
|
|
33
44
|
subject?: string;
|
|
34
|
-
messageId
|
|
45
|
+
messageId?: string;
|
|
35
46
|
inReplyTo?: string;
|
|
36
47
|
references?: string;
|
|
37
48
|
date?: string;
|
|
@@ -56,7 +67,9 @@ declare function decodeWords (
|
|
|
56
67
|
declare type PostalMimeOptions = {
|
|
57
68
|
rfc822Attachments?: boolean,
|
|
58
69
|
forceRfc822Attachments?: boolean,
|
|
59
|
-
attachmentEncoding?: "base64" | "utf8" | "arraybuffer"
|
|
70
|
+
attachmentEncoding?: "base64" | "utf8" | "arraybuffer",
|
|
71
|
+
maxNestingDepth?: number,
|
|
72
|
+
maxHeadersSize?: number
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
declare class PostalMime {
|
package/src/address-parser.js
CHANGED
|
@@ -7,7 +7,6 @@ import { decodeWords } from './decode-strings.js';
|
|
|
7
7
|
* @return {Object} Address object
|
|
8
8
|
*/
|
|
9
9
|
function _handleAddress(tokens) {
|
|
10
|
-
let token;
|
|
11
10
|
let isGroup = false;
|
|
12
11
|
let state = 'text';
|
|
13
12
|
let address;
|
|
@@ -16,28 +15,41 @@ function _handleAddress(tokens) {
|
|
|
16
15
|
address: [],
|
|
17
16
|
comment: [],
|
|
18
17
|
group: [],
|
|
19
|
-
text: []
|
|
18
|
+
text: [],
|
|
19
|
+
textWasQuoted: [] // Track which text tokens came from inside quotes
|
|
20
20
|
};
|
|
21
21
|
let i;
|
|
22
22
|
let len;
|
|
23
|
+
let insideQuotes = false; // Track if we're currently inside a quoted string
|
|
23
24
|
|
|
24
25
|
// Filter out <addresses>, (comments) and regular text
|
|
25
26
|
for (i = 0, len = tokens.length; i < len; i++) {
|
|
26
|
-
token = tokens[i];
|
|
27
|
+
let token = tokens[i];
|
|
28
|
+
let prevToken = i ? tokens[i - 1] : null;
|
|
27
29
|
if (token.type === 'operator') {
|
|
28
30
|
switch (token.value) {
|
|
29
31
|
case '<':
|
|
30
32
|
state = 'address';
|
|
33
|
+
insideQuotes = false;
|
|
31
34
|
break;
|
|
32
35
|
case '(':
|
|
33
36
|
state = 'comment';
|
|
37
|
+
insideQuotes = false;
|
|
34
38
|
break;
|
|
35
39
|
case ':':
|
|
36
40
|
state = 'group';
|
|
37
41
|
isGroup = true;
|
|
42
|
+
insideQuotes = false;
|
|
43
|
+
break;
|
|
44
|
+
case '"':
|
|
45
|
+
// Track quote state for text tokens
|
|
46
|
+
insideQuotes = !insideQuotes;
|
|
47
|
+
state = 'text';
|
|
38
48
|
break;
|
|
39
49
|
default:
|
|
40
50
|
state = 'text';
|
|
51
|
+
insideQuotes = false;
|
|
52
|
+
break;
|
|
41
53
|
}
|
|
42
54
|
} else if (token.value) {
|
|
43
55
|
if (state === 'address') {
|
|
@@ -46,7 +58,19 @@ function _handleAddress(tokens) {
|
|
|
46
58
|
// and so will we
|
|
47
59
|
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
48
60
|
}
|
|
49
|
-
|
|
61
|
+
|
|
62
|
+
if (prevToken && prevToken.noBreak && data[state].length) {
|
|
63
|
+
// join values
|
|
64
|
+
data[state][data[state].length - 1] += token.value;
|
|
65
|
+
if (state === 'text' && insideQuotes) {
|
|
66
|
+
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
data[state].push(token.value);
|
|
70
|
+
if (state === 'text') {
|
|
71
|
+
data.textWasQuoted.push(insideQuotes);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
50
74
|
}
|
|
51
75
|
}
|
|
52
76
|
|
|
@@ -59,16 +83,36 @@ function _handleAddress(tokens) {
|
|
|
59
83
|
if (isGroup) {
|
|
60
84
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
61
85
|
data.text = data.text.join(' ');
|
|
86
|
+
|
|
87
|
+
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
|
|
88
|
+
let groupMembers = [];
|
|
89
|
+
if (data.group.length) {
|
|
90
|
+
let parsedGroup = addressParser(data.group.join(','));
|
|
91
|
+
// Flatten: if any member is itself a group, extract its members into the sequence
|
|
92
|
+
parsedGroup.forEach(member => {
|
|
93
|
+
if (member.group) {
|
|
94
|
+
// Nested group detected - flatten it by adding its members directly
|
|
95
|
+
groupMembers = groupMembers.concat(member.group);
|
|
96
|
+
} else {
|
|
97
|
+
groupMembers.push(member);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
62
102
|
addresses.push({
|
|
63
103
|
name: decodeWords(data.text || (address && address.name)),
|
|
64
|
-
group:
|
|
104
|
+
group: groupMembers
|
|
65
105
|
});
|
|
66
106
|
} else {
|
|
67
107
|
// If no address was found, try to detect one from regular text
|
|
68
108
|
if (!data.address.length && data.text.length) {
|
|
69
109
|
for (i = data.text.length - 1; i >= 0; i--) {
|
|
70
|
-
|
|
110
|
+
// Security fix: Do not extract email addresses from quoted strings
|
|
111
|
+
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
|
|
112
|
+
// Extracting emails from quoted text leads to misrouting vulnerabilities
|
|
113
|
+
if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
|
71
114
|
data.address = data.text.splice(i, 1);
|
|
115
|
+
data.textWasQuoted.splice(i, 1);
|
|
72
116
|
break;
|
|
73
117
|
}
|
|
74
118
|
}
|
|
@@ -85,10 +129,13 @@ function _handleAddress(tokens) {
|
|
|
85
129
|
// still no address
|
|
86
130
|
if (!data.address.length) {
|
|
87
131
|
for (i = data.text.length - 1; i >= 0; i--) {
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
// Security fix: Do not extract email addresses from quoted strings
|
|
133
|
+
if (!data.textWasQuoted[i]) {
|
|
134
|
+
// fixed the regex to parse email address correctly when email address has more than one @
|
|
135
|
+
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
|
|
136
|
+
if (data.address.length) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
92
139
|
}
|
|
93
140
|
}
|
|
94
141
|
}
|
|
@@ -180,11 +227,12 @@ class Tokenizer {
|
|
|
180
227
|
* @return {Array} An array of operator|text tokens
|
|
181
228
|
*/
|
|
182
229
|
tokenize() {
|
|
183
|
-
let
|
|
184
|
-
|
|
230
|
+
let list = [];
|
|
231
|
+
|
|
185
232
|
for (let i = 0, len = this.str.length; i < len; i++) {
|
|
186
|
-
chr = this.str.charAt(i);
|
|
187
|
-
this.
|
|
233
|
+
let chr = this.str.charAt(i);
|
|
234
|
+
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
|
235
|
+
this.checkChar(chr, nextChr);
|
|
188
236
|
}
|
|
189
237
|
|
|
190
238
|
this.list.forEach(node => {
|
|
@@ -202,7 +250,7 @@ class Tokenizer {
|
|
|
202
250
|
*
|
|
203
251
|
* @param {String} chr Character from the address field
|
|
204
252
|
*/
|
|
205
|
-
checkChar(chr) {
|
|
253
|
+
checkChar(chr, nextChr) {
|
|
206
254
|
if (this.escaped) {
|
|
207
255
|
// ignore next condition blocks
|
|
208
256
|
} else if (chr === this.operatorExpecting) {
|
|
@@ -210,10 +258,16 @@ class Tokenizer {
|
|
|
210
258
|
type: 'operator',
|
|
211
259
|
value: chr
|
|
212
260
|
};
|
|
261
|
+
|
|
262
|
+
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
|
263
|
+
this.node.noBreak = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
213
266
|
this.list.push(this.node);
|
|
214
267
|
this.node = null;
|
|
215
268
|
this.operatorExpecting = '';
|
|
216
269
|
this.escaped = false;
|
|
270
|
+
|
|
217
271
|
return;
|
|
218
272
|
} else if (!this.operatorExpecting && chr in this.operators) {
|
|
219
273
|
this.node = {
|
package/src/mime-node.js
CHANGED
|
@@ -4,20 +4,31 @@ import Base64Decoder from './base64-decoder.js';
|
|
|
4
4
|
import QPDecoder from './qp-decoder.js';
|
|
5
5
|
|
|
6
6
|
export default class MimeNode {
|
|
7
|
-
constructor(
|
|
8
|
-
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.options = options || {};
|
|
9
9
|
|
|
10
|
-
this.postalMime =
|
|
10
|
+
this.postalMime = this.options.postalMime;
|
|
11
11
|
|
|
12
|
-
this.root = !!
|
|
12
|
+
this.root = !!this.options.parentNode;
|
|
13
13
|
this.childNodes = [];
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
if (this.options.parentNode) {
|
|
16
|
+
this.parentNode = this.options.parentNode;
|
|
17
|
+
|
|
18
|
+
this.depth = this.parentNode.depth + 1;
|
|
19
|
+
if (this.depth > this.options.maxNestingDepth) {
|
|
20
|
+
throw new Error(`Maximum MIME nesting depth of ${this.options.maxNestingDepth} levels exceeded`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.options.parentNode.childNodes.push(this);
|
|
24
|
+
} else {
|
|
25
|
+
this.depth = 0;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
this.state = 'header';
|
|
19
29
|
|
|
20
30
|
this.headerLines = [];
|
|
31
|
+
this.headerSize = 0;
|
|
21
32
|
|
|
22
33
|
this.contentType = {
|
|
23
34
|
value: 'text/plain',
|
|
@@ -262,6 +273,14 @@ export default class MimeNode {
|
|
|
262
273
|
this.state = 'body';
|
|
263
274
|
return this.processHeaders();
|
|
264
275
|
}
|
|
276
|
+
|
|
277
|
+
this.headerSize += line.length;
|
|
278
|
+
|
|
279
|
+
if (this.headerSize > this.options.maxHeadersSize) {
|
|
280
|
+
let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
|
|
265
284
|
this.headerLines.push(getDecoder().decode(line));
|
|
266
285
|
break;
|
|
267
286
|
case 'body': {
|
package/src/postal-mime.js
CHANGED
|
@@ -6,6 +6,9 @@ import { base64ArrayBuffer } from './base64-encoder.js';
|
|
|
6
6
|
|
|
7
7
|
export { addressParser, decodeWords };
|
|
8
8
|
|
|
9
|
+
const MAX_NESTING_DEPTH = 256;
|
|
10
|
+
const MAX_HEADERS_SIZE = 2 * 1024 * 1024;
|
|
11
|
+
|
|
9
12
|
export default class PostalMime {
|
|
10
13
|
static parse(buf, options) {
|
|
11
14
|
const parser = new PostalMime(options);
|
|
@@ -14,9 +17,14 @@ export default class PostalMime {
|
|
|
14
17
|
|
|
15
18
|
constructor(options) {
|
|
16
19
|
this.options = options || {};
|
|
20
|
+
this.mimeOptions = {
|
|
21
|
+
maxNestingDepth: this.options.maxNestingDepth || MAX_NESTING_DEPTH,
|
|
22
|
+
maxHeadersSize: this.options.maxHeadersSize || MAX_HEADERS_SIZE
|
|
23
|
+
};
|
|
17
24
|
|
|
18
25
|
this.root = this.currentNode = new MimeNode({
|
|
19
|
-
postalMime: this
|
|
26
|
+
postalMime: this,
|
|
27
|
+
...this.mimeOptions
|
|
20
28
|
});
|
|
21
29
|
this.boundaries = [];
|
|
22
30
|
|
|
@@ -78,7 +86,8 @@ export default class PostalMime {
|
|
|
78
86
|
|
|
79
87
|
this.currentNode = new MimeNode({
|
|
80
88
|
postalMime: this,
|
|
81
|
-
parentNode: boundary.node
|
|
89
|
+
parentNode: boundary.node,
|
|
90
|
+
...this.mimeOptions
|
|
82
91
|
});
|
|
83
92
|
}
|
|
84
93
|
|