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
+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
*