New:Socket for Asana Is Now Available.Learn more
Sign In

@zone-eu/mailsplit

Package Overview
Dependencies
Maintainers
2
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zone-eu/mailsplit - npm Package Compare versions

Comparing version
5.4.14
to
5.4.15
+81
-10
lib/headers.js

@@ -133,3 +133,4 @@ 'use strict';

value = value.toString('binary');
this.addFormatted(key, this.libmime.foldLines(key + ': ' + value.replace(/\r?\n/g, ''), 76, false), index);
// a header value may not contain line breaks of its own, folding is added by foldLines
this.addFormatted(key, this.libmime.foldLines(key + ': ' + value.replace(/[\r\n]/g, ''), 76, false), index);
}

@@ -159,2 +160,9 @@

// every header insertion runs through here, so this is where a value or a key
// built from untrusted input is stopped from injecting an extra header line
line = this._normalizeInsertedLine(line);
if (!line) {
return;
}
let header = {

@@ -232,3 +240,6 @@ key: this._normalizeHeader(key),

/**
* @param {string | false} [lineEnd]
* Serializes the headers. Unmodified headers are returned byte for byte as they
* were received, otherwise every line is rebuilt with `lineEnd` line endings.
*
* @param {string | false} [lineEnd] Line ending to use, defaults to CRLF.
* @returns {Buffer}

@@ -246,9 +257,13 @@ */

lineEnd = lineEnd || '\r\n';
const ending = lineEnd || '\r\n';
let headers = lines
.map(line => this._buildHeaderLine(line.line.replace(/\r?\n/g, lineEnd)))
.map(line => this._normalizeLineBreaks(line.line, ending))
// an empty line would close the header block and demote every later header
// into the body, so a line left with nothing in it is dropped instead
.filter(line => line !== '')
.map(line => this._buildHeaderLine(line))
.reduce((joined, line, idx) => {
if (idx) {
joined.push(Buffer.from(lineEnd, 'binary'));
joined.push(Buffer.from(ending, 'binary'));
}

@@ -259,10 +274,10 @@ joined.push(line);

headers.push(Buffer.from(lineEnd + lineEnd, 'binary'));
headers.push(Buffer.from(ending + ending, 'binary'));
if (this.mbox) {
headers.unshift(Buffer.from(this.mbox + lineEnd, 'binary'));
headers.unshift(Buffer.from(this.mbox + ending, 'binary'));
}
if (this.http) {
headers.unshift(Buffer.from(this.http + lineEnd, 'binary'));
headers.unshift(Buffer.from(this.http + ending, 'binary'));
}

@@ -282,2 +297,59 @@

/**
* Rewrites the line breaks of a header line so that the line can only ever parse
* back as the single header it was reported as. A line break followed by whitespace
* is folding and becomes `lineEnd`, every other line break would start a new header
* line and is dropped.
*
* A bare <CR> is never a line break for _parseHeaders, so promoting one here would
* emit a header line that was never reported as parsed.
*
* @param {string} line Header line to normalize.
* @param {string} lineEnd Line ending to fold with.
* @returns {string} Line with only folding line breaks left.
*/
_normalizeLineBreaks(line, lineEnd) {
return (
line
// lines are joined with lineEnd, so a line that opens with a break of its own
// would close the header block. Dropping only the break leaves any whitespace
// behind it folding into the line before, which adds no header of its own.
.replace(/^[\r\n]+/, '')
.replace(/\r\n|\r|\n/g, (match, offset, source) => (match !== '\r' && this._isFoldingChar(source.charAt(offset + match.length)) ? lineEnd : ''))
);
}
/**
* Prepares a caller supplied line for insertion. On top of the line break rules an
* inserted line has to stand on its own: a leading fold or indent would attach it to
* whichever header happens to precede it, and a leading line break would close the
* header block outright.
*
* Lines that were parsed out of a message keep their leading whitespace instead, so
* that rebuilding can never turn an indented continuation into a header of its own.
*
* An inserted line is normalized twice, here with CRLF and again in build() with the
* line ending the caller asked for. That is only sound because _normalizeLineBreaks is
* idempotent over its own output: the folds this pass emits are still recognized as
* folds by the next one. Any change to how a fold is represented has to keep that true.
*
* @param {string} line Formatted header line supplied by the caller.
* @returns {string} Line that inserts as exactly one header, or an empty string.
*/
_normalizeInsertedLine(line) {
return this._normalizeLineBreaks(line.replace(/^[\r\n \t]+/, ''), '\r\n');
}
/**
* Tells whether a character continues the previous header line rather than
* starting a new one. Used by both the parser and the builder so that the two
* can not disagree on what folding is.
*
* @param {string} chr Character that follows a line break.
* @returns {boolean} True if the line break is folding.
*/
_isFoldingChar(chr) {
return chr === ' ' || chr === '\t';
}
/**
* @returns {HeaderLine[]}

@@ -310,4 +382,3 @@ */

let currentLine = /** @type {string} */ (lines[i]);
let chr = currentLine.charAt(0);
if (i && (chr === ' ' || chr === '\t')) {
if (i && this._isFoldingChar(currentLine.charAt(0))) {
lines[i - 1] = /** @type {string} */ (lines[i - 1]) + '\r\n' + currentLine;

@@ -314,0 +385,0 @@ lines.splice(i, 1);

+233
-73

@@ -16,2 +16,17 @@ 'use strict';

// how much of a body line without a line break is buffered before it is flushed
// out as regular content instead of being kept in memory
const MAX_PENDING_LINE_SIZE = 64 * 1024;
// how many separate writes the pending line may be kept in before it is compacted
const MAX_PENDING_LINE_CHUNKS = 1024;
// what a delimiter line may carry after the boundary value: the "--" prefix, an optional
// "--" suffix and the line terminator. This is the bound compareBoundary() accepts.
const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + 2; /* trailing <CR><LF> */
// checkBoundary() additionally allows a line ending in front of the delimiter, so this is
// the longest a delimiter line can ever be once the boundary value is subtracted
const BOUNDARY_LINE_OVERHEAD = BOUNDARY_LINE_SUFFIX + 2; /* leading <CR><LF> */
const HEAD = 0x01;

@@ -21,2 +36,39 @@ const BODY = 0x02;

/**
* Creates the error used for all size limit violations.
*
* @param {string} message Human readable error message.
* @returns {Error & {code: string}} Error tagged with the EMAXLEN code.
*/
function maxLenError(message) {
let err = /** @type {Error & {code: string}} */ (new Error(message));
err.code = 'EMAXLEN';
return err;
}
/**
* Moves an end offset back over the line ending that closes a body line, because the line
* ending in front of a boundary belongs to the delimiter and not to the part content. Only
* a group holding the body of a child node carries such a line ending, anything else is
* returned untouched.
*
* @param {SplitterGroup} group Group the offsets describe.
* @param {Buffer} chunk Chunk the offsets point into.
* @param {number} start Start offset of the body slice.
* @param {number} end End offset of the body slice.
* @returns {number} End offset with a trailing <CR><LF>, <LF> or nothing removed.
*/
function trimBodyLineEnd(group, chunk, start, end) {
if (group.type !== 'body' || !group.node || !group.node.parentNode) {
return end;
}
if (end > start && chunk[end - 1] === 0x0a) {
end--;
if (end > start && chunk[end - 1] === 0x0d) {
end--;
}
}
return end;
}
/**
* Transform stream that splits raw email bytes into MIME node and content chunks.

@@ -38,14 +90,54 @@ */

this.maxChildNodes = this.config.maxChildNodes || MAX_CHILD_NODES;
/** @type {MimeNodeType[]} */
this.tree = [];
this.nodeCounter = 0;
this.node = /** @type {MimeNodeType} */ (/** @type {unknown} */ (null));
// set once the closing delimiter of the current node's multipart has been seen, so
// that any later boundary line of that node counts as epilogue. Reset per node.
this.inEpilogue = false;
this.newNode();
this.tree.push(this.node);
/** @type {Buffer | false} */
this.line = false;
// incomplete trailing line of the previous chunk, kept as a list of chunks so
// that a long line without a line break is not copied over for every write
/** @type {Buffer[]} */
this.lineChunks = [];
this.lineLength = 0;
this.hasFailed = false;
// set when the pending line was flushed as overlong content, the remainder
// of that same line can not be a boundary either
this.pendingLineTruncated = false;
}
/**
* Appends unterminated trailing data to the pending line.
*
* @param {Buffer} chunk Data that follows the last line break of a write.
* @returns {void}
*/
appendPendingLine(chunk) {
if (!chunk.length) {
return;
}
this.lineChunks.push(chunk);
this.lineLength += chunk.length;
if (this.lineChunks.length >= MAX_PENDING_LINE_CHUNKS) {
// a line written one byte at a time would otherwise cost an array slot and a
// Buffer view per byte, which is far more memory than the data itself
this.lineChunks = [Buffer.concat(this.lineChunks, this.lineLength)];
}
}
/**
* Returns the pending line as a single buffer and clears the pending state.
*
* @returns {Buffer | false} Pending line contents or false if there was none.
*/
takePendingLine() {
if (!this.lineLength) {
return false;
}
let line = this.lineChunks.length === 1 ? this.lineChunks[0] : Buffer.concat(this.lineChunks, this.lineLength);
this.lineChunks = [];
this.lineLength = 0;
return line;
}
/**
* @param {Buffer} chunk

@@ -65,3 +157,3 @@ * @param {BufferEncoding} encoding

};
let groupstart = this.line ? -this.line.length : 0;
let groupstart = this.lineLength ? -this.lineLength : 0;
let groupend = 0;

@@ -85,6 +177,5 @@

pos--;
if (groupstart < 0 && !this.line) {
if (groupstart < 0 && !this.lineLength) {
// store only <CR> as <LF> should be on the positive side
this.line = Buffer.allocUnsafe(1);
this.line[0] = 0x0d;
this.appendPendingLine(Buffer.from([0x0d]));
}

@@ -130,17 +221,15 @@ data.value = data.value.slice(0, data.value.length - 2);

if (group && group.type !== 'none') {
if (group.type === 'body' && groupend >= groupstart && group.node && group.node.parentNode) {
// do not include the last line ending for body
if (chunk[groupend - 1] === 0x0a) {
groupend--;
if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) {
groupend--;
}
}
}
if (groupstart !== groupend) {
// do not include the last line ending for body
groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
if (groupstart < groupend) {
// re-slice, the value the line was emitted with may
// still include the line ending we just trimmed
group.value = chunk.slice(groupstart, groupend);
if (groupend < i && 'value' in data) {
// the trimmed line ending belongs to the boundary line
data.value = chunk.slice(groupend, i);
}
}
// the group is pushed even when nothing is left of it, so that a
// part whose whole body is a line ending still reports a body
this.push(group);

@@ -161,11 +250,4 @@ group = {

} else {
if (group.type === 'body' && groupend >= groupstart && group.node && group.node.parentNode) {
// do not include the last line ending for body
if (chunk[groupend - 1] === 0x0a) {
groupend--;
if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) {
groupend--;
}
}
}
// do not include the last line ending for body
groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);

@@ -209,11 +291,3 @@ if (group.type !== 'none' && group.type !== 'node') {

// skip last linebreak for body
if (pos >= groupstart + 1 && group.type === 'body' && group.node && group.node.parentNode) {
// do not include the last line ending for body
if (chunk[pos - 1] === 0x0a) {
pos--;
if (pos >= groupstart && chunk[pos - 1] === 0x0d) {
pos--;
}
}
}
pos = trimBodyLineEnd(group, chunk, groupstart, pos);

@@ -233,8 +307,13 @@ if (group.type !== 'none' && group.type !== 'node' && pos > groupstart) {

if (pos < chunk.length) {
if (this.line) {
this.line = Buffer.concat([this.line, chunk.slice(pos)]);
} else {
this.line = chunk.slice(pos);
}
// checkTrailingLinebreak can push pos before the start of this write when a
// line ending straddles it. A negative start would make slice() count from
// the END of the buffer and hand over the wrong bytes entirely.
this.appendPendingLine(chunk.slice(Math.max(pos, 0)));
}
let pendingLineError = this.enforcePendingLineLimit();
if (pendingLineError) {
this.hasFailed = true;
return callback(pendingLineError);
}
callback();

@@ -273,3 +352,3 @@ };

// --{boundary}\r\n or --{boundary}--\r\n
if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + 6 + startpos) {
if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + BOUNDARY_LINE_SUFFIX + startpos) {
return false;

@@ -321,3 +400,4 @@ }

startpos++;
if (line.length >= 2 && (line[0] === 0x0d || line[1] === 0x0a)) {
if (line.length >= 2 && line[0] === 0x0d && line[1] === 0x0a) {
// only <CR><LF> is two bytes, a lone <CR> in front of a delimiter is one
startpos++;

@@ -333,3 +413,3 @@ }

let boundary;
if (this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) {
if (!this.inEpilogue && this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) {
// 1: next child

@@ -350,2 +430,71 @@ // 2: multipart end

/**
* Checks the header bytes collected for the current node against maxHeadSize.
*
* @param {number} [extra] Bytes that belong to the header block but are not stored yet.
* @returns {(Error & {code?: string}) | null} Error object if the limit was exceeded.
*/
checkHeadSize(extra) {
if (this.node._headerlen + (extra || 0) > this.maxHeadSize) {
return maxLenError('Max header size for a MIME node exceeded');
}
return null;
}
/**
* Enforces the limits on the pending line so that it can not grow without bound.
* A line that is still short enough to become a boundary delimiter is always kept.
* Past that length it is a header line and counts against maxHeadSize, or it is
* body content, in which case it is pushed out rather than held in memory. Flushing
* marks the pending line truncated, so the tail of it is not tested as a delimiter.
*
* @returns {(Error & {code?: string}) | null} Error object if a limit was exceeded.
*/
enforcePendingLineLimit() {
if (!this.lineLength) {
return null;
}
let maxBoundaryLength = Math.max(
this.node._boundary ? this.node._boundary.length : 0,
this.node._parentBoundary ? this.node._parentBoundary.length : 0
);
if (this.lineLength <= maxBoundaryLength + BOUNDARY_LINE_OVERHEAD) {
// might still turn out to be a boundary delimiter line
return null;
}
if (this.state === HEAD) {
// not a boundary line, so it is a header line and counts against the
// header size limit even though it has not been stored on the node yet
return this.checkHeadSize(this.lineLength);
}
if (this.lineLength < MAX_PENDING_LINE_SIZE) {
return null;
}
let value = /** @type {Buffer} */ (this.takePendingLine());
if (value[value.length - 1] === 0x0d) {
// a trailing <CR> may still turn out to be the first half of the line ending
// that closes this line, and a line ending in front of a boundary belongs to
// the delimiter. Keep it pending so the normal trimming can decide. Copy it
// rather than slicing, a view would pin the whole flushed buffer.
this.appendPendingLine(Buffer.from([0x0d]));
value = value.slice(0, value.length - 1);
}
this.push({
node: this.node,
type: this.node.multipart ? 'data' : 'body',
value
});
// whatever follows continues an overlong line, so the tail of it
// can not be a boundary line either
this.pendingLineTruncated = true;
return null;
}
/**
* @param {Buffer | false} line

@@ -359,8 +508,9 @@ * @param {boolean} final

if (this.line && line) {
line = Buffer.concat([this.line, line]);
this.line = false;
} else if (this.line && !line) {
line = this.line;
this.line = false;
// consumed here so that no later branch can leak it into the next line
let truncatedLine = this.pendingLineTruncated;
this.pendingLineTruncated = false;
let pending = this.takePendingLine();
if (pending) {
line = line ? Buffer.concat([pending, line]) : pending;
}

@@ -373,9 +523,8 @@

if (this.nodeCounter > this.maxChildNodes) {
let err = /** @type {Error & {code?: string}} */ (new Error('Max allowed child nodes exceeded'));
err.code = 'EMAXLEN';
return next(err);
return next(maxLenError('Max allowed child nodes exceeded'));
}
// we check boundary outside the HEAD/BODY scope as it may appear anywhere
let boundary = this.checkBoundary(line);
// unless the line is the remainder of an already flushed overlong line
let boundary = truncatedLine ? false : this.checkBoundary(line);
if (boundary) {

@@ -394,13 +543,7 @@ // reached boundary, switch context

// next sibling
let parentNode = this.node.parentNode;
if (parentNode && parentNode.contentType === 'message/rfc822') {
// special case where immediate parent is an inline message block
// move up another step
parentNode = parentNode.parentNode;
}
this.newNode(parentNode);
this.newNode(this.parentMultipartNode());
flush = true;
break;
}
case 4:
case 4: {
// special case when boundary close a node with only header.

@@ -411,8 +554,14 @@ if (this.node && this.node._headerlen && !this.node.headers) {

}
// move up
if (this.tree.length) {
this.node = /** @type {MimeNodeType} */ (this.tree.pop());
// move up to the multipart node this closing delimiter belongs to
let parentNode = this.parentMultipartNode();
if (parentNode) {
this.node = parentNode;
// the closing delimiter of this multipart was just processed, so any
// later boundary line of this node belongs to the epilogue. A closing
// delimiter seen in the preamble (case 2) deliberately does not count.
this.inEpilogue = true;
}
this.state = BODY;
break;
}
}

@@ -434,6 +583,5 @@

this.node.addHeaderChunk(line);
if (this.node._headerlen > this.maxHeadSize) {
let err = /** @type {Error & {code?: string}} */ (new Error('Max header size for a MIME node exceeded'));
err.code = 'EMAXLEN';
return next(err);
let headSizeError = this.checkHeadSize();
if (headSizeError) {
return next(headSizeError);
}

@@ -455,3 +603,6 @@ if (final || (line.length === 1 && line[0] === 0x0a) || (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a)) {

if (currentNode.parentNode) {
// the embedded message continues inside its container, so a
// delimiter of the container's own parent still applies here
this.node._parentBoundary = currentNode.parentNode._boundary;
this.node._parentBoundaryOwner = currentNode.parentNode;
}

@@ -463,5 +614,2 @@ } else {

this.state = BODY;
if (currentNode.multipart && currentNode._boundary) {
this.tree.push(currentNode);
}
}

@@ -491,2 +639,12 @@

/**
* Resolves the multipart node that owns the boundary of the current node, ie. the
* node a sibling delimiter or a closing delimiter of _parentBoundary refers to.
*
* @returns {MimeNodeType | false} Owner of _parentBoundary or false for the root node.
*/
parentMultipartNode() {
return this.node._parentBoundaryOwner || false;
}
/**
* @param {MimeNodeType | false} [parent]

@@ -499,2 +657,4 @@ * @returns {void}

this.nodeCounter++;
// a fresh node starts before its own content, never in an epilogue
this.inEpilogue = false;
}

@@ -501,0 +661,0 @@ }

@@ -21,2 +21,5 @@ import type { ContentStream, MimeNode as MimeNodeShape, PartNumber, PartNumberItem, SplitterOptions } from './types';

/** Node whose boundary `_parentBoundary` was copied from. */
_parentBoundaryOwner: MimeNode | false;
/** Length, in bytes, of the raw header block collected for this node. */

@@ -23,0 +26,0 @@ _headerlen: number;

@@ -38,2 +38,6 @@ 'use strict';

this._parentBoundary = this.parentNode && this.parentNode._boundary;
// the node whose boundary _parentBoundary was copied from, recorded here so that
// a delimiter for it never has to be matched back to an owner by walking the tree
/** @type {MimeNodeType | false} */
this._parentBoundaryOwner = this.parentNode || false;
/** @type {Buffer[]} */

@@ -40,0 +44,0 @@ this._headersLines = [];

@@ -84,2 +84,5 @@ import type { PassThrough, Transform } from 'node:stream';

/** Node whose boundary `_parentBoundary` was copied from. */
_parentBoundaryOwner: MimeNode | false;
/** Length, in bytes, of the raw header block collected for this node. */

@@ -86,0 +89,0 @@ _headerlen: number;

{
"name": "@zone-eu/mailsplit",
"version": "5.4.14",
"version": "5.4.15",
"description": "Split email messages into an object stream",

@@ -19,3 +19,3 @@ "main": "index.js",

"libbase64": "1.3.0",
"libmime": "5.4.1",
"libmime": "5.4.2",
"libqp": "2.1.1"

@@ -29,7 +29,7 @@ },

"@types/libqp": "1.1.3",
"@types/node": "26.1.0",
"@types/node": "26.1.2",
"eslint": "8.29.0",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "9.1.0",
"grunt": "1.6.2",
"grunt": "1.6.3",
"grunt-cli": "1.5.0",

@@ -39,3 +39,3 @@ "grunt-contrib-nodeunit": "5.0.0",

"random-message": "1.1.0",
"typescript": "6.0.3"
"typescript": "7.0.2"
},

@@ -42,0 +42,0 @@ "files": [