UNPKG

imapflow

Version:

IMAP Client for Node

367 lines (321 loc) 15.2 kB
'use strict'; const Transform = require('stream').Transform; const logger = require('../logger'); const LINE = 0x01; const LITERAL = 0x02; const LF = 0x0a; const CR = 0x0d; const NUM_0 = 0x30; const NUM_9 = 0x39; const CURLY_OPEN = 0x7b; const CURLY_CLOSE = 0x7d; // Maximum allowed literal size: 1GB (1073741824 bytes) const MAX_LITERAL_SIZE = 1024 * 1024 * 1024; // Default maximum length of a single line (a response without a literal). Matches the literal cap: // large literal-free responses (e.g. big SEARCH/LIST results) are legitimate, so this bound exists // only to stop a server that never sends a line terminator, not to constrain normal traffic. const MAX_LINE_SIZE = MAX_LITERAL_SIZE; /** * A Transform stream that parses raw IMAP protocol data from a socket into structured * command/response objects. Reads binary input, splits it into lines delimited by LF, * extracts literal data blocks based on IMAP literal size markers (e.g., "{123}\r\n"), * and emits each complete command as a readable object containing the payload Buffer * and any associated literal Buffers. Enforces a maximum literal size of 1GB. * * @extends Transform */ class ImapStream extends Transform { /** * Creates a new ImapStream instance. * * @param {Object} [options] - Stream options. * @param {string} [options.cid] - Connection identifier used for logging. * @param {Object} [options.logger] - A pino-compatible logger instance. If not provided, a default child logger is created. * @param {boolean} [options.logRaw] - If true, logs raw socket data at trace level. * @param {boolean} [options.secureConnection] - Whether the connection uses TLS. * @param {number} [options.maxLineLength] - Maximum allowed length (in bytes) of a single * line (a response without a literal). Defaults to MAX_LITERAL_SIZE (1GB). Guards against a * malicious or broken server that never sends a line terminator, which would otherwise grow * the internal line buffer without bound. * @param {number} [options.maxLiteralSize] - Maximum allowed size (in bytes) of a single * literal block. Defaults to MAX_LITERAL_SIZE (1GB). Lower it to bound peak memory * allocation against a malicious or broken server announcing an oversized literal. */ constructor(options) { super({ //writableHighWaterMark: 3, readableObjectMode: true, writableObjectMode: false }); this.options = options || {}; this.cid = this.options.cid; this.log = this.options.logger && typeof this.options.logger === 'object' ? this.options.logger : logger.child({ component: 'imap-connection', cid: this.cid }); this.readBytesCounter = 0; // Maximum length of a single line (response without a literal). Bounds the line buffer // so a server that never sends a line terminator cannot exhaust memory. A non-negative // integer is honored as-is (including 0); anything else falls back to the default, so an // explicit 0 is not silently swallowed into the 1GB default the way `|| MAX_LINE_SIZE` was. this.maxLineLength = Number.isInteger(this.options.maxLineLength) && this.options.maxLineLength >= 0 ? this.options.maxLineLength : MAX_LINE_SIZE; // Maximum size of a single literal block. Bounds peak memory allocation so a server // announcing an oversized literal cannot exhaust memory. As above, a non-negative integer // (including an explicit 0, meaning "reject all non-empty literals") is honored as-is. this.maxLiteralSize = Number.isInteger(this.options.maxLiteralSize) && this.options.maxLiteralSize >= 0 ? this.options.maxLiteralSize : MAX_LITERAL_SIZE; this.state = LINE; this.literalWaiting = 0; this.inputBuffer = []; // lines this.lineBuffer = []; // current line this.lineBytes = 0; // bytes currently buffered for the in-progress line this.literalBuffer = []; this.literals = []; this.compress = false; this.secureConnection = this.options.secureConnection; this.processingInput = false; this.inputQueue = []; // unprocessed input chunks } /** * Checks whether the given line buffer ends with an IMAP literal size marker * (e.g., "{123}\r\n"). If a valid marker is found and the literal size is within * the allowed maximum, switches the stream state to LITERAL mode and records * the expected number of literal bytes. * * @param {Buffer} line - The line buffer to check for a trailing literal marker. * @returns {boolean} True if a valid literal marker was found and literal state was activated, false otherwise. */ checkLiteralMarker(line) { if (!line || !line.length) { return false; } let pos = line.length - 1; if (line[pos] !== LF) { return false; } pos--; if (pos >= 0 && line[pos] === CR) { pos--; } if (pos < 0 || !pos || line[pos] !== CURLY_CLOSE) { return false; } pos--; // Scan backwards through the line to find an IMAP literal marker: {size}\r\n // The format is: '{' followed by one or more ASCII digits followed by '}' let numBytes = []; for (; pos >= 0; pos--) { let c = line[pos]; if (c >= NUM_0 && c <= NUM_9) { numBytes.unshift(c); continue; } if (c === CURLY_OPEN && numBytes.length) { const literalSize = Number(Buffer.from(numBytes).toString()); if (literalSize > this.maxLiteralSize) { const err = new Error(`Literal size ${literalSize} exceeds maximum allowed size of ${this.maxLiteralSize} bytes`); err.code = 'LiteralTooLarge'; err.literalSize = literalSize; err.maxSize = this.maxLiteralSize; this.emit('error', err); return false; } this.state = LITERAL; this.literalWaiting = literalSize; return true; } return false; } return false; } /** * Processes a single input chunk of raw data. In LINE state, scans for LF-terminated * lines and checks for literal markers. In LITERAL state, collects the expected number * of literal bytes. When a complete command (with all its literals) is assembled, it is * pushed downstream as a readable object. * * @param {Buffer} chunk - The raw data chunk to process. * @param {number} [startPos=0] - The byte offset within the chunk to start processing from. * @returns {Promise<void>} */ async processInputChunk(chunk, startPos) { startPos = startPos || 0; if (startPos >= chunk.length) { return; } switch (this.state) { case LINE: { let lineStart = startPos; for (let i = startPos, len = chunk.length; i < len; i++) { if (chunk[i] === LF) { // line end found this.lineBuffer.push(chunk.slice(lineStart, i + 1)); lineStart = i + 1; let line = Buffer.concat(this.lineBuffer); this.inputBuffer.push(line); this.lineBuffer = []; this.lineBytes = 0; // try to detect if this is a literal start if (this.checkLiteralMarker(line)) { // switch into line mode and start over return await this.processInputChunk(chunk, lineStart); } // reached end of command input, emit it let payload = this.inputBuffer.length === 1 ? this.inputBuffer[0] : Buffer.concat(this.inputBuffer); let literals = this.literals; this.inputBuffer = []; this.literals = []; if (payload.length) { // remove final line terminator (\n or \r\n) if (payload[payload.length - 1] === LF) { let end = payload.length - 1; if (end > 0 && payload[end - 1] === CR) { end--; } payload = payload.slice(0, end); } if (payload.length) { // Whether more buffered input already followed this command on the // wire — more bytes in this chunk or another queued chunk. Captured // per emitted command (immutable on the pushed object) so a later // command cannot overwrite it; consumers that care about pipelining // boundaries can read it from the pushed object. let trailingAfterLine = lineStart < chunk.length || this.inputQueue.length > 0; await new Promise(resolve => { this.push({ payload, literals, next: resolve, trailingAfterLine }); }); } } } } if (lineStart < chunk.length) { // No line terminator was found in the remaining bytes; carry the tail over to // the next chunk. Enforce the line-length cap here, since this is the only // path that grows the line buffer across chunks. let tail = chunk.slice(lineStart); let lineLength = this.lineBytes + tail.length; if (lineLength > this.maxLineLength) { const err = new Error(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`); err.code = 'LineTooLarge'; err.lineLength = lineLength; err.maxSize = this.maxLineLength; this.emit('error', err); return; } this.lineBytes = lineLength; this.lineBuffer.push(tail); } break; } case LITERAL: { const remainingInChunk = chunk.length - startPos; const bytesToRead = Math.min(remainingInChunk, this.literalWaiting); const partial = startPos === 0 && bytesToRead === chunk.length ? chunk : chunk.slice(startPos, startPos + bytesToRead); this.literalBuffer.push(partial); this.literalWaiting -= bytesToRead; if (this.literalWaiting === 0) { this.literals.push(Buffer.concat(this.literalBuffer)); this.literalBuffer = []; this.state = LINE; if (remainingInChunk > bytesToRead) { return await this.processInputChunk(chunk, startPos + bytesToRead); } } break; } } } /** * Drains the input queue by processing each queued chunk sequentially. * Yields to the event loop every 10 chunks to prevent CPU blocking on * large bursts of incoming data. * * @returns {Promise<void>} */ async processInput() { let data; let processedCount = 0; while ((data = this.inputQueue.shift())) { await this.processInputChunk(data.chunk); // mark chunk as processed data.next(); // Yield to event loop every 10 chunks to prevent CPU blocking processedCount++; if (processedCount % 10 === 0) { await new Promise(resolve => setImmediate(resolve)); } } } /** * Transform stream implementation. Receives raw data chunks from the writable side, * converts strings to Buffers, tracks total bytes read, optionally logs raw data, * and queues the chunk for asynchronous processing. * * @param {Buffer|string} chunk - The incoming data chunk. * @param {string} encoding - The encoding if chunk is a string. * @param {Function} next - Callback to signal that this chunk has been consumed. */ _transform(chunk, encoding, next) { if (typeof chunk === 'string') { chunk = Buffer.from(chunk, encoding); } if (!chunk || !chunk.length) { return next(); } this.readBytesCounter += chunk.length; if (this.options.logRaw) { this.log.trace({ src: 's', msg: 'read from socket', data: chunk.toString('base64'), compress: !!this.compress, secure: !!this.secureConnection, cid: this.cid }); } // Queue the chunk for async processing. The 'next' callback serves as // backpressure: it is called only after this chunk is fully processed, // which signals the writable side that more data can be accepted. this.inputQueue.push({ chunk, next }); if (!this.processingInput) { this.processingInput = true; this.processInput() .catch(err => this.emit('error', err)) .finally(() => (this.processingInput = false)); } } /** * Flush implementation called when the writable side ends. Signals completion immediately. * * @param {Function} next - Callback to signal flush completion. */ _flush(next) { next(); } /** * Destroy implementation for cleanup. Clears all internal buffers, drains the input queue * by invoking pending callbacks, and forwards the error (if any) to the callback. * * @param {Error|null} err - The error that caused destruction, or null. * @param {Function} callback - Callback to signal destruction completion. */ _destroy(err, callback) { this.inputBuffer = []; this.lineBuffer = []; this.lineBytes = 0; this.literalBuffer = []; this.literals = []; // Clear inputQueue and call any pending callbacks while (this.inputQueue.length) { const item = this.inputQueue.shift(); if (typeof item.next === 'function') { item.next(); } } callback(err); } } module.exports.ImapStream = ImapStream;