+7
-0
| # Changelog | ||
| ## [5.4.2](https://github.com/nodemailer/libmime/compare/v5.4.1...v5.4.2) (2026-08-07) | ||
| ### Bug Fixes | ||
| * harden header parsing against prototype pollution and parser differentials ([c470201](https://github.com/nodemailer/libmime/commit/c47020107045864c14d0c526acff1c9d2e12ed88)) | ||
| ## [5.4.1](https://github.com/nodemailer/libmime/compare/v5.4.0...v5.4.1) (2026-07-05) | ||
@@ -4,0 +11,0 @@ |
+156
-98
@@ -13,2 +13,27 @@ /* eslint no-control-regex: 0, no-div-regex: 0, quotes: 0 */ | ||
| // Sets an own property on a plain object. A regular assignment with a | ||
| // '__proto__' key would silently modify the object's prototype instead of | ||
| // creating an own property, so it needs special handling. | ||
| const setOwnProperty = (obj, key, value) => { | ||
| if (key === '__proto__') { | ||
| Object.defineProperty(obj, key, { | ||
| value, | ||
| writable: true, | ||
| enumerable: true, | ||
| configurable: true | ||
| }); | ||
| } else { | ||
| obj[key] = value; | ||
| } | ||
| }; | ||
| // Checks own properties only, as attacker controlled keys like '__proto__' | ||
| // or 'toString' would otherwise resolve to inherited Object.prototype members. | ||
| // NB! Not named hasOwnProperty, that would shadow the global builtin | ||
| const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); | ||
| // Whitespace that separates the tokens of a header value. Kept as a char comparison, | ||
| // a regex test would be run for every single char of every parsed header | ||
| const isWSP = chr => chr === ' ' || chr === '\t' || chr === '\r' || chr === '\n' || chr === '\f' || chr === '\v'; | ||
| class Libmime { | ||
@@ -390,3 +415,3 @@ constructor(config) { | ||
| * @param {String} headers Headers string | ||
| * @return {Object} An object of headers, where header keys are object keys. NB! Several values with the same key make up an Array | ||
| * @return {Object} An object of headers, where header keys are object keys and every value is an Array of the values for that key | ||
| */ | ||
@@ -400,4 +425,17 @@ decodeHeaders(headers) { | ||
| for (i = lines.length - 1; i >= 0; i--) { | ||
| if (i && lines[i].match(/^\s/)) { | ||
| // Empty lines in front of the block are not part of any header, a caller might | ||
| // include the line break that separates a mime part boundary from its headers | ||
| let headersPos = 0; | ||
| while (headersPos < lines.length && lines[headersPos] === '') { | ||
| headersPos++; | ||
| } | ||
| // An empty line terminates the header block, anything after it is the message body. | ||
| // Without this a body line that starts with whitespace would be folded into the last header | ||
| let bodyPos = lines.indexOf('', headersPos); | ||
| lines = lines.slice(headersPos, bodyPos >= 0 ? bodyPos : lines.length); | ||
| // unfold folded lines, a continuation line always starts with a space or a tab | ||
| for (i = lines.length - 1; i > 0; i--) { | ||
| if (/^[ \t]/.test(lines[i])) { | ||
| lines[i - 1] += '\r\n' + lines[i]; | ||
@@ -410,4 +448,4 @@ lines.splice(i, 1); | ||
| header = this.decodeHeader(lines[i]); | ||
| if (!headersObj[header.key]) { | ||
| headersObj[header.key] = [header.value]; | ||
| if (!hasOwn(headersObj, header.key)) { | ||
| setOwnProperty(headersObj, header.key, [header.value]); | ||
| } else { | ||
@@ -456,3 +494,3 @@ headersObj[header.key].push(header.value); | ||
| * | ||
| * parseHeaderValue('content-type: text/plain; CHARSET='UTF-8'') -> | ||
| * parseHeaderValue('text/plain; CHARSET=UTF-8') -> | ||
| * { | ||
@@ -475,2 +513,5 @@ * 'value': 'text/plain', | ||
| let value = ''; | ||
| // Length of the significant part of `value`. Whitespace outside of quotes is | ||
| // not part of the value, while whitespace inside quotes is (rfc2045 section 5.1) | ||
| let valueEnd = 0; | ||
| let stage = STAGE_VALUE; | ||
@@ -482,2 +523,24 @@ | ||
| // Stores the collected value, either as the default value, as the value for the | ||
| // current key, or as a key that has no value at all. Runs once per parameter | ||
| let commit = () => { | ||
| let collected = value.substring(0, valueEnd); | ||
| value = ''; | ||
| valueEnd = 0; | ||
| if (stage === STAGE_KEY) { | ||
| // key without a value, see emptykey: | ||
| // Header-Key: somevalue; key=value; emptykey | ||
| if (collected) { | ||
| setOwnProperty(response.params, collected.toLowerCase(), ''); | ||
| } | ||
| } else if (key === false) { | ||
| // default value | ||
| response.value = collected; | ||
| } else { | ||
| // subkey value | ||
| setOwnProperty(response.params, key, collected); | ||
| } | ||
| }; | ||
| for (let i = 0, len = str.length; i < len; i++) { | ||
@@ -488,8 +551,17 @@ chr = str.charAt(i); | ||
| if (chr === '=') { | ||
| key = value.trim().toLowerCase(); | ||
| key = value.substring(0, valueEnd).toLowerCase(); | ||
| value = ''; | ||
| valueEnd = 0; | ||
| stage = STAGE_VALUE; | ||
| value = ''; | ||
| break; | ||
| } else if (chr === ';') { | ||
| // key without a value, eg. the "inline" in "attachment; inline; filename=a.txt" | ||
| commit(); | ||
| } else if (isWSP(chr)) { | ||
| if (value.length) { | ||
| value += chr; | ||
| } | ||
| } else { | ||
| value += chr; | ||
| valueEnd = value.length; | ||
| } | ||
| value += chr; | ||
| break; | ||
@@ -499,19 +571,19 @@ case STAGE_VALUE: | ||
| value += chr; | ||
| valueEnd = value.length; | ||
| } else if (chr === '\\') { | ||
| escaped = true; | ||
| continue; | ||
| } else if (quote && chr === quote) { | ||
| quote = false; | ||
| } else if (!quote && chr === '"') { | ||
| quote = chr; | ||
| } else if (chr === '"') { | ||
| // every char between the quotes is significant, including whitespace | ||
| quote = !quote; | ||
| } else if (!quote && chr === ';') { | ||
| if (key === false) { | ||
| response.value = value.trim(); | ||
| } else { | ||
| response.params[key] = value.trim(); | ||
| commit(); | ||
| stage = STAGE_KEY; | ||
| } else if (!quote && isWSP(chr)) { | ||
| if (value.length) { | ||
| value += chr; | ||
| } | ||
| stage = STAGE_KEY; | ||
| value = ''; | ||
| } else { | ||
| value += chr; | ||
| valueEnd = value.length; | ||
| } | ||
@@ -524,16 +596,3 @@ escaped = false; | ||
| // finalize remainder | ||
| value = value.trim(); | ||
| if (stage === STAGE_VALUE) { | ||
| if (key === false) { | ||
| // default value | ||
| response.value = value; | ||
| } else { | ||
| // subkey value | ||
| response.params[key] = value; | ||
| } | ||
| } else if (value) { | ||
| // treat as key without value, see emptykey: | ||
| // Header-Key: somevalue; key=value; emptykey | ||
| response.params[value.toLowerCase()] = ''; | ||
| } | ||
| commit(); | ||
@@ -543,8 +602,7 @@ // handle parameter value continuations | ||
| // preprocess values | ||
| Object.keys(response.params).forEach(key => { | ||
| let actualKey; | ||
| let nr; | ||
| let value; | ||
| // Collect the segments of every continuation parameter. These are kept out of | ||
| // response.params so that a half assembled value can not leak into the result | ||
| let continuations = new Map(); | ||
| for (let key of Object.keys(response.params)) { | ||
| let match = key.match(/\*((\d+)\*?)?$/); | ||
@@ -554,63 +612,63 @@ | ||
| // nothing to do here, does not seem like a continuation param | ||
| return; | ||
| continue; | ||
| } | ||
| actualKey = key.substr(0, match.index).toLowerCase(); | ||
| nr = Number(match[2]) || 0; | ||
| let actualKey = key.substr(0, match.index).toLowerCase(); | ||
| let nr = Number(match[2]) || 0; | ||
| let value = response.params[key]; | ||
| if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') { | ||
| response.params[actualKey] = { | ||
| // remove the old reference | ||
| delete response.params[key]; | ||
| let continuation = continuations.get(actualKey); | ||
| if (!continuation) { | ||
| continuation = { | ||
| charset: false, | ||
| values: [] | ||
| }; | ||
| continuations.set(actualKey, continuation); | ||
| } | ||
| value = response.params[key]; | ||
| if (nr === 0 && match[0].charAt(match[0].length - 1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) { | ||
| response.params[actualKey].charset = match[1] || 'utf-8'; | ||
| continuation.charset = match[1] || 'utf-8'; | ||
| value = match[2]; | ||
| } | ||
| response.params[actualKey].values.push({ nr, value }); | ||
| continuation.values.push({ nr, value }); | ||
| } | ||
| // remove the old reference | ||
| delete response.params[key]; | ||
| }); | ||
| // concatenate the split rfc2231 strings and decode the encoded ones | ||
| for (let [key, continuation] of continuations) { | ||
| // NB! An assembled continuation always overrides a plain parameter with the same | ||
| // name, in either source order. The plain form is the legacy ascii fallback of the | ||
| // extended one, so rfc6266 section 4.3 requires picking the extended value. Letting | ||
| // the fallback win would make libmime disagree with the mail clients that follow it | ||
| let value = continuation.values | ||
| .sort((a, b) => a.nr - b.nr) | ||
| .map(val => val.value) | ||
| .join(''); | ||
| // concatenate split rfc2231 strings and convert encoded strings to mime encoded words | ||
| Object.keys(response.params).forEach(key => { | ||
| let value; | ||
| if (response.params[key] && Array.isArray(response.params[key].values)) { | ||
| value = response.params[key].values | ||
| .sort((a, b) => a.nr - b.nr) | ||
| .map(val => (val && val.value) || '') | ||
| .join(''); | ||
| if (response.params[key].charset) { | ||
| // convert "%AB" to "=?charset?Q?=AB?=" and then to unicode | ||
| response.params[key] = this.decodeWords( | ||
| '=?' + | ||
| response.params[key].charset + | ||
| '?Q?' + | ||
| value | ||
| // fix invalidly encoded chars | ||
| .replace(/[=?_\s]/g, s => { | ||
| let c = s.charCodeAt(0).toString(16); | ||
| if (s === ' ') { | ||
| return '_'; | ||
| } else { | ||
| return '%' + (c.length < 2 ? '0' : '') + c; | ||
| } | ||
| }) | ||
| // change from urlencoding to percent encoding | ||
| .replace(/%/g, '=') + | ||
| '?=' | ||
| ); | ||
| } else { | ||
| response.params[key] = this.decodeWords(value); | ||
| } | ||
| if (!continuation.charset) { | ||
| setOwnProperty(response.params, key, this.decodeWords(value)); | ||
| continue; | ||
| } | ||
| }); | ||
| // convert "%AB" to the quoted printable "=AB" and decode it as the charset of the parameter. | ||
| // NB! The charset is passed in as an argument instead of building a "=?charset?Q?=AB?=" string, | ||
| // a charset that contains "?" would otherwise inject an encoded word of its own | ||
| let qpValue = value | ||
| // fix invalidly encoded chars | ||
| .replace(/[=_\s]/g, s => { | ||
| if (s === ' ') { | ||
| return '_'; | ||
| } | ||
| let c = s.charCodeAt(0).toString(16); | ||
| return '%' + (c.length < 2 ? '0' : '') + c; | ||
| }) | ||
| // change from urlencoding to percent encoding | ||
| .replace(/%/g, '='); | ||
| setOwnProperty(response.params, key, this.decodeWord(continuation.charset, 'Q', qpValue)); | ||
| } | ||
| return response; | ||
@@ -637,3 +695,9 @@ } | ||
| let list = []; | ||
| let encodedStr = typeof data === 'string' ? data : this.decode(data, fromCharset); | ||
| if (typeof data !== 'string' && !Buffer.isBuffer(data)) { | ||
| // documented input is a string or a Buffer, normalize anything else | ||
| data = data === null || data === undefined ? '' : data.toString(); | ||
| } | ||
| let encodedStr = typeof data === 'string' ? data : libcharset.decode(data, fromCharset); | ||
| let encodedStrArr; | ||
@@ -772,3 +836,3 @@ let chr, ord; | ||
| mimeType = (mimeType || '').toString().toLowerCase().replace(/\s/g, ''); | ||
| if (!(mimeType in mimetypes.list)) { | ||
| if (!hasOwn(mimetypes.list, mimeType)) { | ||
| return 'bin'; | ||
@@ -804,3 +868,3 @@ } | ||
| if (!(extension in mimetypes.extensions)) { | ||
| if (!hasOwn(mimetypes.extensions, extension)) { | ||
| return 'application/octet-stream'; | ||
@@ -923,16 +987,10 @@ } | ||
| let res = ''; | ||
| let ord = chr.charCodeAt(0).toString(16).toUpperCase(); | ||
| // percent encoding is defined over bytes, so encode the char as UTF-8 first | ||
| let buf = Buffer.from(chr, 'utf-8'); | ||
| if (ord.length % 2) { | ||
| ord = '0' + ord; | ||
| for (let i = 0, len = buf.length; i < len; i++) { | ||
| let ord = buf[i].toString(16).toUpperCase(); | ||
| res += '%' + (ord.length < 2 ? '0' : '') + ord; | ||
| } | ||
| if (ord.length > 2) { | ||
| for (let i = 0, len = ord.length / 2; i < len; i++) { | ||
| res += '%' + ord.substr(i, 2); | ||
| } | ||
| } else { | ||
| res += '%' + ord; | ||
| } | ||
| return res; | ||
@@ -939,0 +997,0 @@ } |
+3
-3
| { | ||
| "name": "libmime", | ||
| "description": "Encode and decode quoted printable and base64 strings", | ||
| "version": "5.4.1", | ||
| "version": "5.4.2", | ||
| "main": "lib/libmime.js", | ||
@@ -36,8 +36,8 @@ "files": [ | ||
| "eslint-config-prettier": "10.1.8", | ||
| "grunt": "1.6.2", | ||
| "grunt": "1.6.3", | ||
| "grunt-cli": "1.5.0", | ||
| "grunt-eslint": "24.3.0", | ||
| "grunt-mocha-test": "0.13.3", | ||
| "mocha": "11.7.6" | ||
| "mocha": "11.8.0" | ||
| } | ||
| } |
158052
2.32%3466
1.43%