node_modules: update (#314)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurla
2026-09-07 07:05:39 +02:00
committed by GitHub
parent 40eba5cee7
commit d35571df19
23 changed files with 938 additions and 176 deletions
+166 -8
View File
@@ -1,5 +1,134 @@
'use strict';
/**
* Restores the quoting of a local part that was read out of a quoted string.
*
* RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
* on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
* domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
* same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
* recipient separator once the consumer puts the address back into a header.
*
* This module has no dependencies so that it can ship on its own, which is why the two
* grammar tests below are spelled out here instead of shared with lib/mime-node. Keeping
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
* dot-atom rule on top of this when it emits an address.
*
* @param {String} address Address with an unquoted local part
* @return {String} Address with the local part as a quoted-string
*/
function _quoteLocalPart(address) {
const lastAt = address.lastIndexOf('@');
if (lastAt < 0) {
// no domain to split off, nothing can be misrouted
return address;
}
const user = address.substr(0, lastAt);
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
// a local part that carries no special reads the same with or without the quotes,
// and one that is already a complete quoted-string needs nothing either
return address;
}
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
}
/**
* Reached for every parsed address, so it is built once rather than per call.
*/
const HAS_WHITESPACE = /\s/;
/**
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
*/
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
/**
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
*/
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
/**
* The looser reading applied once the strict one finds nothing, which tolerates the
* further '@' that a domain should not have but malformed headers carry anyway.
*/
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
/**
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
*
* A malformed header can put more than a mailbox between the angle brackets, most often
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
* recipient that is sitting right there in the header.
*
* The run that still reads as an addr-spec is kept and whatever is left over becomes
* display text rather than being dropped. Candidates are read strictly first and then
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
* recipient. When several runs qualify the first wins, which is what that branch's looser
* tier does within a token.
*
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
* '<"user name"@example.com>' is well formed and means exactly what it says.
*
* @param {Object} data Collected address parts, mutated in place
*/
function _recoverAddrSpec(data) {
if (!HAS_WHITESPACE.test(data.address)) {
return;
}
let address;
let rest;
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
if (quoted) {
if (!quoted[2]) {
// the whitespace sits inside the quoted local part, this is a well formed mailbox
return;
}
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
// than splitting into the quotes
address = quoted[1];
rest = [quoted[2]];
} else {
if (data.address.indexOf('"') >= 0) {
// Splitting on whitespace loses track of where the quoted string starts and ends,
// and this module does not take addresses out of quoted strings: the run picked out
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
// quotes were hiding. Every well formed shape was already handled above, so what is
// left is wreckage either way and the original is the honest answer
return;
}
const parts = data.address.split(/\s+/);
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
if (addrIndex < 0) {
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
}
if (addrIndex < 0) {
// nothing in there reads as an address, there is no better answer than the original
return;
}
address = parts.splice(addrIndex, 1)[0];
rest = parts;
}
data.address = address;
data.text = [data.text]
.concat(rest)
.filter(part => part)
.join(' ');
}
/**
* Converts tokens for a single address into an address object
*
@@ -55,7 +184,18 @@ function _handleAddress(tokens, depth) {
token.value = token.value.replace(/^[^<]*<\s*/, '');
}
if (prevToken && prevToken.noBreak && data[state].length) {
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
// of the '@', but it cannot join two atoms into one: gluing across it would read
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
// deliver to a domain the sender never named.
const parts = data[state];
const joins =
prevToken &&
prevToken.noBreak &&
parts.length &&
(prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
if (joins) {
data[state][data[state].length - 1] += token.value;
if (state === 'text' && insideQuotes) {
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
@@ -103,7 +243,7 @@ function _handleAddress(tokens, depth) {
// Security: Do not extract email addresses from quoted strings.
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
// Extracting emails from quoted text leads to misrouting vulnerabilities.
if (!data.textWasQuoted[i] && /^[^@\s]+@[^@\s]+$/.test(data.text[i])) {
if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
data.address = data.text.splice(i, 1);
data.textWasQuoted.splice(i, 1);
break;
@@ -145,10 +285,16 @@ function _handleAddress(tokens, depth) {
data.text = data.text.concat(data.address.splice(1));
}
// An address is only taken from unquoted text, so anything left in the text at this
// point that still has to serve as the address carries its quoting in this flag
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
// Join values with spaces
data.text = data.text.join(' ');
data.address = data.address.join(' ');
_recoverAddrSpec(data);
const address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
@@ -162,6 +308,10 @@ function _handleAddress(tokens, depth) {
}
}
if (addressFromQuotedText && address.address) {
address.address = _quoteLocalPart(address.address);
}
addresses.push(address);
}
@@ -360,8 +510,10 @@ function addressparser(str, options) {
addresses.forEach(addr => {
const handled = _handleAddress(addr, depth);
if (handled.length) {
parsedAddresses = parsedAddresses.concat(handled);
// Appended in place. Rebuilding the accumulator with concat() would copy every
// entry collected so far on each address, making a flat list cost O(n^2).
for (let i = 0; i < handled.length; i++) {
parsedAddresses.push(handled[i]);
}
});
@@ -369,14 +521,20 @@ function addressparser(str, options) {
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
// Recombine: a name-only entry followed by an entry with both name and address.
for (let i = parsedAddresses.length - 2; i >= 0; i--) {
// Walked back to front so that a run of fragments folds into one entry in a single
// pass. Splicing each fragment out of the list instead would cost O(n^2).
const mergedAddresses = [];
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
const current = parsedAddresses[i];
const next = parsedAddresses[i + 1];
if (current.address === '' && current.name && !current.group && next.address && next.name) {
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
next.name = current.name + ', ' + next.name;
parsedAddresses.splice(i, 1);
} else {
mergedAddresses.push(current);
}
}
mergedAddresses.reverse();
parsedAddresses = mergedAddresses;
if (options.flatten) {
const flatAddresses = [];
+6 -1
View File
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { copyOwnKeys } = require('../shared/objects');
const DKIM_ALGO = 'sha256';
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
@@ -225,7 +226,11 @@ class DKIM {
let options = this.options;
if (extraOptions && Object.keys(extraOptions).length) {
options = Object.assign({}, extraOptions, this.options);
// extraOptions is mail.data._dkim, caller supplied message data. An own
// "__proto__" key there would let every option this signer reads and the
// transport did not set, such as skipFields, answer from the caller
options = copyOwnKeys({}, extraOptions);
copyOwnKeys(options, this.options);
}
const signer = new DKIMSigner(options, this.keys, inputStream, output);
+8 -3
View File
@@ -50,15 +50,20 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
module.exports.relaxedHeaders = relaxedHeaders;
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
// the caller supplied tag values are interpolated straight into the tag list, and none of
// them has any way to carry a control char, DEL, or one of the delimiters that would close
// the value and open a tag of its own
const cleanTagValue = value => (value || '').toString().replace(/[\x00-\x1f\x7f;=]/g, '');
const dkim = [
'v=1',
'a=rsa-' + hashAlgo,
'c=relaxed/relaxed',
'd=' + punycode.toASCII(domainName),
'd=' + punycode.toASCII(cleanTagValue(domainName)),
'q=dns/txt',
's=' + keySelector,
's=' + cleanTagValue(keySelector),
'bh=' + bodyHash,
'h=' + fieldNames
'h=' + cleanTagValue(fieldNames)
].join('; ');
return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
+1
View File
@@ -38,6 +38,7 @@ const ERROR_CODES = {
// Resource errors
EMAXLIMIT: 'Pool resource limit reached (max messages per connection)',
EMAXRECIPIENTS: 'Recipient count exceeds maxRecipients',
// Transport-specific errors
ESENDMAIL: 'Sendmail command error',
+115 -5
View File
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
const packageData = require('../../package.json');
const net = require('net');
const errors = require('../errors');
const { isProtoKey } = require('../shared/objects');
const MAX_REDIRECTS = 5;
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
// or lookup would otherwise repoint the request at a destination that never went through
// the URL checks below.
//
// The source of truth is the tls.connect() option list in the Node docs. A key missing
// here is dropped silently, so extend this list rather than working around it.
const TLS_OPTION_KEYS = [
'ALPNProtocols',
'ca',
'cert',
'checkServerIdentity',
'ciphers',
'crl',
'dhparam',
'ecdhCurve',
'honorCipherOrder',
'key',
'maxVersion',
'minVersion',
'passphrase',
'pfx',
'rejectUnauthorized',
'secureContext',
'secureOptions',
'secureProtocol',
'servername',
'sessionIdContext',
'sigalgs'
];
/**
* Resolves a URL only if it is one this module is willing to request.
*
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
* every URL that reaches nmfetch, including ones that arrive from a message attachment
* or from a redirect Location header. An uncaught throw here takes the process down,
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
*
* @param {String} url URL to parse
* @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
*/
function parseFetchUrl(url) {
let parsed;
try {
parsed = urllib.parse(url);
} catch (_err) {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return parsed;
}
module.exports = function (url, options) {
return nmfetch(url, options);
};
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
options.redirects = options.redirects || 0;
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
const fetchRes = options.fetchRes;
const parsed = parseFetchUrl(url);
if (!parsed) {
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
// protocol-relative redirect target etc.) would otherwise be silently served over
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
// is touched so a refused URL can not seed it, and release a caller supplied body:
// this is the one exit that runs before the error handler below is attached to it,
// so an error on that stream would have nowhere to go and the fd or socket behind
// it would never be released.
if (options.body && typeof options.body.destroy === 'function') {
options.body.on('error', () => false);
options.body.destroy();
}
setImmediate(() => {
const err = new Error('Unsupported protocol for URL ' + url);
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
});
return fetchRes;
}
if (options.cookie) {
[].concat(options.cookie || []).forEach(cookie => {
options.cookies.set(cookie, url);
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
options.cookie = false;
}
const fetchRes = options.fetchRes;
const parsed = urllib.parse(url);
let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
let finished = false;
let cookies;
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
};
Object.keys(options.headers || {}).forEach(key => {
// options.headers is the caller's httpHeaders, straight off an attachment
if (isProtoKey(key.toLowerCase().trim())) {
return;
}
headers[key.toLowerCase().trim()] = options.headers[key];
});
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
};
if (options.tls) {
Object.assign(reqOptions, options.tls);
// see TLS_OPTION_KEYS
Object.keys(options.tls).forEach(key => {
if (TLS_OPTION_KEYS.includes(key)) {
reqOptions[key] = options.tls[key];
}
});
}
if (
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
options.method = 'GET';
options.body = false;
const redirectUrl = urllib.resolve(url, res.headers.location);
const redirectParsed = urllib.parse(redirectUrl);
let redirectUrl;
try {
redirectUrl = urllib.resolve(url, res.headers.location);
} catch (_err) {
// the legacy resolver throws on a Location the WHATWG parser also refused,
// so fall through to the check below with what the server actually sent
redirectUrl = res.headers.location;
}
const redirectParsed = parseFetchUrl(redirectUrl);
if (!redirectParsed) {
// Refuse the redirect target here rather than leaving it to the recursive
// call: that call gets its own `finished` flag and no handle on this
// request, so this one would stay open and could emit a second error on
// the shared fetchRes once it times out. Callers listen with req.once().
finished = true;
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
err.code = errors.EFETCH;
err.sourceUrl = redirectUrl;
fetchRes.emit('error', err);
req.abort();
return;
}
// Do not forward credentials when the redirect leaves the original
// security context: a different host, or a downgrade from https to
+1 -1
View File
@@ -33,7 +33,7 @@ class JSONTransport {
// Sendmail strips this header line by itself
mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope();
const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []);
+5 -3
View File
@@ -4,7 +4,7 @@
const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs');
const { parseDataURI } = require('../shared');
const { parseDataURI, copyOwnKeys } = require('../shared');
/**
* Creates the object for composing a MimeNode instance out from the mail options
@@ -205,7 +205,9 @@ class MailComposer {
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = Object.assign({}, this.mail.icalEvent);
// an own "__proto__" key would make the copy inherit path/href from caller
// data, and the mapping below then replaces the content the caller did set
icalEvent = copyOwnKeys({}, this.mail.icalEvent);
} else {
icalEvent = {
content: this.mail.icalEvent
@@ -600,7 +602,7 @@ class MailComposer {
}
// Return empty content for excessively long data URLs
return Object.assign({}, element, {
return Object.assign(copyOwnKeys({}, element), {
path: false,
href: false,
content: Buffer.alloc(0),
+27
View File
@@ -15,6 +15,13 @@ const net = require('net');
const dns = require('dns');
const crypto = require('crypto');
/**
* Recipients allowed on one message unless the caller sets its own maxRecipients. A backstop
* against a runaway or hostile recipient list rather than a delivery policy: RFC 5321 only
* asks a server to accept 100, so a real send is bounded far below this.
*/
const DEFAULT_MAX_RECIPIENTS = 100000;
/**
* Creates an object for exposing the Mail API
*
@@ -191,6 +198,26 @@ class Mail extends EventEmitter {
mail.setPriorityHeaders();
mail.setListHeaders();
const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
const recipientCount = mail.message.getEnvelope().to.length;
if (maxRecipients && recipientCount > maxRecipients) {
const err = new Error(
`Message has ${recipientCount} recipients, which is over the ${maxRecipients} allowed by maxRecipients`
);
err.code = errors.EMAXRECIPIENTS;
this.logger.error(
{
err,
tnx: 'transport',
action: 'send'
},
'Send Error: %s',
err.message
);
return callback(err);
}
this._processPlugins('stream', mail, err => {
if (err) {
this.logger.error(
+68 -37
View File
@@ -4,6 +4,11 @@ const shared = require('../shared');
const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs');
// Only an own key counts as already set. `key in obj` also matches every member of
// Object.prototype, which silently drops a transporter default legitimately named
// toString or constructor.
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
class MailMessage {
constructor(mailer, data) {
this.mailer = mailer;
@@ -14,34 +19,53 @@ class MailMessage {
const options = mailer.options || {};
const defaults = mailer._defaults || {};
Object.assign(this.data, data);
shared.copyOwnKeys(this.data, data);
this.data.headers = this.data.headers || {};
// apply defaults
Object.keys(defaults).forEach(key => {
if (!(key in this.data)) {
this.data[key] = defaults[key];
} else if (key === 'headers') {
// headers is a special case. Allow setting individual default headers
Object.keys(defaults.headers).forEach(key => {
if (!(key in this.data.headers)) {
this.data.headers[key] = defaults.headers[key];
}
});
}
});
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
// createTransport, so it needs the same treatment as `data` above
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
// headers is a special case. Allow setting individual default headers
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
// force specific keys from transporter options
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
if (key in options) {
this.data[key] = options[key];
}
});
// The access flags are a sandbox rather than a message field, so `defaults` counts as
// transporter configuration for them. For a transporter plugin it is the only channel
// there is, createTransport leaves `options` undefined for one, and the defaults copy
// above yields to anything the message already set, which let message data switch the
// sandbox back off. Closing is one way here, same as in resolveContent below: either
// side may switch a flag on, neither can switch off what the other closed.
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
if (!(key in options) && hasOwn(defaults, key)) {
this.data[key] = this.data[key] || defaults[key];
}
});
}
resolveContent(...args) {
return shared.resolveContent(...args);
resolveContent(data, key, options, callback) {
// Most plugins call this with the legacy (data, key, callback) signature, which carries
// no access policy. The policy belongs to the message, so apply it here. Explicit
// options may only tighten it, never reopen what the transporter closed.
if (!callback && typeof options === 'function') {
callback = options;
options = false;
}
options = options || {};
const policy = {
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
};
return shared.resolveContent(data, key, policy, callback);
}
resolveAll(callback) {
@@ -123,11 +147,12 @@ class MailMessage {
content: value
};
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
Object.keys(args[0][args[1]]).forEach(key => {
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
node[key] = args[0][args[1]][key];
}
});
// The keys are the caller's, so copying them takes the same "__proto__"
// rule as the constructor. `key in node` stays as the already-set test
// here, unlike for the defaults: it also skips the Object.prototype
// member names, and letting message data land a `toString` string on a
// node only buys a TypeError the first time something stringifies it.
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
}
args[0][args[1]] = node;
@@ -140,7 +165,7 @@ class MailMessage {
}
normalize(callback) {
const envelope = this.data.envelope || this.message.getEnvelope();
const envelope = this.message.getEnvelope();
const messageId = this.message.messageId();
this.resolveAll((err, data) => {
@@ -186,6 +211,9 @@ class MailMessage {
data.normalizedHeaders = {};
Object.keys(data.headers || {}).forEach(key => {
if (shared.isProtoKey(key)) {
return;
}
let value = [].concat(data.headers[key] || []).shift();
value = (value && value.value) || value;
if (value) {
@@ -271,15 +299,16 @@ class MailMessage {
}
if (value && value.url) {
// strip CR/LF so a comment can't inject extra header lines. DEL is neither
// qtext nor ctext, so it can not be carried literally by either construct
// and has to become an encoded word like any other non-plaintext value
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
if (key.toLowerCase().trim() === 'id') {
// List-ID: "comment" <domain>
// strip CR/LF so a comment can't inject extra header lines
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
if (mimeFuncs.isPlainText(comment)) {
comment = '"' + comment + '"';
} else {
comment = mimeFuncs.encodeWord(comment);
}
// List-ID: "comment" <domain>, where an unescaped quote or a trailing
// backslash in the comment would swallow the <domain> behind it
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
// List-ID expects a bare domain-like identifier, so strip the
// scheme prefix that _formatListUrl adds or passes through
@@ -289,11 +318,11 @@ class MailMessage {
}
// List-*: <http://domain> (comment)
// strip CR/LF so a comment can't inject extra header lines
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
if (!mimeFuncs.isPlainText(comment)) {
comment = mimeFuncs.encodeWord(comment);
}
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the
// comment early and leaves the rest as junk, an unpaired "(" opens a
// nested comment that never closes, and a trailing backslash escapes
// the closing ")" so the comment swallows whatever follows it
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
}
@@ -307,7 +336,9 @@ class MailMessage {
}
_formatListUrl(url) {
url = url.replace(/[\s<]+|[\s>]+/g, '');
// a url has no way to carry a control char or DEL, and the angle brackets around it
// are not a quoting construct, so anything left here lands in the header raw
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
if (/^(https?|mailto|ftp):/.test(url)) {
return '<' + url + '>';
}
+80 -19
View File
@@ -5,19 +5,39 @@
const base64 = require('../base64');
const qp = require('../qp');
const mimeTypes = require('./mime-types');
const { isProtoKey } = require('../shared/objects');
module.exports = {
/**
* Checks if a value is plaintext string (uses only printable 7bit chars)
*
* When isParam is set the value is destined for a header parameter, so HT, CR and LF
* are not plaintext either: a header parameter has no way to carry them. HT is a valid
* fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
* cannot appear in a header value at all. DEL is neither a token character nor qtext,
* so it can not be carried bare or quoted. Such values have to go through the rfc2231
* parameter continuation encoding instead, the same way a quote already does.
*
* @param {String} value String to be tested
* @param {Boolean} [isParam] Set to true if the value is a header parameter value
* @returns {Boolean} true if it is a plaintext string
*/
isPlainText(value, isParam) {
const re = isParam ? /[\x00-\x08\x0b\x0c\x0e-\x1f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
return typeof value === 'string' && !re.test(value);
},
/**
* Wraps a value into a quoted-string. Inside one a quote would end the string early
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
*
* @param {String} value String to be quoted
* @returns {String} The value as a quoted-string, quotes included
*/
quoteString(value) {
return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
},
/**
* Checks if a multi line string containes lines longer than the selected value.
*
@@ -80,8 +100,10 @@ module.exports = {
for (let i = 0, len = encodedStr.length; i < len; i++) {
let chr = encodedStr.charAt(i);
if (/[\ud83c\ud83d\ud83e]/.test(chr) && i < len - 1) {
// composite emoji byte, so add the next byte as well
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
// leading surrogate, so add the trailing surrogate as well
// an unpaired one must not swallow the next unit, that would destroy
// a valid pair following it
chr += encodedStr.charAt(++i);
}
@@ -168,10 +190,12 @@ module.exports = {
buildHeaderValue(structured) {
const paramsArray = [];
Object.keys(structured.params || {}).forEach(param => {
Object.keys(structured.params || {}).forEach(key => {
// filename might include unicode characters so it is a special case
// other values probably do not
const value = structured.params[param];
const value = structured.params[key];
// a parameter name is a token too and it is emitted without any quoting around it
const param = key.replace(/[\x00-\x1f\x7f]/g, '');
if (!this.isPlainText(value, true) || value.length >= 75) {
this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
@@ -187,7 +211,11 @@ module.exports = {
}
});
return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
// the value ahead of the parameters is a token, it has no way to carry a control
// char or DEL and there is no quoting construct around it to escape one into
const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
},
/**
@@ -208,7 +236,7 @@ module.exports = {
buildHeaderParam(key, data, maxLength) {
const list = [];
let encodedStr = typeof data === 'string' ? data : (data || '').toString();
let chr, ord;
let chr;
let line;
let startPos = 0;
let i, len;
@@ -245,8 +273,9 @@ module.exports = {
const encodedStrArr = [];
for (i = 0, len = encodedStr.length; i < len; i++) {
chr = encodedStr.charAt(i);
ord = chr.charCodeAt(0);
if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) {
if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
// an unpaired leading surrogate must not consume the next unit, that
// would tear apart a valid pair following it
chr += encodedStr.charAt(i + 1);
encodedStrArr.push(chr);
i++;
@@ -284,8 +313,11 @@ module.exports = {
line,
encoded
});
// the line we start here holds an encoded char, so it has to be
// flagged as one. otherwise it gets an unstarred continuation key
// and a receiver reads the percent escapes as literal text
line = '';
startPos = i - 1;
encoded = true;
} else {
encoded = true;
i = startPos;
@@ -350,6 +382,16 @@ module.exports = {
value: false,
params: {}
};
// Parameter names come from a caller supplied contentType/contentDisposition. A
// "__proto__" name would target the prototype chain of the params object instead of
// an own property of it, and read back as Object.prototype, so it is dropped.
const setParam = (name, value) => {
if (!isProtoKey(name)) {
response.params[name] = value;
}
};
let key = false;
let value = '';
let type = 'value';
@@ -381,7 +423,7 @@ module.exports = {
if (key === false) {
response.value = value.trim();
} else {
response.params[key] = value.trim();
setParam(key, value.trim());
}
type = 'key';
value = '';
@@ -396,10 +438,10 @@ module.exports = {
if (key === false) {
response.value = value.trim();
} else {
response.params[key] = value.trim();
setParam(key, value.trim());
}
} else if (value.trim()) {
response.params[value.trim().toLowerCase()] = '';
setParam(value.trim().toLowerCase(), '');
}
// handle parameter value continuations
@@ -412,6 +454,14 @@ module.exports = {
actualKey = key.substr(0, match.index);
nr = Number(match[2] || match[3]) || 0;
if (isProtoKey(actualKey)) {
// see setParam. Reading it back would yield Object.prototype, which is
// an object, so the initializer below would be skipped and the write
// that follows would throw out of a header build the caller can not catch
delete response.params[key];
return;
}
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
response.params[actualKey] = {
charset: false,
@@ -538,7 +588,7 @@ module.exports = {
*/
splitMimeEncodedString: (str, maxlen) => {
const lines = [];
let curLine, match, chr, done;
let curLine, fallbackLine, match, chr, done;
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences
maxlen = Math.max(maxlen || 0, 12);
@@ -551,8 +601,14 @@ module.exports = {
curLine = curLine.substr(0, match.index);
}
// Malformed input (a run of stray UTF-8 continuation bytes) has no split point
// that keeps a character sequence whole, so the loop below walks back to an
// empty line looking for one. Keep the widest chunk that at least does not cut
// a "=XX" escape in half, so the part stays a decodable encoded word.
fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
done = false;
while (!done) {
while (!done && curLine.length) {
done = true;
// check if not middle of a unicode char sequence
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
@@ -565,9 +621,11 @@ module.exports = {
}
}
if (curLine.length) {
lines.push(curLine);
if (!curLine.length) {
curLine = fallbackLine;
}
lines.push(curLine);
str = str.substr(curLine.length);
}
@@ -600,8 +658,11 @@ module.exports = {
// might throw if we try to encode invalid sequences, eg. partial emoji
str = encodeURIComponent(str);
} catch (_E) {
// should never run
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
// an unpaired surrogate has no utf-8 representation, so run the value through a
// utf-8 roundtrip to get the same U+FFFD every other encoder here produces and
// retry. the value must never come back unencoded, it goes into a header parameter
// where a bare quote or semicolon would break it out into a parameter of its own
str = encodeURIComponent(Buffer.from(str, 'utf-8').toString('utf-8'));
}
// ensure chars that are not handled by encodeURICompent are converted as well
+288 -60
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const punycode = require('../punycode');
const { PassThrough } = require('stream');
const shared = require('../shared');
const urlModule = require('url');
const mimeFuncs = require('../mime-funcs');
const qp = require('../qp');
@@ -21,6 +22,59 @@ const LeUnix = require('./le-unix');
const FORMATTED_HEADERS = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
// RFC 5321 atext, plus the non-ascii bytes that SMTPUTF8 (RFC 6531) adds to it. A local part
// built from these, with '.' as a separator, is a dot-atom and can be emitted bare
const ATEXT = "[A-Za-z0-9!#$%&'*+\\-/=?^_`{|}~\\x80-\\uFFFF]";
const DOT_ATOM = new RegExp('^' + ATEXT + '+(?:\\.' + ATEXT + '+)*$');
// A complete quoted-string: everything between the outer quotes is either a plain char or
// a quoted-pair. Anchored, so a value that only starts and ends with a quote does not pass
const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
// An address that carries no special anywhere can be emitted bare in a header, everything
// else goes into angle brackets so that the header can not be read as more addresses than
// the envelope carries
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
// deliverable 'evil.example', which would turn a value the bundled codec leaves as
// unroutable garbage into mail for a domain the sender never named. None of these
// characters are legal in a domain, so keep them away from the mapper.
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
/**
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
* which is with UTS-46 mapping applied before the Punycode step.
*
* The bundled codec is plain RFC 3492 and maps nothing, so it disagrees with every
* conformant parser on any domain holding a mapped or ignored code point. An invisible
* U+00AD in 'compa\u00ADny.com' encoded to 'xn--company-pka.com' where a validator reads
* 'company.com', which let an allow-listed domain be checked and a different one mailed.
*
* Anything the URL parser does not accept as a hostname, an address literal such as
* '[127.0.0.1]' included, comes back empty and falls through to the bundled codec, which
* leaves those as they were supplied.
*
* @param {String} domain Domain to encode, already lowercased by the caller
* @param {Boolean} toUnicode Return the U-label form instead of the A-label form
* @return {String} Encoded domain
*/
function normalizeDomain(domain, toUnicode) {
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
const mapper = toUnicode ? urlModule.domainToUnicode : urlModule.domainToASCII;
if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
const mapped = mapper(domain);
if (mapped) {
return mapped;
}
}
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
}
/**
* Creates a new mime tree node. Assumes 'multipart/*' as the content type
* if it is a branch, anything else counts as leaf. If rootNode is missing from
@@ -190,6 +244,14 @@ class MimeNode {
* @return {Object} Appended node object
*/
appendChild(childNode) {
// Take the node out of the tree it is in first. Leaving it there keeps it in that
// parent's childNodes, so it still streams as part of the old tree while parentNode
// already points at the new one, and anything read off the parent chain answers for
// the wrong tree.
if (childNode.parentNode && childNode.parentNode !== this) {
childNode.remove();
}
if (childNode.rootNode !== this.rootNode) {
childNode.rootNode = this.rootNode;
childNode._nodeId = ++this.rootNode.nodeCounter;
@@ -519,11 +581,10 @@ class MimeNode {
const formattedHeaders = FORMATTED_HEADERS;
if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
Object.keys(value).forEach(key => {
if (key !== 'value') {
options[key] = value[key];
}
});
// the keys come from a caller supplied header object and `options.prepared`
// below decides whether the value is emitted raw, so an own "__proto__" key
// here would turn an unfolded value into header injection
shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
value = (value.value || '').toString();
if (!value.trim()) {
return;
@@ -552,6 +613,11 @@ class MimeNode {
case 'Content-Type':
structured = mimeFuncs.parseHeaderValue(value);
// the type token decides multipart and charset below, so clean it before
// those run and not just on the way out, otherwise a control char makes
// the checks miss and the header ends up claiming a type it is not set up for
structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
this._handleContentType(structured);
if (
@@ -568,11 +634,18 @@ class MimeNode {
// add support for non-compliant clients like QQ webmail
// we can't build the value with buildHeaderValue as the value is non standard and
// would be converted to parameter continuation encoding that we do not want
param = this._encodeWords(this.filename);
// control chars can not be quoted here: HT is a fold point that unfolding would
// turn into a space, CR/LF can not appear in a header at all and DEL is not
// qtext, so force the mime encoded word that a non-ascii filename would get anyway
param = /[\x00-\x1f\x7f]/.test(this.filename)
? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
: this._encodeWords(this.filename);
if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
// include value in quotes if needed
param = '"' + param + '"';
// include value in quotes if needed, escaping backslashes and quotes as
// quoted-pairs exactly like buildHeaderValue does for filename=, otherwise
// a trailing backslash would escape the closing quote
param = JSON.stringify(param);
}
value += '; name=' + param;
}
@@ -595,8 +668,12 @@ class MimeNode {
if (typeof this.normalizeHeaderKey === 'function') {
const normalized = this.normalizeHeaderKey(key, value);
if (normalized && typeof normalized === 'string' && normalized.length) {
key = normalized;
// the result replaces the key on the way into the header, so it gets the same
// treatment the key it replaces already had. a line break here would end the
// header and start one of the caller's own
const cleaned = typeof normalized === 'string' ? normalized.replace(/[\x00-\x1f\x7f]/g, '') : '';
if (cleaned) {
key = cleaned;
}
}
@@ -837,26 +914,23 @@ class MimeNode {
if (envelope.from) {
list = [];
this._convertAddresses(this._parseAddresses(envelope.from), list);
this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
list = list.filter(address => address && address.address);
if (list.length && list[0]) {
this._envelope.from = list[0].address;
}
}
const seenRecipients = new Set();
['to', 'cc', 'bcc'].forEach(key => {
if (envelope[key]) {
this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
}
});
this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
const standardFields = ['to', 'cc', 'bcc', 'from'];
Object.keys(envelope).forEach(key => {
if (!standardFields.includes(key)) {
this._envelope[key] = envelope[key];
}
});
shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
return this;
}
@@ -868,15 +942,17 @@ class MimeNode {
*/
getAddresses() {
const addresses = {};
const seenByKey = new Map();
this._headers.forEach(header => {
const key = header.key.toLowerCase();
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
if (!Array.isArray(addresses[key])) {
addresses[key] = [];
seenByKey.set(key, new Set());
}
this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
}
});
@@ -897,6 +973,12 @@ class MimeNode {
from: false,
to: []
};
// Built once and carried across the headers. Letting _convertAddresses seed it per
// call would cost O(headers x recipients), and a message can carry many address
// headers: `headers: { to: [...] }` emits one To per entry.
const seenRecipients = new Set();
this._headers.forEach(header => {
const list = [];
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
@@ -905,7 +987,7 @@ class MimeNode {
envelope.from = list[0].address;
}
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
this._convertAddresses(this._parseAddresses(header.value), envelope.to);
this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
}
});
@@ -952,6 +1034,26 @@ class MimeNode {
/////// PRIVATE METHODS
/**
* Checks an access policy flag for this node and every node above it. The flags are set
* from the options the node was built with, and createChild only ever sees the options
* the caller passed, so a child of a closed tree starts out open. Reading the answer off
* the parent chain keeps it right whatever order the tree was assembled in.
*
* @param {String} flag Either 'disableFileAccess' or 'disableUrlAccess'
* @return {Boolean} true if this node or an ancestor closed that access
*/
_accessDisabled(flag) {
let node = this;
while (node) {
if (node[flag]) {
return true;
}
node = node.parentNode;
}
return false;
}
/**
* Detects and returns handle to a stream related with the content.
*
@@ -982,7 +1084,7 @@ class MimeNode {
}
if (content && typeof content.path === 'string' && !content.href) {
if (this.disableFileAccess) {
if (this._accessDisabled('disableFileAccess')) {
contentStream = new PassThrough();
setImmediate(() => {
const err = new Error('File access rejected for ' + content.path);
@@ -996,7 +1098,7 @@ class MimeNode {
}
if (content && typeof content.href === 'string') {
if (this.disableUrlAccess) {
if (this._accessDisabled('disableUrlAccess')) {
contentStream = new PassThrough();
setImmediate(() => {
const err = new Error('Url access rejected for ' + content.href);
@@ -1005,7 +1107,9 @@ class MimeNode {
});
return contentStream;
}
// fetch URL
// fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
// that on the parsed URL. Testing the raw string here instead would reject
// forms the parser accepts, such as a leading space or a slash-less authority
return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
}
@@ -1030,17 +1134,81 @@ class MimeNode {
* @return {Array} An array of address objects
*/
_parseAddresses(addresses) {
return [].concat.apply(
[],
[].concat(addresses).map(address => {
if (address && address.address) {
address.address = this._normalizeAddress(address.address);
address.name = address.name || '';
return [address];
// Collected into one list as we go. concat.apply spreads the entries into arguments
// and throws a RangeError once a recipient array is long enough to pass the
// argument limit, which a large Bcc list reaches on its own.
const flattened = [];
[].concat(addresses).forEach(address => {
if (address && address.address) {
const normalized = this._normalizeAddress(address.address);
if (normalized === address.address && typeof address.name === 'string') {
// there is nothing to rewrite, so there is nothing to keep off the original
flattened.push(address);
return;
}
return addressparser(address);
})
);
// rewriting would land on the object the caller passed in and might
// still hold a reference to, so rewrite a copy of it instead. An own
// "__proto__" key would make the copy inherit from caller data, and
// _convertAddresses reads `group` off it straight into the envelope
const copy = shared.copyOwnKeys({}, address);
copy.address = normalized;
copy.name = address.name || '';
flattened.push(copy);
return;
}
const parsed = this._normalizeParsedAddresses(addressparser(address));
for (let i = 0; i < parsed.length; i++) {
flattened.push(parsed[i]);
}
});
return flattened;
}
/**
* Normalizes the addresses of a freshly parsed address list, groups included.
*
* Everything this method returns carries a normalized address, whether it arrived as an
* object or was parsed out of a header value. Without this the two shapes disagree, and
* a consumer reading the parsed form back is handed the ambiguous
* 'user@evil.com@good.com' that the header and the envelope no longer carry.
*
* @param {Array} parsed An array of address objects, as returned by addressparser
* @return {Array} The same array, with every address normalized
*/
_normalizeParsedAddresses(parsed) {
// addressparser builds these objects, so no caller holds a reference to rewrite around
parsed.forEach(entry => {
if (entry.address) {
entry.address = this._normalizeAddress(entry.address);
} else if (entry.group) {
this._normalizeParsedAddresses(entry.group);
}
});
return parsed;
}
/**
* Parses the addresses of an explicitly set envelope.
*
* An envelope value is an addr-spec and never a display name, so a bare local username
* such as 'root' is the address here. Header parsing has to read the same value as a
* display name, as a value with no '@' in it can not be an addr-spec in a header.
*
* @param {Mixed} addresses Addresses to be parsed
* @return {Array} An array of address objects
*/
_parseEnvelopeAddresses(addresses) {
return this._parseAddresses(addresses).map(entry => {
if (entry.address || entry.group || !entry.name || /[\s@]/.test(entry.name)) {
return entry;
}
return { address: this._normalizeAddress(entry.name), name: '' };
});
}
/**
@@ -1054,6 +1222,9 @@ class MimeNode {
.toString()
// no newlines in keys
.replace(/\r?\n|\r/g, ' ')
// a field name is printable ascii without the colon, so a control char or DEL
// can only be dropped, there is no quoting construct around a field name
.replace(/[\x00-\x1f\x7f]/g, '')
.trim()
.toLowerCase()
// use uppercase words, except MIME
@@ -1114,7 +1285,13 @@ class MimeNode {
case 'Message-ID':
case 'In-Reply-To':
case 'Content-Id':
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
// a msg-id is structured, so an encoded word inside the angle brackets would
// be read as literal text. drop the characters that can not appear in a header
// at all, but leave HT alone, it separates the ids of a multi id value
value = (value || '')
.toString()
.replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
if (value.charAt(0) !== '<') {
value = '<' + value;
@@ -1134,6 +1311,7 @@ class MimeNode {
elm = (elm || '')
.toString()
.replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim();
return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
})
@@ -1156,7 +1334,7 @@ class MimeNode {
}
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
return this._encodeWords(value);
return this._encodeHeaderText(value);
case 'Content-Type':
case 'Content-Disposition':
@@ -1165,8 +1343,7 @@ class MimeNode {
default:
value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
// encodeWords only encodes if needed, otherwise the original string is returned
return this._encodeWords(value);
return this._encodeHeaderText(value);
}
}
@@ -1177,26 +1354,44 @@ class MimeNode {
* @param {Array} [uniqueList] An array to be populated with addresses
* @return {String} address string
*/
_convertAddresses(addresses, uniqueList) {
_convertAddresses(addresses, uniqueList, seenAddresses) {
const values = [];
uniqueList = uniqueList || [];
// Membership is checked once per address, so scanning uniqueList itself would make
// a recipient list cost O(n^2). Groups recurse with the same set so that a nested
// group still dedupes against the addresses collected around it, and a caller that
// passes a partly filled list (To, then Cc, then Bcc) keeps deduping across headers.
if (!seenAddresses) {
seenAddresses = new Set();
for (let i = 0; i < uniqueList.length; i++) {
seenAddresses.add(uniqueList[i].address);
}
}
[].concat(addresses || []).forEach(address => {
if (address.address) {
address.address = this._normalizeAddress(address.address);
if (!address.name) {
values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`);
// an address that carries a special, be it a quoted local part or a domain
// that could not be normalized, is only unambiguous inside angle brackets.
// Without them a ',' or a ';' anywhere in it reads as a recipient separator
// and the header would list more recipients than the envelope carries
values.push(PLAIN_ADDRESS.test(address.address) ? address.address : `<${address.address}>`);
} else {
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
}
if (!uniqueList.some(a => a.address === address.address)) {
if (!seenAddresses.has(address.address)) {
seenAddresses.add(address.address);
uniqueList.push(address);
}
} else if (address.group) {
const groupListAddresses = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim();
const groupListAddresses = (
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
).trim();
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
}
});
@@ -1213,45 +1408,63 @@ class MimeNode {
_normalizeAddress(address) {
address = (address || '')
.toString()
.replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
.replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
.trim();
const lastAt = address.lastIndexOf('@');
if (lastAt < 0) {
// Bare username
if (!address) {
// callers use an empty value to detect a missing address
return address;
}
let user = address.substr(0, lastAt);
const lastAt = address.lastIndexOf('@');
if (lastAt < 0) {
// Bare username, there is no domain to split off
return this._normalizeLocalPart(address);
}
const user = address.substr(0, lastAt);
const domain = address.substr(lastAt + 1);
// Usernames are not touched and are kept as is even if these include unicode.
// Unicode in the local part is kept as is, see _normalizeLocalPart for the rest of it.
// A domain has no quoting construct to fall back on, so whatever is not a valid domain
// is kept as supplied and it is _convertAddresses that keeps such an address unambiguous.
// Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
// When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
// so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
let encodedDomain = domain;
// A non-ASCII local part already requires SMTPUTF8, so the domain stays UTF-8 for
// symmetry on both sides of the '@' rather than being encoded to an A-label
const smtputf8 = /[\x80-\uFFFF]/.test(user);
try {
if (/[\x80-\uFFFF]/.test(user)) {
encodedDomain = punycode.toUnicode(domain.toLowerCase());
} else {
encodedDomain = punycode.toASCII(domain.toLowerCase());
}
encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
} catch (_err) {
// keep domain as supplied
}
if (user.indexOf(' ') >= 0) {
if (user.charAt(0) !== '"') {
user = '"' + user;
}
if (user.substr(-1) !== '"') {
user = user + '"';
}
return `${this._normalizeLocalPart(user)}@${encodedDomain}`;
}
/**
* Normalizes the local part of an address into a form that can be emitted as is.
*
* A local part is either a dot-atom or a quoted-string, anything else is not a valid
* addr-spec. The quotes of a quoted local part get lost along the way, and a bare
* 'user@evil.com@good.com' leaves it to the receiver which '@' splits the domain off,
* while the split here is always at the last one. So whatever is not already one of
* the two valid forms goes back out as a quoted-string.
*
* @param {String} user Local part of an address
* @return {String} Local part as a dot-atom or as a quoted-string
*/
_normalizeLocalPart(user) {
if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
return user;
}
return `${user}@${encodedDomain}`;
return mimeFuncs.quoteString(user);
}
/**
@@ -1263,7 +1476,7 @@ class MimeNode {
_encodeAddressName(name) {
if (!/^[\w ]*$/.test(name)) {
if (/^[\x20-\x7e]*$/.test(name)) {
return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
return mimeFuncs.quoteString(name);
} else {
return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
}
@@ -1271,6 +1484,21 @@ class MimeNode {
return name;
}
/**
* Encodes an unstructured header value. Such a value can only carry VCHAR and WSP, so a
* control char or DEL has to be forced into the mime encoded word that a non-ascii value
* would get anyway. HT stays as it is, it is valid folding whitespace here.
*
* @param {String} value Header value to encode
* @returns {String} Mime word encoded string if needed
*/
_encodeHeaderText(value) {
return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
? mimeFuncs.encodeWord(value, this._getTextEncoding(value), 52)
: // encodeWords only encodes if needed, otherwise the original string is returned
this._encodeWords(value);
}
/**
* If needed, mime encodes the name part
*
+1 -1
View File
@@ -250,7 +250,7 @@ const decode = function (input) {
// Main decoding loop: start just after the last delimiter if any basic code
// points were copied; start at the beginning otherwise.
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */; ) {
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */;) {
// `index` is the index of the next character to be consumed.
// Decode a generalized variable-length integer into `delta`,
// which gets added to `i`. The overflow checking is easier
+5 -2
View File
@@ -62,14 +62,17 @@ class SendmailTransport {
// Sendmail strips this header line by itself
mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope();
const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId();
let returned;
const hasInvalidAddresses = []
.concat(envelope.from || [])
.concat(envelope.to || [])
.some(addr => /^-/.test(addr));
// a local part is either a dot-atom or a quoted-string, so a leading dash sits at
// offset 0 or, behind the opening quote, at offset 1. Only the first shape is read
// as an option by sendmail, but both are the address this guard keeps out of argv
.some(addr => /^"?-/.test(addr));
if (hasInvalidAddresses) {
const err = new Error('Can not send mail. Invalid envelope addresses.');
err.code = errors.ESENDMAIL;
+4 -3
View File
@@ -66,7 +66,7 @@ class SESTransport extends EventEmitter {
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
}
const envelope = mail.data.envelope || mail.message.getEnvelope();
const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []);
@@ -130,7 +130,8 @@ class SESTransport extends EventEmitter {
return callback(err);
}
const sesMessage = Object.assign(
// mail.data.ses is caller supplied message data, so copy its own keys only
const sesMessage = shared.copyOwnKeys(
{
Content: {
Raw: {
@@ -143,7 +144,7 @@ class SESTransport extends EventEmitter {
ToAddresses: envelope.to
}
},
mail.data.ses || {}
mail.data.ses
);
this.getRegion((err, region) => {
+29 -13
View File
@@ -7,10 +7,15 @@ const util = require('util');
const fs = require('fs');
const nmfetch = require('../fetch');
const errors = require('../errors');
const objects = require('./objects');
const dns = require('dns');
const net = require('net');
const os = require('os');
// re-exported for the callers that already depend on this module, see ./objects
const isProtoKey = (module.exports.isProtoKey = objects.isProtoKey);
module.exports.copyOwnKeys = objects.copyOwnKeys;
const DNS_TTL = 5 * 60 * 1000;
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
@@ -355,7 +360,9 @@ module.exports.parseConnectionUrl = str => {
return;
}
if (!(lKey in obj)) {
// `in` already keeps "__proto__" out, but only as a side effect of it being an
// Object.prototype member. Say it, so the protection survives a change to the check
if (!isProtoKey(lKey) && !(lKey in obj)) {
obj[lKey] = value;
}
});
@@ -470,7 +477,7 @@ module.exports.parseDataURI = uri => {
// Ensure there's a key before the '='
const key = entry.substring(0, sepPos).trim();
const value = entry.substring(sepPos + 1).trim();
if (key) {
if (key && !isProtoKey(key)) {
params[key] = value;
}
}
@@ -561,19 +568,24 @@ function resolveContentValue(data, key, options, callback) {
}
callback(null, value);
});
} else if (/^https?:\/\//i.test(content.path || content.href)) {
if (options.disableUrlAccess) {
return setImmediate(() => {
const err = new Error('Url access rejected for ' + (content.path || content.href));
err.code = errors.EURLACCESS;
callback(err);
});
}
return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
} else if (/^data:/i.test(content.path || content.href)) {
const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
} else if (content.href || /^https?:\/\//i.test(content.path)) {
// An href is always a URL, and so is a path that looks like one. Let nmfetch
// decide whether it is fetchable, it validates the parsed URL. Testing the raw
// string here instead would let a file: href fall through to the "return as is"
// default below and travel on inside the resolved message.
const url = content.href || content.path;
if (options.disableUrlAccess) {
return setImmediate(() => {
const err = new Error('Url access rejected for ' + url);
err.code = errors.EURLACCESS;
callback(err);
});
}
return resolveStream(nmfetch(url, { headers: content.httpHeaders, tls: content.tls }), callback);
} else if (content.path) {
if (options.disableFileAccess) {
return setImmediate(() => {
@@ -603,10 +615,14 @@ module.exports.assign = function (/* target, ... sources */) {
args.forEach(source => {
Object.keys(source || {}).forEach(key => {
if (isProtoKey(key)) {
return;
}
if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
// tls and auth are special keys that need to be enumerated separately
// other objects are passed as is
target[key] = Object.assign(target[key] || {}, source[key]);
// other objects are passed as is. Enumerating is a copy of user supplied
// keys just like the loop above, so it gets the same treatment
target[key] = module.exports.copyOwnKeys(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Safe copying of objects whose keys come from the caller.
//
// This lives in its own leaf module, like ./url.js, so that every layer can reach it.
// lib/shared/index.js requires lib/fetch, so lib/fetch can not require lib/shared back,
// and lib/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
// comparison. lib/shared/index.js re-exports both functions for the callers that already
// depend on it.
/**
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
*
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
* prototype of the target instead of adding a property to it, so a caller can smuggle
* values past validation that only inspects own keys. JSON.parse produces such a key
* where an object literal can not. "constructor" and "prototype" have no such setter and
* become ordinary own properties, so dropping them would only discard legitimate values.
*
* @param {String} key Key to check
* @returns {Boolean} true if the key must not be copied
*/
module.exports.isProtoKey = key => key === '__proto__';
/**
* Copies own enumerable keys from a source object to a target object. Every copy that
* walks the keys of user supplied data goes through here, see isProtoKey.
*
* @param {Object} target Object to copy the keys to
* @param {Object} source Object to copy the keys from
* @param {Function} [skip] Optional predicate, return true to leave a key out
* @returns {Object} The target object
*/
module.exports.copyOwnKeys = (target, source, skip) => {
Object.keys(source || {}).forEach(key => {
if (module.exports.isProtoKey(key) || (skip && skip(key))) {
return;
}
target[key] = source[key];
});
return target;
};
+1 -1
View File
@@ -42,7 +42,7 @@ class StreamTransport {
// We probably need this in the output
mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope();
const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []);
+14
View File
@@ -577,6 +577,20 @@
"port": 587
},
"TurboSMTP": {
"description": "TurboSMTP",
"host": "pro.turbo-smtp.com",
"port": 465,
"secure": true
},
"TurboSMTP-EU": {
"description": "TurboSMTP (EU region)",
"host": "pro.eu.turbo-smtp.com",
"port": 465,
"secure": true
},
"Tutanota": {
"description": "Tutanota (Tuta Mail)",
"domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],