From 6d91308e462ed0fb1f5139e50ee4fe0e1ad5bb48 Mon Sep 17 00:00:00 2001 From: Dawid Dziurla Date: Fri, 25 Sep 2026 18:42:41 +0200 Subject: [PATCH] node_modules: update (#325) Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com> --- node_modules/.package-lock.json | 6 +- node_modules/nodemailer/CHANGELOG.md | 17 ++++ .../dist/cjs/addressparser/index.js | 61 ++++++++++++-- .../dist/cjs/dkim/message-parser.js | 17 ++-- .../dist/cjs/mail-composer/index.js | 4 +- .../dist/cjs/mailer/mail-message.js | 3 +- .../nodemailer/dist/cjs/package-info.d.ts | 2 +- .../nodemailer/dist/cjs/package-info.js | 2 +- .../dist/cjs/smtp-connection/index.d.ts | 3 + .../dist/cjs/smtp-connection/index.js | 80 ++++++++++++++----- .../dist/esm/addressparser/index.js | 61 ++++++++++++-- .../dist/esm/dkim/message-parser.js | 17 ++-- .../dist/esm/mail-composer/index.js | 4 +- .../dist/esm/mailer/mail-message.js | 3 +- .../nodemailer/dist/esm/package-info.d.ts | 2 +- .../nodemailer/dist/esm/package-info.js | 2 +- .../dist/esm/smtp-connection/index.d.ts | 3 + .../dist/esm/smtp-connection/index.js | 80 ++++++++++++++----- node_modules/nodemailer/package.json | 12 +-- 19 files changed, 300 insertions(+), 79 deletions(-) diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 0076de30..49b24142 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -94,9 +94,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.8.tgz", - "integrity": "sha512-uCZ0AtFDI46sHwJ6mhwNuIoA/KiTC0ZCzX7g01941+H9VMIXEiZjtgjQkzoBRTeQba0cIrKfwk31q99PfcIMRw==", + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz", + "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/node_modules/nodemailer/CHANGELOG.md b/node_modules/nodemailer/CHANGELOG.md index 4fba4c95..b1d1c9cb 100644 --- a/node_modules/nodemailer/CHANGELOG.md +++ b/node_modules/nodemailer/CHANGELOG.md @@ -1,5 +1,22 @@ # CHANGELOG +## [10.0.10](https://github.com/nodemailer/nodemailer/compare/v10.0.9...v10.0.10) (2026-09-14) + + +### Bug Fixes + +* derive the attachment filename from the basename of a Windows path ([c7cc7ce](https://github.com/nodemailer/nodemailer/commit/c7cc7ce41a3602441747476a2a2c4a8ff466a83e)) +* **dkim:** unfold folded header lines in linear time ([28a5909](https://github.com/nodemailer/nodemailer/commit/28a5909cec27646cb001dc7e97a8f5d26c973078)) +* **smtp-connection:** reassemble multiline replies in linear time ([f2d82fa](https://github.com/nodemailer/nodemailer/commit/f2d82fa84d47015a7bbb4253da61eeb778c658b1)) + +## [10.0.9](https://github.com/nodemailer/nodemailer/compare/v10.0.8...v10.0.9) (2026-09-12) + + +### Bug Fixes + +* **addressparser:** bound the '@' probe to the run being scanned ([1465c3f](https://github.com/nodemailer/nodemailer/commit/1465c3f5ff74a7c4fbbe9853bd01448bb92bf5b7)) +* **addressparser:** keep the text after a comment out of a quoted local part address ([2f36eb1](https://github.com/nodemailer/nodemailer/commit/2f36eb1aa1dd33e312411dc9b888548e14db54ee)) + ## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11) diff --git a/node_modules/nodemailer/dist/cjs/addressparser/index.js b/node_modules/nodemailer/dist/cjs/addressparser/index.js index 935edac0..ae807012 100644 --- a/node_modules/nodemailer/dist/cjs/addressparser/index.js +++ b/node_modules/nodemailer/dist/cjs/addressparser/index.js @@ -88,6 +88,29 @@ function _isWordCode(code) { function _isBoundary(text, at) { return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at)); } +/** + * Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none. + * + * indexOf would scan on to the end of the value, and the value here is a whole header. The + * walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits + * inside the run it is on, so an unbounded probe rescans everything behind that run once per + * run and grows with the square of the header: 400KB of free text carrying no '@' took a + * quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left + * the probe unbounded behind it. + * + * @param text Text to look in + * @param from Offset to start at + * @param to Offset to stop before + * @return Offset of the '@', or -1 + */ +function _indexOfAt(text, from, to) { + for (let i = from; i < to; i++) { + if (text.charCodeAt(i) === 0x40) { + return i; + } + } + return -1; +} /** * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * @@ -118,8 +141,8 @@ function _looseAddressStart(text) { while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) { runEnd++; } - let at = text.indexOf('@', runStart); - if (at >= 0 && at < runEnd) { + let at = _indexOfAt(text, runStart, runEnd); + if (at >= 0) { let lastBoundary = -1; for (let k = runEnd; k > runStart; k--) { if (_isBoundary(text, k)) { @@ -128,7 +151,7 @@ function _looseAddressStart(text) { } } let atomStart = runStart; - while (lastBoundary >= 0 && at >= 0 && at < runEnd) { + while (lastBoundary >= 0 && at >= 0) { // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it, // and the boundary that ends the match has to sit past both if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) { @@ -148,7 +171,7 @@ function _looseAddressStart(text) { } } atomStart = at + 1; - at = text.indexOf('@', atomStart); + at = _indexOfAt(text, atomStart, runEnd); } } pos = runEnd; @@ -275,6 +298,19 @@ function _handleAddress(tokens, depth) { } } else if (token.value) { + // 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 + // quoting instead. Without this '""@example.com' reads as the bare '@example.com', + // 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. @@ -302,7 +338,7 @@ function _handleAddress(tokens, depth) { data[state].push(token.value); lastChars[state] = token.value.charAt(token.value.length - 1); if (state === 'text') { - data.textWasQuoted.push(insideQuotes); + data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString); } } } @@ -387,6 +423,18 @@ function _handleAddress(tokens, depth) { // Join values with spaces data.text = data.text.join(' '); data.address = data.address.join(' '); + if (addressFromQuotedText && data.text) { + // 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); const address = { address: data.address || data.text || '', @@ -400,9 +448,6 @@ function _handleAddress(tokens, depth) { address.address = ''; } } - if (addressFromQuotedText && address.address) { - address.address = _quoteLocalPart(address.address); - } addresses.push(address); } return addresses; diff --git a/node_modules/nodemailer/dist/cjs/dkim/message-parser.js b/node_modules/nodemailer/dist/cjs/dkim/message-parser.js index 8004d0ba..603d1299 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/message-parser.js +++ b/node_modules/nodemailer/dist/cjs/dkim/message-parser.js @@ -126,11 +126,18 @@ class MessageParser extends node_stream_1.Transform { // signature covers exactly the bytes the receiving side canonicalizes // Only SP and HTAB fold a line, and only they are trimmed from the field name, the // same whitespace the relaxed canonicalization in sign.ts works with - const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); - for (let i = lines.length - 1; i > 0; i--) { - if (/^[ \t]/.test(lines[i])) { - lines[i - 1] += '\n' + lines[i]; - lines.splice(i, 1); + const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); + // Unfold in a single forward pass and only ever test a freshly split line for the + // continuation prefix. Testing an already merged line instead would rescan a string + // that grows with every continuation line, so a header folded into many continuation + // lines (a large recipient list, for example) would take quadratic time to unfold + const lines = []; + for (const rawLine of rawLines) { + if (lines.length && /^[ \t]/.test(rawLine)) { + lines[lines.length - 1] += '\n' + rawLine; + } + else { + lines.push(rawLine); } } return lines diff --git a/node_modules/nodemailer/dist/cjs/mail-composer/index.js b/node_modules/nodemailer/dist/cjs/mail-composer/index.js index 19f7a3dd..437c8ebf 100644 --- a/node_modules/nodemailer/dist/cjs/mail-composer/index.js +++ b/node_modules/nodemailer/dist/cjs/mail-composer/index.js @@ -151,8 +151,10 @@ class MailComposer { data.filename = attachment.filename; } else if (!isMessageNode && attachment.filename !== false) { + // a backslash separates as well, so a Windows path does not put the sender's directories in the headers data.filename = - (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1); + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || + 'attachment-' + (i + 1); if (data.filename.indexOf('.') < 0) { data.filename += '.' + mimeFuncs.detectExtension(data.contentType); } diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js index 2de1be89..2f17516c 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js @@ -107,8 +107,9 @@ class MailMessage { if (this.data.attachments && this.data.attachments.length) { this.data.attachments.forEach((attachment, i) => { if (!attachment.filename) { + // a backslash separates as well, so a Windows path does not put the sender's directories in the headers attachment.filename = - (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (attachment.filename.indexOf('.') < 0) { attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); diff --git a/node_modules/nodemailer/dist/cjs/package-info.d.ts b/node_modules/nodemailer/dist/cjs/package-info.d.ts index 97c2887f..4e7cec12 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.0.8"; +export declare const version = "10.0.10"; 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 f87ff0ee..018f7634 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.0.8'; +exports.version = '10.0.10'; exports.homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts index 4eac3f3a..d8f1d34d 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts @@ -41,6 +41,8 @@ export interface SMTPConnectionOptions { greetingTimeout?: number | undefined; /** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */ socketTimeout?: number | undefined; + /** Largest single server response to accept in bytes, defaults to 1 MB */ + maxResponseSize?: number | undefined; /** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */ dnsTimeout?: number | undefined; /** Use LMTP instead of SMTP */ @@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void; * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) + * * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **logger** - bunyan compatible logger interface diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js index 467c8cbe..ed494e41 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js @@ -51,6 +51,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown +// how many bytes a single server response may occupy while it is still being received. +// Generous compared to any real reply, it only stops a peer that never completes one +const MAX_RESPONSE_SIZE = 1024 * 1024; /** * Re-interpret a server response stored in fake 8-bit byte-container form * (the result of chunk.toString('binary') in _onData) as UTF-8. @@ -79,11 +82,19 @@ function decodeServerResponse(str) { * Called with the byte-container form the queue holds (see _onData): the check only looks * at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never * 0x0A, so line boundaries and the tested prefix are the same before and after decoding. - * The last line is read with lastIndexOf rather than split() because a queue entry grows - * with every chunk appended to it and only its final line matters. + * The last line is read with lastIndexOf rather than split() because a queue entry may hold + * a whole multiline reply and only its final line matters. */ function isPartialResponse(str) { - return /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)); + return isPartialLine(str.slice(str.lastIndexOf('\n') + 1)); +} +/** + * True when a single reply line is a continuation ("250-..."). Used where the line is + * already known to be one line, which skips the scan isPartialResponse needs to find the + * last line of a whole reply. + */ +function isPartialLine(line) { + return /^\d+-/.test(line); } /** * Generates a SMTP connection object @@ -100,6 +111,7 @@ function isPartialResponse(str) { * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) + * * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **logger** - bunyan compatible logger interface @@ -146,6 +158,7 @@ class SMTPConnection extends node_events_1.EventEmitter { this.secure = !!this.secureConnection; this._remainder = ''; this._responseQueue = []; + this._responsePartial = false; this.lastServerResponse = false; this._socket = false; this._supportedAuth = []; @@ -714,28 +727,58 @@ class SMTPConnection extends node_events_1.EventEmitter { if (this._destroyed || !chunk || !chunk.length) { return; } - let data = chunk.toString('binary'); - let lines = (this._remainder + data).split(/\r?\n/); - let lastline; + const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE; + const data = chunk.toString('binary'); + // A chunk without a line break only extends the line currently being received, so + // keep it in the remainder and leave that string unflattened. Splitting the whole + // remainder again on every chunk would rescan everything buffered for that line so + // far, which is quadratic in the length of a line the peer never terminates + if (!data.includes('\n')) { + this._remainder += data; + if (this._remainder.length > maxResponseSize) { + return this._onResponseTooLarge(); + } + return; + } + const lines = (this._remainder + data).split(/\r?\n/); this._remainder = lines.pop(); for (let i = 0, len = lines.length; i < len; i++) { - if (this._responseQueue.length) { - lastline = this._responseQueue[this._responseQueue.length - 1]; - if (isPartialResponse(lastline)) { - this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; - continue; - } + if (this._responsePartial) { + this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; + } + else { + this._responseQueue.push(lines[i]); + } + // The line just added is the last line of that queue entry, so it alone decides + // whether the reply is still partial. Looking for the last line of the accumulated + // entry instead would rescan a string that grows with every continuation line, + // which is quadratic in the size of the reply + this._responsePartial = isPartialLine(lines[i]); + // Checked as each line lands, so a peer that never completes a reply cannot keep + // buffering, and the limit does not depend on how it split its bytes into chunks + if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) { + return this._onResponseTooLarge(); } - this._responseQueue.push(lines[i]); } - if (this._responseQueue.length) { - lastline = this._responseQueue[this._responseQueue.length - 1]; - if (isPartialResponse(lastline)) { - return; - } + if (this._remainder.length > maxResponseSize) { + return this._onResponseTooLarge(); + } + if (this._responsePartial) { + return; } this._processResponse(); } + /** + * Drops a connection whose peer keeps extending a reply it never completes, releasing + * whatever was buffered for that reply + * @internal + */ + _onResponseTooLarge() { + this._remainder = ''; + this._responseQueue = []; + this._responsePartial = false; + this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN'); + } /** * 'error' listener for the socket * @@ -876,6 +919,7 @@ class SMTPConnection extends node_events_1.EventEmitter { // part of the secured EHLO capabilities). STARTTLS response injection. this._remainder = ''; this._responseQueue = []; + this._responsePartial = false; // do not remove all listeners or it breaks node v0.10 as there's // apparently a 'finish' event set that would be cleared as well // we can safely keep 'error', 'end', 'close' etc. events diff --git a/node_modules/nodemailer/dist/esm/addressparser/index.js b/node_modules/nodemailer/dist/esm/addressparser/index.js index b661cf0a..9babfc61 100644 --- a/node_modules/nodemailer/dist/esm/addressparser/index.js +++ b/node_modules/nodemailer/dist/esm/addressparser/index.js @@ -85,6 +85,29 @@ function _isWordCode(code) { function _isBoundary(text, at) { return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at)); } +/** + * Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none. + * + * indexOf would scan on to the end of the value, and the value here is a whole header. The + * walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits + * inside the run it is on, so an unbounded probe rescans everything behind that run once per + * run and grows with the square of the header: 400KB of free text carrying no '@' took a + * quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left + * the probe unbounded behind it. + * + * @param text Text to look in + * @param from Offset to start at + * @param to Offset to stop before + * @return Offset of the '@', or -1 + */ +function _indexOfAt(text, from, to) { + for (let i = from; i < to; i++) { + if (text.charCodeAt(i) === 0x40) { + return i; + } + } + return -1; +} /** * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * @@ -115,8 +138,8 @@ function _looseAddressStart(text) { while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) { runEnd++; } - let at = text.indexOf('@', runStart); - if (at >= 0 && at < runEnd) { + let at = _indexOfAt(text, runStart, runEnd); + if (at >= 0) { let lastBoundary = -1; for (let k = runEnd; k > runStart; k--) { if (_isBoundary(text, k)) { @@ -125,7 +148,7 @@ function _looseAddressStart(text) { } } let atomStart = runStart; - while (lastBoundary >= 0 && at >= 0 && at < runEnd) { + while (lastBoundary >= 0 && at >= 0) { // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it, // and the boundary that ends the match has to sit past both if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) { @@ -145,7 +168,7 @@ function _looseAddressStart(text) { } } atomStart = at + 1; - at = text.indexOf('@', atomStart); + at = _indexOfAt(text, atomStart, runEnd); } } pos = runEnd; @@ -272,6 +295,19 @@ function _handleAddress(tokens, depth) { } } else if (token.value) { + // 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 + // quoting instead. Without this '""@example.com' reads as the bare '@example.com', + // 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. @@ -299,7 +335,7 @@ function _handleAddress(tokens, depth) { data[state].push(token.value); lastChars[state] = token.value.charAt(token.value.length - 1); if (state === 'text') { - data.textWasQuoted.push(insideQuotes); + data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString); } } } @@ -384,6 +420,18 @@ function _handleAddress(tokens, depth) { // Join values with spaces data.text = data.text.join(' '); data.address = data.address.join(' '); + if (addressFromQuotedText && data.text) { + // 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); const address = { address: data.address || data.text || '', @@ -397,9 +445,6 @@ function _handleAddress(tokens, depth) { address.address = ''; } } - if (addressFromQuotedText && address.address) { - address.address = _quoteLocalPart(address.address); - } addresses.push(address); } return addresses; diff --git a/node_modules/nodemailer/dist/esm/dkim/message-parser.js b/node_modules/nodemailer/dist/esm/dkim/message-parser.js index 39e64673..53316417 100644 --- a/node_modules/nodemailer/dist/esm/dkim/message-parser.js +++ b/node_modules/nodemailer/dist/esm/dkim/message-parser.js @@ -124,11 +124,18 @@ export default class MessageParser extends Transform { // signature covers exactly the bytes the receiving side canonicalizes // Only SP and HTAB fold a line, and only they are trimmed from the field name, the // same whitespace the relaxed canonicalization in sign.ts works with - const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); - for (let i = lines.length - 1; i > 0; i--) { - if (/^[ \t]/.test(lines[i])) { - lines[i - 1] += '\n' + lines[i]; - lines.splice(i, 1); + const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); + // Unfold in a single forward pass and only ever test a freshly split line for the + // continuation prefix. Testing an already merged line instead would rescan a string + // that grows with every continuation line, so a header folded into many continuation + // lines (a large recipient list, for example) would take quadratic time to unfold + const lines = []; + for (const rawLine of rawLines) { + if (lines.length && /^[ \t]/.test(rawLine)) { + lines[lines.length - 1] += '\n' + rawLine; + } + else { + lines.push(rawLine); } } return lines diff --git a/node_modules/nodemailer/dist/esm/mail-composer/index.js b/node_modules/nodemailer/dist/esm/mail-composer/index.js index b6e6fb39..bc85f063 100644 --- a/node_modules/nodemailer/dist/esm/mail-composer/index.js +++ b/node_modules/nodemailer/dist/esm/mail-composer/index.js @@ -113,8 +113,10 @@ class MailComposer { data.filename = attachment.filename; } else if (!isMessageNode && attachment.filename !== false) { + // a backslash separates as well, so a Windows path does not put the sender's directories in the headers data.filename = - (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1); + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || + 'attachment-' + (i + 1); if (data.filename.indexOf('.') < 0) { data.filename += '.' + mimeFuncs.detectExtension(data.contentType); } diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.js b/node_modules/nodemailer/dist/esm/mailer/mail-message.js index c643506a..9df7734c 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.js @@ -69,8 +69,9 @@ export default class MailMessage { if (this.data.attachments && this.data.attachments.length) { this.data.attachments.forEach((attachment, i) => { if (!attachment.filename) { + // a backslash separates as well, so a Windows path does not put the sender's directories in the headers attachment.filename = - (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || + (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() || 'attachment-' + (i + 1); if (attachment.filename.indexOf('.') < 0) { attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); diff --git a/node_modules/nodemailer/dist/esm/package-info.d.ts b/node_modules/nodemailer/dist/esm/package-info.d.ts index 97c2887f..4e7cec12 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.0.8"; +export declare const version = "10.0.10"; 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 4eccd605..bdb2ae43 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.0.8'; +export const version = '10.0.10'; export const homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts index 4eac3f3a..d8f1d34d 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts @@ -41,6 +41,8 @@ export interface SMTPConnectionOptions { greetingTimeout?: number | undefined; /** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */ socketTimeout?: number | undefined; + /** Largest single server response to accept in bytes, defaults to 1 MB */ + maxResponseSize?: number | undefined; /** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */ dnsTimeout?: number | undefined; /** Use LMTP instead of SMTP */ @@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void; * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) + * * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **logger** - bunyan compatible logger interface diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.js b/node_modules/nodemailer/dist/esm/smtp-connection/index.js index 1f73cd54..b7e00cb4 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.js @@ -13,6 +13,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown +// how many bytes a single server response may occupy while it is still being received. +// Generous compared to any real reply, it only stops a peer that never completes one +const MAX_RESPONSE_SIZE = 1024 * 1024; /** * Re-interpret a server response stored in fake 8-bit byte-container form * (the result of chunk.toString('binary') in _onData) as UTF-8. @@ -41,11 +44,19 @@ function decodeServerResponse(str) { * Called with the byte-container form the queue holds (see _onData): the check only looks * at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never * 0x0A, so line boundaries and the tested prefix are the same before and after decoding. - * The last line is read with lastIndexOf rather than split() because a queue entry grows - * with every chunk appended to it and only its final line matters. + * The last line is read with lastIndexOf rather than split() because a queue entry may hold + * a whole multiline reply and only its final line matters. */ function isPartialResponse(str) { - return /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)); + return isPartialLine(str.slice(str.lastIndexOf('\n') + 1)); +} +/** + * True when a single reply line is a continuation ("250-..."). Used where the line is + * already known to be one line, which skips the scan isPartialResponse needs to find the + * last line of a whole reply. + */ +function isPartialLine(line) { + return /^\d+-/.test(line); } /** * Generates a SMTP connection object @@ -62,6 +73,7 @@ function isPartialResponse(str) { * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) + * * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **logger** - bunyan compatible logger interface @@ -108,6 +120,7 @@ class SMTPConnection extends EventEmitter { this.secure = !!this.secureConnection; this._remainder = ''; this._responseQueue = []; + this._responsePartial = false; this.lastServerResponse = false; this._socket = false; this._supportedAuth = []; @@ -676,28 +689,58 @@ class SMTPConnection extends EventEmitter { if (this._destroyed || !chunk || !chunk.length) { return; } - let data = chunk.toString('binary'); - let lines = (this._remainder + data).split(/\r?\n/); - let lastline; + const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE; + const data = chunk.toString('binary'); + // A chunk without a line break only extends the line currently being received, so + // keep it in the remainder and leave that string unflattened. Splitting the whole + // remainder again on every chunk would rescan everything buffered for that line so + // far, which is quadratic in the length of a line the peer never terminates + if (!data.includes('\n')) { + this._remainder += data; + if (this._remainder.length > maxResponseSize) { + return this._onResponseTooLarge(); + } + return; + } + const lines = (this._remainder + data).split(/\r?\n/); this._remainder = lines.pop(); for (let i = 0, len = lines.length; i < len; i++) { - if (this._responseQueue.length) { - lastline = this._responseQueue[this._responseQueue.length - 1]; - if (isPartialResponse(lastline)) { - this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; - continue; - } + if (this._responsePartial) { + this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; + } + else { + this._responseQueue.push(lines[i]); + } + // The line just added is the last line of that queue entry, so it alone decides + // whether the reply is still partial. Looking for the last line of the accumulated + // entry instead would rescan a string that grows with every continuation line, + // which is quadratic in the size of the reply + this._responsePartial = isPartialLine(lines[i]); + // Checked as each line lands, so a peer that never completes a reply cannot keep + // buffering, and the limit does not depend on how it split its bytes into chunks + if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) { + return this._onResponseTooLarge(); } - this._responseQueue.push(lines[i]); } - if (this._responseQueue.length) { - lastline = this._responseQueue[this._responseQueue.length - 1]; - if (isPartialResponse(lastline)) { - return; - } + if (this._remainder.length > maxResponseSize) { + return this._onResponseTooLarge(); + } + if (this._responsePartial) { + return; } this._processResponse(); } + /** + * Drops a connection whose peer keeps extending a reply it never completes, releasing + * whatever was buffered for that reply + * @internal + */ + _onResponseTooLarge() { + this._remainder = ''; + this._responseQueue = []; + this._responsePartial = false; + this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN'); + } /** * 'error' listener for the socket * @@ -838,6 +881,7 @@ class SMTPConnection extends EventEmitter { // part of the secured EHLO capabilities). STARTTLS response injection. this._remainder = ''; this._responseQueue = []; + this._responsePartial = false; // do not remove all listeners or it breaks node v0.10 as there's // apparently a 'finish' event set that would be cleared as well // we can safely keep 'error', 'end', 'close' etc. events diff --git a/node_modules/nodemailer/package.json b/node_modules/nodemailer/package.json index ea6d4b73..3524e2c9 100644 --- a/node_modules/nodemailer/package.json +++ b/node_modules/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "nodemailer", - "version": "10.0.8", + "version": "10.0.10", "description": "Easy as cake e-mail sending from your Node.js applications", "type": "module", "main": "./dist/cjs/nodemailer.js", @@ -147,24 +147,24 @@ }, "homepage": "https://nodemailer.com/", "devDependencies": { - "@aws-sdk/client-sesv2": "3.1124.0", + "@aws-sdk/client-sesv2": "3.1131.0", "@types/node": "20.19.43", "bunyan": "1.8.15", "c8": "12.0.0", - "eslint": "10.9.1", + "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", "globals": "17.12.0", "libbase64": "1.3.0", "libmime": "5.4.3", "libqp": "2.1.1", - "mailauth": "5.0.2", + "mailauth": "5.0.3", "prettier": "3.9.6", "proxy": "1.0.2", "proxy-test-server": "1.0.0", - "smtp-server": "3.19.7", + "smtp-server": "3.19.11", "tsx": "4.23.13", "typescript": "6.0.3", - "typescript-eslint": "8.69.0" + "typescript-eslint": "8.70.0" }, "engines": { "node": ">=20.0.0"