nodemailer 9.0.5 → 9.0.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **addressparser:** recover the addr-spec from an angle-addr holding whitespace ([e989a22](https://github.com/nodemailer/nodemailer/commit/e989a22ca4f5161929bf37be8fb07de635016fa7))
9
+ * harden copies of user supplied keys and URL fetching ([2f667f4](https://github.com/nodemailer/nodemailer/commit/2f667f4272cb2d7cb479b2e3903ab10600fc0eae))
10
+
3
11
  ## [9.0.5](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5) (2026-08-07)
4
12
 
5
13
 
package/README.md CHANGED
@@ -8,10 +8,7 @@ Send emails from Node.js – easy as cake! 🍰✉️
8
8
 
9
9
  See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
10
10
 
11
- > [!TIP]
12
- > Check out **[EmailEngine](https://emailengine.app/?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link)** – a self-hosted email gateway that allows making **REST requests against IMAP and SMTP servers**. EmailEngine also sends webhooks whenever something changes on the registered accounts.\
13
- > \
14
- > Using the email accounts registered with EmailEngine, you can receive and [send emails](https://emailengine.app/sending-emails?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link). EmailEngine supports OAuth2, delayed sends, opens and clicks tracking, bounce detection, etc. All on top of regular email accounts without an external MTA service.
11
+ > Nodemailer is developed by the team behind **[EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs)**, a self-hosted email API that turns any Gmail, Microsoft 365, or IMAP account into a REST endpoint, with managed OAuth2, webhooks for incoming mail, and built-in [sending](https://emailengine.app/sending-emails?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs). If you would rather call an HTTP API than maintain IMAP and SMTP connections yourself, that is what it is for.
15
12
 
16
13
  ## Having an issue?
17
14
 
@@ -25,7 +22,7 @@ You are using an older Node.js version than v6.0. Upgrade Node.js to get support
25
22
 
26
23
  #### I'm having issues with Gmail
27
24
 
28
- Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/).
25
+ Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/). If the blocker is OAuth2 setup rather than Gmail itself, [EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=faq-gmail) handles the OAuth2 flow and token refresh for you.
29
26
 
30
27
  #### I get ETIMEDOUT errors
31
28
 
@@ -34,6 +34,101 @@ function _quoteLocalPart(address) {
34
34
  return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
35
35
  }
36
36
 
37
+ /**
38
+ * Reached for every parsed address, so it is built once rather than per call.
39
+ */
40
+ const HAS_WHITESPACE = /\s/;
41
+
42
+ /**
43
+ * An addr-spec that carries its whitespace legally, inside a quoted local part. The
44
+ * optional tail is the malformed shape: a real mailbox with wreckage trailing it.
45
+ */
46
+ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
47
+
48
+ /**
49
+ * One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
50
+ */
51
+ const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
52
+
53
+ /**
54
+ * The looser reading applied once the strict one finds nothing, which tolerates the
55
+ * further '@' that a domain should not have but malformed headers carry anyway.
56
+ */
57
+ const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
58
+
59
+ /**
60
+ * Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
61
+ *
62
+ * A malformed header can put more than a mailbox between the angle brackets, most often
63
+ * because the generator wrote the recipient twice: '<user@example.com user@example.com>'
64
+ * or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
65
+ * never be a mailbox anyone could deliver to, and passing it on as the address loses the
66
+ * recipient that is sitting right there in the header.
67
+ *
68
+ * The run that still reads as an addr-spec is kept and whatever is left over becomes
69
+ * display text rather than being dropped. Candidates are read strictly first and then
70
+ * under the looser grammar, the same two tiers the unquoted-text branch below applies to
71
+ * the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
72
+ * recipient. When several runs qualify the first wins, which is what that branch's looser
73
+ * tier does within a token.
74
+ *
75
+ * A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
76
+ * '<"user name"@example.com>' is well formed and means exactly what it says.
77
+ *
78
+ * @param {Object} data Collected address parts, mutated in place
79
+ */
80
+ function _recoverAddrSpec(data) {
81
+ if (!HAS_WHITESPACE.test(data.address)) {
82
+ return;
83
+ }
84
+
85
+ let address;
86
+ let rest;
87
+
88
+ const quoted = data.address.match(QUOTED_LOCAL_ADDR);
89
+ if (quoted) {
90
+ if (!quoted[2]) {
91
+ // the whitespace sits inside the quoted local part, this is a well formed mailbox
92
+ return;
93
+ }
94
+
95
+ // a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
96
+ // than splitting into the quotes
97
+ address = quoted[1];
98
+ rest = [quoted[2]];
99
+ } else {
100
+ if (data.address.indexOf('"') >= 0) {
101
+ // Splitting on whitespace loses track of where the quoted string starts and ends,
102
+ // and this module does not take addresses out of quoted strings: the run picked out
103
+ // of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
104
+ // quotes were hiding. Every well formed shape was already handled above, so what is
105
+ // left is wreckage either way and the original is the honest answer
106
+ return;
107
+ }
108
+
109
+ const parts = data.address.split(/\s+/);
110
+
111
+ let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
112
+ if (addrIndex < 0) {
113
+ addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
114
+ }
115
+
116
+ if (addrIndex < 0) {
117
+ // nothing in there reads as an address, there is no better answer than the original
118
+ return;
119
+ }
120
+
121
+ address = parts.splice(addrIndex, 1)[0];
122
+ rest = parts;
123
+ }
124
+
125
+ data.address = address;
126
+ data.text = [data.text]
127
+ .concat(rest)
128
+ .filter(part => part)
129
+ .join(' ');
130
+ }
131
+
37
132
  /**
38
133
  * Converts tokens for a single address into an address object
39
134
  *
@@ -137,7 +232,7 @@ function _handleAddress(tokens, depth) {
137
232
  // Security: Do not extract email addresses from quoted strings.
138
233
  // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
139
234
  // Extracting emails from quoted text leads to misrouting vulnerabilities.
140
- if (!data.textWasQuoted[i] && /^[^@\s]+@[^@\s]+$/.test(data.text[i])) {
235
+ if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
141
236
  data.address = data.text.splice(i, 1);
142
237
  data.textWasQuoted.splice(i, 1);
143
238
  break;
@@ -187,6 +282,8 @@ function _handleAddress(tokens, depth) {
187
282
  data.text = data.text.join(' ');
188
283
  data.address = data.address.join(' ');
189
284
 
285
+ _recoverAddrSpec(data);
286
+
190
287
  const address = {
191
288
  address: data.address || data.text || '',
192
289
  name: data.text || data.address || ''
package/lib/dkim/index.js CHANGED
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const crypto = require('crypto');
13
+ const { copyOwnKeys } = require('../shared/objects');
13
14
 
14
15
  const DKIM_ALGO = 'sha256';
15
16
  const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
@@ -225,7 +226,11 @@ class DKIM {
225
226
 
226
227
  let options = this.options;
227
228
  if (extraOptions && Object.keys(extraOptions).length) {
228
- options = Object.assign({}, extraOptions, this.options);
229
+ // extraOptions is mail.data._dkim, caller supplied message data. An own
230
+ // "__proto__" key there would let every option this signer reads and the
231
+ // transport did not set, such as skipFields, answer from the caller
232
+ options = copyOwnKeys({}, extraOptions);
233
+ copyOwnKeys(options, this.options);
229
234
  }
230
235
 
231
236
  const signer = new DKIMSigner(options, this.keys, inputStream, output);
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
9
9
  const packageData = require('../../package.json');
10
10
  const net = require('net');
11
11
  const errors = require('../errors');
12
+ const { isProtoKey } = require('../shared/objects');
12
13
 
13
14
  const MAX_REDIRECTS = 5;
14
15
 
16
+ // Only genuine TLS settings are taken from options.tls. That object reaches us straight
17
+ // from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
18
+ // or lookup would otherwise repoint the request at a destination that never went through
19
+ // the URL checks below.
20
+ //
21
+ // The source of truth is the tls.connect() option list in the Node docs. A key missing
22
+ // here is dropped silently, so extend this list rather than working around it.
23
+ const TLS_OPTION_KEYS = [
24
+ 'ALPNProtocols',
25
+ 'ca',
26
+ 'cert',
27
+ 'checkServerIdentity',
28
+ 'ciphers',
29
+ 'crl',
30
+ 'dhparam',
31
+ 'ecdhCurve',
32
+ 'honorCipherOrder',
33
+ 'key',
34
+ 'maxVersion',
35
+ 'minVersion',
36
+ 'passphrase',
37
+ 'pfx',
38
+ 'rejectUnauthorized',
39
+ 'secureContext',
40
+ 'secureOptions',
41
+ 'secureProtocol',
42
+ 'servername',
43
+ 'sessionIdContext',
44
+ 'sigalgs'
45
+ ];
46
+
47
+ /**
48
+ * Resolves a URL only if it is one this module is willing to request.
49
+ *
50
+ * urllib.parse throws for a host that contains forbidden bytes, and it is called for
51
+ * every URL that reaches nmfetch, including ones that arrive from a message attachment
52
+ * or from a redirect Location header. An uncaught throw here takes the process down,
53
+ * so a URL that does not parse is reported the same way as one with a scheme we refuse.
54
+ *
55
+ * @param {String} url URL to parse
56
+ * @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
57
+ */
58
+ function parseFetchUrl(url) {
59
+ let parsed;
60
+ try {
61
+ parsed = urllib.parse(url);
62
+ } catch (_err) {
63
+ return false;
64
+ }
65
+
66
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
67
+ return false;
68
+ }
69
+
70
+ return parsed;
71
+ }
72
+
15
73
  module.exports = function (url, options) {
16
74
  return nmfetch(url, options);
17
75
  };
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
26
84
  options.redirects = options.redirects || 0;
27
85
  options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
28
86
 
87
+ const fetchRes = options.fetchRes;
88
+ const parsed = parseFetchUrl(url);
89
+
90
+ if (!parsed) {
91
+ // Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
92
+ // protocol-relative redirect target etc.) would otherwise be silently served over
93
+ // plain HTTP, possibly against an unintended host. Bail out before the cookie jar
94
+ // is touched so a refused URL can not seed it, and release a caller supplied body:
95
+ // this is the one exit that runs before the error handler below is attached to it,
96
+ // so an error on that stream would have nowhere to go and the fd or socket behind
97
+ // it would never be released.
98
+ if (options.body && typeof options.body.destroy === 'function') {
99
+ options.body.on('error', () => false);
100
+ options.body.destroy();
101
+ }
102
+ setImmediate(() => {
103
+ const err = new Error('Unsupported protocol for URL ' + url);
104
+ err.code = errors.EFETCH;
105
+ err.sourceUrl = url;
106
+ fetchRes.emit('error', err);
107
+ });
108
+ return fetchRes;
109
+ }
110
+
29
111
  if (options.cookie) {
30
112
  [].concat(options.cookie || []).forEach(cookie => {
31
113
  options.cookies.set(cookie, url);
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
33
115
  options.cookie = false;
34
116
  }
35
117
 
36
- const fetchRes = options.fetchRes;
37
- const parsed = urllib.parse(url);
38
118
  let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
39
119
  let finished = false;
40
120
  let cookies;
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
48
128
  };
49
129
 
50
130
  Object.keys(options.headers || {}).forEach(key => {
131
+ // options.headers is the caller's httpHeaders, straight off an attachment
132
+ if (isProtoKey(key.toLowerCase().trim())) {
133
+ return;
134
+ }
51
135
  headers[key.toLowerCase().trim()] = options.headers[key];
52
136
  });
53
137
 
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
131
215
  };
132
216
 
133
217
  if (options.tls) {
134
- Object.assign(reqOptions, options.tls);
218
+ // see TLS_OPTION_KEYS
219
+ Object.keys(options.tls).forEach(key => {
220
+ if (TLS_OPTION_KEYS.includes(key)) {
221
+ reqOptions[key] = options.tls[key];
222
+ }
223
+ });
135
224
  }
136
225
 
137
226
  if (
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
216
305
  options.method = 'GET';
217
306
  options.body = false;
218
307
 
219
- const redirectUrl = urllib.resolve(url, res.headers.location);
220
- const redirectParsed = urllib.parse(redirectUrl);
308
+ let redirectUrl;
309
+ try {
310
+ redirectUrl = urllib.resolve(url, res.headers.location);
311
+ } catch (_err) {
312
+ // the legacy resolver throws on a Location the WHATWG parser also refused,
313
+ // so fall through to the check below with what the server actually sent
314
+ redirectUrl = res.headers.location;
315
+ }
316
+ const redirectParsed = parseFetchUrl(redirectUrl);
317
+
318
+ if (!redirectParsed) {
319
+ // Refuse the redirect target here rather than leaving it to the recursive
320
+ // call: that call gets its own `finished` flag and no handle on this
321
+ // request, so this one would stay open and could emit a second error on
322
+ // the shared fetchRes once it times out. Callers listen with req.once().
323
+ finished = true;
324
+ const err = new Error('Unsupported protocol for URL ' + redirectUrl);
325
+ err.code = errors.EFETCH;
326
+ err.sourceUrl = redirectUrl;
327
+ fetchRes.emit('error', err);
328
+ req.abort();
329
+ return;
330
+ }
221
331
 
222
332
  // Do not forward credentials when the redirect leaves the original
223
333
  // security context: a different host, or a downgrade from https to
@@ -4,7 +4,7 @@
4
4
 
5
5
  const MimeNode = require('../mime-node');
6
6
  const mimeFuncs = require('../mime-funcs');
7
- const { parseDataURI } = require('../shared');
7
+ const { parseDataURI, copyOwnKeys } = require('../shared');
8
8
 
9
9
  /**
10
10
  * Creates the object for composing a MimeNode instance out from the mail options
@@ -205,7 +205,9 @@ class MailComposer {
205
205
  typeof this.mail.icalEvent === 'object' &&
206
206
  (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
207
207
  ) {
208
- icalEvent = Object.assign({}, this.mail.icalEvent);
208
+ // an own "__proto__" key would make the copy inherit path/href from caller
209
+ // data, and the mapping below then replaces the content the caller did set
210
+ icalEvent = copyOwnKeys({}, this.mail.icalEvent);
209
211
  } else {
210
212
  icalEvent = {
211
213
  content: this.mail.icalEvent
@@ -600,7 +602,7 @@ class MailComposer {
600
602
  }
601
603
 
602
604
  // Return empty content for excessively long data URLs
603
- return Object.assign({}, element, {
605
+ return Object.assign(copyOwnKeys({}, element), {
604
606
  path: false,
605
607
  href: false,
606
608
  content: Buffer.alloc(0),
@@ -4,6 +4,11 @@ const shared = require('../shared');
4
4
  const MimeNode = require('../mime-node');
5
5
  const mimeFuncs = require('../mime-funcs');
6
6
 
7
+ // Only an own key counts as already set. `key in obj` also matches every member of
8
+ // Object.prototype, which silently drops a transporter default legitimately named
9
+ // toString or constructor.
10
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
11
+
7
12
  class MailMessage {
8
13
  constructor(mailer, data) {
9
14
  this.mailer = mailer;
@@ -14,23 +19,16 @@ class MailMessage {
14
19
  const options = mailer.options || {};
15
20
  const defaults = mailer._defaults || {};
16
21
 
17
- Object.assign(this.data, data);
22
+ shared.copyOwnKeys(this.data, data);
18
23
 
19
24
  this.data.headers = this.data.headers || {};
20
25
 
21
- // apply defaults
22
- Object.keys(defaults).forEach(key => {
23
- if (!(key in this.data)) {
24
- this.data[key] = defaults[key];
25
- } else if (key === 'headers') {
26
- // headers is a special case. Allow setting individual default headers
27
- Object.keys(defaults.headers).forEach(key => {
28
- if (!(key in this.data.headers)) {
29
- this.data.headers[key] = defaults.headers[key];
30
- }
31
- });
32
- }
33
- });
26
+ // Apply defaults. `_defaults` is caller supplied too, it is the second argument of
27
+ // createTransport, so it needs the same treatment as `data` above
28
+ shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
29
+
30
+ // headers is a special case. Allow setting individual default headers
31
+ shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
34
32
 
35
33
  // force specific keys from transporter options
36
34
  ['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
@@ -123,11 +121,12 @@ class MailMessage {
123
121
  content: value
124
122
  };
125
123
  if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
126
- Object.keys(args[0][args[1]]).forEach(key => {
127
- if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
128
- node[key] = args[0][args[1]][key];
129
- }
130
- });
124
+ // The keys are the caller's, so copying them takes the same "__proto__"
125
+ // rule as the constructor. `key in node` stays as the already-set test
126
+ // here, unlike for the defaults: it also skips the Object.prototype
127
+ // member names, and letting message data land a `toString` string on a
128
+ // node only buys a TypeError the first time something stringifies it.
129
+ shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
131
130
  }
132
131
 
133
132
  args[0][args[1]] = node;
@@ -186,6 +185,9 @@ class MailMessage {
186
185
 
187
186
  data.normalizedHeaders = {};
188
187
  Object.keys(data.headers || {}).forEach(key => {
188
+ if (shared.isProtoKey(key)) {
189
+ return;
190
+ }
189
191
  let value = [].concat(data.headers[key] || []).shift();
190
192
  value = (value && value.value) || value;
191
193
  if (value) {
@@ -5,6 +5,7 @@
5
5
  const base64 = require('../base64');
6
6
  const qp = require('../qp');
7
7
  const mimeTypes = require('./mime-types');
8
+ const { isProtoKey } = require('../shared/objects');
8
9
 
9
10
  module.exports = {
10
11
  /**
@@ -381,6 +382,16 @@ module.exports = {
381
382
  value: false,
382
383
  params: {}
383
384
  };
385
+
386
+ // Parameter names come from a caller supplied contentType/contentDisposition. A
387
+ // "__proto__" name would target the prototype chain of the params object instead of
388
+ // an own property of it, and read back as Object.prototype, so it is dropped.
389
+ const setParam = (name, value) => {
390
+ if (!isProtoKey(name)) {
391
+ response.params[name] = value;
392
+ }
393
+ };
394
+
384
395
  let key = false;
385
396
  let value = '';
386
397
  let type = 'value';
@@ -412,7 +423,7 @@ module.exports = {
412
423
  if (key === false) {
413
424
  response.value = value.trim();
414
425
  } else {
415
- response.params[key] = value.trim();
426
+ setParam(key, value.trim());
416
427
  }
417
428
  type = 'key';
418
429
  value = '';
@@ -427,10 +438,10 @@ module.exports = {
427
438
  if (key === false) {
428
439
  response.value = value.trim();
429
440
  } else {
430
- response.params[key] = value.trim();
441
+ setParam(key, value.trim());
431
442
  }
432
443
  } else if (value.trim()) {
433
- response.params[value.trim().toLowerCase()] = '';
444
+ setParam(value.trim().toLowerCase(), '');
434
445
  }
435
446
 
436
447
  // handle parameter value continuations
@@ -443,6 +454,14 @@ module.exports = {
443
454
  actualKey = key.substr(0, match.index);
444
455
  nr = Number(match[2] || match[3]) || 0;
445
456
 
457
+ if (isProtoKey(actualKey)) {
458
+ // see setParam. Reading it back would yield Object.prototype, which is
459
+ // an object, so the initializer below would be skipped and the write
460
+ // that follows would throw out of a header build the caller can not catch
461
+ delete response.params[key];
462
+ return;
463
+ }
464
+
446
465
  if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
447
466
  response.params[actualKey] = {
448
467
  charset: false,
@@ -569,7 +588,7 @@ module.exports = {
569
588
  */
570
589
  splitMimeEncodedString: (str, maxlen) => {
571
590
  const lines = [];
572
- let curLine, match, chr, done;
591
+ let curLine, fallbackLine, match, chr, done;
573
592
 
574
593
  // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
575
594
  maxlen = Math.max(maxlen || 0, 12);
@@ -582,8 +601,14 @@ module.exports = {
582
601
  curLine = curLine.substr(0, match.index);
583
602
  }
584
603
 
604
+ // Malformed input (a run of stray UTF-8 continuation bytes) has no split point
605
+ // that keeps a character sequence whole, so the loop below walks back to an
606
+ // empty line looking for one. Keep the widest chunk that at least does not cut
607
+ // a "=XX" escape in half, so the part stays a decodable encoded word.
608
+ fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
609
+
585
610
  done = false;
586
- while (!done) {
611
+ while (!done && curLine.length) {
587
612
  done = true;
588
613
  // check if not middle of a unicode char sequence
589
614
  if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
@@ -596,9 +621,11 @@ module.exports = {
596
621
  }
597
622
  }
598
623
 
599
- if (curLine.length) {
600
- lines.push(curLine);
624
+ if (!curLine.length) {
625
+ curLine = fallbackLine;
601
626
  }
627
+
628
+ lines.push(curLine);
602
629
  str = str.substr(curLine.length);
603
630
  }
604
631
 
@@ -533,11 +533,10 @@ class MimeNode {
533
533
  const formattedHeaders = FORMATTED_HEADERS;
534
534
 
535
535
  if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
536
- Object.keys(value).forEach(key => {
537
- if (key !== 'value') {
538
- options[key] = value[key];
539
- }
540
- });
536
+ // the keys come from a caller supplied header object and `options.prepared`
537
+ // below decides whether the value is emitted raw, so an own "__proto__" key
538
+ // here would turn an unfolded value into header injection
539
+ shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
541
540
  value = (value.value || '').toString();
542
541
  if (!value.trim()) {
543
542
  return;
@@ -882,11 +881,7 @@ class MimeNode {
882
881
  this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
883
882
 
884
883
  const standardFields = ['to', 'cc', 'bcc', 'from'];
885
- Object.keys(envelope).forEach(key => {
886
- if (!standardFields.includes(key)) {
887
- this._envelope[key] = envelope[key];
888
- }
889
- });
884
+ shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
890
885
 
891
886
  return this;
892
887
  }
@@ -1035,7 +1030,9 @@ class MimeNode {
1035
1030
  });
1036
1031
  return contentStream;
1037
1032
  }
1038
- // fetch URL
1033
+ // fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
1034
+ // that on the parsed URL. Testing the raw string here instead would reject
1035
+ // forms the parser accepts, such as a leading space or a slash-less authority
1039
1036
  return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
1040
1037
  }
1041
1038
 
@@ -1071,8 +1068,10 @@ class MimeNode {
1071
1068
  }
1072
1069
 
1073
1070
  // rewriting would land on the object the caller passed in and might
1074
- // still hold a reference to, so rewrite a copy of it instead
1075
- const copy = Object.assign({}, address);
1071
+ // still hold a reference to, so rewrite a copy of it instead. An own
1072
+ // "__proto__" key would make the copy inherit from caller data, and
1073
+ // _convertAddresses reads `group` off it straight into the envelope
1074
+ const copy = shared.copyOwnKeys({}, address);
1076
1075
  copy.address = normalized;
1077
1076
  copy.name = address.name || '';
1078
1077
  return [copy];
@@ -130,7 +130,8 @@ class SESTransport extends EventEmitter {
130
130
  return callback(err);
131
131
  }
132
132
 
133
- const sesMessage = Object.assign(
133
+ // mail.data.ses is caller supplied message data, so copy its own keys only
134
+ const sesMessage = shared.copyOwnKeys(
134
135
  {
135
136
  Content: {
136
137
  Raw: {
@@ -143,7 +144,7 @@ class SESTransport extends EventEmitter {
143
144
  ToAddresses: envelope.to
144
145
  }
145
146
  },
146
- mail.data.ses || {}
147
+ mail.data.ses
147
148
  );
148
149
 
149
150
  this.getRegion((err, region) => {
@@ -7,10 +7,15 @@ const util = require('util');
7
7
  const fs = require('fs');
8
8
  const nmfetch = require('../fetch');
9
9
  const errors = require('../errors');
10
+ const objects = require('./objects');
10
11
  const dns = require('dns');
11
12
  const net = require('net');
12
13
  const os = require('os');
13
14
 
15
+ // re-exported for the callers that already depend on this module, see ./objects
16
+ const isProtoKey = (module.exports.isProtoKey = objects.isProtoKey);
17
+ module.exports.copyOwnKeys = objects.copyOwnKeys;
18
+
14
19
  const DNS_TTL = 5 * 60 * 1000;
15
20
  const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
16
21
  const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
@@ -355,7 +360,9 @@ module.exports.parseConnectionUrl = str => {
355
360
  return;
356
361
  }
357
362
 
358
- if (!(lKey in obj)) {
363
+ // `in` already keeps "__proto__" out, but only as a side effect of it being an
364
+ // Object.prototype member. Say it, so the protection survives a change to the check
365
+ if (!isProtoKey(lKey) && !(lKey in obj)) {
359
366
  obj[lKey] = value;
360
367
  }
361
368
  });
@@ -470,7 +477,7 @@ module.exports.parseDataURI = uri => {
470
477
  // Ensure there's a key before the '='
471
478
  const key = entry.substring(0, sepPos).trim();
472
479
  const value = entry.substring(sepPos + 1).trim();
473
- if (key) {
480
+ if (key && !isProtoKey(key)) {
474
481
  params[key] = value;
475
482
  }
476
483
  }
@@ -561,19 +568,24 @@ function resolveContentValue(data, key, options, callback) {
561
568
  }
562
569
  callback(null, value);
563
570
  });
564
- } else if (/^https?:\/\//i.test(content.path || content.href)) {
571
+ } else if (/^data:/i.test(content.path || content.href)) {
572
+ const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
573
+
574
+ return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
575
+ } else if (content.href || /^https?:\/\//i.test(content.path)) {
576
+ // An href is always a URL, and so is a path that looks like one. Let nmfetch
577
+ // decide whether it is fetchable, it validates the parsed URL. Testing the raw
578
+ // string here instead would let a file: href fall through to the "return as is"
579
+ // default below and travel on inside the resolved message.
580
+ const url = content.href || content.path;
565
581
  if (options.disableUrlAccess) {
566
582
  return setImmediate(() => {
567
- const err = new Error('Url access rejected for ' + (content.path || content.href));
583
+ const err = new Error('Url access rejected for ' + url);
568
584
  err.code = errors.EURLACCESS;
569
585
  callback(err);
570
586
  });
571
587
  }
572
- return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
573
- } else if (/^data:/i.test(content.path || content.href)) {
574
- const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
575
-
576
- return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
588
+ return resolveStream(nmfetch(url, { headers: content.httpHeaders, tls: content.tls }), callback);
577
589
  } else if (content.path) {
578
590
  if (options.disableFileAccess) {
579
591
  return setImmediate(() => {
@@ -603,10 +615,14 @@ module.exports.assign = function (/* target, ... sources */) {
603
615
 
604
616
  args.forEach(source => {
605
617
  Object.keys(source || {}).forEach(key => {
618
+ if (isProtoKey(key)) {
619
+ return;
620
+ }
606
621
  if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
607
622
  // tls and auth are special keys that need to be enumerated separately
608
- // other objects are passed as is
609
- target[key] = Object.assign(target[key] || {}, source[key]);
623
+ // other objects are passed as is. Enumerating is a copy of user supplied
624
+ // keys just like the loop above, so it gets the same treatment
625
+ target[key] = module.exports.copyOwnKeys(target[key] || {}, source[key]);
610
626
  } else {
611
627
  target[key] = source[key];
612
628
  }
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ // Safe copying of objects whose keys come from the caller.
4
+ //
5
+ // This lives in its own leaf module, like ./url.js, so that every layer can reach it.
6
+ // lib/shared/index.js requires lib/fetch, so lib/fetch can not require lib/shared back,
7
+ // and lib/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
8
+ // comparison. lib/shared/index.js re-exports both functions for the callers that already
9
+ // depend on it.
10
+
11
+ /**
12
+ * Detects a key that can not be copied onto a plain object with `target[key] = value`.
13
+ *
14
+ * "__proto__" is the only one: assigning it runs the inherited setter and replaces the
15
+ * prototype of the target instead of adding a property to it, so a caller can smuggle
16
+ * values past validation that only inspects own keys. JSON.parse produces such a key
17
+ * where an object literal can not. "constructor" and "prototype" have no such setter and
18
+ * become ordinary own properties, so dropping them would only discard legitimate values.
19
+ *
20
+ * @param {String} key Key to check
21
+ * @returns {Boolean} true if the key must not be copied
22
+ */
23
+ module.exports.isProtoKey = key => key === '__proto__';
24
+
25
+ /**
26
+ * Copies own enumerable keys from a source object to a target object. Every copy that
27
+ * walks the keys of user supplied data goes through here, see isProtoKey.
28
+ *
29
+ * @param {Object} target Object to copy the keys to
30
+ * @param {Object} source Object to copy the keys from
31
+ * @param {Function} [skip] Optional predicate, return true to leave a key out
32
+ * @returns {Object} The target object
33
+ */
34
+ module.exports.copyOwnKeys = (target, source, skip) => {
35
+ Object.keys(source || {}).forEach(key => {
36
+ if (module.exports.isProtoKey(key) || (skip && skip(key))) {
37
+ return;
38
+ }
39
+ target[key] = source[key];
40
+ });
41
+ return target;
42
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "9.0.5",
3
+ "version": "9.0.6",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "main": "lib/nodemailer.js",
6
6
  "scripts": {
@@ -27,19 +27,19 @@
27
27
  },
28
28
  "homepage": "https://nodemailer.com/",
29
29
  "devDependencies": {
30
- "@aws-sdk/client-sesv2": "3.1104.0",
30
+ "@aws-sdk/client-sesv2": "3.1119.0",
31
31
  "bunyan": "1.8.15",
32
32
  "c8": "12.0.0",
33
- "eslint": "10.8.0",
33
+ "eslint": "10.9.1",
34
34
  "eslint-config-prettier": "10.1.8",
35
- "globals": "17.9.0",
35
+ "globals": "17.11.0",
36
36
  "libbase64": "1.3.0",
37
- "libmime": "5.4.1",
37
+ "libmime": "5.4.2",
38
38
  "libqp": "2.1.1",
39
39
  "prettier": "3.9.6",
40
40
  "proxy": "1.0.2",
41
41
  "proxy-test-server": "1.0.0",
42
- "smtp-server": "3.19.2"
42
+ "smtp-server": "3.19.3"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=6.0.0"