imapflow 1.6.4 → 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.
Files changed (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
@@ -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
  }
@@ -4,11 +4,27 @@
4
4
 
5
5
  const imapFormalSyntax = require('./imap-formal-syntax');
6
6
 
7
- // A sequence-set as defined by the RFC 9051 grammar: comma-separated numbers and
8
- // ranges, where "*" stands for the largest number in use. Digit strings are not
7
+ // A single element of a sequence-set as defined by the RFC 9051 grammar: a number
8
+ // or a range, where "*" stands for the largest number in use. Digit strings are not
9
9
  // range-checked here (a server rejects "0" or an overlong number on its own); the
10
10
  // point of the check is that nothing outside this alphabet can reach the wire.
11
- const SEQUENCE_SET = /^(\d+|\*)(:(\d+|\*))?(,(\d+|\*)(:(\d+|\*))?)*$/;
11
+ const SEQ_RANGE = /^(\d+|\*)(:(\d+|\*))?$/;
12
+
13
+ // Validates a full sequence-set: comma-separated SEQ_RANGE elements, or "$"
14
+ // (RFC 5182 SEARCHRES), which references the previous SEARCH result and is only
15
+ // valid as the entire set. Split into per-element tests on purpose - a whole-set
16
+ // regex with an unbounded repeat group overflows the regex engine's backtrack
17
+ // stack with an uncoded RangeError on valid sets in the million-element range,
18
+ // while the per-element regex is bounded.
19
+ const isValidSequenceSet = value => value === '$' || value.split(',').every(part => SEQ_RANGE.test(part));
20
+
21
+ // Numeric tokens may only put the digit alphabet on the wire. Anything that does
22
+ // not round to a bounded non-negative integer (NaN, Infinity, negatives, unsafe
23
+ // magnitudes) degrades to 0 - the fallback the NaN coercion has always used.
24
+ const safeNumber = value => {
25
+ let num = Math.round(Number(value));
26
+ return Number.isSafeInteger(num) && num >= 0 ? num : 0;
27
+ };
12
28
 
13
29
  // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
14
30
  // command line, and NUL is outside the CHAR production entirely. A value carrying
@@ -37,33 +53,6 @@ const quoteString = value => {
37
53
  return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
38
54
  };
39
55
 
