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 CHANGED
@@ -6,4 +6,5 @@ coverage
6
6
  .DS_Store
7
7
  yarn.lock
8
8
  package-lock.json
9
- CLAUDE.md
9
+ CLAUDE.md
10
+ CHANGELOG.md
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.5",
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": "eslint && node --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 = Record<string, string>;
3
+ export type Header = {
4
+ key: string;
5
+ value: string;
6
+ };
4
7
 
5
- export type Address = {
8
+ export type Mailbox = {
6
9
  name: string;
7
- address?: string;
8
- group?: Address[]
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: Address;
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: string;
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 {
@@ -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
- data[state].push(token.value);
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: data.group.length ? addressParser(data.group.join(',')) : []
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
- if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
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
- // fixed the regex to parse email address correctly when email address has more than one @
89
- data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
90
- if (data.address.length) {
91
- break;
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 chr,
184
- list = [];
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.checkChar(chr);
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(opts) {
8
- opts = opts || {};
7
+ constructor(options) {
8
+ this.options = options || {};
9
9
 
10
- this.postalMime = opts.postalMime;
10
+ this.postalMime = this.options.postalMime;
11
11
 
12
- this.root = !!opts.parentNode;
12
+ this.root = !!this.options.parentNode;
13
13
  this.childNodes = [];
14
- if (opts.parentNode) {
15
- opts.parentNode.childNodes.push(this);
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': {
@@ -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