imapflow 1.6.5 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const { parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS } = require('../tools.js');
4
+
5
+ // STATUS data items (RFC 3501 section 6.3.10, RFC 7162 for HIGHESTMODSEQ, RFC 9051 for SIZE
6
+ // and DELETED) mapped to the property name each one is exposed under, together with the
7
+ // parser that turns the raw response token into a usable value. Shared by the STATUS command
8
+ // and by the inline STATUS responses of LIST-STATUS (RFC 5819) so the two cannot drift apart.
9
+ //
10
+ // Every parser rejects anything that is not a bounded decimal digit run, returning false.
11
+ // These values are server-controlled and several of them are written straight into the live
12
+ // mailbox state, where a NaN or a value coerced to Infinity corrupts every later range
13
+ // computation. A plain isNaN() test is not enough: it passes '1e5', ' 12 ' and 'Infinity',
14
+ // and BigInt() throws on all three, aborting the walk over the remaining fields.
15
+ const uint32 = value => parseUintValue(value, MAX_UINT32_DIGITS);
16
+
17
+ const STATUS_FIELDS = {
18
+ MESSAGES: { key: 'messages', parser: uint32 },
19
+ RECENT: { key: 'recent', parser: uint32 },
20
+ UIDNEXT: { key: 'uidNext', parser: uint32 },
21
+ // Nominally 32-bit, but stored as a BigInt precisely so a server that exceeds that still
22
+ // round-trips, so the wider bound applies
23
+ UIDVALIDITY: { key: 'uidValidity', parser: value => parseBigIntValue(value) },
24
+ UNSEEN: { key: 'unseen', parser: uint32 },
25
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: value => parseBigIntValue(value) },
26
+ // IMAP4rev2 additions (RFC 9051): total mailbox size in octets (number64, exact as a JS
27
+ // number up to 2^53-1) and count of messages carrying the \Deleted flag
28
+ SIZE: { key: 'size', parser: value => parseUintValue(value) },
29
+ DELETED: { key: 'deleted', parser: uint32 }
30
+ };
31
+
32
+ /**
33
+ * Walks a STATUS data-item list - alternating item-name and item-value tokens - and reports
34
+ * every recognized field that parsed successfully. Unknown item names and unusable values are
35
+ * skipped, so one bad field never costs the rest of the response.
36
+ *
37
+ * @param {Array} list - Parsed attribute list from the untagged STATUS response.
38
+ * @param {Function} onField - Called as (key, value) for each usable field.
39
+ */
40
+ const parseStatusList = (list, onField) => {
41
+ let name;
42
+ list.forEach((entry, i) => {
43
+ if (i % 2 === 0) {
44
+ name = entry && typeof entry.value === 'string' ? entry.value : false;
45
+ return;
46
+ }
47
+
48
+ if (!name || !entry) {
49
+ return;
50
+ }
51
+
52
+ // The item name is server-controlled, but uppercasing it before the lookup means no
53
+ // Object.prototype member can be reached: every builtin name has a lowercase letter.
54
+ const field = STATUS_FIELDS[name.toUpperCase()];
55
+ if (!field) {
56
+ return;
57
+ }
58
+
59
+ const value = field.parser(entry.value);
60
+ if (value === false) {
61
+ return;
62
+ }
63
+
64
+ onField(field.key, value);
65
+ });
66
+ };
67
+
68
+ module.exports = { parseStatusList };
@@ -1,6 +1,25 @@
1
1
  'use strict';
2
2
 
3
3
  const { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active } = require('../tools.js');
4
+ const { parseStatusList } = require('./status-fields.js');
5
+
6
+ // STATUS fields that also refresh the live mailbox state when the queried mailbox is the
7
+ // currently selected one. Keyed by the output property name parseStatusList() reports.
8
+ const MAILBOX_UPDATERS = {
9
+ messages: (value, connection, path) => {
10
+ let prevCount = connection.mailbox.exists;
11
+ if (prevCount !== value) {
12
+ connection.mailbox.exists = value;
13
+ connection.emit('exists', { path, count: value, prevCount });
14
+ }
15
+ },
16
+ uidNext: (value, connection) => {
17
+ connection.mailbox.uidNext = value;
18
+ },
19
+ highestModseq: (value, connection) => {
20
+ connection.mailbox.highestModseq = value;
21
+ }
22
+ };
4
23
 