40
- /**
41
- * Formats a response entry into a Buffer.
42
- *
43
- * @param {string|number|Buffer} entry - The value to convert to a Buffer.
44
- * @param {boolean} [returnEmpty] - If true, returns null instead of an empty Buffer when the entry is not a recognized type.
45
- * @returns {Buffer|null} The entry as a Buffer, or null if returnEmpty is true and the entry is not a recognized type.
46
- */
47
- const formatRespEntry = (entry, returnEmpty) => {
48
- if (typeof entry === 'string') {
49
- return Buffer.from(entry);
50
- }
51
-
52
- if (typeof entry === 'number') {
53
- return Buffer.from(entry.toString());
54
- }
55
-
56
- if (Buffer.isBuffer(entry)) {
57
- return entry;
58
- }
59
-
60
- if (returnEmpty) {
61
- return null;
62
- }
63
-
64
- return Buffer.alloc(0);
65
- };
66
-
67
56
  /**
68
57
  * Compiles an input object into a sequence of Buffers representing an IMAP protocol response string.
69
58
  * Handles various node types including literals, strings, atoms, sections, sequences, and nested lists.
@@ -83,7 +72,43 @@ module.exports = async (response, options) => {
83
72
  let { asArray, isLogging, literalPlus, literalMinus } = options || {};
84
73
  const respParts = [];
85
74
 
86
- let resp = [].concat(formatRespEntry(response.tag, true) || []).concat(response.command ? formatRespEntry(' ' + response.command) : []);
75
+ // Formats an entry (string, number or Buffer) into the Buffer that is written to
76
+ // the wire, and is the choke point every emission passes through: a line
77
+ // terminator ends an IMAP command, so no token - the tag and command name
78
+ // included - may put one on the wire. The literal size marker and literal data
79
+ // are the only emissions where CRLF is legitimate; those call sites opt out with
80
+ // `raw`. Never enforced when logging: re-encoding an incoming server response for
81
+ // the log or for error text must not throw, whatever the server sent. With
82
+ // `returnEmpty`, an unrecognized entry type yields null instead of an empty
83
+ // Buffer.
84
+ const emitEntry = (entry, opts) => {
85
+ let { returnEmpty, raw } = opts || {};
86
+ if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) {
87
+ let error = new Error('Line terminator in IMAP token');
88
+ error.code = 'InvalidTokenValue';
89
+ throw error;
90
+ }
91
+
92
+ if (typeof entry === 'string') {
93
+ return Buffer.from(entry);
94
+ }
95
+
96
+ if (typeof entry === 'number') {
97
+ return Buffer.from(entry.toString());
98
+ }
99
+
100
+ if (Buffer.isBuffer(entry)) {
101
+ return entry;
102
+ }
103
+
104
+ if (returnEmpty) {
105
+ return null;
106
+ }
107
+
108
+ return Buffer.alloc(0);
109
+ };
110
+
111
+ let resp = [].concat(emitEntry(response.tag, { returnEmpty: true }) || []).concat(response.command ? emitEntry(' ' + response.command) : []);
87
112
  let val;
88
113
  let lastType;
89
114
 
@@ -108,7 +133,7 @@ module.exports = async (response, options) => {
108
133
  // adjacent lists).
109
134
  if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
110
135
  if (!options.subArray) {
111
- resp.push(formatRespEntry(' '));
136
+ resp.push(emitEntry(' '));
112
137
  }
113
138
  }
114
139
 
@@ -119,7 +144,7 @@ module.exports = async (response, options) => {
119
144
 
120
145
  if (Array.isArray(node)) {
121
146
  lastType = 'LIST';
122
- resp.push(formatRespEntry('('));
147
+ resp.push(emitEntry('('));
123
148
 
124
149
  // check if we need to skip separator WS between two arrays
125
150
  let subArray = node.length > 1 && Array.isArray(node[0]);
@@ -131,40 +156,40 @@ module.exports = async (response, options) => {
131
156
  await walk(child, { subArray });
132
157
  }
133
158
 
134
- resp.push(formatRespEntry(')'));
159
+ resp.push(emitEntry(')'));
135
160
  return;
136
161
  }
137
162
 
138
163
  if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
139
- resp.push(formatRespEntry('NIL'));
164
+ resp.push(emitEntry('NIL'));
140
165
  return;
141
166
  }
142
167
 
143
168
  if (typeof node === 'string' || Buffer.isBuffer(node)) {
144
169
  if (isLogging && node.length > 100) {
145
- resp.push(formatRespEntry('"(* ' + node.length + 'B string *)"'));
170
+ resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
146
171
  } else {
147
- resp.push(formatRespEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
172
+ resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
148
173
  }
149
174
  return;
150
175
  }
151
176
 
152
177
  if (typeof node === 'number') {
153
- resp.push(formatRespEntry(Math.round(node) || 0)); // Only integers allowed
178
+ resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed
154
179
  return;
155
180
  }
156
181
 
157
182
  lastType = node.type;
158
183
 
159
184
  if (isLogging && node.sensitive) {
160
- resp.push(formatRespEntry('"(* value hidden *)"'));
185
+ resp.push(emitEntry('"(* value hidden *)"'));
161
186
  return;
162
187
  }
163
188
 
164
189
  switch (node.type.toUpperCase()) {
165
190
  case 'LITERAL':
166
191
  if (isLogging) {
167
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B literal *)"'));
192
+ resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
168
193
  } else {
169
194
  // The literal size marker counts octets - string values are written as
170
195
  // UTF-8, so their UTF-16 .length would undercount multi-byte characters
@@ -180,29 +205,29 @@ module.exports = async (response, options) => {
180
205
  let canAppend = !asArray || usePlus;
181
206
 
182
207
  // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
183
- resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
208
+ resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true }));
184
209
 
185
210
  if (canAppend) {
186
211
  // Literal data follows immediately in the same buffer segment
187
212
  if (node.value && node.value.length) {
188
- resp.push(formatRespEntry(node.value));
213
+ resp.push(emitEntry(node.value, { raw: true }));
189
214
  }
190
215
  } else {
191
216
  // For synchronizing literals in asArray mode, split output into separate
192
217
  // parts. The caller must send each part and wait for a continuation
193
218
  // response from the server before sending the next.
194
219
  respParts.push(resp);
195
- resp = [].concat(formatRespEntry(node.value, true) || []);
220
+ resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []);
196
221
  }
197
222
  }
198
223
  break;
199
224
 
200
225
  case 'STRING':
201
226
  if (isLogging && node.value.length > 100) {
202
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B string *)"'));
227
+ resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
203
228
  } else {
204
229
  val = (node.value || '').toString();
205
- resp.push(formatRespEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
230
+ resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
206
231
  }
207
232
  break;
208
233
 
@@ -210,34 +235,39 @@ module.exports = async (response, options) => {
210
235
  // Sequence sets are written verbatim - they are the one token type with
211
236
  // no quoting to fall back on. Callers build them from user-supplied
212
237
  // ranges, so validate here, at the single point every outgoing sequence
213
- // set passes through, rather than trusting each command module. Values
214
- // the parser produced for a server response are already within this
215
- // alphabet, so re-compiling a response (logging, error text) is unaffected.
216
- if (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value)) {
238
+ // set passes through, rather than trusting each command module. Skipped
239
+ // when logging: the incoming token parser accepts sequence-shaped tokens
240
+ // this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
241
+ // name like "12:30:00"), and re-compiling a server response for the log
242
+ // or for error text must never throw.
243
+ if (!isLogging && (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value))) {
217
244
  val = node.value.toString();
218
- if (val && !SEQUENCE_SET.test(val)) {
245
+ if (val && !isValidSequenceSet(val)) {
219
246
  let error = new Error('Invalid sequence set value');
220
247
  error.code = 'InvalidSequenceSet';
221
248
  throw error;
222
249
  }
223
250
  }
224
251
  if (node.value) {
225
- resp.push(formatRespEntry(node.value));
252
+ // raw: the validated alphabet cannot contain a line terminator, and
253
+ // re-scanning a potentially multi-megabyte set in the choke point
254
+ // would double the cost of exactly the sets this branch exists for
255
+ resp.push(emitEntry(node.value, { raw: true }));
226
256
  }
227
257
  break;
228
258
 
229
259
  case 'TEXT':
230
260
  // Response text is written verbatim. Only the parser produces it today, for
231
261
  // incoming lines, so this is a re-encoding path rather than a command-building
232
- // one - but the choke point is only worth anything if it holds for every token
233
- // type, so a line terminator is refused here too.
262
+ // one. The emitEntry choke point would refuse a line terminator here too;
263
+ // this check runs first only to raise the more specific InvalidTextValue code.
234
264
  if (node.value) {
235
265
  if (!isLogging && CRLF.test(node.value.toString())) {
236
266
  let error = new Error('Line terminator in IMAP text value');
237
267
  error.code = 'InvalidTextValue';
238
268
  throw error;
239
269
  }
240
- resp.push(formatRespEntry(node.value));
270
+ resp.push(emitEntry(node.value));
241
271
  }
242
272
  break;
243
273
 
@@ -245,7 +275,7 @@ module.exports = async (response, options) => {
245
275
  // Coerced rather than written through: formatRespEntry passes a string or
246
276
  // Buffer straight to the wire, so a numeric token carrying a string value
247
277
  // would be another verbatim channel
248
- resp.push(formatRespEntry(Math.round(Number(node.value)) || 0));
278
+ resp.push(emitEntry(safeNumber(node.value)));
249
279
  break;
250
280
 
251
281
  case 'ATOM':
@@ -255,24 +285,25 @@ module.exports = async (response, options) => {
255
285
  if (!node.section || val) {
256
286
  // Verify the value contains only valid ATOM-CHAR characters.
257
287
  // Strip a leading backslash before checking (system flags like \Seen start with '\').
258
- // If any character fails verification, quote-escape the entire value with JSON.stringify.
288
+ // If any character fails verification, fall back to an IMAP quoted string
289
+ // (JSON.stringify is used only for log output, where values are display-escaped).
259
290
  if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
260
291
  val = isLogging ? JSON.stringify(val) : quoteString(val);
261
292
  }
262
293
 
263
- resp.push(formatRespEntry(val));
294
+ resp.push(emitEntry(val));
264
295
  }
265
296
 
266
297
  // Section bracket handling: emit [section-contents] after the ATOM value
267
298
  // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
268
299
  if (node.section) {
269
- resp.push(formatRespEntry('['));
300
+ resp.push(emitEntry('['));
270
301
 
271
302
  for (let child of node.section) {
272
303
  await walk(child);
273
304
  }
274
305
 
275
- resp.push(formatRespEntry(']'));
306
+ resp.push(emitEntry(']'));
276
307
  }
277
308
  // Partial range: emit <origin.length> after the section brackets. Coerced
278
309
  // rather than joined as-is: this is the last token component written
@@ -280,7 +311,7 @@ module.exports = async (response, options) => {
280
311
  // all of them. Every producer already passes numbers, so nothing changes
281
312
  // for them.
282
313
  if (node.partial) {
283
- resp.push(formatRespEntry(`<${node.partial.map(entry => Number(entry) || 0).join('.')}>`));
314
+ resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`));
284
315
  }
285
316
  break;
286
317
  }
@@ -81,6 +81,13 @@ module.exports = async (command, options) => {
81
81
  if (err.code === 'ParserErrorExchange' && err.parserContext && err.parserContext.value) {
82
82
  return err.parserContext.value;
83
83
  }
84
+ if (response.tag) {
85
+ // The tag had already been parsed when the rest of the line failed. Expose it
86
+ // so the connection can settle the command this line was addressed to - unlike
87
+ // re-deriving the tag from the raw bytes, this inherits the leading-NUL
88
+ // workaround above.
89
+ err.parsedTag = response.tag;
90
+ }
84
91
  throw err;
85
92
  }
86
93
 
@@ -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;
@@ -166,23 +179,35 @@ class ImapStream extends Transform {
166
179
 
167
180
  // Scan backwards through the line to find an IMAP literal marker: {size}\r\n
168
181
  // The format is: '{' followed by one or more ASCII digits followed by '}'.
169
- // Only the digit run's bounds are tracked, and the run is capped: the size is
170
- // a number64 at most, so anything longer cannot be a valid marker. Collecting
171
- // the digits into a growing array instead would make a line of n digits cost
172
- // O(n^2), letting a few hundred KB of digits block the event loop for seconds
173
- // before the size is even known - well inside the default line-length cap.
174
- const MAX_SIZE_DIGITS = 19;
182
+ // Only the digit run's bounds are tracked - a single linear pass, unlike
183
+ // collecting digits into a growing array, which would make a line of n digits
184
+ // cost O(n^2). The run length is deliberately not capped: the RFC "number"
185
+ // production permits leading zeros, so a long digit run can still denote a
186
+ // small, valid size, and treating the marker as an ordinary line instead
187
+ // would feed the announced literal body to the line parser and desynchronize
188
+ // the session.
175
189
  let digitsEnd = pos;
176
190
  for (; pos >= 0; pos--) {
177
191
  let c = line[pos];
178
192
  if (c >= NUM_0 && c <= NUM_9) {
179
- if (digitsEnd - pos >= MAX_SIZE_DIGITS) {
180
- return false;
181
- }
182
193
  continue;
183
194
  }
184
195
  if (c === CURLY_OPEN && pos < digitsEnd) {
185
- const literalSize = Number(line.toString('latin1', pos + 1, digitsEnd + 1));
196
+ // Skip leading zeros so only the significant digits are converted: a
197
+ // marker padded with megabytes of zeros must not cost a string
198
+ // allocation and Number() parse of the whole run.
199
+ let digitsStart = pos + 1;
200
+ while (digitsStart < digitsEnd && line[digitsStart] === NUM_0) {
201
+ digitsStart++;
202
+ }
203
+
204
+ // More significant digits than any number64 has cannot fit any
205
+ // permissible maxLiteralSize; fail closed without materializing them
206
+ if (digitsEnd + 1 - digitsStart > 19) {
207
+ return this.failStream(createLiteralTooLargeError(Infinity, this.maxLiteralSize, 'the widest permissible literal size (19 digits)'));
208
+ }
209
+
210
+ const literalSize = Number(line.toString('latin1', digitsStart, digitsEnd + 1));
186
211
 
187
212
  if (literalSize > this.maxLiteralSize) {
188
213
  return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
@@ -216,6 +241,33 @@ class ImapStream extends Transform {
216
241
  return this.failStream(err);
217
242
  }
218
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
+
219
271
  /**
220
272
  * Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
221
273
  * lines and checks for literal markers. In LITERAL state, collects the expected number
@@ -261,6 +313,13 @@ class ImapStream extends Transform {
261
313
  return;
262
314
  }
263
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
+
264
323
  this.inputBuffer.push(line);
265
324
 
266
325
  if (isLiteralMarker) {
@@ -273,6 +332,7 @@ class ImapStream extends Transform {
273
332
  let literals = this.literals;
274
333
  this.inputBuffer = [];
275
334
  this.literals = [];
335
+ this.responseBytes = 0;
276
336
 
277
337
  if (payload.length) {
278
338
  // remove final line terminator (\n or \r\n)
@@ -311,7 +371,12 @@ class ImapStream extends Transform {
311
371
  // No line terminator was found in the remaining bytes; carry the tail over to
312
372
  // the next chunk after measuring the line it belongs to.
313
373
  let tail = chunk.slice(lineStart);
314
- 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)) {
315
380
  return;
316
381
  }
317
382
  this.lineBytes += tail.length;
@@ -441,6 +506,7 @@ class ImapStream extends Transform {
441
506
  this.lineBytes = 0;
442
507
  this.literalBuffer = [];
443
508
  this.literals = [];
509
+ this.responseBytes = 0;
444
510
 
445
511
  // Settle an in-flight push() wait so processInput() can unwind
446
512
  if (typeof this.pendingPush === 'function') {