diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 2fbd12e0..2cb30877 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -94,9 +94,9 @@ } }, "node_modules/nodemailer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.1.tgz", - "integrity": "sha512-pbqZRoeYUO+1E8TJ/11WeY4D2EVujj1hsYjz3qff/vUFDtGnmi7p3iqzTFslYOfQ6PnjVcWxx/CIUSHCSrO/QA==", + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.6.tgz", + "integrity": "sha512-8j/UEmA4AXr51URr3Ob0h+RCBtr0NKJ76Y2P9wJT6Eu3dRyruj2ULh2e6aiKrGg8nlUj1oX6N5h8dHA82EouaQ==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/node_modules/nodemailer/CHANGELOG.md b/node_modules/nodemailer/CHANGELOG.md index 5a434aa8..438ccd60 100644 --- a/node_modules/nodemailer/CHANGELOG.md +++ b/node_modules/nodemailer/CHANGELOG.md @@ -1,5 +1,49 @@ # CHANGELOG +## [10.1.6](https://github.com/nodemailer/nodemailer/compare/v10.1.5...v10.1.6) (2026-10-18) + + +### Bug Fixes + +* **addressparser:** read groups in the same pass as the rest of the list ([ddf0de1](https://github.com/nodemailer/nodemailer/commit/ddf0de1e7615908101bf593c62edcadccc05c8d3)) + +## [10.1.5](https://github.com/nodemailer/nodemailer/compare/v10.1.4...v10.1.5) (2026-10-18) + + +### Bug Fixes + +* **addressparser:** handle an address list without copying it per address ([6bd09b3](https://github.com/nodemailer/nodemailer/commit/6bd09b395cb7ffd1b0ae95f960f3985ba9c25afb)) +* **addressparser:** tokenize into offsets instead of an object per token ([3fd3f50](https://github.com/nodemailer/nodemailer/commit/3fd3f50ce86671884896ef1eaddafff5e137f820)) + +## [10.1.4](https://github.com/nodemailer/nodemailer/compare/v10.1.3...v10.1.4) (2026-10-18) + + +### Bug Fixes + +* **addressparser:** apply the unexpected "<" rule outside quotes and comments ([ee35f57](https://github.com/nodemailer/nodemailer/commit/ee35f5745658bf46952651fa53207255e9dc3406)) +* **addressparser:** bound group nesting at ten levels ([ce63303](https://github.com/nodemailer/nodemailer/commit/ce633031d58f8d2e9c9b0f5b671944a1afe71f95)) +* **addressparser:** drop the obsolete route from an angle-addr ([3dbc730](https://github.com/nodemailer/nodemailer/commit/3dbc7304b0cfa06551543fa2dc01222447c2b554)) +* **addressparser:** end a comment only at the ")" that closes the outermost one ([0a87617](https://github.com/nodemailer/nodemailer/commit/0a8761762ac78f2db2ba37f712f2a52a404ce011)) +* **addressparser:** honour quoted strings and comments inside an angle-addr and a group ([1cd57d2](https://github.com/nodemailer/nodemailer/commit/1cd57d2b8c49dff048c21cdbafdb54439a7f07e2)) +* **addressparser:** never read a mailbox out of a comment ([a1538ea](https://github.com/nodemailer/nodemailer/commit/a1538ea39c76c48a4134806c41e31e59a258b1e5)) +* **addressparser:** read CFWS beside the "@" of an addr-spec as nothing ([ba3c095](https://github.com/nodemailer/nodemailer/commit/ba3c0952542581f95cb20d9322cc9e27baa470a2)) +* **addressparser:** read the first addr-spec of the display text ([038940a](https://github.com/nodemailer/nodemailer/commit/038940a069a2cecb64a70d0e81b17eb8ce200d3a)) +* split a domain literal off at the "@" in front of it ([d5983b0](https://github.com/nodemailer/nodemailer/commit/d5983b0ae234cd22ab45be817fefd434e9a53d58)) + +## [10.1.3](https://github.com/nodemailer/nodemailer/compare/v10.1.2...v10.1.3) (2026-10-18) + + +### Bug Fixes + +* format References and strip URLs in linear time, release any number of streams ([2f18641](https://github.com/nodemailer/nodemailer/commit/2f186418cf6c8fff18f235d4318892fd42f810fa)) + +## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16) + + +### Bug Fixes + +* bound the work a message can make the mailer do and keep per-message auth off the transport's token endpoint ([e347fa2](https://github.com/nodemailer/nodemailer/commit/e347fa2260a7e342e772a7e187937aa87ac96364)) + ## [10.1.1](https://github.com/nodemailer/nodemailer/compare/v10.1.0...v10.1.1) (2026-10-14) diff --git a/node_modules/nodemailer/dist/cjs/addressparser/index.js b/node_modules/nodemailer/dist/cjs/addressparser/index.js index 6618b403..78aa99e5 100644 --- a/node_modules/nodemailer/dist/cjs/addressparser/index.js +++ b/node_modules/nodemailer/dist/cjs/addressparser/index.js @@ -1,5 +1,99 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +/* eslint-disable no-bitwise -- the kind of a token is a set of bit flags packed into one slot of a typed array */ +/** The token is an operator, a text run otherwise */ +const TOKEN_OPERATOR = 1; +/** The next character continues the same run without whitespace in between */ +const TOKEN_NO_BREAK = 2; +/** The text run holds characters its value leaves out or changes, so it is no plain slice */ +const TOKEN_DIRTY = 4; +/** A backslash escapes the next character in the text run, inside a quoted string or a comment */ +const TOKEN_ESCAPES = 8; +/** The text run opens on a character an escaping backslash in front of it has escaped */ +const TOKEN_STARTS_ESCAPED = 16; +/** + * The tokens of an address field, kept as offsets into it. + * + * Every token used to be an object of its own, an operator for every ',' and a value string + * for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its + * size in tokens before any address was read. A token is now three numbers in one typed + * array, its kind and the range of the field it covers, and its value is sliced off the field + * when it is read. The few text runs whose value differs from their slice, as they hold an + * escaping backslash, a line break or a control character, are rebuilt once and kept aside. + */ +class TokenList { + constructor(str) { + this.str = str; + this.slots = new Int32Array(48); + this.length = 0; + this.cleaned = new Map(); + } + /** + * Appends a token + * + * @param kind Kind and flags of the token + * @param start Offset of its first character + * @param end Offset just past its last character + * @return Index of the token + */ + push(kind, start, end) { + if ((this.length + 1) * 3 > this.slots.length) { + const grown = new Int32Array(this.slots.length * 2); + grown.set(this.slots); + this.slots = grown; + } + const at = this.length * 3; + this.slots[at] = kind; + this.slots[at + 1] = start; + this.slots[at + 2] = end; + return this.length++; + } + /** + * The character of an operator token, an empty string for a text run + */ + operatorAt(index) { + const at = index * 3; + return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : ''; + } + /** + * Whether a token is an operator + */ + isOperator(index) { + return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0; + } + /** + * Whether the next character continues the run of a token without whitespace in between + */ + noBreak(index) { + return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0; + } + /** + * Value of a token + */ + value(index) { + const cleaned = this.cleaned.get(index); + if (cleaned !== undefined) { + return cleaned; + } + return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]); + } +} +/** + * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@' + * in front of the literal is the one that splits the domain off, not the last one: reading + * 'a@[x@evil.com]' at its last '@' made it an address at evil.com. + */ +const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/; +/** + * Offset of the '@' that splits the domain off an address, or -1 when there is none + * + * @param address Address to split + * @return Offset of the '@' + */ +function _domainAt(address) { + const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null; + return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@'); +} /** * Restores the quoting of a local part that was read out of a quoted string. * @@ -18,7 +112,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); * @return Address with the local part as a quoted-string */ function _quoteLocalPart(address) { - const lastAt = address.lastIndexOf('@'); + const lastAt = _domainAt(address); if (lastAt < 0) { // no domain to split off, nothing can be misrouted return address; @@ -44,6 +138,23 @@ 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 same with a domain literal for the domain, which may hold an '@' of its own. + */ +const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/; +/** + * The '@' and domain that follow a quoted local part in the same run. + */ +const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/; +/** + * Whether a whitespace free run reads as an addr-spec under the strict grammar + * + * @param value Run to check + * @return true when it does + */ +function _isAddrSpec(value) { + return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value); +} /** * 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. @@ -110,6 +221,62 @@ function _indexOfAt(text, from, to) { } return -1; } +/** + * A quoted-string a value opens with. + */ +const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/; +/** + * Drops the whitespace on either side of the '@' at an offset. + * + * obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com' + * names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'. + * The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that + * no '@' follows. + * + * @param value Value holding the '@' + * @param at Offset of the '@', or -1 + * @return The value with the whitespace beside that '@' removed + */ +function _joinAtSpacing(value, at) { + if (at < 0) { + return value; + } + let start = at; + while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) { + start--; + } + let end = at + 1; + while (end < value.length && _isSpaceCode(value.charCodeAt(end))) { + end++; + } + return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end); +} +/** + * Drops the whitespace beside the '@' that splits the domain off a value. That is the '@' + * right after a quoted local part the value opens with, or else the only '@' it holds outside + * any quoted string. With several it is not clear which one the whitespace belongs to, and + * '' is left to the addr-spec recovery to read as it always has. + * + * @param value Value to clean up + * @return The value with the whitespace beside that '@' removed + */ +function _collapseAtSpacing(value) { + const quoted = LEADING_QUOTED_STRING.exec(value); + if (quoted) { + let at = quoted[0].length; + while (at < value.length && _isSpaceCode(value.charCodeAt(at))) { + at++; + } + if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) { + return _joinAtSpacing(value, at); + } + } + const at = value.indexOf('@'); + if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) { + return value; + } + return _joinAtSpacing(value, at); +} /** * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * @@ -199,6 +366,7 @@ function _looseAddressStart(text) { * @param data Collected address parts, mutated in place */ function _recoverAddrSpec(data) { + data.address = _collapseAtSpacing(data.address); if (!HAS_WHITESPACE.test(data.address)) { return; } @@ -225,7 +393,7 @@ function _recoverAddrSpec(data) { return; } const parts = data.address.split(/\s+/); - let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part)); + let addrIndex = parts.findIndex(_isAddrSpec); if (addrIndex < 0) { addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part)); } @@ -252,6 +420,15 @@ function _recoverAddrSpec(data) { * A comment is folding whitespace, so it is read as a space that splits two atoms and as * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted * strings and domain literals are copied through untouched, a '(' in there is not a comment. + * An unexpected '<' outside them drops everything in front of it, comments included. + * + * RFC 822 also let an angle-addr name the relays to go through in front of the mailbox, + * '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route + * is not part of the mailbox, and folded into the local part it made + * '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not + * start with '@', so contents that do, up to the first ':' outside a domain literal and in + * front of any quoted string, are a route and are dropped, as long as a mailbox with an '@' + * of its own follows. * * @param address Contents of the angle brackets * @return The address with the comments removed, and the text of the comments @@ -265,6 +442,16 @@ function _stripAddressComments(address) { // carried along rather than read back off the growing result, which would flatten it on // every comment (see lastChars in _handleAddress) let lastChar = ''; + // A comment that just closed stands for a space, written once the next character shows + // whether one is wanted: not beside an '@', and not on top of whitespace that follows. + // Deciding on the spot saw only the character right after the ')', so a second comment + // in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b' + let pendingSpace = false; + // first character of the result and whether a quoted string has opened since, which + // together tell a route from a local part + let firstChar = ''; + let quoteSeen = false; + let routeEnd = -1; for (let i = 0, len = address.length; i < len; i++) { const chr = address.charAt(i); if (depth) { @@ -278,16 +465,20 @@ function _stripAddressComments(address) { else if (chr === ')' && !--depth) { comments.push(comment.trim()); comment = ''; - if (lastChar !== '@' && address.charAt(i + 1) !== '@') { - result += ' '; - lastChar = ' '; - } + pendingSpace = lastChar !== '@'; } else { comment += chr; } continue; } + if (pendingSpace && chr !== '(') { + pendingSpace = false; + if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) { + result += ' '; + lastChar = ' '; + } + } if (closer) { if (chr === '\\' && closer === '"' && i < len - 1) { result += chr + address.charAt(++i); @@ -300,6 +491,10 @@ function _stripAddressComments(address) { } else if (chr === '"') { closer = '"'; + quoteSeen = true; + } + else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) { + routeEnd = result.length + 1; } else if (chr === '[') { closer = ']'; @@ -308,8 +503,27 @@ function _stripAddressComments(address) { depth = 1; continue; } + else if (chr === '<') { + // Apple Mail drops everything between an unexpected '<' and the address after it. + // Read here rather than off the raw brackets so that a '<' inside a quoted string, + // a comment or a domain literal stays where it is: '<"a' cut at + // the first '<' left the address 'b"@victim.com' + result = ''; + lastChar = ''; + comments.length = 0; + firstChar = ''; + quoteSeen = false; + routeEnd = -1; + continue; + } result += chr; lastChar = chr; + if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) { + firstChar = chr; + } + } + if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) { + result = result.slice(routeEnd); } if (depth) { // an unterminated comment runs to the end of the address @@ -318,36 +532,41 @@ function _stripAddressComments(address) { return { address: result.trim(), comments: comments.filter(text => text) }; } /** - * Converts tokens for a single address into an address object + * Walks the tokens of one entry of an address list and sorts them into the runs of display + * text, angle-addr and comment they belong to * - * @param tokens Tokens object - * @param depth Current recursion depth for nested group protection - * @return Address object + * @param tokens Tokens of the whole address list + * @param from Index of the first token of the entry + * @param to Index just past the last token of the entry + * @return The collected runs, and whether the display text came out of a comment */ -function _handleAddress(tokens, depth) { - let isGroup = false; +function _collectAddressParts(tokens, from, to) { let state = 'text'; - const addresses = []; const data = { address: [], comment: [], - group: [], text: [], - textWasQuoted: [] + textWasQuoted: [], + quotedLocalAt: [] }; let insideQuotes = false; + // whether the last text run holds nothing but quoted text so far, which is what a quoted + // local part is until the '@' after it joins the run + let lastTextPureQuoted = false; // Last character of the run each state is currently accumulating. Reading it back off // the accumulator with slice(-1) makes the engine flatten the whole growing string on // every token, which is quadratic over an address built from many comment-joined atoms // (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the // character is carried along instead of re-read. - const lastChars = { address: '', comment: '', group: '', text: '' }; + const lastChars = { address: '', comment: '', text: '' }; // Filter out , (comments) and regular text - for (let i = 0, len = tokens.length; i < len; i++) { - const token = tokens[i]; - const prevToken = i ? tokens[i - 1] : null; - if (token.type === 'operator') { - switch (token.value) { + for (let i = from; i < to; i++) { + const value = tokens.value(i); + // the operator in front of this token, if there is one, and whether it runs on into it + const prevOperator = i > from ? tokens.operatorAt(i - 1) : ''; + const prevNoBreak = i > from && tokens.noBreak(i - 1); + if (tokens.isOperator(i)) { + switch (value) { case '<': state = 'address'; insideQuotes = false; @@ -356,11 +575,6 @@ function _handleAddress(tokens, depth) { state = 'comment'; insideQuotes = false; break; - case ':': - state = 'group'; - isGroup = true; - insideQuotes = false; - break; case '"': insideQuotes = !insideQuotes; state = 'text'; @@ -371,7 +585,7 @@ function _handleAddress(tokens, depth) { break; } } - else if (token.value) { + else { // An empty quoted string is dropped by the tokenizer, leaving no text token of its // own for textWasQuoted to be recorded on, so the pair of quote operators right in // front of this token is all that is left of it and the run it opens carries the @@ -379,181 +593,200 @@ function _handleAddress(tokens, depth) { // the quotes never go back on, and the value is no longer an addr-spec a trailing // comment can be peeled off of. It only ever opens a run: a run that already holds // material collected outside the quotes is not a quoted string, whatever follows it - const prevPrevToken = i > 1 ? tokens[i - 2] : null; - const opensAfterEmptyQuotedString = prevToken?.type === 'operator' && - prevToken.value === '"' && - !!prevToken.noBreak && - prevPrevToken?.type === 'operator' && - prevPrevToken.value === '"'; - if (state === 'address') { - // Handle unquoted name that includes a "<". - // Apple Mail truncates everything between an unexpected < and an address. - token.value = token.value.replace(/^[^<]*<\s*/, ''); - } + const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"'; // 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 !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@'); + const joins = prevNoBreak && parts.length && (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@'); if (joins) { - data[state][data[state].length - 1] += token.value; - if (token.value) { - lastChars[state] = token.value.charAt(token.value.length - 1); + const last = data[state].length - 1; + if (state === 'text' && !insideQuotes) { + if (lastTextPureQuoted && value.charAt(0) === '@') { + data.quotedLocalAt[last] = data.text[last].length; + } + lastTextPureQuoted = false; } + data[state][last] += value; + lastChars[state] = value.charAt(value.length - 1); if (state === 'text' && insideQuotes) { - data.textWasQuoted[data.textWasQuoted.length - 1] = true; + data.textWasQuoted[last] = true; } } else { - data[state].push(token.value); - lastChars[state] = token.value.charAt(token.value.length - 1); + data[state].push(value); + lastChars[state] = value.charAt(value.length - 1); if (state === 'text') { data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString); + // an empty quoted string leaves no text of its own, so the '@' opens the run + data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1); + lastTextPureQuoted = insideQuotes; } } } } - // If there is no text but a comment, replace the two + // If there is no text but a comment, replace the two. A comment is never part of a + // mailbox, so text that came out of one only ever names it: reading an address out of it + // made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all + let textFromComment = false; if (!data.text.length && data.comment.length) { data.text = data.comment; data.comment = []; + textFromComment = true; } - if (isGroup) { - // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 - data.text = data.text.join(' '); - // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting) - let groupMembers = []; - if (data.group.length) { - const parsedGroup = _parseAddressList(data.group.join(','), depth + 1); - parsedGroup.forEach(member => { - if (member.group) { - groupMembers = groupMembers.concat(member.group); - } - else { - groupMembers.push(member); - } - }); + return { data, textFromComment }; +} +/** + * Name of a group, from the tokens in front of its ':'. A comment names it when no text does, + * as it names a mailbox. + * + * @param tokens Tokens of the whole address list + * @param from Index of the first token in front of the ':' + * @param to Index of the ':' + * @return Name of the group + */ +function _groupName(tokens, from, to) { + // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 + return _collectAddressParts(tokens, from, to).data.text.join(' '); +} +/** + * Converts tokens for a single address into an address object + * + * @param tokens Tokens of the whole address list + * @param from Index of the first token of this address + * @param to Index just past the last token of this address + * @return Address object + */ +function _handleAddress(tokens, from, to) { + const collected = _collectAddressParts(tokens, from, to); + const data = collected.data; + let textFromComment = collected.textFromComment; + // Comments come out of the angle-addr before anything asks whether one was found, so + // that brackets holding nothing but a comment read the same as empty ones + const addressComments = []; + const addressParts = []; + for (const part of data.address) { + const stripped = _stripAddressComments(part); + for (const comment of stripped.comments) { + addressComments.push(comment); + } + if (stripped.address) { + addressParts.push(stripped.address); } - addresses.push({ - name: data.text || '', - group: groupMembers - }); } - else { - // Comments come out of the angle-addr before anything asks whether one was found, so - // that brackets holding nothing but a comment read the same as empty ones - const addressComments = []; - const addressParts = []; - for (const part of data.address) { - if (part.indexOf('(') < 0) { - addressParts.push(part); - continue; - } - const stripped = _stripAddressComments(part); - for (const comment of stripped.comments) { - addressComments.push(comment); - } - if (stripped.address) { - addressParts.push(stripped.address); + data.address = addressParts; + // If no address was found, try to detect one from regular text + if (!data.address.length && data.text.length && !textFromComment) { + // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both + // tiers read the collapsed runs, and only a run an address comes out of is replaced + // by its collapsed form, the display text keeps its spacing + const candidates = data.text.map((part) => _collapseAtSpacing(part)); + // The first addr-spec in the text is the mailbox, as it is for a conformant parser + // reading as far as it can. Taking the last one let a comment part two of them and + // pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the + // same two without the comment, one run, went to good.com + for (let i = 0; i < data.text.length; i++) { + // 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. + // A run that is a quoted local part with its '@' and domain is an addr-spec of + // its own, with the quoting put back on, and takes its turn like any other + const local = data.quotedLocalAt[i]; + const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) + ? _quoteLocalPart(data.text[i]) + : ''; + if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { + data.address = [quotedLocal || candidates[i]]; + data.text.splice(i, 1); + data.textWasQuoted.splice(i, 1); + data.quotedLocalAt.splice(i, 1); + break; } } - data.address = addressParts; - // If no address was found, try to detect one from regular text - if (!data.address.length && data.text.length) { - for (let i = data.text.length - 1; i >= 0; i--) { - // 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] && ADDR_SPEC.test(data.text[i])) { - data.address = data.text.splice(i, 1); - data.textWasQuoted.splice(i, 1); - break; - } - } - // Try a looser regex match if strict match found nothing - if (!data.address.length) { - let extracted = false; - for (let i = data.text.length - 1; i >= 0; i--) { - // Security: Do not extract email addresses from quoted strings - if (!data.textWasQuoted[i]) { - const part = data.text[i]; - let remainder = part; - const at = _looseAddressStart(part); - if (at >= 0) { - LOOSE_TEXT_ADDR.lastIndex = at; - const match = LOOSE_TEXT_ADDR.exec(part); - if (match) { - data.address = [match[0].trim()]; - extracted = true; - remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length); - } - } - data.text[i] = remainder.trim(); - if (extracted) { - break; + // Try a looser regex match if strict match found nothing + if (!data.address.length) { + let extracted = false; + for (let i = 0; i < data.text.length; i++) { + // Security: Do not extract email addresses from quoted strings + if (!data.textWasQuoted[i]) { + // the strict tier found nothing, so it spliced nothing and the indexes still line up + const part = candidates[i]; + let remainder = data.text[i]; + const at = _looseAddressStart(part); + if (at >= 0) { + LOOSE_TEXT_ADDR.lastIndex = at; + const match = LOOSE_TEXT_ADDR.exec(part); + if (match) { + data.address = [match[0].trim()]; + extracted = true; + remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length); } } + data.text[i] = remainder.trim(); + if (extracted) { + break; + } } } } - // If there's still no text but a comment exists, replace the two - if (!data.text.length && data.comment.length) { - data.text = data.comment; - data.comment = []; - } - // Keep only the first address occurrence, push others to regular text - if (data.address.length > 1) { - 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(' '); - if (addressFromQuotedText && data.text.indexOf('@') >= 0) { - // A quoted run with no '@' in it is a display name and nothing else, so it stays - // where it is. Moving it over anyway made a comment the mailbox was read from: - // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the - // comment as the name, and a quoted name holding a ',' or a ';' became an address - // that reads as two recipients once a consumer writes it back into a header. - // - // The mailbox is still sitting in the text, so it moves over here and is quoted - // before the recovery below rather than after it. Anything else the text holds - // came along with it: a comment ends the domain but leaves the atoms behind it in - // the same text, and '"user"@example.com(x)evil.com' was handed on as the address - // 'user@example.com evil.com', a second domain riding into the envelope recipient - // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes - // back first is what lets the recovery tell the whitespace an addr-spec may carry - // from the wreckage trailing one, as only a quoted local part may hold whitespace - data.address = _quoteLocalPart(data.text); - data.text = ''; - } - _recoverAddrSpec(data); - // a comment names the mailbox only when nothing else does, as one outside the brackets - if (!data.text && addressComments.length) { - data.text = addressComments.join(' '); - } - const address = { - address: data.address || data.text || '', - name: data.text || data.address || '' - }; - if (address.address === address.name) { - if (/@/.test(address.address || '')) { - address.name = ''; - } - else { - address.address = ''; - } - } - addresses.push(address); } - return addresses; + // If there's still no text but a comment exists, replace the two + if (!data.text.length && data.comment.length) { + data.text = data.comment; + data.comment = []; + } + // Keep only the first address occurrence, push others to regular text + if (data.address.length > 1) { + 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(' '); + if (addressFromQuotedText && data.text.indexOf('@') >= 0) { + // A quoted run with no '@' in it is a display name and nothing else, so it stays + // where it is. Moving it over anyway made a comment the mailbox was read from: + // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the + // comment as the name, and a quoted name holding a ',' or a ';' became an address + // that reads as two recipients once a consumer writes it back into a header. + // + // The mailbox is still sitting in the text, so it moves over here and is quoted + // before the recovery below rather than after it. Anything else the text holds + // came along with it: a comment ends the domain but leaves the atoms behind it in + // the same text, and '"user"@example.com(x)evil.com' was handed on as the address + // 'user@example.com evil.com', a second domain riding into the envelope recipient + // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes + // back first is what lets the recovery tell the whitespace an addr-spec may carry + // from the wreckage trailing one, as only a quoted local part may hold whitespace + // The quotes are gone at this point, so the '@' the domain splits off at is the + // last one, not the single one _collapseAtSpacing insists on + data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text))); + data.text = ''; + } + _recoverAddrSpec(data); + // a comment names the mailbox only when nothing else does, as one outside the brackets + if (!data.text && addressComments.length) { + data.text = addressComments.join(' '); + textFromComment = true; + } + const address = { + address: data.address || (textFromComment ? '' : data.text) || '', + name: data.text || data.address || '' + }; + if (address.address === address.name) { + if (/@/.test(address.address || '')) { + address.name = ''; + } + else { + address.address = ''; + } + } + return address; } +/* eslint-disable no-bitwise -- see TokenList */ /** * Creates a Tokenizer object for tokenizing address field strings * @@ -565,11 +798,16 @@ class Tokenizer { this.str = (str || '').toString(); this.operatorCurrent = ''; this.operatorExpecting = ''; - this.node = null; + this.node = -1; this.escaped = false; this.inDomainLiteral = false; this.afterAt = false; - this.list = []; + this.inGroup = false; + this.innerQuoted = false; + this.innerComment = 0; + this.innerLiteral = false; + this.innerEscaped = false; + this.list = new TokenList(this.str); /** * Operator tokens and which tokens are expected to end the sequence */ @@ -578,7 +816,9 @@ class Tokenizer { '(': ')', '<': '>', ',': '', - ':': ';', + // A group is read in the same pass as the rest of the list, so its ':' only marks + // where the name ends and its members are tokenized like any other address + ':': '', // Semicolons are not a legal delimiter per the RFC2822 grammar other // than for terminating a group, but they are also not valid for any // other use in this context. Given that some mail clients have @@ -594,27 +834,96 @@ class Tokenizer { * @return An array of operator|text tokens */ tokenize() { - const list = []; - for (let i = 0, len = this.str.length; i < len; i++) { - const chr = this.str.charAt(i); - const nextChr = i < len - 1 ? this.str.charAt(i + 1) : null; - this.checkChar(chr, nextChr); + const str = this.str; + const len = str.length; + for (let i = 0; i < len; i++) { + this.checkChar(str.charAt(i), i); } - this.list.forEach(node => { - node.value = (node.value || '').toString().trim(); - if (node.value) { - list.push(node); + this._closeText(len); + // Compacted in place: a text run is trimmed to its value and one left empty is + // dropped, and the value of a run that is no plain slice is rebuilt and kept aside + const list = this.list; + const slots = list.slots; + let kept = 0; + for (let i = 0, count = list.length; i < count; i++) { + const kind = slots[i * 3]; + let start = slots[i * 3 + 1]; + let end = slots[i * 3 + 2]; + if (kind & TOKEN_DIRTY) { + const value = this._cleanValue(start, end, kind).trim(); + if (!value) { + continue; + } + list.cleaned.set(kept, value); } - }); + else if (!(kind & TOKEN_OPERATOR)) { + while (start < end && _isSpaceCode(str.charCodeAt(start))) { + start++; + } + while (end > start && _isSpaceCode(str.charCodeAt(end - 1))) { + end--; + } + if (start === end) { + continue; + } + } + slots[kept * 3] = kind; + slots[kept * 3 + 1] = start; + slots[kept * 3 + 2] = end; + kept++; + } + list.length = kept; return list; } + /** + * Ends the text run being collected, if there is one + * + * @param end Offset just past its last character + */ + _closeText(end) { + if (this.node >= 0) { + this.list.slots[this.node * 3 + 2] = end; + this.node = -1; + } + } + /** + * Rebuilds the value of a text run that is no plain slice of the field, applying the + * rules checkChar collects characters by: an escaping backslash is dropped, a line break + * reads as a space and any other control character is left out + * + * @param start Offset of the first character of the run + * @param end Offset just past its last character + * @param kind Kind and flags of the run + * @return Value of the run + */ + _cleanValue(start, end, kind) { + const str = this.str; + const escapes = (kind & TOKEN_ESCAPES) !== 0; + let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0; + let value = ''; + for (let i = start; i < end; i++) { + const chr = str.charAt(i); + if (escapes && !escaped && chr === '\\') { + escaped = true; + continue; + } + escaped = false; + if (chr === '\n') { + value += ' '; + } + else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') { + value += chr; + } + } + return value; + } /** * Checks if a character is an operator or text and acts accordingly * * @param chr Character from the address field - * @param nextChr Character following chr, null at the end of the field + * @param i Offset of the character in the field */ - checkChar(chr, nextChr) { + checkChar(chr, i) { // Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6 // address-literal (user@[IPv6:2001:db8::1]) is dtext and must not be treated as // the group delimiter while inside the brackets. That is the only operator the @@ -635,39 +944,67 @@ class Tokenizer { this.inDomainLiteral = false; } } + // The closer of an angle-addr is only read outside the quoted strings, comments and + // domain literals it holds. Looking for nothing but the closer let a '>' inside a + // quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an + // address at evil.com. The characters are still collected as they are, the angle-addr + // is read again later + let nested = false; + if (this.operatorExpecting === '>') { + nested = this._trackInner(chr); + if (!nested && chr === ';' && this.inGroup) { + // The ';' of a group ends an angle-addr left open inside it, as it always did + // when a group was cut out of the field at its ';' and parsed on its own. Read + // as text instead, an unclosed '<' swallowed the end of the group and every + // recipient after it + this._closeText(i); + this.operatorExpecting = ''; + this._resetInner(); + } + } + else if (this.operatorExpecting === ')' && !this.escaped) { + // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a + // nested comment close the outer one and released the rest of it, an address + // included, into the header + if (chr === '(') { + this.innerComment++; + } + else if (chr === ')' && this.innerComment) { + this.innerComment--; + nested = true; + } + } if (this.escaped) { // ignore next condition blocks } - else if (chr === this.operatorExpecting) { - this.node = { - type: 'operator', - value: chr - }; - if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) { - this.node.noBreak = true; - } - this.list.push(this.node); - this.node = null; + else if (chr === this.operatorExpecting && !nested) { + const nextChr = this.str.charAt(i + 1); + this._closeText(i); + this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr) ? TOKEN_OPERATOR | TOKEN_NO_BREAK : TOKEN_OPERATOR, i, i + 1); if (chr !== ')') { // a comment is folding whitespace, it does not part the "@" from the "[" this.afterAt = false; } this.operatorExpecting = ''; this.escaped = false; + this._resetInner(); return; } else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) { - this.node = { - type: 'operator', - value: chr - }; - this.list.push(this.node); - this.node = null; + this._closeText(i); + this.list.push(TOKEN_OPERATOR, i, i + 1); + if (chr === ':') { + this.inGroup = true; + } + else if (chr === ';') { + this.inGroup = false; + } if (chr !== '(') { this.afterAt = false; } this.operatorExpecting = this.operators[chr]; this.escaped = false; + this._resetInner(); return; } else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') { @@ -676,104 +1013,195 @@ class Tokenizer { // only in quoted strings let an escaped parenthesis close the comment, which // released the rest of it, an address included, into the header this.escaped = true; + if (this.node >= 0) { + // the backslash is left out of the value + this.list.slots[this.node * 3] |= TOKEN_DIRTY; + } return; } - if (!this.node) { - this.node = { - type: 'text', - value: '' - }; - this.list.push(this.node); + if (this.node < 0) { + this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | + (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); } if (chr === '\n') { // Convert newlines to spaces. Carriage return is ignored as \r and \n usually // go together anyway and there already is a WS for \n. Lone \r means something is fishy. chr = ' '; + this.list.slots[this.node * 3] |= TOKEN_DIRTY; } - if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) { - // skip command bytes - this.node.value += chr; + if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') { + // the character is part of the value, which is read off the field later // text inside a quoted string or a comment is not part of an addr-spec. Tracked // as it goes rather than read back off the value (see lastChars in _handleAddress) if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') { this.afterAt = chr === '@'; } } + else { + // skip command bytes + this.list.slots[this.node * 3] |= TOKEN_DIRTY; + } this.escaped = false; } -} -/** - * Maximum recursion depth for parsing nested groups. - * RFC 5322 doesn't allow nested groups, so this is a safeguard against - * malicious input that could cause stack overflow. - */ -const MAX_NESTED_GROUP_DEPTH = 50; -/** - * Parses an address list, recursing into the groups it holds. - * - * The depth is threaded through the calls rather than carried in an options object, so a - * caller supplied one can not seed it and lift the recursion limit: `{ _depth: -1e9 }` - * bought a billion levels of nesting and turned the guard below into the stack overflow it - * is there to prevent. - * - * @param str Address field - * @param depth Current recursion depth for nested group protection - * @return An array of address objects - */ -function _parseAddressList(str, depth) { - // Prevent stack overflow from deeply nested groups (DoS protection) - if (depth > MAX_NESTED_GROUP_DEPTH) { - return []; - } - const tokenizer = new Tokenizer(str); - const tokens = tokenizer.tokenize(); - const addresses = []; - let address = []; - const parsedAddresses = []; - tokens.forEach(token => { - if (token.type === 'operator' && (token.value === ',' || token.value === ';')) { - if (address.length) { - addresses.push(address); + /** + * Tracks the quoted strings, comments and domain literals inside an angle-addr, which its + * closer must not be read from. A backslash escapes the next character inside a quoted + * string and a comment. A domain literal opens right after an "@" and ends at the "]". + * dtext holds the list separators as well, and inside the brackets they never split the + * list anyway. + * + * @param chr Character from the address field + * @return true while the character sits inside one of them, the one that closes it included + */ + _trackInner(chr) { + if (this.innerEscaped) { + this.innerEscaped = false; + return true; + } + if (this.innerQuoted) { + if (chr === '\\') { + this.innerEscaped = true; } - address = []; + else if (chr === '"') { + this.innerQuoted = false; + } + return true; } - else { - address.push(token); + if (this.innerComment) { + if (chr === '\\') { + this.innerEscaped = true; + } + else if (chr === '(') { + this.innerComment++; + } + else if (chr === ')') { + this.innerComment--; + } + return true; } - }); - if (address.length) { - addresses.push(address); + if (this.innerLiteral) { + if (chr === ']') { + this.innerLiteral = false; + } + return true; + } + if (chr === '"') { + this.innerQuoted = true; + return true; + } + if (chr === '(') { + this.innerComment = 1; + return true; + } + if (chr === '[' && this.afterAt) { + this.innerLiteral = true; + return true; + } + // afterAt is otherwise only kept outside the operators and is cleared when one opens + // or ends, so inside an angle-addr it serves the literal tracked here + if (!_isSpaceCode(chr.charCodeAt(0))) { + this.afterAt = chr === '@'; + } + return false; } - addresses.forEach(addr => { - const handled = _handleAddress(addr, depth); - // 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]); - } - }); - // Merge fragments produced when unquoted display names contain commas. - // "Joe Foo, PhD " 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. - // 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 = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null; + /** + * Forgets the nesting collected for the operator that just opened or ended + */ + _resetInner() { + this.innerQuoted = false; + this.innerComment = 0; + this.innerLiteral = false; + this.innerEscaped = false; + } +} +/* eslint-enable no-bitwise */ +/** + * Folds the fragments an unquoted display name holding a comma is split into back together. + * + * "Joe Foo, PhD " is split on the comma into + * [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry + * followed by an entry with both name and address is recombined. 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). + * + * @param list Parsed entries, in order + * @return The entries with the fragments folded in + */ +function _mergeNameFragments(list) { + const merged = []; + for (let i = list.length - 1; i >= 0; i--) { + const current = list[i]; + const next = merged.length ? merged[merged.length - 1] : null; if (next && current.address === '' && current.name && !current.group && next.address && next.name) { next.name = current.name + ', ' + next.name; } else { - mergedAddresses.push(current); + merged.push(current); } } - mergedAddresses.reverse(); - return mergedAddresses; + return merged.reverse(); +} +/** + * Parses an address list, groups included, in a single pass over its tokens. + * + * A group used to be cut out of the field at the ';' that ends it and parsed again as an + * address list of its own, recursively, so every level of nesting parsed what was left of + * the field once more and the depth had to be capped. RFC 5322 does not nest groups at all, + * and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a + * group, its members are read as they come, a ';' closes it. A ':' inside an open group + * stands for a group nested in it, whose name is dropped and whose members belong to the + * outer one, which is how they were flattened before. The first ';' ends the group, nested + * or not, as it did. + * + * @param str Address field + * @return An array of address objects + */ +function _parseAddressList(str) { + const tokens = new Tokenizer(str).tokenize(); + const parsedAddresses = []; + let group = null; + let members = []; + // Every address is handed over as the range of the token list it covers, as soon as the + // separator after it is seen. Copying the tokens of each address into an array of its own, + // and collecting those before handling any, held a second structure as large as the token + // list for the whole parse, and an array that receives a single push is allocated room for + // seventeen: a list of one letter names took some 80 times its size in that alone + let from = 0; + for (let i = 0, len = tokens.length; i <= len; i++) { + const operator = i < len ? tokens.operatorAt(i) : ''; + if (i < len && operator !== ',' && operator !== ';' && operator !== ':') { + continue; + } + if (operator === ':') { + if (!group) { + group = { name: _groupName(tokens, from, i), group: [] }; + members = []; + } + from = i + 1; + continue; + } + if (i > from) { + // 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). + const address = _handleAddress(tokens, from, i); + if (group) { + members.push(address); + } + else { + parsedAddresses.push(address); + } + } + from = i + 1; + if (group && (operator === ';' || i === len)) { + group.group = _mergeNameFragments(members); + parsedAddresses.push(group); + group = null; + } + } + return _mergeNameFragments(parsedAddresses); } function addressparser(str, options) { - const parsedAddresses = _parseAddressList(str, 0); + const parsedAddresses = _parseAddressList(str); if (options?.flatten) { const flatAddresses = []; const walkAddressList = (list) => { diff --git a/node_modules/nodemailer/dist/cjs/dkim/index.js b/node_modules/nodemailer/dist/cjs/dkim/index.js index 17bb0b6f..4d00b6e0 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/index.js +++ b/node_modules/nodemailer/dist/cjs/dkim/index.js @@ -133,8 +133,9 @@ class DKIMSigner { } createWriteCache() { this.output.usingCache = true; - // pipe remainings to cache file - this.cache = node_fs_1.default.createWriteStream(this.cachePath); + // pipe remainings to cache file. It holds the message body and cacheDir is often a + // shared directory such as /tmp, so nobody but the owner gets to read it + this.cache = node_fs_1.default.createWriteStream(this.cachePath, { mode: 0o600 }); this.cache.once('error', err => { // drain input this.relaxedBody.unpipe(this.cache); diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.d.ts b/node_modules/nodemailer/dist/cjs/fetch/index.d.ts index 731c88c2..cbfa90c5 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/fetch/index.d.ts @@ -27,6 +27,8 @@ export interface FetchOptions { } | undefined; /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */ timeout?: number | undefined; + /** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */ + maxTime?: number | undefined; /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */ maxBytes?: number | undefined; /** Maximum number of redirects to follow (default 5) */ @@ -37,6 +39,8 @@ export interface FetchOptions { redirects?: number | undefined; /** Response stream shared across redirects, set by nmfetch itself */ fetchRes?: FetchResponse | undefined; + /** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */ + deadline?: number | undefined; } /** * The stream nmfetch returns. The response body is piped into it, the status code and @@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse; declare namespace nmfetch { var Cookies: typeof import("./cookies.js").default; var DEFAULT_TIMEOUT: number; + var DEFAULT_MAX_TIME: number; } type CookiesJar = Cookies; /** diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.js b/node_modules/nodemailer/dist/cjs/fetch/index.js index 6a5a848d..9107f514 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.js +++ b/node_modules/nodemailer/dist/cjs/fetch/index.js @@ -49,6 +49,10 @@ const objects_js_1 = require("../shared/objects.js"); const MAX_REDIRECTS = 5; // a stalled server would otherwise hold the request, and whatever waits on it, open forever const DEFAULT_TIMEOUT = 60 * 1000; +// the inactivity timeout above is reset by every byte, so a server dripping one within each +// window could still hold the request, and the message being built from it, open for as +// long as it liked. The whole request, redirects included, has to be done within this +const DEFAULT_MAX_TIME = 2 * 60 * 1000; // the body is usually buffered in memory by the caller, so an unbounded download is refused const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; // Only genuine TLS settings are taken from options.tls. That object reaches us straight @@ -126,6 +130,12 @@ function nmfetch(url, options) { options.cookies = options.cookies || new cookies_js_1.default(); options.redirects = options.redirects || 0; options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects; + if (!options.deadline) { + options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME; + // the one deadline is shared with the requests the redirects lead to, every hop + // gets the time that is left rather than starting over + options.deadline = options.maxTime ? Date.now() + options.maxTime : 0; + } const fetchRes = options.fetchRes; const parsed = parseFetchUrl(url); if (!parsed) { @@ -271,12 +281,22 @@ function nmfetch(url, options) { }); return fetchRes; } + // the whole request has to be done by options.deadline, see maxTime. The timer is + // released on every way out of the request, so it never outlives it + let deadlineTimer; + const clearDeadline = () => { + if (deadlineTimer) { + clearTimeout(deadlineTimer); + deadlineTimer = undefined; + } + }; // reports the first failure of this request on fetchRes and releases the request const fail = (err, sourceUrl = url) => { if (finished) { return; } finished = true; + clearDeadline(); setFetchCode(err); err.sourceUrl = sourceUrl; fetchRes.emit('error', err); @@ -286,10 +306,16 @@ function nmfetch(url, options) { if (timeout) { req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); } + if (options.deadline) { + deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1)); + // a pending deadline is no reason to keep the process alive + deadlineTimer.unref(); + } req.on('error', (err) => fail(err)); // a consumer that destroys the response stream before it ended does not want the rest of // the body, release the request now instead of when the timeout fires fetchRes.once('close', () => { + clearDeadline(); if (finished || fetchRes.readableEnded) { return; } @@ -358,6 +384,7 @@ function nmfetch(url, options) { // this request is done with, release it so its socket and timeout do not // outlive it and report a late error on the shared fetchRes finished = true; + clearDeadline(); res.resume(); req.abort(); return nmfetch(redirectUrl, options); @@ -368,6 +395,8 @@ function nmfetch(url, options) { return fail(new Error('Invalid status code ' + res.statusCode)); } res.on('error', (err) => fail(err)); + // the response is in, whatever is left of it is in memory and no longer on the network + res.once('end', () => clearDeadline()); const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES; const source = inflate || res; let received = 0; @@ -405,6 +434,7 @@ function nmfetch(url, options) { } nmfetch.Cookies = cookies_js_1.default; nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; +nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME; exports.default = nmfetch; module.exports = exports.default; Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true }); diff --git a/node_modules/nodemailer/dist/cjs/mailer/index.js b/node_modules/nodemailer/dist/cjs/mailer/index.js index 4a200c8e..386bf851 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/index.js +++ b/node_modules/nodemailer/dist/cjs/mailer/index.js @@ -398,9 +398,12 @@ class Mail extends node_events_1.EventEmitter { return callback(err); } let cidCounter = 0; + // the media type is a bounded token: a quote, whitespace, '>' or '<' ends the + // attribute value and with it the type, so one img tag never scans ahead to a + // ';' that belongs to a later tag, which was quadratic html = (html || '') .toString() - .replace(/(]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { + .replace(/(]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { const cid = node_crypto_1.default.randomBytes(10).toString('hex') + '@localhost'; if (!mail.data.attachments) { mail.data.attachments = []; diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts b/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts index 8f2973fe..4eca09eb 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts @@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions { dsn?: SMTPEnvelopeDsn | undefined; /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */ requireTLSExtensionEnabled?: boolean | undefined; - /** SMTP transports: per-message authentication settings, used instead of the transport level auth */ + /** + * SMTP transports: per-message authentication settings, used instead of the transport level auth. + * Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires), + * the token endpoint and client settings stay those of the transport + */ auth?: SMTPTransportAuthOptions | undefined; /** SES transport: extra SendEmailCommand parameters merged into the API call */ ses?: SESSendEmailRequest | undefined; diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js index 4d9f8196..29b206d4 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js @@ -183,7 +183,10 @@ class MailMessage { const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; for (const key of ['attachments', 'alternatives']) { if (Array.isArray(data[key])) { - values.push(...data[key]); + // one at a time, spreading a long enough list into the arguments throws + for (const entry of data[key]) { + values.push(entry); + } } } for (const value of values) { diff --git a/node_modules/nodemailer/dist/cjs/mime-funcs/index.js b/node_modules/nodemailer/dist/cjs/mime-funcs/index.js index a2603b95..d706ba32 100644 --- a/node_modules/nodemailer/dist/cjs/mime-funcs/index.js +++ b/node_modules/nodemailer/dist/cjs/mime-funcs/index.js @@ -186,26 +186,38 @@ function encodeWord(data, mimeWordEncoding, maxLength) { */ function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) { maxLength = maxLength || 0; - // find first word with a non-printable ascii or special symbol in it - const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/); - if (!firstMatch) { + // find the first and the last character that needs encoding, a quote or anything outside + // printable ascii. A scan rather than a regex: matching the last such word with a + // backtracking pattern took quadratic time on a long word of non-ascii characters + let firstSpecial = -1; + let lastSpecial = -1; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 0x22 || code >= 0x80) { + if (firstSpecial < 0) { + firstSpecial = i; + } + lastSpecial = i; + } + } + if (firstSpecial < 0) { return value; } if (encodeAll) { // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything return encodeWord(value, mimeWordEncoding, maxLength); } - // find the last word with a non-printable ascii in it - const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/); - if (!lastMatch) { - // should not happen - return value; + // encode from the start of the word holding the first such character to the end of the + // word holding the last one, so the encoded words stay separated from the plain text by + // the whitespace that was already there + let startIndex = firstSpecial; + while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) { + startIndex--; + } + let endIndex = lastSpecial + 1; + while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) { + endIndex++; } - const startIndex = firstMatch.index + - (firstMatch[0].match(/[^\s]/) || { - index: 0 - }).index; - const endIndex = lastMatch.index + (lastMatch[1] || '').length; return ((startIndex ? value.substr(0, startIndex) : '') + encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) + (endIndex < value.length ? value.substr(endIndex) : '')); diff --git a/node_modules/nodemailer/dist/cjs/mime-node/index.js b/node_modules/nodemailer/dist/cjs/mime-node/index.js index b5989dd0..3d0d5d0a 100644 --- a/node_modules/nodemailer/dist/cjs/mime-node/index.js +++ b/node_modules/nodemailer/dist/cjs/mime-node/index.js @@ -64,6 +64,35 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/; // 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"(),:;<>@[\\\]]+$/; +/** + * A domain literal that ends an address, see _normalizeAddress + */ +const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/; +/** + * Removes the whitespace inside every <...> of a References value. A "<" with no ">" after + * it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value + * from every such "<" + * + * @param value References header value + * @returns The value with the msg-ids closed up + */ +function stripWhitespaceInAngleBrackets(value) { + let result = ''; + let pos = 0; + for (;;) { + const open = value.indexOf('<', pos); + if (open < 0) { + break; + } + const close = value.indexOf('>', open + 1); + if (close < 0) { + break; + } + result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, ''); + pos = close + 1; + } + return result + value.slice(pos); +} // 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 @@ -71,6 +100,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; // 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]/; +// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than +// the code points it encodes, so anything past these bounds could not have become a valid +// domain whichever way it is encoded. The bundled codec is super-linear in the length of a +// label, a 60 KB label of distinct code points took seconds to encode, several times per +// address, so it is only handed what could be a domain +const MAX_DOMAIN_LENGTH = 253; +const MAX_LABEL_LENGTH = 63; // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway const PIPELINE_NOOP = () => false; // The error a node streaming into an output that was destroyed stops with @@ -79,6 +115,27 @@ function abortedError() { err.code = errors.ESTREAM; return err; } +/** + * Checks a domain against the DNS length limits, counted in code points per label and in + * total. The label separators are the ones the bundled codec splits on. + * + * @param domain Domain to check + * @return true if a label or the domain as a whole is too long to be a valid domain + */ +function exceedsDomainLimits(domain) { + let total = 0; + let label = 0; + for (const char of domain) { + total++; + if (char === '.' || char === '。' || char === '.' || char === '。') { + label = 0; + } + else if (++label > MAX_LABEL_LENGTH) { + return true; + } + } + return total > MAX_DOMAIN_LENGTH; +} /** * 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. @@ -110,6 +167,10 @@ function normalizeDomain(domain, toUnicode) { return mapped; } } + if (exceedsDomainLimits(domain)) { + // too long to be a domain whichever way it is encoded, keep it as supplied + return domain; + } return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain); } /** @@ -614,6 +675,17 @@ class MimeNode { /[\u0080-\uFFFF]/.test(this.content)) { structured.params.charset = 'utf-8'; } + if (this.filename) { + // the name parameter is derived from the filename below, so a name that + // arrived inside the content type is dropped first (a plain parameter or + // rfc2231 continuation sections), otherwise the header carries two and a + // receiver that reads the first sees a name the filename does not match + for (const paramKey of Object.keys(structured.params)) { + if (/^name(\*|$)/i.test(paramKey)) { + delete structured.params[paramKey]; + } + } + } value = mimeFuncs.buildHeaderValue(structured); if (this.filename) { // add support for non-compliant clients like QQ webmail @@ -1223,12 +1295,12 @@ class MimeNode { _normalizeHeaderKey(key) { key = (key || '') .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() + // a field name is printable ascii without the colon, so a control char, DEL, + // whitespace or a colon can only be dropped, there is no quoting construct + // around a field name. A colon or a space left in place ends the name early on + // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a + // second From header that neither setHeader() nor the Bcc filter could match + .replace(/[\s\x00-\x20\x7f:]/g, '') .toLowerCase() // use uppercase words, except MIME .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase()) @@ -1314,26 +1386,26 @@ class MimeNode { } return value; // space separated list of values enclosed in <> - case 'References': - value = [].concat - .apply([], [].concat(value || '').map(elm => { - elm = (elm || '') + case 'References': { + const ids = []; + for (const entry of [].concat(value || '')) { + const elm = (entry || '') .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+/); - })) - .map(elm => { - if (elm.charAt(0) !== '<') { - elm = '<' + elm; + for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) { + if (id.charAt(0) !== '<') { + id = '<' + id; + } + if (id.charAt(id.length - 1) !== '>') { + id = id + '>'; + } + ids.push(id); } - if (elm.charAt(elm.length - 1) !== '>') { - elm = elm + '>'; - } - return elm; - }); - return value.join(' ').trim(); + } + return ids.join(' ').trim(); + } case 'Date': if (Object.prototype.toString.call(value) === '[object Date]') { return value.toUTCString().replace(/GMT/, '+0000'); @@ -1412,7 +1484,10 @@ class MimeNode { // callers use an empty value to detect a missing address return address; } - const lastAt = address.lastIndexOf('@'); + // dtext includes '@', so a domain literal is split off at the '@' in front of it and + // not at the last one, which delivered 'a@[x@evil.com]' to evil.com + const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null; + const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@'); if (lastAt < 0) { // Bare username, there is no domain to split off return this._normalizeLocalPart(address); diff --git a/node_modules/nodemailer/dist/cjs/package-info.d.ts b/node_modules/nodemailer/dist/cjs/package-info.d.ts index 80159c23..b6096e51 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.d.ts +++ b/node_modules/nodemailer/dist/cjs/package-info.d.ts @@ -1,3 +1,3 @@ export declare const name = "nodemailer"; -export declare const version = "10.1.1"; +export declare const version = "10.1.6"; export declare const homepage = "https://nodemailer.com/"; diff --git a/node_modules/nodemailer/dist/cjs/package-info.js b/node_modules/nodemailer/dist/cjs/package-info.js index 4a9004cb..62350541 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.js +++ b/node_modules/nodemailer/dist/cjs/package-info.js @@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.homepage = exports.version = exports.name = void 0; exports.name = 'nodemailer'; -exports.version = '10.1.1'; +exports.version = '10.1.6'; exports.homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/cjs/shared/url.js b/node_modules/nodemailer/dist/cjs/shared/url.js index 43f428be..b3bb6490 100644 --- a/node_modules/nodemailer/dist/cjs/shared/url.js +++ b/node_modules/nodemailer/dist/cjs/shared/url.js @@ -58,10 +58,27 @@ const punycode = __importStar(require("../punycode/index.js")); // Matches a "scheme:" not followed by "//" (and with something after it), used // to re-insert the authority separator the legacy parser did not require. const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/; -// Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it -// looks at the input. Stripped up front so that the slash-less form is recognized in a -// value read from a file with a trailing newline as well. -const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g; +/** + * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips + * before it looks at the input. Stripped up front so that the slash-less form is recognized + * in a value read from a file with a trailing newline as well. An index scan, the regex + * /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every + * position in it + * + * @param input URL string + * @returns The string without the surrounding controls and spaces + */ +function stripSurroundingWhitespace(input) { + let start = 0; + let end = input.length; + while (start < end && input.charCodeAt(start) <= 0x20) { + start++; + } + while (end > start && input.charCodeAt(end - 1) <= 0x20) { + end--; + } + return input.slice(start, end); +} // Leading characters legacy url.parse() skips before it reads the scheme const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/; // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..." @@ -156,7 +173,7 @@ function normalizeHostname(raw, href) { return mapped; } const parse = (input, parseQueryString) => { - input = (input || '').replace(SURROUNDING_WHITESPACE, ''); + input = stripSurroundingWhitespace(input || ''); // Legacy url.parse() parses a "user:pass@host:port" authority that follows // the scheme even without the "//" separator, for schemes outside its // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js index 5c6476cc..81a61498 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js @@ -114,6 +114,21 @@ function isPartialResponse(str) { function isPartialLine(line) { return /^\d+-/.test(line); } +/** + * The log form of a command a custom authentication handler sends. Such a command carries + * the credentials in whatever shape its mechanism uses, so only an AUTH verb with its + * mechanism name is kept, the way the built-in mechanisms hide their own secrets. + * + * @param cmd Command the handler sends + * @return What the transaction log gets instead + */ +function maskAuthCommand(cmd) { + const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i); + if (!match) { + return '/* secret */'; + } + return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */'; +} /** * Generates a SMTP connection object * @@ -601,7 +616,7 @@ class SMTPConnection extends node_events_1.EventEmitter { } done(null, data); }); - setImmediate(() => this._sendCommand(cmd)); + setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd))); return promise; }; const handlerResponse = handler({ diff --git a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js index fe550d76..08036e19 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js @@ -43,6 +43,13 @@ const shared = __importStar(require("../shared/index.js")); const index_js_3 = __importDefault(require("../xoauth2/index.js")); const errors = __importStar(require("../errors.js")); const packageData = __importStar(require("../package-info.js")); +// The keys a message's `auth` may set on top of the transport's auth settings: the ones that +// say who logs in and with what. Everything else in the auth block (accessUrl, clientId, +// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest) +// names the endpoint the credentials go to or is a secret of the application itself, and +// a message that could set `auth.accessUrl` and force a refresh would have the transport's +// client secret and refresh token posted to a URL of its choosing +const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires']; /** * Creates a SMTP transport object for Nodemailer * @@ -102,7 +109,11 @@ class SMTPTransport extends node_events_1.EventEmitter { } return this.auth; } - const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {}); + const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}); + if (typeof authOpts === 'object') { + // see MESSAGE_AUTH_KEYS + shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key)); + } if (Object.keys(authData).length === 0) { return false; } diff --git a/node_modules/nodemailer/dist/cjs/well-known/services.js b/node_modules/nodemailer/dist/cjs/well-known/services.js index 847959ff..470b9c3f 100644 --- a/node_modules/nodemailer/dist/cjs/well-known/services.js +++ b/node_modules/nodemailer/dist/cjs/well-known/services.js @@ -363,6 +363,13 @@ exports.services = { "port": 587, "secure": false }, + "Oveyon": { + "description": "Oveyon", + "host": "smtp.oveyon.com", + "port": 587, + "secure": false, + "requireTLS": true + }, "Postmark": { "description": "Postmark", "aliases": [ diff --git a/node_modules/nodemailer/dist/cjs/xoauth2/index.js b/node_modules/nodemailer/dist/cjs/xoauth2/index.js index 6738b0b3..94a687fb 100644 --- a/node_modules/nodemailer/dist/cjs/xoauth2/index.js +++ b/node_modules/nodemailer/dist/cjs/xoauth2/index.js @@ -264,8 +264,14 @@ class XOAuth2 extends node_stream_1.Stream { grant_type: 'refresh_token' }; } - Object.assign(urlOptions, this.options.customParams); - Object.assign(loggedUrlOptions, this.options.customParams); + const customParams = this.options.customParams || {}; + Object.assign(urlOptions, customParams); + // a custom param is as likely to be a client assertion or an API key as a tenant + // name, so the log gets the same glimpse of it that it gets of the client secret + for (const key of Object.keys(customParams)) { + const param = customParams[key]; + loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...'; + } this.logger.debug({ tnx: 'OAUTH2', user: this.options.user, diff --git a/node_modules/nodemailer/dist/esm/addressparser/index.js b/node_modules/nodemailer/dist/esm/addressparser/index.js index aa579fcd..ec5dad1d 100644 --- a/node_modules/nodemailer/dist/esm/addressparser/index.js +++ b/node_modules/nodemailer/dist/esm/addressparser/index.js @@ -1,3 +1,97 @@ +/* eslint-disable no-bitwise -- the kind of a token is a set of bit flags packed into one slot of a typed array */ +/** The token is an operator, a text run otherwise */ +const TOKEN_OPERATOR = 1; +/** The next character continues the same run without whitespace in between */ +const TOKEN_NO_BREAK = 2; +/** The text run holds characters its value leaves out or changes, so it is no plain slice */ +const TOKEN_DIRTY = 4; +/** A backslash escapes the next character in the text run, inside a quoted string or a comment */ +const TOKEN_ESCAPES = 8; +/** The text run opens on a character an escaping backslash in front of it has escaped */ +const TOKEN_STARTS_ESCAPED = 16; +/** + * The tokens of an address field, kept as offsets into it. + * + * Every token used to be an object of its own, an operator for every ',' and a value string + * for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its + * size in tokens before any address was read. A token is now three numbers in one typed + * array, its kind and the range of the field it covers, and its value is sliced off the field + * when it is read. The few text runs whose value differs from their slice, as they hold an + * escaping backslash, a line break or a control character, are rebuilt once and kept aside. + */ +class TokenList { + constructor(str) { + this.str = str; + this.slots = new Int32Array(48); + this.length = 0; + this.cleaned = new Map(); + } + /** + * Appends a token + * + * @param kind Kind and flags of the token + * @param start Offset of its first character + * @param end Offset just past its last character + * @return Index of the token + */ + push(kind, start, end) { + if ((this.length + 1) * 3 > this.slots.length) { + const grown = new Int32Array(this.slots.length * 2); + grown.set(this.slots); + this.slots = grown; + } + const at = this.length * 3; + this.slots[at] = kind; + this.slots[at + 1] = start; + this.slots[at + 2] = end; + return this.length++; + } + /** + * The character of an operator token, an empty string for a text run + */ + operatorAt(index) { + const at = index * 3; + return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : ''; + } + /** + * Whether a token is an operator + */ + isOperator(index) { + return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0; + } + /** + * Whether the next character continues the run of a token without whitespace in between + */ + noBreak(index) { + return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0; + } + /** + * Value of a token + */ + value(index) { + const cleaned = this.cleaned.get(index); + if (cleaned !== undefined) { + return cleaned; + } + return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]); + } +} +/** + * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@' + * in front of the literal is the one that splits the domain off, not the last one: reading + * 'a@[x@evil.com]' at its last '@' made it an address at evil.com. + */ +const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/; +/** + * Offset of the '@' that splits the domain off an address, or -1 when there is none + * + * @param address Address to split + * @return Offset of the '@' + */ +function _domainAt(address) { + const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null; + return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@'); +} /** * Restores the quoting of a local part that was read out of a quoted string. * @@ -16,7 +110,7 @@ * @return Address with the local part as a quoted-string */ function _quoteLocalPart(address) { - const lastAt = address.lastIndexOf('@'); + const lastAt = _domainAt(address); if (lastAt < 0) { // no domain to split off, nothing can be misrouted return address; @@ -42,6 +136,23 @@ 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 same with a domain literal for the domain, which may hold an '@' of its own. + */ +const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/; +/** + * The '@' and domain that follow a quoted local part in the same run. + */ +const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/; +/** + * Whether a whitespace free run reads as an addr-spec under the strict grammar + * + * @param value Run to check + * @return true when it does + */ +function _isAddrSpec(value) { + return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value); +} /** * 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. @@ -108,6 +219,62 @@ function _indexOfAt(text, from, to) { } return -1; } +/** + * A quoted-string a value opens with. + */ +const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/; +/** + * Drops the whitespace on either side of the '@' at an offset. + * + * obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com' + * names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'. + * The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that + * no '@' follows. + * + * @param value Value holding the '@' + * @param at Offset of the '@', or -1 + * @return The value with the whitespace beside that '@' removed + */ +function _joinAtSpacing(value, at) { + if (at < 0) { + return value; + } + let start = at; + while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) { + start--; + } + let end = at + 1; + while (end < value.length && _isSpaceCode(value.charCodeAt(end))) { + end++; + } + return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end); +} +/** + * Drops the whitespace beside the '@' that splits the domain off a value. That is the '@' + * right after a quoted local part the value opens with, or else the only '@' it holds outside + * any quoted string. With several it is not clear which one the whitespace belongs to, and + * '' is left to the addr-spec recovery to read as it always has. + * + * @param value Value to clean up + * @return The value with the whitespace beside that '@' removed + */ +function _collapseAtSpacing(value) { + const quoted = LEADING_QUOTED_STRING.exec(value); + if (quoted) { + let at = quoted[0].length; + while (at < value.length && _isSpaceCode(value.charCodeAt(at))) { + at++; + } + if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) { + return _joinAtSpacing(value, at); + } + } + const at = value.indexOf('@'); + if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) { + return value; + } + return _joinAtSpacing(value, at); +} /** * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * @@ -197,6 +364,7 @@ function _looseAddressStart(text) { * @param data Collected address parts, mutated in place */ function _recoverAddrSpec(data) { + data.address = _collapseAtSpacing(data.address); if (!HAS_WHITESPACE.test(data.address)) { return; } @@ -223,7 +391,7 @@ function _recoverAddrSpec(data) { return; } const parts = data.address.split(/\s+/); - let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part)); + let addrIndex = parts.findIndex(_isAddrSpec); if (addrIndex < 0) { addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part)); } @@ -250,6 +418,15 @@ function _recoverAddrSpec(data) { * A comment is folding whitespace, so it is read as a space that splits two atoms and as * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted * strings and domain literals are copied through untouched, a '(' in there is not a comment. + * An unexpected '<' outside them drops everything in front of it, comments included. + * + * RFC 822 also let an angle-addr name the relays to go through in front of the mailbox, + * '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route + * is not part of the mailbox, and folded into the local part it made + * '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not + * start with '@', so contents that do, up to the first ':' outside a domain literal and in + * front of any quoted string, are a route and are dropped, as long as a mailbox with an '@' + * of its own follows. * * @param address Contents of the angle brackets * @return The address with the comments removed, and the text of the comments @@ -263,6 +440,16 @@ function _stripAddressComments(address) { // carried along rather than read back off the growing result, which would flatten it on // every comment (see lastChars in _handleAddress) let lastChar = ''; + // A comment that just closed stands for a space, written once the next character shows + // whether one is wanted: not beside an '@', and not on top of whitespace that follows. + // Deciding on the spot saw only the character right after the ')', so a second comment + // in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b' + let pendingSpace = false; + // first character of the result and whether a quoted string has opened since, which + // together tell a route from a local part + let firstChar = ''; + let quoteSeen = false; + let routeEnd = -1; for (let i = 0, len = address.length; i < len; i++) { const chr = address.charAt(i); if (depth) { @@ -276,16 +463,20 @@ function _stripAddressComments(address) { else if (chr === ')' && !--depth) { comments.push(comment.trim()); comment = ''; - if (lastChar !== '@' && address.charAt(i + 1) !== '@') { - result += ' '; - lastChar = ' '; - } + pendingSpace = lastChar !== '@'; } else { comment += chr; } continue; } + if (pendingSpace && chr !== '(') { + pendingSpace = false; + if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) { + result += ' '; + lastChar = ' '; + } + } if (closer) { if (chr === '\\' && closer === '"' && i < len - 1) { result += chr + address.charAt(++i); @@ -298,6 +489,10 @@ function _stripAddressComments(address) { } else if (chr === '"') { closer = '"'; + quoteSeen = true; + } + else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) { + routeEnd = result.length + 1; } else if (chr === '[') { closer = ']'; @@ -306,8 +501,27 @@ function _stripAddressComments(address) { depth = 1; continue; } + else if (chr === '<') { + // Apple Mail drops everything between an unexpected '<' and the address after it. + // Read here rather than off the raw brackets so that a '<' inside a quoted string, + // a comment or a domain literal stays where it is: '<"a' cut at + // the first '<' left the address 'b"@victim.com' + result = ''; + lastChar = ''; + comments.length = 0; + firstChar = ''; + quoteSeen = false; + routeEnd = -1; + continue; + } result += chr; lastChar = chr; + if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) { + firstChar = chr; + } + } + if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) { + result = result.slice(routeEnd); } if (depth) { // an unterminated comment runs to the end of the address @@ -316,36 +530,41 @@ function _stripAddressComments(address) { return { address: result.trim(), comments: comments.filter(text => text) }; } /** - * Converts tokens for a single address into an address object + * Walks the tokens of one entry of an address list and sorts them into the runs of display + * text, angle-addr and comment they belong to * - * @param tokens Tokens object - * @param depth Current recursion depth for nested group protection - * @return Address object + * @param tokens Tokens of the whole address list + * @param from Index of the first token of the entry + * @param to Index just past the last token of the entry + * @return The collected runs, and whether the display text came out of a comment */ -function _handleAddress(tokens, depth) { - let isGroup = false; +function _collectAddressParts(tokens, from, to) { let state = 'text'; - const addresses = []; const data = { address: [], comment: [], - group: [], text: [], - textWasQuoted: [] + textWasQuoted: [], + quotedLocalAt: [] }; let insideQuotes = false; + // whether the last text run holds nothing but quoted text so far, which is what a quoted + // local part is until the '@' after it joins the run + let lastTextPureQuoted = false; // Last character of the run each state is currently accumulating. Reading it back off // the accumulator with slice(-1) makes the engine flatten the whole growing string on // every token, which is quadratic over an address built from many comment-joined atoms // (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the // character is carried along instead of re-read. - const lastChars = { address: '', comment: '', group: '', text: '' }; + const lastChars = { address: '', comment: '', text: '' }; // Filter out , (comments) and regular text - for (let i = 0, len = tokens.length; i < len; i++) { - const token = tokens[i]; - const prevToken = i ? tokens[i - 1] : null; - if (token.type === 'operator') { - switch (token.value) { + for (let i = from; i < to; i++) { + const value = tokens.value(i); + // the operator in front of this token, if there is one, and whether it runs on into it + const prevOperator = i > from ? tokens.operatorAt(i - 1) : ''; + const prevNoBreak = i > from && tokens.noBreak(i - 1); + if (tokens.isOperator(i)) { + switch (value) { case '<': state = 'address'; insideQuotes = false; @@ -354,11 +573,6 @@ function _handleAddress(tokens, depth) { state = 'comment'; insideQuotes = false; break; - case ':': - state = 'group'; - isGroup = true; - insideQuotes = false; - break; case '"': insideQuotes = !insideQuotes; state = 'text'; @@ -369,7 +583,7 @@ function _handleAddress(tokens, depth) { break; } } - else if (token.value) { + else { // An empty quoted string is dropped by the tokenizer, leaving no text token of its // own for textWasQuoted to be recorded on, so the pair of quote operators right in // front of this token is all that is left of it and the run it opens carries the @@ -377,181 +591,200 @@ function _handleAddress(tokens, depth) { // the quotes never go back on, and the value is no longer an addr-spec a trailing // comment can be peeled off of. It only ever opens a run: a run that already holds // material collected outside the quotes is not a quoted string, whatever follows it - const prevPrevToken = i > 1 ? tokens[i - 2] : null; - const opensAfterEmptyQuotedString = prevToken?.type === 'operator' && - prevToken.value === '"' && - !!prevToken.noBreak && - prevPrevToken?.type === 'operator' && - prevPrevToken.value === '"'; - if (state === 'address') { - // Handle unquoted name that includes a "<". - // Apple Mail truncates everything between an unexpected < and an address. - token.value = token.value.replace(/^[^<]*<\s*/, ''); - } + const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"'; // 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 !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@'); + const joins = prevNoBreak && parts.length && (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@'); if (joins) { - data[state][data[state].length - 1] += token.value; - if (token.value) { - lastChars[state] = token.value.charAt(token.value.length - 1); + const last = data[state].length - 1; + if (state === 'text' && !insideQuotes) { + if (lastTextPureQuoted && value.charAt(0) === '@') { + data.quotedLocalAt[last] = data.text[last].length; + } + lastTextPureQuoted = false; } + data[state][last] += value; + lastChars[state] = value.charAt(value.length - 1); if (state === 'text' && insideQuotes) { - data.textWasQuoted[data.textWasQuoted.length - 1] = true; + data.textWasQuoted[last] = true; } } else { - data[state].push(token.value); - lastChars[state] = token.value.charAt(token.value.length - 1); + data[state].push(value); + lastChars[state] = value.charAt(value.length - 1); if (state === 'text') { data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString); + // an empty quoted string leaves no text of its own, so the '@' opens the run + data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1); + lastTextPureQuoted = insideQuotes; } } } } - // If there is no text but a comment, replace the two + // If there is no text but a comment, replace the two. A comment is never part of a + // mailbox, so text that came out of one only ever names it: reading an address out of it + // made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all + let textFromComment = false; if (!data.text.length && data.comment.length) { data.text = data.comment; data.comment = []; + textFromComment = true; } - if (isGroup) { - // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 - data.text = data.text.join(' '); - // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting) - let groupMembers = []; - if (data.group.length) { - const parsedGroup = _parseAddressList(data.group.join(','), depth + 1); - parsedGroup.forEach(member => { - if (member.group) { - groupMembers = groupMembers.concat(member.group); - } - else { - groupMembers.push(member); - } - }); + return { data, textFromComment }; +} +/** + * Name of a group, from the tokens in front of its ':'. A comment names it when no text does, + * as it names a mailbox. + * + * @param tokens Tokens of the whole address list + * @param from Index of the first token in front of the ':' + * @param to Index of the ':' + * @return Name of the group + */ +function _groupName(tokens, from, to) { + // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 + return _collectAddressParts(tokens, from, to).data.text.join(' '); +} +/** + * Converts tokens for a single address into an address object + * + * @param tokens Tokens of the whole address list + * @param from Index of the first token of this address + * @param to Index just past the last token of this address + * @return Address object + */ +function _handleAddress(tokens, from, to) { + const collected = _collectAddressParts(tokens, from, to); + const data = collected.data; + let textFromComment = collected.textFromComment; + // Comments come out of the angle-addr before anything asks whether one was found, so + // that brackets holding nothing but a comment read the same as empty ones + const addressComments = []; + const addressParts = []; + for (const part of data.address) { + const stripped = _stripAddressComments(part); + for (const comment of stripped.comments) { + addressComments.push(comment); + } + if (stripped.address) { + addressParts.push(stripped.address); } - addresses.push({ - name: data.text || '', - group: groupMembers - }); } - else { - // Comments come out of the angle-addr before anything asks whether one was found, so - // that brackets holding nothing but a comment read the same as empty ones - const addressComments = []; - const addressParts = []; - for (const part of data.address) { - if (part.indexOf('(') < 0) { - addressParts.push(part); - continue; - } - const stripped = _stripAddressComments(part); - for (const comment of stripped.comments) { - addressComments.push(comment); - } - if (stripped.address) { - addressParts.push(stripped.address); + data.address = addressParts; + // If no address was found, try to detect one from regular text + if (!data.address.length && data.text.length && !textFromComment) { + // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both + // tiers read the collapsed runs, and only a run an address comes out of is replaced + // by its collapsed form, the display text keeps its spacing + const candidates = data.text.map((part) => _collapseAtSpacing(part)); + // The first addr-spec in the text is the mailbox, as it is for a conformant parser + // reading as far as it can. Taking the last one let a comment part two of them and + // pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the + // same two without the comment, one run, went to good.com + for (let i = 0; i < data.text.length; i++) { + // 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. + // A run that is a quoted local part with its '@' and domain is an addr-spec of + // its own, with the quoting put back on, and takes its turn like any other + const local = data.quotedLocalAt[i]; + const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local)) + ? _quoteLocalPart(data.text[i]) + : ''; + if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) { + data.address = [quotedLocal || candidates[i]]; + data.text.splice(i, 1); + data.textWasQuoted.splice(i, 1); + data.quotedLocalAt.splice(i, 1); + break; } } - data.address = addressParts; - // If no address was found, try to detect one from regular text - if (!data.address.length && data.text.length) { - for (let i = data.text.length - 1; i >= 0; i--) { - // 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] && ADDR_SPEC.test(data.text[i])) { - data.address = data.text.splice(i, 1); - data.textWasQuoted.splice(i, 1); - break; - } - } - // Try a looser regex match if strict match found nothing - if (!data.address.length) { - let extracted = false; - for (let i = data.text.length - 1; i >= 0; i--) { - // Security: Do not extract email addresses from quoted strings - if (!data.textWasQuoted[i]) { - const part = data.text[i]; - let remainder = part; - const at = _looseAddressStart(part); - if (at >= 0) { - LOOSE_TEXT_ADDR.lastIndex = at; - const match = LOOSE_TEXT_ADDR.exec(part); - if (match) { - data.address = [match[0].trim()]; - extracted = true; - remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length); - } - } - data.text[i] = remainder.trim(); - if (extracted) { - break; + // Try a looser regex match if strict match found nothing + if (!data.address.length) { + let extracted = false; + for (let i = 0; i < data.text.length; i++) { + // Security: Do not extract email addresses from quoted strings + if (!data.textWasQuoted[i]) { + // the strict tier found nothing, so it spliced nothing and the indexes still line up + const part = candidates[i]; + let remainder = data.text[i]; + const at = _looseAddressStart(part); + if (at >= 0) { + LOOSE_TEXT_ADDR.lastIndex = at; + const match = LOOSE_TEXT_ADDR.exec(part); + if (match) { + data.address = [match[0].trim()]; + extracted = true; + remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length); } } + data.text[i] = remainder.trim(); + if (extracted) { + break; + } } } } - // If there's still no text but a comment exists, replace the two - if (!data.text.length && data.comment.length) { - data.text = data.comment; - data.comment = []; - } - // Keep only the first address occurrence, push others to regular text - if (data.address.length > 1) { - 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(' '); - if (addressFromQuotedText && data.text.indexOf('@') >= 0) { - // A quoted run with no '@' in it is a display name and nothing else, so it stays - // where it is. Moving it over anyway made a comment the mailbox was read from: - // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the - // comment as the name, and a quoted name holding a ',' or a ';' became an address - // that reads as two recipients once a consumer writes it back into a header. - // - // The mailbox is still sitting in the text, so it moves over here and is quoted - // before the recovery below rather than after it. Anything else the text holds - // came along with it: a comment ends the domain but leaves the atoms behind it in - // the same text, and '"user"@example.com(x)evil.com' was handed on as the address - // 'user@example.com evil.com', a second domain riding into the envelope recipient - // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes - // back first is what lets the recovery tell the whitespace an addr-spec may carry - // from the wreckage trailing one, as only a quoted local part may hold whitespace - data.address = _quoteLocalPart(data.text); - data.text = ''; - } - _recoverAddrSpec(data); - // a comment names the mailbox only when nothing else does, as one outside the brackets - if (!data.text && addressComments.length) { - data.text = addressComments.join(' '); - } - const address = { - address: data.address || data.text || '', - name: data.text || data.address || '' - }; - if (address.address === address.name) { - if (/@/.test(address.address || '')) { - address.name = ''; - } - else { - address.address = ''; - } - } - addresses.push(address); } - return addresses; + // If there's still no text but a comment exists, replace the two + if (!data.text.length && data.comment.length) { + data.text = data.comment; + data.comment = []; + } + // Keep only the first address occurrence, push others to regular text + if (data.address.length > 1) { + 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(' '); + if (addressFromQuotedText && data.text.indexOf('@') >= 0) { + // A quoted run with no '@' in it is a display name and nothing else, so it stays + // where it is. Moving it over anyway made a comment the mailbox was read from: + // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the + // comment as the name, and a quoted name holding a ',' or a ';' became an address + // that reads as two recipients once a consumer writes it back into a header. + // + // The mailbox is still sitting in the text, so it moves over here and is quoted + // before the recovery below rather than after it. Anything else the text holds + // came along with it: a comment ends the domain but leaves the atoms behind it in + // the same text, and '"user"@example.com(x)evil.com' was handed on as the address + // 'user@example.com evil.com', a second domain riding into the envelope recipient + // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes + // back first is what lets the recovery tell the whitespace an addr-spec may carry + // from the wreckage trailing one, as only a quoted local part may hold whitespace + // The quotes are gone at this point, so the '@' the domain splits off at is the + // last one, not the single one _collapseAtSpacing insists on + data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text))); + data.text = ''; + } + _recoverAddrSpec(data); + // a comment names the mailbox only when nothing else does, as one outside the brackets + if (!data.text && addressComments.length) { + data.text = addressComments.join(' '); + textFromComment = true; + } + const address = { + address: data.address || (textFromComment ? '' : data.text) || '', + name: data.text || data.address || '' + }; + if (address.address === address.name) { + if (/@/.test(address.address || '')) { + address.name = ''; + } + else { + address.address = ''; + } + } + return address; } +/* eslint-disable no-bitwise -- see TokenList */ /** * Creates a Tokenizer object for tokenizing address field strings * @@ -563,11 +796,16 @@ class Tokenizer { this.str = (str || '').toString(); this.operatorCurrent = ''; this.operatorExpecting = ''; - this.node = null; + this.node = -1; this.escaped = false; this.inDomainLiteral = false; this.afterAt = false; - this.list = []; + this.inGroup = false; + this.innerQuoted = false; + this.innerComment = 0; + this.innerLiteral = false; + this.innerEscaped = false; + this.list = new TokenList(this.str); /** * Operator tokens and which tokens are expected to end the sequence */ @@ -576,7 +814,9 @@ class Tokenizer { '(': ')', '<': '>', ',': '', - ':': ';', + // A group is read in the same pass as the rest of the list, so its ':' only marks + // where the name ends and its members are tokenized like any other address + ':': '', // Semicolons are not a legal delimiter per the RFC2822 grammar other // than for terminating a group, but they are also not valid for any // other use in this context. Given that some mail clients have @@ -592,27 +832,96 @@ class Tokenizer { * @return An array of operator|text tokens */ tokenize() { - const list = []; - for (let i = 0, len = this.str.length; i < len; i++) { - const chr = this.str.charAt(i); - const nextChr = i < len - 1 ? this.str.charAt(i + 1) : null; - this.checkChar(chr, nextChr); + const str = this.str; + const len = str.length; + for (let i = 0; i < len; i++) { + this.checkChar(str.charAt(i), i); } - this.list.forEach(node => { - node.value = (node.value || '').toString().trim(); - if (node.value) { - list.push(node); + this._closeText(len); + // Compacted in place: a text run is trimmed to its value and one left empty is + // dropped, and the value of a run that is no plain slice is rebuilt and kept aside + const list = this.list; + const slots = list.slots; + let kept = 0; + for (let i = 0, count = list.length; i < count; i++) { + const kind = slots[i * 3]; + let start = slots[i * 3 + 1]; + let end = slots[i * 3 + 2]; + if (kind & TOKEN_DIRTY) { + const value = this._cleanValue(start, end, kind).trim(); + if (!value) { + continue; + } + list.cleaned.set(kept, value); } - }); + else if (!(kind & TOKEN_OPERATOR)) { + while (start < end && _isSpaceCode(str.charCodeAt(start))) { + start++; + } + while (end > start && _isSpaceCode(str.charCodeAt(end - 1))) { + end--; + } + if (start === end) { + continue; + } + } + slots[kept * 3] = kind; + slots[kept * 3 + 1] = start; + slots[kept * 3 + 2] = end; + kept++; + } + list.length = kept; return list; } + /** + * Ends the text run being collected, if there is one + * + * @param end Offset just past its last character + */ + _closeText(end) { + if (this.node >= 0) { + this.list.slots[this.node * 3 + 2] = end; + this.node = -1; + } + } + /** + * Rebuilds the value of a text run that is no plain slice of the field, applying the + * rules checkChar collects characters by: an escaping backslash is dropped, a line break + * reads as a space and any other control character is left out + * + * @param start Offset of the first character of the run + * @param end Offset just past its last character + * @param kind Kind and flags of the run + * @return Value of the run + */ + _cleanValue(start, end, kind) { + const str = this.str; + const escapes = (kind & TOKEN_ESCAPES) !== 0; + let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0; + let value = ''; + for (let i = start; i < end; i++) { + const chr = str.charAt(i); + if (escapes && !escaped && chr === '\\') { + escaped = true; + continue; + } + escaped = false; + if (chr === '\n') { + value += ' '; + } + else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') { + value += chr; + } + } + return value; + } /** * Checks if a character is an operator or text and acts accordingly * * @param chr Character from the address field - * @param nextChr Character following chr, null at the end of the field + * @param i Offset of the character in the field */ - checkChar(chr, nextChr) { + checkChar(chr, i) { // Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6 // address-literal (user@[IPv6:2001:db8::1]) is dtext and must not be treated as // the group delimiter while inside the brackets. That is the only operator the @@ -633,39 +942,67 @@ class Tokenizer { this.inDomainLiteral = false; } } + // The closer of an angle-addr is only read outside the quoted strings, comments and + // domain literals it holds. Looking for nothing but the closer let a '>' inside a + // quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an + // address at evil.com. The characters are still collected as they are, the angle-addr + // is read again later + let nested = false; + if (this.operatorExpecting === '>') { + nested = this._trackInner(chr); + if (!nested && chr === ';' && this.inGroup) { + // The ';' of a group ends an angle-addr left open inside it, as it always did + // when a group was cut out of the field at its ';' and parsed on its own. Read + // as text instead, an unclosed '<' swallowed the end of the group and every + // recipient after it + this._closeText(i); + this.operatorExpecting = ''; + this._resetInner(); + } + } + else if (this.operatorExpecting === ')' && !this.escaped) { + // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a + // nested comment close the outer one and released the rest of it, an address + // included, into the header + if (chr === '(') { + this.innerComment++; + } + else if (chr === ')' && this.innerComment) { + this.innerComment--; + nested = true; + } + } if (this.escaped) { // ignore next condition blocks } - else if (chr === this.operatorExpecting) { - this.node = { - type: 'operator', - value: chr - }; - if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) { - this.node.noBreak = true; - } - this.list.push(this.node); - this.node = null; + else if (chr === this.operatorExpecting && !nested) { + const nextChr = this.str.charAt(i + 1); + this._closeText(i); + this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr) ? TOKEN_OPERATOR | TOKEN_NO_BREAK : TOKEN_OPERATOR, i, i + 1); if (chr !== ')') { // a comment is folding whitespace, it does not part the "@" from the "[" this.afterAt = false; } this.operatorExpecting = ''; this.escaped = false; + this._resetInner(); return; } else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) { - this.node = { - type: 'operator', - value: chr - }; - this.list.push(this.node); - this.node = null; + this._closeText(i); + this.list.push(TOKEN_OPERATOR, i, i + 1); + if (chr === ':') { + this.inGroup = true; + } + else if (chr === ';') { + this.inGroup = false; + } if (chr !== '(') { this.afterAt = false; } this.operatorExpecting = this.operators[chr]; this.escaped = false; + this._resetInner(); return; } else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') { @@ -674,104 +1011,195 @@ class Tokenizer { // only in quoted strings let an escaped parenthesis close the comment, which // released the rest of it, an address included, into the header this.escaped = true; + if (this.node >= 0) { + // the backslash is left out of the value + this.list.slots[this.node * 3] |= TOKEN_DIRTY; + } return; } - if (!this.node) { - this.node = { - type: 'text', - value: '' - }; - this.list.push(this.node); + if (this.node < 0) { + this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) | + (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i); } if (chr === '\n') { // Convert newlines to spaces. Carriage return is ignored as \r and \n usually // go together anyway and there already is a WS for \n. Lone \r means something is fishy. chr = ' '; + this.list.slots[this.node * 3] |= TOKEN_DIRTY; } - if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) { - // skip command bytes - this.node.value += chr; + if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') { + // the character is part of the value, which is read off the field later // text inside a quoted string or a comment is not part of an addr-spec. Tracked // as it goes rather than read back off the value (see lastChars in _handleAddress) if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') { this.afterAt = chr === '@'; } } + else { + // skip command bytes + this.list.slots[this.node * 3] |= TOKEN_DIRTY; + } this.escaped = false; } -} -/** - * Maximum recursion depth for parsing nested groups. - * RFC 5322 doesn't allow nested groups, so this is a safeguard against - * malicious input that could cause stack overflow. - */ -const MAX_NESTED_GROUP_DEPTH = 50; -/** - * Parses an address list, recursing into the groups it holds. - * - * The depth is threaded through the calls rather than carried in an options object, so a - * caller supplied one can not seed it and lift the recursion limit: `{ _depth: -1e9 }` - * bought a billion levels of nesting and turned the guard below into the stack overflow it - * is there to prevent. - * - * @param str Address field - * @param depth Current recursion depth for nested group protection - * @return An array of address objects - */ -function _parseAddressList(str, depth) { - // Prevent stack overflow from deeply nested groups (DoS protection) - if (depth > MAX_NESTED_GROUP_DEPTH) { - return []; - } - const tokenizer = new Tokenizer(str); - const tokens = tokenizer.tokenize(); - const addresses = []; - let address = []; - const parsedAddresses = []; - tokens.forEach(token => { - if (token.type === 'operator' && (token.value === ',' || token.value === ';')) { - if (address.length) { - addresses.push(address); + /** + * Tracks the quoted strings, comments and domain literals inside an angle-addr, which its + * closer must not be read from. A backslash escapes the next character inside a quoted + * string and a comment. A domain literal opens right after an "@" and ends at the "]". + * dtext holds the list separators as well, and inside the brackets they never split the + * list anyway. + * + * @param chr Character from the address field + * @return true while the character sits inside one of them, the one that closes it included + */ + _trackInner(chr) { + if (this.innerEscaped) { + this.innerEscaped = false; + return true; + } + if (this.innerQuoted) { + if (chr === '\\') { + this.innerEscaped = true; } - address = []; + else if (chr === '"') { + this.innerQuoted = false; + } + return true; } - else { - address.push(token); + if (this.innerComment) { + if (chr === '\\') { + this.innerEscaped = true; + } + else if (chr === '(') { + this.innerComment++; + } + else if (chr === ')') { + this.innerComment--; + } + return true; } - }); - if (address.length) { - addresses.push(address); + if (this.innerLiteral) { + if (chr === ']') { + this.innerLiteral = false; + } + return true; + } + if (chr === '"') { + this.innerQuoted = true; + return true; + } + if (chr === '(') { + this.innerComment = 1; + return true; + } + if (chr === '[' && this.afterAt) { + this.innerLiteral = true; + return true; + } + // afterAt is otherwise only kept outside the operators and is cleared when one opens + // or ends, so inside an angle-addr it serves the literal tracked here + if (!_isSpaceCode(chr.charCodeAt(0))) { + this.afterAt = chr === '@'; + } + return false; } - addresses.forEach(addr => { - const handled = _handleAddress(addr, depth); - // 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]); - } - }); - // Merge fragments produced when unquoted display names contain commas. - // "Joe Foo, PhD " 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. - // 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 = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null; + /** + * Forgets the nesting collected for the operator that just opened or ended + */ + _resetInner() { + this.innerQuoted = false; + this.innerComment = 0; + this.innerLiteral = false; + this.innerEscaped = false; + } +} +/* eslint-enable no-bitwise */ +/** + * Folds the fragments an unquoted display name holding a comma is split into back together. + * + * "Joe Foo, PhD " is split on the comma into + * [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry + * followed by an entry with both name and address is recombined. 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). + * + * @param list Parsed entries, in order + * @return The entries with the fragments folded in + */ +function _mergeNameFragments(list) { + const merged = []; + for (let i = list.length - 1; i >= 0; i--) { + const current = list[i]; + const next = merged.length ? merged[merged.length - 1] : null; if (next && current.address === '' && current.name && !current.group && next.address && next.name) { next.name = current.name + ', ' + next.name; } else { - mergedAddresses.push(current); + merged.push(current); } } - mergedAddresses.reverse(); - return mergedAddresses; + return merged.reverse(); +} +/** + * Parses an address list, groups included, in a single pass over its tokens. + * + * A group used to be cut out of the field at the ';' that ends it and parsed again as an + * address list of its own, recursively, so every level of nesting parsed what was left of + * the field once more and the depth had to be capped. RFC 5322 does not nest groups at all, + * and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a + * group, its members are read as they come, a ';' closes it. A ':' inside an open group + * stands for a group nested in it, whose name is dropped and whose members belong to the + * outer one, which is how they were flattened before. The first ';' ends the group, nested + * or not, as it did. + * + * @param str Address field + * @return An array of address objects + */ +function _parseAddressList(str) { + const tokens = new Tokenizer(str).tokenize(); + const parsedAddresses = []; + let group = null; + let members = []; + // Every address is handed over as the range of the token list it covers, as soon as the + // separator after it is seen. Copying the tokens of each address into an array of its own, + // and collecting those before handling any, held a second structure as large as the token + // list for the whole parse, and an array that receives a single push is allocated room for + // seventeen: a list of one letter names took some 80 times its size in that alone + let from = 0; + for (let i = 0, len = tokens.length; i <= len; i++) { + const operator = i < len ? tokens.operatorAt(i) : ''; + if (i < len && operator !== ',' && operator !== ';' && operator !== ':') { + continue; + } + if (operator === ':') { + if (!group) { + group = { name: _groupName(tokens, from, i), group: [] }; + members = []; + } + from = i + 1; + continue; + } + if (i > from) { + // 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). + const address = _handleAddress(tokens, from, i); + if (group) { + members.push(address); + } + else { + parsedAddresses.push(address); + } + } + from = i + 1; + if (group && (operator === ';' || i === len)) { + group.group = _mergeNameFragments(members); + parsedAddresses.push(group); + group = null; + } + } + return _mergeNameFragments(parsedAddresses); } function addressparser(str, options) { - const parsedAddresses = _parseAddressList(str, 0); + const parsedAddresses = _parseAddressList(str); if (options?.flatten) { const flatAddresses = []; const walkAddressList = (list) => { diff --git a/node_modules/nodemailer/dist/esm/dkim/index.js b/node_modules/nodemailer/dist/esm/dkim/index.js index ff815b4f..1dad2388 100644 --- a/node_modules/nodemailer/dist/esm/dkim/index.js +++ b/node_modules/nodemailer/dist/esm/dkim/index.js @@ -128,8 +128,9 @@ class DKIMSigner { } createWriteCache() { this.output.usingCache = true; - // pipe remainings to cache file - this.cache = fs.createWriteStream(this.cachePath); + // pipe remainings to cache file. It holds the message body and cacheDir is often a + // shared directory such as /tmp, so nobody but the owner gets to read it + this.cache = fs.createWriteStream(this.cachePath, { mode: 0o600 }); this.cache.once('error', err => { // drain input this.relaxedBody.unpipe(this.cache); diff --git a/node_modules/nodemailer/dist/esm/fetch/index.d.ts b/node_modules/nodemailer/dist/esm/fetch/index.d.ts index 731c88c2..cbfa90c5 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.d.ts +++ b/node_modules/nodemailer/dist/esm/fetch/index.d.ts @@ -27,6 +27,8 @@ export interface FetchOptions { } | undefined; /** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */ timeout?: number | undefined; + /** Time limit for the whole request in milliseconds, redirects included, defaults to 120000, 0 disables it */ + maxTime?: number | undefined; /** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */ maxBytes?: number | undefined; /** Maximum number of redirects to follow (default 5) */ @@ -37,6 +39,8 @@ export interface FetchOptions { redirects?: number | undefined; /** Response stream shared across redirects, set by nmfetch itself */ fetchRes?: FetchResponse | undefined; + /** Timestamp the whole request has to be done by, shared across redirects, set by nmfetch itself */ + deadline?: number | undefined; } /** * The stream nmfetch returns. The response body is piped into it, the status code and @@ -50,6 +54,7 @@ declare function nmfetch(url: string, options?: FetchOptions): FetchResponse; declare namespace nmfetch { var Cookies: typeof import("./cookies.js").default; var DEFAULT_TIMEOUT: number; + var DEFAULT_MAX_TIME: number; } type CookiesJar = Cookies; /** diff --git a/node_modules/nodemailer/dist/esm/fetch/index.js b/node_modules/nodemailer/dist/esm/fetch/index.js index 6d1cef31..46e61c73 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.js +++ b/node_modules/nodemailer/dist/esm/fetch/index.js @@ -11,6 +11,10 @@ import { isProtoKey } from '../shared/objects.js'; const MAX_REDIRECTS = 5; // a stalled server would otherwise hold the request, and whatever waits on it, open forever const DEFAULT_TIMEOUT = 60 * 1000; +// the inactivity timeout above is reset by every byte, so a server dripping one within each +// window could still hold the request, and the message being built from it, open for as +// long as it liked. The whole request, redirects included, has to be done within this +const DEFAULT_MAX_TIME = 2 * 60 * 1000; // the body is usually buffered in memory by the caller, so an unbounded download is refused const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; // Only genuine TLS settings are taken from options.tls. That object reaches us straight @@ -88,6 +92,12 @@ function nmfetch(url, options) { options.cookies = options.cookies || new Cookies(); options.redirects = options.redirects || 0; options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects; + if (!options.deadline) { + options.maxTime = typeof options.maxTime === 'number' && options.maxTime >= 0 ? options.maxTime : DEFAULT_MAX_TIME; + // the one deadline is shared with the requests the redirects lead to, every hop + // gets the time that is left rather than starting over + options.deadline = options.maxTime ? Date.now() + options.maxTime : 0; + } const fetchRes = options.fetchRes; const parsed = parseFetchUrl(url); if (!parsed) { @@ -233,12 +243,22 @@ function nmfetch(url, options) { }); return fetchRes; } + // the whole request has to be done by options.deadline, see maxTime. The timer is + // released on every way out of the request, so it never outlives it + let deadlineTimer; + const clearDeadline = () => { + if (deadlineTimer) { + clearTimeout(deadlineTimer); + deadlineTimer = undefined; + } + }; // reports the first failure of this request on fetchRes and releases the request const fail = (err, sourceUrl = url) => { if (finished) { return; } finished = true; + clearDeadline(); setFetchCode(err); err.sourceUrl = sourceUrl; fetchRes.emit('error', err); @@ -248,10 +268,16 @@ function nmfetch(url, options) { if (timeout) { req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); } + if (options.deadline) { + deadlineTimer = setTimeout(() => fail(new Error('Request exceeded the allowed ' + options.maxTime + ' ms')), Math.max(options.deadline - Date.now(), 1)); + // a pending deadline is no reason to keep the process alive + deadlineTimer.unref(); + } req.on('error', (err) => fail(err)); // a consumer that destroys the response stream before it ended does not want the rest of // the body, release the request now instead of when the timeout fires fetchRes.once('close', () => { + clearDeadline(); if (finished || fetchRes.readableEnded) { return; } @@ -320,6 +346,7 @@ function nmfetch(url, options) { // this request is done with, release it so its socket and timeout do not // outlive it and report a late error on the shared fetchRes finished = true; + clearDeadline(); res.resume(); req.abort(); return nmfetch(redirectUrl, options); @@ -330,6 +357,8 @@ function nmfetch(url, options) { return fail(new Error('Invalid status code ' + res.statusCode)); } res.on('error', (err) => fail(err)); + // the response is in, whatever is left of it is in memory and no longer on the network + res.once('end', () => clearDeadline()); const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES; const source = inflate || res; let received = 0; @@ -367,4 +396,5 @@ function nmfetch(url, options) { } nmfetch.Cookies = Cookies; nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; +nmfetch.DEFAULT_MAX_TIME = DEFAULT_MAX_TIME; export default nmfetch; diff --git a/node_modules/nodemailer/dist/esm/mailer/index.js b/node_modules/nodemailer/dist/esm/mailer/index.js index eebd3b9f..af3dfb11 100644 --- a/node_modules/nodemailer/dist/esm/mailer/index.js +++ b/node_modules/nodemailer/dist/esm/mailer/index.js @@ -360,9 +360,12 @@ class Mail extends EventEmitter { return callback(err); } let cidCounter = 0; + // the media type is a bounded token: a quote, whitespace, '>' or '<' ends the + // attribute value and with it the type, so one img tag never scans ahead to a + // ';' that belongs to a later tag, which was quadratic html = (html || '') .toString() - .replace(/(]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { + .replace(/(]{0,1024} src\s{0,20}=[\s"']{0,20})(data:([^;"'<>\s]{1,255});[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => { const cid = crypto.randomBytes(10).toString('hex') + '@localhost'; if (!mail.data.attachments) { mail.data.attachments = []; diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts b/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts index 8f2973fe..4eca09eb 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts @@ -23,7 +23,11 @@ export interface SendMailOptions extends MailComposerOptions { dsn?: SMTPEnvelopeDsn | undefined; /** SMTP transports: RFC 8689, send the REQUIRETLS parameter with MAIL FROM */ requireTLSExtensionEnabled?: boolean | undefined; - /** SMTP transports: per-message authentication settings, used instead of the transport level auth */ + /** + * SMTP transports: per-message authentication settings, used instead of the transport level auth. + * Only the credential fields are taken from it (type, user, pass, accessToken, refreshToken, expires), + * the token endpoint and client settings stay those of the transport + */ auth?: SMTPTransportAuthOptions | undefined; /** SES transport: extra SendEmailCommand parameters merged into the API call */ ses?: SESSendEmailRequest | undefined; diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.js b/node_modules/nodemailer/dist/esm/mailer/mail-message.js index 5d039886..835b8527 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.js @@ -145,7 +145,10 @@ export default class MailMessage { const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; for (const key of ['attachments', 'alternatives']) { if (Array.isArray(data[key])) { - values.push(...data[key]); + // one at a time, spreading a long enough list into the arguments throws + for (const entry of data[key]) { + values.push(entry); + } } } for (const value of values) { diff --git a/node_modules/nodemailer/dist/esm/mime-funcs/index.js b/node_modules/nodemailer/dist/esm/mime-funcs/index.js index 24e5fb0e..8d68686b 100644 --- a/node_modules/nodemailer/dist/esm/mime-funcs/index.js +++ b/node_modules/nodemailer/dist/esm/mime-funcs/index.js @@ -137,26 +137,38 @@ export function encodeWord(data, mimeWordEncoding, maxLength) { */ export function encodeWords(value, mimeWordEncoding, maxLength, encodeAll) { maxLength = maxLength || 0; - // find first word with a non-printable ascii or special symbol in it - const firstMatch = value.match(/(?:^|\s)([^\s]*["\u0080-\uFFFF])/); - if (!firstMatch) { + // find the first and the last character that needs encoding, a quote or anything outside + // printable ascii. A scan rather than a regex: matching the last such word with a + // backtracking pattern took quadratic time on a long word of non-ascii characters + let firstSpecial = -1; + let lastSpecial = -1; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 0x22 || code >= 0x80) { + if (firstSpecial < 0) { + firstSpecial = i; + } + lastSpecial = i; + } + } + if (firstSpecial < 0) { return value; } if (encodeAll) { // if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything return encodeWord(value, mimeWordEncoding, maxLength); } - // find the last word with a non-printable ascii in it - const lastMatch = value.match(/(["\u0080-\uFFFF][^\s]*)[^"\u0080-\uFFFF]*$/); - if (!lastMatch) { - // should not happen - return value; + // encode from the start of the word holding the first such character to the end of the + // word holding the last one, so the encoded words stay separated from the plain text by + // the whitespace that was already there + let startIndex = firstSpecial; + while (startIndex > 0 && !/\s/.test(value.charAt(startIndex - 1))) { + startIndex--; + } + let endIndex = lastSpecial + 1; + while (endIndex < value.length && !/\s/.test(value.charAt(endIndex))) { + endIndex++; } - const startIndex = firstMatch.index + - (firstMatch[0].match(/[^\s]/) || { - index: 0 - }).index; - const endIndex = lastMatch.index + (lastMatch[1] || '').length; return ((startIndex ? value.substr(0, startIndex) : '') + encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) + (endIndex < value.length ? value.substr(endIndex) : '')); diff --git a/node_modules/nodemailer/dist/esm/mime-node/index.js b/node_modules/nodemailer/dist/esm/mime-node/index.js index e473a7fd..a5e15d90 100644 --- a/node_modules/nodemailer/dist/esm/mime-node/index.js +++ b/node_modules/nodemailer/dist/esm/mime-node/index.js @@ -26,6 +26,35 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/; // 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"(),:;<>@[\\\]]+$/; +/** + * A domain literal that ends an address, see _normalizeAddress + */ +const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/; +/** + * Removes the whitespace inside every <...> of a References value. A "<" with no ">" after + * it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value + * from every such "<" + * + * @param value References header value + * @returns The value with the msg-ids closed up + */ +function stripWhitespaceInAngleBrackets(value) { + let result = ''; + let pos = 0; + for (;;) { + const open = value.indexOf('<', pos); + if (open < 0) { + break; + } + const close = value.indexOf('>', open + 1); + if (close < 0) { + break; + } + result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, ''); + pos = close + 1; + } + return result + value.slice(pos); +} // 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 @@ -33,6 +62,13 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; // 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]/; +// RFC 1035 bounds a label at 63 octets and a domain at 253. An A-label is never shorter than +// the code points it encodes, so anything past these bounds could not have become a valid +// domain whichever way it is encoded. The bundled codec is super-linear in the length of a +// label, a 60 KB label of distinct code points took seconds to encode, several times per +// address, so it is only handed what could be a domain +const MAX_DOMAIN_LENGTH = 253; +const MAX_LABEL_LENGTH = 63; // pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway const PIPELINE_NOOP = () => false; // The error a node streaming into an output that was destroyed stops with @@ -41,6 +77,27 @@ function abortedError() { err.code = errors.ESTREAM; return err; } +/** + * Checks a domain against the DNS length limits, counted in code points per label and in + * total. The label separators are the ones the bundled codec splits on. + * + * @param domain Domain to check + * @return true if a label or the domain as a whole is too long to be a valid domain + */ +function exceedsDomainLimits(domain) { + let total = 0; + let label = 0; + for (const char of domain) { + total++; + if (char === '.' || char === '。' || char === '.' || char === '。') { + label = 0; + } + else if (++label > MAX_LABEL_LENGTH) { + return true; + } + } + return total > MAX_DOMAIN_LENGTH; +} /** * 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. @@ -72,6 +129,10 @@ function normalizeDomain(domain, toUnicode) { return mapped; } } + if (exceedsDomainLimits(domain)) { + // too long to be a domain whichever way it is encoded, keep it as supplied + return domain; + } return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain); } /** @@ -576,6 +637,17 @@ class MimeNode { /[\u0080-\uFFFF]/.test(this.content)) { structured.params.charset = 'utf-8'; } + if (this.filename) { + // the name parameter is derived from the filename below, so a name that + // arrived inside the content type is dropped first (a plain parameter or + // rfc2231 continuation sections), otherwise the header carries two and a + // receiver that reads the first sees a name the filename does not match + for (const paramKey of Object.keys(structured.params)) { + if (/^name(\*|$)/i.test(paramKey)) { + delete structured.params[paramKey]; + } + } + } value = mimeFuncs.buildHeaderValue(structured); if (this.filename) { // add support for non-compliant clients like QQ webmail @@ -1185,12 +1257,12 @@ class MimeNode { _normalizeHeaderKey(key) { key = (key || '') .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() + // a field name is printable ascii without the colon, so a control char, DEL, + // whitespace or a colon can only be dropped, there is no quoting construct + // around a field name. A colon or a space left in place ends the name early on + // the wire: the key 'From: ceo@example.com (' with the value ')' was emitted as a + // second From header that neither setHeader() nor the Bcc filter could match + .replace(/[\s\x00-\x20\x7f:]/g, '') .toLowerCase() // use uppercase words, except MIME .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase()) @@ -1276,26 +1348,26 @@ class MimeNode { } return value; // space separated list of values enclosed in <> - case 'References': - value = [].concat - .apply([], [].concat(value || '').map(elm => { - elm = (elm || '') + case 'References': { + const ids = []; + for (const entry of [].concat(value || '')) { + const elm = (entry || '') .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+/); - })) - .map(elm => { - if (elm.charAt(0) !== '<') { - elm = '<' + elm; + for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) { + if (id.charAt(0) !== '<') { + id = '<' + id; + } + if (id.charAt(id.length - 1) !== '>') { + id = id + '>'; + } + ids.push(id); } - if (elm.charAt(elm.length - 1) !== '>') { - elm = elm + '>'; - } - return elm; - }); - return value.join(' ').trim(); + } + return ids.join(' ').trim(); + } case 'Date': if (Object.prototype.toString.call(value) === '[object Date]') { return value.toUTCString().replace(/GMT/, '+0000'); @@ -1374,7 +1446,10 @@ class MimeNode { // callers use an empty value to detect a missing address return address; } - const lastAt = address.lastIndexOf('@'); + // dtext includes '@', so a domain literal is split off at the '@' in front of it and + // not at the last one, which delivered 'a@[x@evil.com]' to evil.com + const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null; + const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@'); if (lastAt < 0) { // Bare username, there is no domain to split off return this._normalizeLocalPart(address); diff --git a/node_modules/nodemailer/dist/esm/package-info.d.ts b/node_modules/nodemailer/dist/esm/package-info.d.ts index 80159c23..b6096e51 100644 --- a/node_modules/nodemailer/dist/esm/package-info.d.ts +++ b/node_modules/nodemailer/dist/esm/package-info.d.ts @@ -1,3 +1,3 @@ export declare const name = "nodemailer"; -export declare const version = "10.1.1"; +export declare const version = "10.1.6"; export declare const homepage = "https://nodemailer.com/"; diff --git a/node_modules/nodemailer/dist/esm/package-info.js b/node_modules/nodemailer/dist/esm/package-info.js index e7d3bb05..4a89c7a0 100644 --- a/node_modules/nodemailer/dist/esm/package-info.js +++ b/node_modules/nodemailer/dist/esm/package-info.js @@ -1,4 +1,4 @@ // Generated by scripts/build.js from package.json. Do not edit by hand. export const name = 'nodemailer'; -export const version = '10.1.1'; +export const version = '10.1.6'; export const homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/esm/shared/url.js b/node_modules/nodemailer/dist/esm/shared/url.js index 8b555943..e225e771 100644 --- a/node_modules/nodemailer/dist/esm/shared/url.js +++ b/node_modules/nodemailer/dist/esm/shared/url.js @@ -19,10 +19,27 @@ import * as punycode from '../punycode/index.js'; // Matches a "scheme:" not followed by "//" (and with something after it), used // to re-insert the authority separator the legacy parser did not require. const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/; -// Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it -// looks at the input. Stripped up front so that the slash-less form is recognized in a -// value read from a file with a trailing newline as well. -const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g; +/** + * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips + * before it looks at the input. Stripped up front so that the slash-less form is recognized + * in a value read from a file with a trailing newline as well. An index scan, the regex + * /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every + * position in it + * + * @param input URL string + * @returns The string without the surrounding controls and spaces + */ +function stripSurroundingWhitespace(input) { + let start = 0; + let end = input.length; + while (start < end && input.charCodeAt(start) <= 0x20) { + start++; + } + while (end > start && input.charCodeAt(end - 1) <= 0x20) { + end--; + } + return input.slice(start, end); +} // Leading characters legacy url.parse() skips before it reads the scheme const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/; // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..." @@ -117,7 +134,7 @@ function normalizeHostname(raw, href) { return mapped; } export const parse = (input, parseQueryString) => { - input = (input || '').replace(SURROUNDING_WHITESPACE, ''); + input = stripSurroundingWhitespace(input || ''); // Legacy url.parse() parses a "user:pass@host:port" authority that follows // the scheme even without the "//" separator, for schemes outside its // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.js b/node_modules/nodemailer/dist/esm/smtp-connection/index.js index 5c98c6ef..bf372a4c 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.js @@ -76,6 +76,21 @@ function isPartialResponse(str) { function isPartialLine(line) { return /^\d+-/.test(line); } +/** + * The log form of a command a custom authentication handler sends. Such a command carries + * the credentials in whatever shape its mechanism uses, so only an AUTH verb with its + * mechanism name is kept, the way the built-in mechanisms hide their own secrets. + * + * @param cmd Command the handler sends + * @return What the transaction log gets instead + */ +function maskAuthCommand(cmd) { + const match = (cmd || '').toString().match(/^AUTH\s+[^\s]+/i); + if (!match) { + return '/* secret */'; + } + return match[0].length === cmd.length ? cmd : match[0] + ' /* secret */'; +} /** * Generates a SMTP connection object * @@ -563,7 +578,7 @@ class SMTPConnection extends EventEmitter { } done(null, data); }); - setImmediate(() => this._sendCommand(cmd)); + setImmediate(() => this._sendCommand(cmd, maskAuthCommand(cmd))); return promise; }; const handlerResponse = handler({ diff --git a/node_modules/nodemailer/dist/esm/smtp-transport/index.js b/node_modules/nodemailer/dist/esm/smtp-transport/index.js index 521280cc..5ba51d41 100644 --- a/node_modules/nodemailer/dist/esm/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-transport/index.js @@ -5,6 +5,13 @@ import * as shared from '../shared/index.js'; import XOAuth2 from '../xoauth2/index.js'; import * as errors from '../errors.js'; import * as packageData from '../package-info.js'; +// The keys a message's `auth` may set on top of the transport's auth settings: the ones that +// say who logs in and with what. Everything else in the auth block (accessUrl, clientId, +// clientSecret, customHeaders, customParams, tls, privateKey, serviceClient and the rest) +// names the endpoint the credentials go to or is a secret of the application itself, and +// a message that could set `auth.accessUrl` and force a refresh would have the transport's +// client secret and refresh token posted to a URL of its choosing +const MESSAGE_AUTH_KEYS = ['type', 'user', 'pass', 'accessToken', 'refreshToken', 'expires']; /** * Creates a SMTP transport object for Nodemailer * @@ -64,7 +71,11 @@ class SMTPTransport extends EventEmitter { } return this.auth; } - const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}, typeof authOpts === 'object' ? authOpts : {}); + const authData = Object.assign({}, this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {}); + if (typeof authOpts === 'object') { + // see MESSAGE_AUTH_KEYS + shared.copyOwnKeys(authData, authOpts, key => !MESSAGE_AUTH_KEYS.includes(key)); + } if (Object.keys(authData).length === 0) { return false; } diff --git a/node_modules/nodemailer/dist/esm/well-known/services.js b/node_modules/nodemailer/dist/esm/well-known/services.js index 938e73d1..be1cc3fe 100644 --- a/node_modules/nodemailer/dist/esm/well-known/services.js +++ b/node_modules/nodemailer/dist/esm/well-known/services.js @@ -360,6 +360,13 @@ export const services = { "port": 587, "secure": false }, + "Oveyon": { + "description": "Oveyon", + "host": "smtp.oveyon.com", + "port": 587, + "secure": false, + "requireTLS": true + }, "Postmark": { "description": "Postmark", "aliases": [ diff --git a/node_modules/nodemailer/dist/esm/xoauth2/index.js b/node_modules/nodemailer/dist/esm/xoauth2/index.js index 4094aa8d..0c00ae29 100644 --- a/node_modules/nodemailer/dist/esm/xoauth2/index.js +++ b/node_modules/nodemailer/dist/esm/xoauth2/index.js @@ -226,8 +226,14 @@ class XOAuth2 extends Stream { grant_type: 'refresh_token' }; } - Object.assign(urlOptions, this.options.customParams); - Object.assign(loggedUrlOptions, this.options.customParams); + const customParams = this.options.customParams || {}; + Object.assign(urlOptions, customParams); + // a custom param is as likely to be a client assertion or an API key as a tenant + // name, so the log gets the same glimpse of it that it gets of the client secret + for (const key of Object.keys(customParams)) { + const param = customParams[key]; + loggedUrlOptions[key] = (param === undefined || param === null ? '' : String(param)).substr(0, 6) + '...'; + } this.logger.debug({ tnx: 'OAUTH2', user: this.options.user, diff --git a/node_modules/nodemailer/dist/well-known/services.json b/node_modules/nodemailer/dist/well-known/services.json index 2be39d3b..62108193 100644 --- a/node_modules/nodemailer/dist/well-known/services.json +++ b/node_modules/nodemailer/dist/well-known/services.json @@ -333,6 +333,14 @@ "secure": false }, + "Oveyon": { + "description": "Oveyon", + "host": "smtp.oveyon.com", + "port": 587, + "secure": false, + "requireTLS": true + }, + "Postmark": { "description": "Postmark", "aliases": ["PostmarkApp"], diff --git a/node_modules/nodemailer/package.json b/node_modules/nodemailer/package.json index 6affd1ab..ab7ed6b5 100644 --- a/node_modules/nodemailer/package.json +++ b/node_modules/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "nodemailer", - "version": "10.1.1", + "version": "10.1.6", "description": "Easy as cake e-mail sending from your Node.js applications", "type": "module", "main": "./dist/cjs/nodemailer.js",