5
24
  /**
6
25
  * Requests status information about a mailbox.
@@ -56,68 +75,11 @@ module.exports = async (connection, path, query) => {
56
75
  if (!list) {
57
76
  return;
58
77
  }
59
- // Maps IMAP STATUS field names to their output key names, type parsers,
60
- // and optional callbacks to update the live mailbox state.
61
- const STATUS_FIELD_MAP = {
62
- MESSAGES: {
63
- key: 'messages',
64
- parser: Number,
65
- updateMailbox: (val, conn) => {
66
- let prevCount = conn.mailbox.exists;
67
- if (prevCount !== val) {
68
- conn.mailbox.exists = val;
69
- conn.emit('exists', { path, count: val, prevCount });
70
- }
71
- }
72
- },
73
- RECENT: { key: 'recent', parser: Number },
74
- UIDNEXT: {
75
- key: 'uidNext',
76
- parser: Number,
77
- updateMailbox: (val, conn) => {
78
- conn.mailbox.uidNext = val;
79
- }
80
- },
81
- UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
82
- UNSEEN: { key: 'unseen', parser: Number },
83
- HIGHESTMODSEQ: {
84
- key: 'highestModseq',
85
- parser: BigInt,
86
- updateMailbox: (val, conn) => {
87
- conn.mailbox.highestModseq = val;
88
- }
89
- },
90
- // IMAP4rev2 additions (RFC 9051): total mailbox size in octets
91
- // (number64, exact as a JS number up to 2^53-1) and count of
92
- // messages with the \Deleted flag
93
- SIZE: { key: 'size', parser: Number },
94
- DELETED: { key: 'deleted', parser: Number }
95
- };
96
-
97
- let key;
98
- list.forEach((entry, i) => {
99
- if (i % 2 === 0) {
100
- key = entry && typeof entry.value === 'string' ? entry.value : false;
101
- return;
102
- }
103
- if (!key || !entry || typeof entry.value !== 'string') {
104
- return;
105
- }
106
-
107
- const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
108
- if (!fieldConfig) {
109
- return;
110
- }
111
-
112
- const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
113
- if (value === false) {
114
- return;
115
- }
116
-
117
- map[fieldConfig.key] = value;
78
+ parseStatusList(list, (key, value) => {
79
+ map[key] = value;
118
80
 
119
- if (updateCurrent && fieldConfig.updateMailbox) {
120
- fieldConfig.updateMailbox(value, connection);
81
+ if (updateCurrent && MAILBOX_UPDATERS[key]) {
82
+ MAILBOX_UPDATERS[key](value, connection, path);
121
83
  }
122
84
  });
123
85
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  const Transform = require('stream').Transform;
4
4
  const logger = require('../logger');
5
- const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
5
+ const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
6
6
 
7
7
  const LINE = 0x01;
8
8
  const LITERAL = 0x02;
@@ -44,6 +44,16 @@ class ImapStream extends Transform {
44
44
  * exactly at the limit is accepted. Exceeding it is terminal: the stream is destroyed with a
45
45
  * `LiteralTooLarge` error, the marker line is not emitted, and no byte of the rejected
46
46
  * literal body is parsed as protocol.
47
+ * @param {number} [options.maxResponseSize] - Maximum allowed total size (in bytes) of a
48
+ * single assembled response: every line segment and literal of one response combined.
49
+ * Defaults to MAX_RESPONSE_SIZE (2GB), which leaves room above the literal cap for a
50
+ * maximum-size literal plus its marker line. The per-line and per-literal caps alone
51
+ * cannot stop a server that spreads attacker-controlled bytes across an unbounded
52
+ * number of tokens of a single response. Declared literal sizes count when their
53
+ * marker is parsed, so an oversized total is rejected before the literal bytes arrive,
54
+ * and a line still being assembled counts against whatever budget is left.
55
+ * Exceeding the limit is terminal: the stream is destroyed with a `ResponseTooLarge`
56
+ * error and no further input is parsed.
47
57
  */
