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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/compress.js +29 -18
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +78 -12
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +209 -94
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/search-compiler.js +24 -16
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +780 -5
- package/test/copyuid-parser-test.js +20 -0
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-flow-secure-test.js +133 -116
- package/test/imap-flow-server-test.js +126 -0
- package/test/imap-parser-test.js +25 -0
- package/test/imap-stream-edge-cases-test.js +163 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/search-compiler-test.js +90 -3
- package/test/timer-policy-test.js +27 -1
- 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 };
|
package/lib/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
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 &&
|
|
120
|
-
|
|
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:
|
|
8
|
-
//
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
170
|
+
resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
|
|
146
171
|
} else {
|
|
147
|
-
resp.push(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
227
|
+
resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
|
|
203
228
|
} else {
|
|
204
229
|
val = (node.value || '').toString();
|
|
205
|
-
resp.push(
|
|
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.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
|
233
|
-
//
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|