imapflow 1.6.3 → 1.6.4

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.3"
2
+ ".": "1.6.4"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * block IMAP command injection and harden rev2 protocol handling ([e107292](https://github.com/postalsys/imapflow/commit/e107292bc907b29d94b0d810dc01e9680f820bfa))
9
+
3
10
  ## [1.6.3](https://github.com/postalsys/imapflow/compare/v1.6.2...v1.6.3) (2026-07-28)
4
11
 
5
12
 
@@ -19,7 +19,23 @@ module.exports = async connection => {
19
19
  let response;
20
20
  try {
21
21
  response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
22
+ // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4).
23
+ // The socket stays piped into the plaintext parser until this call returns, so
24
+ // bytes that arrived in the same chunk as the OK have been consumed as cleartext
25
+ // and are missing from the head of the deflate stream - the inflater would then
26
+ // fail and take the connection with it. Rare (it needs the server to write again
27
+ // before we re-pipe), and staying uncompressed is a cheaper outcome than a dead
28
+ // connection, so decline the upgrade instead.
29
+ // This only covers the bytes the parser had already buffered when the OK was
30
+ // handled. Closing the remaining window, and keeping compression rather than
31
+ // dropping it, needs the stream to hand back its unconsumed tail on unpipe so
32
+ // the transport can feed it into the inflater - not something a command module
33
+ // can reach from here.
22
34
  response.next();
35
+ if (response.hasTrailingData) {
36
+ connection.log.warn({ msg: 'Server sent data immediately after the COMPRESS response, skipping compression', cid: connection.id });
37
+ return false;
38
+ }
23
39
  return true;
24
40
  } catch (err) {
25
41
  connection.log.warn({ err, cid: connection.id });
@@ -27,6 +27,8 @@ const stripEsearchPrefix = attrs => {
27
27
  *
28
28
  * ALL and PARTIAL.messages are kept as compact sequence-set strings.
29
29
  * Use expandRange() from tools.js if you need to expand them.
30
+ * MODSEQ (RFC 7162, sent when the search used a MODSEQ criterion) is
31
+ * returned as a BigInt.
30
32
  *
31
33
  * @param {Array} attrs - Attribute array from the IMAP parser
32
34
  * @returns {Object} ESearchResult object
@@ -61,6 +63,14 @@ function parseEsearchResponse(attrs) {
61
63
  if (!isNaN(n)) result.max = n;
62
64
  break;
63
65
  }
66
+ case 'MODSEQ': {
67
+ // RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
68
+ // criterion on a CONDSTORE-enabled session. BigInt because
69
+ // mod-sequence values are unsigned 63-bit
70
+ const value = attrs[++i]?.value;
71
+ if (typeof value === 'string' && /^\d+$/.test(value)) result.modseq = BigInt(value);
72
+ break;
73
+ }
64
74
  case 'ALL': {
65
75
  const allToken = attrs[++i];
66
76
  if (allToken && typeof allToken.value === 'string') {
@@ -4,6 +4,39 @@
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
9
+ // range-checked here (a server rejects "0" or an overlong number on its own); the
10
+ // point of the check is that nothing outside this alphabet can reach the wire.
11
+ const SEQUENCE_SET = /^(\d+|\*)(:(\d+|\*))?(,(\d+|\*)(:(\d+|\*))?)*$/;
12
+
13
+ // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
14
+ // command line, and NUL is outside the CHAR production entirely. A value carrying
15
+ // any of them has to be sent as a literal, so quoting it is never correct.
16
+ const NOT_QUOTABLE = /[\r\n\0]/;
17
+
18
+ // A line terminator ends an IMAP command, so no token may carry one to the wire.
19
+ const CRLF = /[\r\n]/;
20
+
21
+ /**
22
+ * Quotes a value as an IMAP quoted string. Only DQUOTE and backslash are escaped -
23
+ * the IMAP grammar defines no other escape sequence, so JSON-style escaping (which
24
+ * turns a tab into a literal backslash-t and a control character into \\uXXXX) would
25
+ * silently change the value the server receives.
26
+ *
27
+ * @param {string} value - The value to quote.
28
+ * @returns {string} The quoted string, ready to be written to the wire.
29
+ * @throws {Error} If the value contains CR, LF or NUL, which a quoted string cannot carry.
30
+ */
31
+ const quoteString = value => {
32
+ if (NOT_QUOTABLE.test(value)) {
33
+ let error = new Error('Unquotable character in IMAP string value');
34
+ error.code = 'InvalidStringValue';
35
+ throw error;
36
+ }
37
+ return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
38
+ };
39
+
7
40
  /**
8
41
  * Formats a response entry into a Buffer.
9
42
  *
@@ -111,7 +144,7 @@ module.exports = async (response, options) => {
111
144
  if (isLogging && node.length > 100) {
112
145
  resp.push(formatRespEntry('"(* ' + node.length + 'B string *)"'));
113
146
  } else {
114
- resp.push(formatRespEntry(JSON.stringify(node.toString())));
147
+ resp.push(formatRespEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
115
148
  }
116
149
  return;
117
150
  }
@@ -168,19 +201,51 @@ module.exports = async (response, options) => {
168
201
  if (isLogging && node.value.length > 100) {
169
202
  resp.push(formatRespEntry('"(* ' + node.value.length + 'B string *)"'));
170
203
  } else {
171
- resp.push(formatRespEntry(JSON.stringify((node.value || '').toString())));
204
+ val = (node.value || '').toString();
205
+ resp.push(formatRespEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
172
206
  }
173
207
  break;
174
208
 
175
- case 'TEXT':
176
209
  case 'SEQUENCE':
210
+ // Sequence sets are written verbatim - they are the one token type with
211
+ // no quoting to fall back on. Callers build them from user-supplied
212
+ // 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)) {
217
+ val = node.value.toString();
218
+ if (val && !SEQUENCE_SET.test(val)) {
219
+ let error = new Error('Invalid sequence set value');
220
+ error.code = 'InvalidSequenceSet';
221
+ throw error;
222
+ }
223
+ }
177
224
  if (node.value) {
178
225
  resp.push(formatRespEntry(node.value));
179
226
  }
180
227
  break;
181
228
 
229
+ case 'TEXT':
230
+ // Response text is written verbatim. Only the parser produces it today, for
231
+ // 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.
234
+ if (node.value) {
235
+ if (!isLogging && CRLF.test(node.value.toString())) {
236
+ let error = new Error('Line terminator in IMAP text value');
237
+ error.code = 'InvalidTextValue';
238
+ throw error;
239
+ }
240
+ resp.push(formatRespEntry(node.value));
241
+ }
242
+ break;
243
+
182
244
  case 'NUMBER':
183
- resp.push(formatRespEntry(node.value || 0));
245
+ // Coerced rather than written through: formatRespEntry passes a string or
246
+ // Buffer straight to the wire, so a numeric token carrying a string value
247
+ // would be another verbatim channel
248
+ resp.push(formatRespEntry(Math.round(Number(node.value)) || 0));
184
249
  break;
185
250
 
186
251
  case 'ATOM':
@@ -192,7 +257,7 @@ module.exports = async (response, options) => {
192
257
  // Strip a leading backslash before checking (system flags like \Seen start with '\').
193
258
  // If any character fails verification, quote-escape the entire value with JSON.stringify.
194
259
  if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
195
- val = JSON.stringify(val);
260
+ val = isLogging ? JSON.stringify(val) : quoteString(val);
196
261
  }
197
262
 
198
263
  resp.push(formatRespEntry(val));
@@ -209,9 +274,13 @@ module.exports = async (response, options) => {
209
274
 
210
275
  resp.push(formatRespEntry(']'));
211
276
  }
212
- // Partial range: emit <origin.length> after the section brackets
277
+ // Partial range: emit <origin.length> after the section brackets. Coerced
278
+ // rather than joined as-is: this is the last token component written
279
+ // verbatim, and the choke point is only worth relying on if it holds for
280
+ // all of them. Every producer already passes numbers, so nothing changes
281
+ // for them.
213
282
  if (node.partial) {
214
- resp.push(formatRespEntry(`<${node.partial.join('.')}>`));
283
+ resp.push(formatRespEntry(`<${node.partial.map(entry => Number(entry) || 0).join('.')}>`));
215
284
  }
216
285
  break;
217
286
  }
@@ -165,16 +165,24 @@ class ImapStream extends Transform {
165
165
  pos--;
166
166
 
167
167
  // Scan backwards through the line to find an IMAP literal marker: {size}\r\n
168
- // The format is: '{' followed by one or more ASCII digits followed by '}'
169
- let numBytes = [];
168
+ // 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;
175
+ let digitsEnd = pos;
170
176
  for (; pos >= 0; pos--) {
171
177
  let c = line[pos];
172
178
  if (c >= NUM_0 && c <= NUM_9) {
173
- numBytes.unshift(c);
179
+ if (digitsEnd - pos >= MAX_SIZE_DIGITS) {
180
+ return false;
181
+ }
174
182
  continue;
175
183
  }
176
- if (c === CURLY_OPEN && numBytes.length) {
177
- const literalSize = Number(Buffer.from(numBytes).toString());
184
+ if (c === CURLY_OPEN && pos < digitsEnd) {
185
+ const literalSize = Number(line.toString('latin1', pos + 1, digitsEnd + 1));
178
186
 
179
187
  if (literalSize > this.maxLiteralSize) {
180
188
  return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
@@ -77,8 +77,13 @@ class ParserInstance {
77
77
  {
78
78
  let match = this.remainder.match(/^\s+\[/);
79
79
  if (match) {
80
+ // Find the ']' that closes the response code. Inner brackets are
81
+ // tracked because servers do put bracketed values inside a code
82
+ // (e.g. a "[css3-page]" keyword in a PERMANENTFLAGS list), which a
83
+ // first-']' scan would cut in half.
80
84
  let nesting = 1;
81
- for (let i = match[0].length; i <= this.remainder.length; i++) {
85
+ let end = -1;
86
+ for (let i = match[0].length; i < this.remainder.length; i++) {
82
87
  let c = this.remainder[i];
83
88
 
84
89
  if (c === '[') {
@@ -87,11 +92,25 @@ class ParserInstance {
87
92
  nesting--;
88
93
  }
89
94
  if (!nesting) {
90
- this.humanReadable = this.remainder.substring(i + 1).trim();
91
- this.remainder = this.remainder.substring(0, i + 1);
95
+ end = i;
92
96
  break;
93
97
  }
94
98
  }
99
+
100
+ // Unbalanced '[' inside the code: the RFC 9051 free-text form
101
+ // (`atom [SP 1*<any TEXT-CHAR except "]">]`) permits '[' but not
102
+ // ']', so the code really does end at the first ']' here. Without
103
+ // this fallback the scan finds no closing bracket at all and the
104
+ // human-readable text - what every error message is built from -
105
+ // is swallowed into the response code.
106
+ if (end < 0) {
107
+ end = this.remainder.indexOf(']', match[0].length);
108
+ }
109
+
110
+ if (end >= 0) {
111
+ this.humanReadable = this.remainder.substring(end + 1).trim();
112
+ this.remainder = this.remainder.substring(0, end + 1);
113
+ }
95
114
  } else {
96
115
  this.humanReadable = this.remainder.trim();
97
116
  this.remainder = '';
@@ -699,6 +699,8 @@ export interface ESearchResult {
699
699
  /** Matching UIDs in that range as compact sequence-set */
700
700
  messages: string;
701
701
  };
702
+ /** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
703
+ modseq?: bigint;
702
704
  }
703
705
 
704
706
  export class AuthenticationFailure extends Error {
package/lib/imap-flow.js CHANGED
@@ -834,6 +834,33 @@ class ImapFlow extends EventEmitter {
834
834
  }
835
835
  }
836
836
 
837
+ /**
838
+ * Fails the in-flight command when a line that could not be parsed was addressed to its tag.
839
+ * Only the leading tag is read from the raw payload - the rest of the line is by definition
840
+ * not trustworthy - and only the command that is actually on the wire may be settled this way,
841
+ * the same invariant the parsed tagged-response path enforces.
842
+ *
843
+ * @param {Buffer} payload - Raw bytes of the line that failed to parse.
844
+ * @param {Error} parserError - The error the parser raised.
845
+ */
846
+ rejectUnparsedCompletion(payload, parserError) {
847
+ if (!this.currentRequest || !this.currentRequest.sent) {
848
+ return;
849
+ }
850
+
851
+ let tag = payload.toString('latin1', 0, 64).match(/^(\S+)/);
852
+ if (!tag || tag[1] !== this.currentRequest.tag) {
853
+ return;
854
+ }
855
+
856
+ let err = new Error('Failed to parse the server response for this command');
857
+ err.code = parserError.code || 'ParserError';
858
+ err.parserError = parserError;
859
+ this.rejectCurrentRequest(err);
860
+
861
+ this.trySend().catch(sendErr => this.log.warn({ err: sendErr, cid: this.id }));
862
+ }
863
+
837
864
  /**
838
865
  * Handles a single parsed server response: telemetry, continuation requests, response-code
839
866
  * section handlers, untagged handlers and tagged command completion.
@@ -863,6 +890,14 @@ class ImapFlow extends EventEmitter {
863
890
  } catch (err) {
864
891
  // can not make sense of this
865
892
  this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
893
+ // An unparseable untagged line is junk that can be skipped, but the line may
894
+ // have been the in-flight command's tagged completion. Dropping that one
895
+ // silently strands the command: currentRequest is never cleared, so trySend()
896
+ // stops dispatching and every later command queues behind it until the socket
897
+ // timeout fires. The tag is recovered from the raw bytes (a tag is
898
+ // ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
899
+ // the command is failed with the parser error instead of hanging.
900
+ this.rejectUnparsedCompletion(data.payload, err);
866
901
  return true;
867
902
  }
868
903
 
@@ -1629,11 +1664,14 @@ class ImapFlow extends EventEmitter {
1629
1664
  this.writeSocket = this.socket;
1630
1665
  });
1631
1666
 
1632
- if (upgraded && this.expectCapabilityUpdate) {
1633
- // After STARTTLS the server may advertise a different capability set
1634
- // (e.g., LOGINDISABLED removed, new AUTH= methods). Clear the pre-TLS
1635
- // map before re-fetching so stale capabilities cannot leak through
1636
- // if the CAPABILITY response is delayed or absent.
1667
+ if (upgraded) {
1668
+ // RFC 9051 section 6.2.1: once TLS is started the client MUST discard the
1669
+ // cached capabilities and reissue CAPABILITY, because everything learned
1670
+ // before the handshake was plaintext an active attacker could rewrite.
1671
+ // Unconditional on purpose: a server that stamps [CAPABILITY ...] on the
1672
+ // STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
1673
+ // on that flag would keep exactly the pre-TLS list an attacker controls -
1674
+ // the list that then picks the AUTH mechanism and answers LOGINDISABLED.
1637
1675
  this.capabilities.clear();
1638
1676
  this.authCapabilities.clear();
1639
1677
  await this.run('CAPABILITY');
@@ -233,10 +233,13 @@ module.exports.searchCompiler = (connection, query) => {
233
233
  }
234
234
  break;
235
235
 
236
- // UID sequences
236
+ // UID sequences. The key stays an ATOM and only the value is a
237
+ // SEQUENCE token, so the compiler validates the sequence set
238
+ // itself rather than the "UID" keyword in front of it.
237
239
  case 'UID':
238
240
  if (params[term]) {
239
- setOpt(attributes, term, params[term], 'SEQUENCE');
241
+ attributes.push({ type: 'ATOM', value: 'UID' });
242
+ [].concat(params[term]).forEach(entry => attributes.push({ type: 'SEQUENCE', value: (entry ?? '').toString() }));
240
243
  }
241
244
  break;
242
245
 
package/lib/tools.js CHANGED
@@ -20,9 +20,13 @@ const EXPANDED_RANGE_LIMIT = 0x1000000;
20
20
  // When IMAP4rev2 is active, these are available even without their own capability
21
21
  // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
22
22
  // which fetch.js handles with its own isRev2Active check, while the APPEND side
23
- // stays gated on the BINARY token. The set mirrors the Appendix E list in full,
24
- // including entries no call site consults yet, so any future capability check
25
- // gets the rev2 folding for free.
23
+ // stays gated on the BINARY token. SPECIAL-USE is a partial fold: Appendix E only
24
+ // folds in the special-use mailbox attributes, not the RFC 6154 LIST selection and
25
+ // RETURN options - the only call sites that act on this entry are in list.js,
26
+ // where a staged retry ladder recovers if a rev2-only server rejects the RETURN
27
+ // option. The set mirrors the rest of the Appendix E list in full, including
28
+ // entries no call site consults yet, so any future capability check gets the
29
+ // rev2 folding for free.
26
30
  const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
27
31
  'ENABLE',
28
32
  'ESEARCH',
@@ -308,7 +312,17 @@ const tools = {
308
312
  return false;
309
313
  }
310
314
 
311
- return (await compiler(response)).toString();
315
+ try {
316
+ return (await compiler(response)).toString();
317
+ } catch {
318
+ // The wire encoder refuses values that cannot be expressed as a valid IMAP
319
+ // string, which is what keeps user-supplied data from breaking out of a
320
+ // command. A server response is not held to that: the parser deliberately
321
+ // tolerates stray bytes inside an OK/NO/BAD atom, and those bytes then have
322
+ // no valid re-encoding. This text is diagnostic, so fall back to the logging
323
+ // encoder rather than replacing the server's error with an encoding failure.
324
+ return (await compiler(response, { isLogging: true })).toString();
325
+ }
312
326
  },
313
327
 
314
328
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -606,6 +606,97 @@ module.exports['Commands: search drops * from ESEARCH UID results'] = async test
606
606
  test.done();
607
607
  };
608
608
 
609
+ module.exports['Commands: search discards invalid single values in an ESEARCH ALL set'] = async test => {
610
+ const connection = createMockConnection({
611
+ state: 3,
612
+ capabilities: new Map([['IMAP4rev2', true]]),
613
+ mailbox: { path: 'INBOX', exists: 5 },
614
+ exec: async (cmd, attrs, opts) => {
615
+ // '0' is not a valid nz-number and 'foo' is garbage - both single
616
+ // values must be dropped while the valid one survives
617
+ await opts.untagged.ESEARCH({
618
+ attributes: [
619
+ { type: 'ATOM', value: 'ALL' },
620
+ { type: 'ATOM', value: '0,foo,4' }
621
+ ]
622
+ });
623
+ return { next: () => {} };
624
+ }
625
+ });
626
+
627
+ const result = await searchCommand(connection, true, {});
628
+ test.deepEqual(result, [4]);
629
+ test.done();
630
+ };
631
+
632
+ module.exports['Commands: search truncates single-value ESEARCH ALL entries at the mailbox size'] = async test => {
633
+ const connection = createMockConnection({
634
+ state: 3,
635
+ capabilities: new Map([['IMAP4rev2', true]]),
636
+ mailbox: { path: 'INBOX', exists: 2 },
637
+ exec: async (cmd, attrs, opts) => {
638
+ // More single values than the mailbox holds - the walk must stop at
639
+ // the EXISTS budget instead of collecting the excess
640
+ await opts.untagged.ESEARCH({
641
+ attributes: [
642
+ { type: 'ATOM', value: 'ALL' },
643
+ { type: 'ATOM', value: '1,2,3,4' }
644
+ ]
645
+ });
646
+ return { next: () => {} };
647
+ }
648
+ });
649
+
650
+ const result = await searchCommand(connection, true, {});
651
+ test.deepEqual(result, [1, 2]);
652
+ test.done();
653
+ };
654
+
655
+ module.exports['Commands: search ignores an ESEARCH reply without attributes on the plain path'] = async test => {
656
+ const connection = createMockConnection({
657
+ state: 3,
658
+ capabilities: new Map([['IMAP4rev2', true]]),
659
+ exec: async (cmd, attrs, opts) => {
660
+ // A degenerate untagged ESEARCH with no attributes must not crash
661
+ // the collector or contribute results
662
+ await opts.untagged.ESEARCH({ attributes: null });
663
+ await opts.untagged.ESEARCH({
664
+ attributes: [
665
+ { type: 'ATOM', value: 'ALL' },
666
+ { type: 'ATOM', value: '2' }
667
+ ]
668
+ });
669
+ return { next: () => {} };
670
+ }
671
+ });
672
+
673
+ const result = await searchCommand(connection, true, {});
674
+ test.deepEqual(result, [2]);
675
+ test.done();
676
+ };
677
+
678
+ module.exports['Commands: search treats an ESEARCH ALL set without a mailbox size as empty'] = async test => {
679
+ const connection = createMockConnection({
680
+ state: 3,
681
+ capabilities: new Map([['IMAP4rev2', true]]),
682
+ // No exists value at all - the budget is zero, nothing may be collected
683
+ mailbox: { path: 'INBOX' },
684
+ exec: async (cmd, attrs, opts) => {
685
+ await opts.untagged.ESEARCH({
686
+ attributes: [
687
+ { type: 'ATOM', value: 'ALL' },
688
+ { type: 'ATOM', value: '1:3' }
689
+ ]
690
+ });
691
+ return { next: () => {} };
692
+ }
693
+ });
694
+
695
+ const result = await searchCommand(connection, true, {});
696
+ test.deepEqual(result, []);
697
+ test.done();
698
+ };
699
+
609
700
  module.exports['Commands: search with UID option'] = async test => {
610
701
  let execCmd = null;
611
702
  const connection = createMockConnection({
@@ -639,3 +639,101 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
639
639
  ).toString();
640
640
  test.ok(compiled.includes('<0.50>'), 'should contain partial range <0.50>');
641
641
  });
642
+
643
+ // Returns the error a compile attempt raised, or undefined when it succeeded.
644
+ // Several cases below assert that a token can never reach the wire, and the
645
+ // try/catch is the only interesting part of each.
646
+ const compileError = async attributes => {
647
+ try {
648
+ await compiler({ tag: 'A', command: 'CMD', attributes });
649
+ } catch (err) {
650
+ return err;
651
+ }
652
+ };
653
+
654
+ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence sets'] = test =>
655
+ asyncWrapper(test, async test => {
656
+ // Sequence sets are written verbatim, so a range string that reached the
657
+ // compiler unvalidated would put a second command on the wire
658
+ for (let value of ['1\r\nZZ1 LOGOUT', '1 2', '1;2', 'ALL', '1:2)', '1\t2', Buffer.from('1\r\nZZ NOOP')]) {
659
+ let err = await compileError([{ type: 'SEQUENCE', value }]);
660
+ test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value.toString())} must be rejected`);
661
+ }
662
+ });
663
+
664
+ module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
665
+ asyncWrapper(test, async test => {
666
+ for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295']) {
667
+ const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
668
+ test.equal(compiled, `A FETCH ${value}`);
669
+ }
670
+ });
671
+
672
+ module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
673
+ asyncWrapper(test, async test => {
674
+ // Only DQUOTE and backslash have escapes in the IMAP grammar - a tab must
675
+ // survive as a raw byte rather than becoming a literal backslash-t
676
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'STRING', value: 'a\tb"c\\d' }] })).toString();
677
+ test.equal(compiled, 'A CMD "a\tb\\"c\\\\d"');
678
+ });
679
+
680
+ module.exports['IMAP Compiler: quoted strings reject CR, LF and NUL'] = test =>
681
+ asyncWrapper(test, async test => {
682
+ for (let value of ['a\rb', 'a\nb', 'a\0b']) {
683
+ let err = await compileError([{ type: 'STRING', value }]);
684
+ test.equal(err && err.code, 'InvalidStringValue', `${JSON.stringify(value)} cannot be sent as a quoted string`);
685
+ }
686
+ });
687
+
688
+ module.exports['IMAP Compiler: a mailbox name with CRLF cannot reach the wire'] = test =>
689
+ asyncWrapper(test, async test => {
690
+ // Paths travel as ATOM tokens and get quoted when they fall outside ATOM-CHAR
691
+ let err = await compileError([{ type: 'ATOM', value: 'INBOX\r\nZZ LOGOUT' }]);
692
+ test.equal(err && err.code, 'InvalidStringValue');
693
+ });
694
+
695
+ module.exports['IMAP Compiler: response text cannot carry a line terminator'] = test =>
696
+ asyncWrapper(test, async test => {
697
+ // TEXT is written verbatim as well, so it gets the same guarantee even though
698
+ // only the parser produces it today
699
+ let err = await compileError([{ type: 'TEXT', value: 'oops\r\nZZ NOOP' }]);
700
+ test.equal(err && err.code, 'InvalidTextValue');
701
+ });
702
+
703
+ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it through'] = test =>
704
+ asyncWrapper(test, async test => {
705
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '1\r\nZZ NOOP' }] })).toString();
706
+ test.equal(compiled, 'A CMD 0', 'a non-numeric value must not reach the wire verbatim');
707
+ test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
708
+ });
709
+
710
+ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
711
+ asyncWrapper(test, async test => {
712
+ // The partial range is the last token component written verbatim, so it is
713
+ // coerced rather than joined as-is
714
+ const attributes = [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['0>\r\nZZ NOOP'] }];
715
+ const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes })).toString();
716
+ test.equal(compiled, 'A FETCH BODY.PEEK[]<0>');
717
+
718
+ const normal = (
719
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
720
+ ).toString();
721
+ test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
722
+ });
723
+
724
+ module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
725
+ asyncWrapper(test, async test => {
726
+ // The logging pass must survive whatever the wire pass refuses, so a rejected
727
+ // command can still be logged
728
+ const compiled = (
729
+ await compiler(
730
+ {
731
+ tag: 'A',
732
+ command: 'LOGIN',
733
+ attributes: [{ type: 'STRING', value: 'a\r\nb' }]
734
+ },
735
+ { isLogging: true }
736
+ )
737
+ ).toString();
738
+ test.ok(compiled.includes('\\r\\n'), 'control characters stay escaped for the log');
739
+ });
@@ -135,6 +135,62 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
135
135
  test.done();
136
136
  };
137
137
 
138
+ module.exports['Secure: STARTTLS discards capabilities even when the OK carries a CAPABILITY code'] = async test => {
139
+ // A server (or a MITM rewriting the plaintext stream) may stamp [CAPABILITY ...]
140
+ // on the STARTTLS OK itself. That marks the capability set as freshly updated, so
141
+ // a discard conditioned on "an update is still pending" would keep exactly the
142
+ // pre-TLS list an attacker controls - the list that then chooses the AUTH
143
+ // mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
144
+ let server = net.createServer(rawSocket => {
145
+ rawSocket.on('error', () => {});
146
+
147
+ let detachPlain;
148
+ detachPlain = lineReader(rawSocket, line => {
149
+ let parts = line.split(' ');
150
+ let tag = parts[0];
151
+ let cmd = (parts[1] || '').toUpperCase();
152
+
153
+ if (cmd === 'STARTTLS') {
154
+ rawSocket.write(`${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`);
155
+ detachPlain();
156
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
157
+ tlsSocket.on('error', () => {});
158
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
159
+ return;
160
+ }
161
+
162
+ handleLine(rawSocket, line, null, `${CAPS} STARTTLS PRETLS-ONLY`);
163
+ });
164
+
165
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
166
+ });
167
+
168
+ let port = await listen(server);
169
+ let client = new ImapFlow({
170
+ host: '127.0.0.1',
171
+ port,
172
+ secure: false,
173
+ doSTARTTLS: true,
174
+ servername: 'localhost',
175
+ tls: { rejectUnauthorized: false },
176
+ disableAutoIdle: true,
177
+ disableCompression: true,
178
+ logger: false,
179
+ auth: { user: 'test', pass: 'secret' }
180
+ });
181
+ client.on('error', () => {});
182
+
183
+ await client.connect();
184
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
185
+ test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
186
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
187
+
188
+ await client.logout();
189
+ client.close();
190
+ server.close();
191
+ test.done();
192
+ };
193
+
138
194
  // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
139
195
  const createStartTlsServer = () =>
140
196
  net.createServer(rawSocket => {
@@ -1310,3 +1310,39 @@ module.exports['Server: socket close triggers close handling'] = async test => {
1310
1310
  client.close();
1311
1311
  test.done();
1312
1312
  };
1313
+
1314
+ module.exports['Server: an unparseable tagged completion fails the command instead of stalling'] = async test => {
1315
+ // A tagged line the parser cannot make sense of used to be logged and dropped.
1316
+ // currentRequest then stayed set forever, so trySend() stopped dispatching and
1317
+ // every later command queued behind a promise that never settled.
1318
+ let server = createServer({
1319
+ handlers: {
1320
+ SELECT(ctx) {
1321
+ // A control character inside the response code is not parseable
1322
+ ctx.write(`${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
1323
+ }
1324
+ }
1325
+ });
1326
+ let port = await listen(server);
1327
+ let client = makeClient(port);
1328
+ client.on('error', () => {});
1329
+
1330
+ await client.connect();
1331
+
1332
+ let selectErr = null;
1333
+ try {
1334
+ await client.mailboxOpen('INBOX');
1335
+ } catch (err) {
1336
+ selectErr = err;
1337
+ }
1338
+ test.ok(selectErr, 'the command whose completion could not be parsed must reject');
1339
+
1340
+ // The connection has to keep working: the next command still gets dispatched
1341
+ let folders = await client.list();
1342
+ test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
1343
+
1344
+ await client.logout();
1345
+ client.close();
1346
+ server.close();
1347
+ test.done();
1348
+ };
@@ -1421,3 +1421,29 @@ module.exports['IMAP Parser, ATOM with <, [, ]'] = test =>
1421
1421
  ]
1422
1422
  });
1423
1423
  });
1424
+
1425
+ module.exports['IMAP Parser: unbalanced bracket in a response code keeps the human-readable text'] = test =>
1426
+ asyncWrapper(test, async test => {
1427
+ // RFC 9051 lets a response code carry free text containing '[' but not ']'.
1428
+ // Counting that '[' as a nested bracket loses the whole human-readable text,
1429
+ // which is what NO/BAD error messages are built from.
1430
+ const parsed = await parser('A1 NO [XFOO see bar[baz] mailbox is busy');
1431
+ const text = (parsed.attributes || [])
1432
+ .filter(entry => entry && entry.type === 'TEXT')
1433
+ .map(entry => entry.value)
1434
+ .join('');
1435
+ test.equal(text, 'mailbox is busy');
1436
+ });
1437
+
1438
+ module.exports['IMAP Parser: balanced brackets inside a response code are still tolerated'] = test =>
1439
+ asyncWrapper(test, async test => {
1440
+ // Servers do put bracketed values inside a code, so a plain first-']' scan
1441
+ // would cut the code in half
1442
+ const parsed = await parser('* OK [PERMANENTFLAGS ([css3-page] \\*)] Flags permitted.');
1443
+ const text = (parsed.attributes || [])
1444
+ .filter(entry => entry && entry.type === 'TEXT')
1445
+ .map(entry => entry.value)
1446
+ .join('');
1447
+ test.equal(text, 'Flags permitted.');
1448
+ test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
1449
+ });
@@ -456,3 +456,51 @@ module.exports['ImapStream: _destroy drains pending input queue callbacks'] = te
456
456
  test.done();
457
457
  });
458
458
  };
459
+
460
+ module.exports['Literal marker scan stays linear on a long digit run'] = test => {
461
+ // A backwards scan that accumulated digits one at a time cost O(n^2), so a few
462
+ // hundred KB of digits blocked the event loop for seconds before the size was
463
+ // even known - all inside the default line-length budget
464
+ const stream = new ImapStream({ cid: 'test' });
465
+ stream.on('error', () => {});
466
+ stream.resume();
467
+
468
+ const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('9'.repeat(200000)), Buffer.from('}\r\n')]);
469
+
470
+ const started = process.hrtime.bigint();
471
+ stream.checkLiteralMarker(line);
472
+ const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
473
+
474
+ test.ok(elapsedMs < 250, `scan should stay cheap, took ${elapsedMs.toFixed(1)}ms`);
475
+ test.done();
476
+ };
477
+
478
+ module.exports['Literal marker rejects an oversized digit run without parsing it'] = test => {
479
+ const stream = new ImapStream({ cid: 'test' });
480
+ stream.on('error', () => {});
481
+ stream.resume();
482
+
483
+ // Longer than any number64 can be, so it cannot be a valid marker
484
+ const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
485
+
486
+ test.equal(stream.checkLiteralMarker(line), false, 'an impossible size must not start literal mode');
487
+ test.done();
488
+ };
489
+
490
+ module.exports['Literal marker still accepts sizes at the digit-length bound'] = test => {
491
+ const stream = new ImapStream({ cid: 'test', maxLiteralSize: Number.MAX_SAFE_INTEGER });
492
+ stream.on('error', () => {});
493
+ stream.resume();
494
+
495
+ // 19 digits is the widest a number64 gets, so it must still be recognized
496
+ const line = Buffer.from(`* OK {${'9'.repeat(19)}}\r\n`);
497
+
498
+ test.equal(stream.checkLiteralMarker(line), false, 'a size beyond the configured maximum fails the stream rather than parsing');
499
+
500
+ const ok = new ImapStream({ cid: 'test' });
501
+ ok.on('error', () => {});
502
+ ok.resume();
503
+ test.equal(ok.checkLiteralMarker(Buffer.from('* OK {1024}\r\n')), true);
504
+ test.equal(ok.literalWaiting, 1024);
505
+ test.done();
506
+ };
@@ -150,7 +150,12 @@ module.exports['Live rev2: statusQuery is answered inline via LIST-STATUS'] = as
150
150
 
151
151
  const folders = await client.list({ statusQuery: { messages: true, unseen: true } });
152
152
 
153
- test.ok(clientSent(logs, 'STATUS'), 'LIST should carry RETURN (STATUS ...)');
153
+ // Pin the inline LIST-STATUS behavior: the STATUS request must ride on the
154
+ // LIST command itself, and no standalone STATUS command may be issued -
155
+ // a plain substring check would also pass on the fallback path
156
+ const listLine = wireLines(logs).find(entry => entry.src === 'c' && /^\S+ LIST /.test(entry.msg));
157
+ test.ok(listLine && listLine.msg.includes('RETURN') && listLine.msg.includes('STATUS'), 'LIST should carry RETURN (STATUS ...)');
158
+ test.ok(!wireLines(logs).some(entry => entry.src === 'c' && /^\S+ STATUS /.test(entry.msg)), 'no standalone STATUS command should be needed');
154
159
  const inbox = folders.find(folder => folder.path === 'INBOX');
155
160
  test.equal(inbox.status.messages, 1, 'inline STATUS should report the appended message');
156
161
  } finally {
@@ -207,6 +212,26 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
207
212
  test.done();
208
213
  };
209
214
 
215
+ module.exports['Live rev2: MODSEQ search criterion surfaces modseq from the ESEARCH response'] = async test => {
216
+ const client = await connectClient();
217
+ try {
218
+ await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
219
+ await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
220
+
221
+ await client.mailboxOpen('INBOX');
222
+ // RFC 7162: a MODSEQ criterion on a CONDSTORE session makes the server
223
+ // append MODSEQ to the ESEARCH response
224
+ const result = await client.search({ modseq: 1 }, { returnOptions: ['ALL', 'COUNT'] });
225
+
226
+ test.equal(result.count, 2);
227
+ test.equal(result.all, '1:2');
228
+ test.ok(typeof result.modseq === 'bigint' && result.modseq > 0n, 'modseq should surface as a positive BigInt');
229
+ } finally {
230
+ await client.logout();
231
+ }
232
+ test.done();
233
+ };
234
+
210
235
  module.exports['Live rev2: STATUS reports SIZE and DELETED'] = async test => {
211
236
  const logs = [];
212
237
  const client = await connectClient(null, logs);
@@ -253,8 +278,17 @@ module.exports['Live rev2: SELECT response carries an untagged LIST and re-selec
253
278
  test.equal(mailbox.path, 'INBOX');
254
279
 
255
280
  // RFC 9051 6.3.1: the SELECT response includes an untagged LIST for the
256
- // selected mailbox - the client must consume it without issue
257
- test.ok(serverSentUntagged(logs, 'LIST'), 'rev2 SELECT should include an untagged LIST response');
281
+ // selected mailbox - the client must consume it without issue. Scoped to
282
+ // the SELECT exchange itself: mailboxOpen() also issues its own LIST
283
+ // command first, whose untagged replies would satisfy a global check
284
+ // even if the SELECT response omitted the LIST
285
+ const lines = wireLines(logs);
286
+ const selectIdx = lines.findIndex(entry => entry.src === 'c' && /^\S+ SELECT /.test(entry.msg));
287
+ test.ok(selectIdx >= 0, 'SELECT command should be on the wire');
288
+ const selectTag = lines[selectIdx].msg.split(' ')[0];
289
+ const doneIdx = lines.findIndex((entry, i) => i > selectIdx && entry.src === 's' && entry.msg.startsWith(`${selectTag} `));
290
+ const listInSelect = lines.some((entry, i) => i > selectIdx && i < doneIdx && entry.src === 's' && /^\* LIST( |$)/.test(entry.msg));
291
+ test.ok(listInSelect, 'rev2 SELECT response should include an untagged LIST response');
258
292
 
259
293
  // switching mailboxes must produce a CLOSED response code for the old one
260
294
  await client.mailboxOpen('Closer');
@@ -212,16 +212,45 @@ module.exports['ESEARCH: command path returns false on exec error'] = test => {
212
212
  };
213
213
 
214
214
  module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => {
215
- // Dovecot with CONDSTORE may append MODSEQ to ESEARCH responses
215
+ // Unknown result keywords must be skipped with their value so the
216
+ // key/value stream stays aligned for the entries that follow
217
+ const attrs = [
218
+ { type: 'ATOM', value: 'RELEVANCY' },
219
+ { type: 'ATOM', value: '87' },
220
+ { type: 'ATOM', value: 'COUNT' },
221
+ { type: 'ATOM', value: '5' }
222
+ ];
223
+ const result = parseEsearchResponse(attrs);
224
+ test.equal(result.count, 5);
225
+ test.equal(result.relevancy, undefined, 'unknown keys should not appear in result');
226
+ test.done();
227
+ };
228
+
229
+ module.exports['ESEARCH: parseEsearchResponse parses MODSEQ as BigInt'] = test => {
230
+ // RFC 7162: CONDSTORE sessions append MODSEQ to ESEARCH responses when the
231
+ // search used a MODSEQ criterion
216
232
  const attrs = [
217
233
  { type: 'ATOM', value: 'COUNT' },
218
234
  { type: 'ATOM', value: '5' },
219
235
  { type: 'ATOM', value: 'MODSEQ' },
220
- { type: 'ATOM', value: '12345' }
236
+ { type: 'ATOM', value: '9007199254740993' }
221
237
  ];
222
238
  const result = parseEsearchResponse(attrs);
223
239
  test.equal(result.count, 5);
224
- test.equal(result.modseq, undefined, 'unknown keys should not appear in result');
240
+ test.strictEqual(result.modseq, 9007199254740993n, 'modseq should be an exact BigInt');
241
+ test.done();
242
+ };
243
+
244
+ module.exports['ESEARCH: parseEsearchResponse drops non-numeric MODSEQ'] = test => {
245
+ const attrs = [
246
+ { type: 'ATOM', value: 'MODSEQ' },
247
+ { type: 'ATOM', value: 'bogus' },
248
+ { type: 'ATOM', value: 'COUNT' },
249
+ { type: 'ATOM', value: '3' }
250
+ ];
251
+ const result = parseEsearchResponse(attrs);
252
+ test.equal(result.modseq, undefined, 'invalid modseq must be dropped');
253
+ test.equal(result.count, 3, 'stream must stay aligned after a dropped value');
225
254
  test.done();
226
255
  };
227
256
 
@@ -234,8 +234,23 @@ module.exports['Tools: hasCapability with advertised token'] = test => {
234
234
 
235
235
  module.exports['Tools: hasCapability folds extensions into active rev2'] = test => {
236
236
  let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
237
- // RFC 9051 Appendix E folds these into base IMAP4rev2
238
- for (let capability of ['UIDPLUS', 'MOVE', 'NAMESPACE', 'ESEARCH', 'LITERAL-', 'LIST-EXTENDED', 'LIST-STATUS', 'SPECIAL-USE', 'ENABLE']) {
237
+ // RFC 9051 Appendix E folds these into base IMAP4rev2 - the complete set
238
+ for (let capability of [
239
+ 'ENABLE',
240
+ 'ESEARCH',
241
+ 'IDLE',
242
+ 'LIST-EXTENDED',
243
+ 'LIST-STATUS',
244
+ 'LITERAL-',
245
+ 'MOVE',
246
+ 'NAMESPACE',
247
+ 'SASL-IR',
248
+ 'SEARCHRES',
249
+ 'SPECIAL-USE',
250
+ 'STATUS=SIZE',
251
+ 'UIDPLUS',
252
+ 'UNSELECT'
253
+ ]) {
239
254
  test.equal(tools.hasCapability(connection, capability), true, `${capability} should be folded into rev2`);
240
255
  }
241
256
  // BINARY is intentionally not folded
@@ -336,6 +351,17 @@ module.exports['Tools: getErrorText with valid response'] = async test => {
336
351
  test.done();
337
352
  };
338
353
 
354
+ module.exports['Tools: getErrorText survives a response that cannot be re-encoded'] = async test => {
355
+ // The parser tolerates stray bytes inside an OK/NO/BAD atom, and those have no
356
+ // valid IMAP string encoding. The error text is diagnostic, so it must still be
357
+ // produced rather than replacing the server's error with an encoding failure.
358
+ let response = await parser(Buffer.from('A1 NO [SERVERBUG\x00X] it failed', 'binary'));
359
+ let result = await tools.getErrorText(response);
360
+ test.ok(typeof result === 'string', 'error text should still be produced');
361
+ test.ok(result.includes('it failed'), 'the human-readable part must survive');
362
+ test.done();
363
+ };
364
+
339
365
  // ============================================
340
366
  // getFlagColor tests
341
367
  // ============================================