48
58
  constructor(options) {
49
59
  super({
@@ -73,6 +83,8 @@ class ImapStream extends Transform {
73
83
  // announcing an oversized literal cannot exhaust memory.
74
84
  this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
75
85
 
86
+ this.maxResponseSize = normalizeLimit(this.options.maxResponseSize, MAX_RESPONSE_SIZE);
87
+
76
88
  this.state = LINE;
77
89
  this.literalWaiting = 0;
78
90
  this.inputBuffer = []; // lines
@@ -80,6 +92,7 @@ class ImapStream extends Transform {
80
92
  this.lineBytes = 0; // bytes currently buffered for the in-progress line
81
93
  this.literalBuffer = [];
82
94
  this.literals = [];
95
+ this.responseBytes = 0; // bytes accumulated for the in-progress response (lines + declared literals)
83
96
 
84
97
  this.compress = false;
85
98
  this.secureConnection = this.options.secureConnection;
@@ -228,6 +241,33 @@ class ImapStream extends Transform {
228
241
  return this.failStream(err);
229
242
  }
230
243
 
244
+ /**
245
+ * Enforces the configured per-response size cap: the cumulative bytes of every line
246
+ * segment and declared literal of the response currently being assembled. Counting
247
+ * declared literal sizes at marker time means an oversized total is rejected before
248
+ * the literal bytes even arrive. The counter is reset when a response is emitted.
249
+ *
250
+ * @param {number} additionalBytes - Bytes the next token would add to the response.
251
+ * @param {boolean} [peek] - Measure only, without committing the bytes to the counter.
252
+ * Used for a line that is still being assembled: its bytes are committed once, when the
253
+ * line completes.
254
+ * @returns {boolean} True if within the limit, false if the stream was failed.
255
+ */
256
+ checkResponseSize(additionalBytes, peek) {
257
+ let total = this.responseBytes + additionalBytes;
258
+ if (total <= this.maxResponseSize) {
259
+ if (!peek) {
260
+ this.responseBytes = total;
261
+ }
262
+ return true;
263
+ }
264
+ const err = new Error(`Response size ${total} exceeds maximum allowed size of ${this.maxResponseSize} bytes`);
265
+ err.code = 'ResponseTooLarge';
266
+ err.responseSize = total;
267
+ err.maxSize = this.maxResponseSize;
268
+ return this.failStream(err);
269
+ }
270
+
231
271
  /**
232
272
  * Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
233
273
  * lines and checks for literal markers. In LITERAL state, collects the expected number
@@ -273,6 +313,13 @@ class ImapStream extends Transform {
273
313
  return;
274
314
  }
275
315
 
316
+ // Count the line itself and, for a literal marker, the declared
317
+ // literal bytes against the cumulative per-response budget, so a
318
+ // response assembled from many tokens stays bounded as a whole
319
+ if (!this.checkResponseSize(line.length + (isLiteralMarker ? this.literalWaiting : 0))) {
320
+ return;
321
+ }
322
+
276
323
  this.inputBuffer.push(line);
277
324
 
278
325
  if (isLiteralMarker) {
@@ -285,6 +332,7 @@ class ImapStream extends Transform {
285
332
  let literals = this.literals;
286
333
  this.inputBuffer = [];
287
334
  this.literals = [];
335
+ this.responseBytes = 0;
288
336
 
289
337
  if (payload.length) {
290
338
  // remove final line terminator (\n or \r\n)
@@ -323,7 +371,12 @@ class ImapStream extends Transform {
323
371
  // No line terminator was found in the remaining bytes; carry the tail over to
324
372
  // the next chunk after measuring the line it belongs to.
325
373
  let tail = chunk.slice(lineStart);
326
- if (!this.checkLineLength(this.lineBytes + tail.length)) {
374
+ // The response counter is only committed when a line completes, so an
375
+ // in-progress line is measured against the remaining budget separately.
376
+ // Without this a response cap lowered to bound parser memory buys nothing
377
+ // while a server streams a line that never terminates - only the much
378
+ // larger line cap would hold it back.
379
+ if (!this.checkLineLength(this.lineBytes + tail.length) || !this.checkResponseSize(this.lineBytes + tail.length, true)) {
327
380
  return;
328
381
  }
329
382
  this.lineBytes += tail.length;
@@ -453,6 +506,7 @@ class ImapStream extends Transform {
453
506
  this.lineBytes = 0;
454
507
  this.literalBuffer = [];
455
508
  this.literals = [];
509
+ this.responseBytes = 0;
456
510
 
457
511
  // Settle an in-flight push() wait so processInput() can unwind
458
512
  if (typeof this.pendingPush === 'function') {
@@ -12,16 +12,28 @@ const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
12
12
  // only to stop a server that never sends a line terminator, not to constrain normal traffic.
13
13
  const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
14
14
 
15
+ // Default maximum total size of a single assembled response: every line segment and literal of
16
+ // one response combined. The per-line and per-literal caps alone cannot stop a server that
17
+ // spreads attacker-controlled bytes across an unbounded number of tokens of a single response
18
+ // (e.g. one FETCH answer carrying many maximum-size literals).
19
+ //
20
+ // Deliberately above the literal cap: the response total also carries the literal's marker line
21
+ // and the rest of the response framing, so a cap equal to MAX_LITERAL_SIZE would make a literal
22
+ // of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
23
+ // the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
24
+ const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
25
+
15
26
  /**
16
27
  * Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
17
- * means "reject anything non-empty"); anything else falls back to the default, so an explicit 0 is
18
- * not silently swallowed the way `value || DEFAULT` would swallow it.
28
+ * means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
29
+ * to the default, so an explicit 0 is not silently swallowed the way `value || DEFAULT` would
30
+ * swallow it.
19
31
  *
20
32
  * @param {*} value - The configured value.
21
33
  * @param {number} defaultValue - Fallback when the value is not a usable limit.
22
34
  * @returns {number} The normalized limit.
23
35
  */
24
- const normalizeLimit = (value, defaultValue) => (Number.isInteger(value) && value >= 0 ? value : defaultValue);
36
+ const normalizeLimit = (value, defaultValue) => ((Number.isInteger(value) || value === Infinity) && value >= 0 ? value : defaultValue);
25
37
 
26
38
  /**
27
39
  * Builds the `LiteralTooLarge` error. One shape for every place a literal is refused, so callers
@@ -40,4 +52,4 @@ const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
40
52
  return err;
41
53
  };
42
54
 
43
- module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError };
55
+ module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError };
@@ -85,18 +85,34 @@ export interface ImapFlowOptions {
85
85
  * Maximum allowed length in bytes of a single response line (a response without a literal).
86
86
  * Guards against a malicious or broken server that never sends a line terminator. Defaults to
87
87
  * 1GB. The line terminator counts towards the limit and a line exactly at the limit is
88
- * accepted. Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
88
+ * accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
89
+ * whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
90
+ * Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
89
91
  * no further input is parsed.
90
92
  */
91
93
  maxLineLength?: number;
92
94
  /**
93
95
  * Maximum allowed size in bytes of a single IMAP literal block. Bounds peak memory allocation
94
96
  * against a malicious or broken server announcing an oversized literal. Defaults to 1GB. A
95
- * literal exactly at the limit is accepted. Exceeding it is terminal: the connection fails
97
+ * literal exactly at the limit is accepted, provided `maxResponseSize` leaves room for the
98
+ * marker line as the defaults do. `Infinity` disables the limit. Exceeding it is terminal: the connection fails
96
99
  * with error code `LiteralTooLarge`, and neither the marker line nor any byte of the rejected
97
100
  * literal is interpreted as protocol.
98
101
  */
99
102
  maxLiteralSize?: number;
103
+ /**
104
+ * Maximum allowed total size in bytes of a single assembled IMAP response (every line
105
+ * segment and literal of one response combined). Bounds peak memory allocation against
106
+ * a malicious or broken server that spreads response data across an unbounded number of
107
+ * tokens, which the per-line and per-literal caps alone cannot stop. Defaults to 2GB,
108
+ * which is above the default literal cap on purpose: the total also carries the literal
109
+ * marker line and the rest of the response framing, so a value equal to `maxLiteralSize`
110
+ * would make a literal of exactly the maximum permitted size impossible to receive. Set
111
+ * this above `maxLiteralSize` when configuring both. `Infinity` disables the limit.
112
+ * Exceeding it is terminal: the connection fails with error code `ResponseTooLarge` and
113
+ * no further input is parsed.
114
+ */
115
+ maxResponseSize?: number;
100
116
  /**
101
117
  * Threshold in milliseconds for warning that a mailbox lock has been held
102
118
  * for a long time (diagnostic for forgotten release() calls). Defaults to
@@ -145,6 +161,10 @@ export interface MailboxObject {
145
161
  uidNext: number;
146
162
  /** Messages in this folder */
147
163
  exists: number;
164
+ /** Sequence number of the first unseen message, if the server reported [UNSEEN] on SELECT. Not a count of unseen messages - use mailboxStatus() with {unseen: true} for that */
165
+ unseen?: number;
166
+ /** Largest message size in octets the server accepts for APPEND into this mailbox, if it reported [APPENDLIMIT] (RFC 7889) */
167
+ appendlimit?: number;
148
168
  /** Read-only state */
149
169
  readOnly?: boolean;
150
170
  }