imapflow 1.6.5 → 1.7.0
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 +20 -0
- package/lib/commands/append.js +26 -4
- 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/idle.js +26 -5
- 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-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +33 -2
- package/lib/imap-flow.js +307 -97
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/auto-idle-test.js +470 -0
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/connection-edge-cases-test.js +3 -1
- package/test/copyuid-parser-test.js +20 -0
- package/test/fixtures/test-client.js +57 -0
- package/test/idle-polling-test.js +88 -0
- package/test/imap-flow-coverage-test.js +8 -12
- package/test/imap-flow-fetch-download-test.js +29 -10
- package/test/imap-flow-internals-test.js +14 -32
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -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/timer-policy-test.js +31 -18
- package/test/tools-test.js +151 -2
package/lib/commands/select.js
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { encodePath, normalizePath, enhanceCommandError, parseBigIntValue, parseUintValue, getStringList, MAX_UINT32_DIGITS } = require('../tools.js');
|
|
4
|
+
|
|
5
|
+
// Response codes carrying a value that SELECT/EXAMINE may write to the mailbox object, keyed by
|
|
6
|
+
// the lowercased code, mapped to the fixed public property name and the parser for the value.
|
|
7
|
+
// Every parser returns false for a value it cannot use, and the field is then left unset.
|
|
8
|
+
//
|
|
9
|
+
// This is an allowlist on purpose. The mailbox object is API surface: without one, an arbitrary
|
|
10
|
+
// server-sent [KEY value] code could overwrite `path` (defeating the DELETE/RENAME guards that
|
|
11
|
+
// compare paths) or `flags`, and a parenthesized value under "__proto__" would replace the
|
|
12
|
+
// object's prototype. The lookup itself is on a null-prototype object for the same reason - the
|
|
13
|
+
// key is lowercased, so "constructor" would otherwise resolve to an inherited member.
|
|
14
|
+
const VALUED_RESPONSE_CODES = Object.assign(Object.create(null), {
|
|
15
|
+
// CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox, used for incremental
|
|
16
|
+
// sync. Stored as a BigInt since modseq values can exceed Number.MAX_SAFE_INTEGER.
|
|
17
|
+
//
|
|
18
|
+
// A value that is not a bounded digit run is dropped rather than stored raw: every consumer
|
|
19
|
+
// compares highestModseq relationally, and a relational compare against a non-numeric string
|
|
20
|
+
// is false in both directions, so the value could never advance and delta sync would stop.
|
|
21
|
+
highestmodseq: { key: 'highestModseq', parse: value => parseBigIntValue(value) },
|
|
22
|
+
|
|
23
|
+
// Unique identifier validity. If this changes between sessions, all previously cached UIDs
|
|
24
|
+
// are invalid and the client must re-sync from scratch. Nominally 32-bit, but stored as a
|
|
25
|
+
// BigInt precisely so a server that exceeds that still round-trips, hence the wider bound.
|
|
26
|
+
uidvalidity: { key: 'uidValidity', parse: value => parseBigIntValue(value) },
|
|
27
|
+
|
|
28
|
+
// The next UID to be assigned in this mailbox, useful for detecting new arrivals. A huge
|
|
29
|
+
// digit run would coerce to Infinity and corrupt every later UID range computation.
|
|
30
|
+
uidnext: { key: 'uidNext', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
|
|
31
|
+
|
|
32
|
+
// Sequence number of the first unseen message (RFC 3501 section 7.1). Not a count of unseen
|
|
33
|
+
// messages - use mailboxStatus() with {unseen: true} for that.
|
|
34
|
+
unseen: { key: 'unseen', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
|
|
35
|
+
|
|
36
|
+
// APPENDLIMIT (RFC 7889): largest message size in octets the server accepts for APPEND into
|
|
37
|
+
// this mailbox. Spelled all lowercase, unlike the camelCase fields around it, because that is
|
|
38
|
+
// the name this object has always exposed.
|
|
39
|
+
appendlimit: { key: 'appendlimit', parse: value => parseUintValue(value) },
|
|
40
|
+
|
|
41
|
+
// OBJECTID (RFC 8474): server-assigned mailbox identifier that survives renames. Sent as a
|
|
42
|
+
// parenthesized list, but servers in the wild send it bare too.
|
|
43
|
+
mailboxid: {
|
|
44
|
+
key: 'mailboxId',
|
|
45
|
+
parse: value => (Array.isArray(value) ? value.length > 0 && value[0] : typeof value === 'string' && value)
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Flags the client may change permanently on messages in this mailbox, including \* if the
|
|
49
|
+
// server allows custom flags. Only the parenthesized form carries flags, and a malformed
|
|
50
|
+
// value must leave permanentFlags unset rather than set an empty Set: canUseFlag() reads
|
|
51
|
+
// unset as permissive and empty as deny-all, so an empty Set would turn every later flag
|
|
52
|
+
// update into a silent no-op for the rest of the session.
|
|
53
|
+
permanentflags: { key: 'permanentFlags', parse: value => Array.isArray(value) && new Set(value) }
|
|
54
|
+
});
|
|
4
55
|
|
|
5
56
|
/**
|
|
6
57
|
* Selects or examines a mailbox, making it the current mailbox for subsequent operations.
|
|
@@ -92,78 +143,33 @@ module.exports = async (connection, path, options) => {
|
|
|
92
143
|
}
|
|
93
144
|
let section = !untagged.attributes[0].value && untagged.attributes[0].section;
|
|
94
145
|
// Handle response codes with a key-value pair (section has 2+ elements)
|
|
95
|
-
if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
146
|
+
if (section && section.length > 1 && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
96
147
|
let key = section[0].value.toLowerCase();
|
|
97
148
|
let value;
|
|
98
149
|
|
|
99
|
-
// Value can be a single string or a list of strings (e.g., PERMANENTFLAGS)
|
|
100
|
-
|
|
150
|
+
// Value can be a single string or a list of strings (e.g., PERMANENTFLAGS).
|
|
151
|
+
// section[1] can be a parsed NIL (null), and so can any element inside a
|
|
152
|
+
// parenthesized list, so both levels need the guard
|
|
153
|
+
if (section[1] && typeof section[1].value === 'string') {
|
|
101
154
|
value = section[1].value;
|
|
102
155
|
} else if (Array.isArray(section[1])) {
|
|
103
|
-
value = section[1]
|
|
156
|
+
value = getStringList(section[1]);
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
key = 'highestModseq';
|
|
113
|
-
if (/^[0-9]+$/.test(value)) {
|
|
114
|
-
value = BigInt(value);
|
|
115
|
-
}
|
|
116
|
-
break;
|
|
117
|
-
|
|
118
|
-
// OBJECTID (RFC 8474): server-assigned unique mailbox identifier.
|
|
119
|
-
// Unlike path, this ID survives renames. Value comes as a
|
|
120
|
-
// parenthesized list, so extract the first (only) element.
|
|
121
|
-
case 'mailboxid':
|
|
122
|
-
key = 'mailboxId';
|
|
123
|
-
if (Array.isArray(value) && value.length) {
|
|
124
|
-
value = value[0];
|
|
125
|
-
}
|
|
126
|
-
break;
|
|
127
|
-
|
|
128
|
-
// Flags that the client can change permanently on messages in
|
|
129
|
-
// this mailbox. Includes \* if the server allows custom flags.
|
|
130
|
-
case 'permanentflags':
|
|
131
|
-
key = 'permanentFlags';
|
|
132
|
-
value = new Set(value);
|
|
133
|
-
break;
|
|
134
|
-
|
|
135
|
-
// The next UID that will be assigned to a new message in this
|
|
136
|
-
// mailbox. Useful for detecting new arrivals.
|
|
137
|
-
case 'uidnext':
|
|
138
|
-
key = 'uidNext';
|
|
139
|
-
value = Number(value);
|
|
140
|
-
break;
|
|
141
|
-
|
|
142
|
-
// Unique identifier validity value. If this changes between
|
|
143
|
-
// sessions, all previously cached UIDs are invalid and the
|
|
144
|
-
// client must re-sync from scratch.
|
|
145
|
-
case 'uidvalidity':
|
|
146
|
-
key = 'uidValidity';
|
|
147
|
-
if (/^[0-9]+$/.test(value)) {
|
|
148
|
-
value = BigInt(value);
|
|
149
|
-
}
|
|
150
|
-
break;
|
|
159
|
+
let field = VALUED_RESPONSE_CODES[key];
|
|
160
|
+
if (field) {
|
|
161
|
+
let parsed = field.parse(value);
|
|
162
|
+
if (parsed !== false) {
|
|
163
|
+
map[field.key] = parsed;
|
|
164
|
+
}
|
|
151
165
|
}
|
|
152
|
-
|
|
153
|
-
map[key] = value;
|
|
154
166
|
}
|
|
155
167
|
|
|
156
|
-
// Handle response codes with only a keyword (no value), e.g., [NOMODSEQ]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// CONDSTORE/QRESYNC features are unavailable for this mailbox.
|
|
162
|
-
case 'nomodseq':
|
|
163
|
-
key = 'noModseq';
|
|
164
|
-
map[key] = true;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
168
|
+
// Handle response codes with only a keyword (no value), e.g., [NOMODSEQ].
|
|
169
|
+
// NOMODSEQ means the mailbox does not support mod-sequences, so the
|
|
170
|
+
// CONDSTORE/QRESYNC features are unavailable for it.
|
|
171
|
+
if (section && section.length === 1 && section[0] && section[0].type === 'ATOM' && section[0].value?.toUpperCase() === 'NOMODSEQ') {
|
|
172
|
+
map.noModseq = true;
|
|
167
173
|
}
|
|
168
174
|
},
|
|
169
175
|
|
|
@@ -173,15 +179,16 @@ module.exports = async (connection, path, options) => {
|
|
|
173
179
|
if (!untagged.attributes || !untagged.attributes.length || !Array.isArray(untagged.attributes[0])) {
|
|
174
180
|
return;
|
|
175
181
|
}
|
|
176
|
-
|
|
177
|
-
map.flags = new Set(flags);
|
|
182
|
+
map.flags = new Set(getStringList(untagged.attributes[0]));
|
|
178
183
|
},
|
|
179
184
|
|
|
180
185
|
// Untagged EXISTS response: "* <count> EXISTS" tells us the total number
|
|
181
186
|
// of messages in the mailbox. The count is in the command field (numeric prefix).
|
|
182
187
|
EXISTS: async untagged => {
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
// Not a usable count: anything but a bounded digit run. A long digit run
|
|
189
|
+
// coerces to Infinity, which would corrupt every later range computation
|
|
190
|
+
let num = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
191
|
+
if (num === false) {
|
|
185
192
|
return false;
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -204,9 +211,13 @@ module.exports = async (connection, path, options) => {
|
|
|
204
211
|
});
|
|
205
212
|
|
|
206
213
|
// The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE]
|
|
207
|
-
// in its response code, indicating the access mode the server granted.
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
// in its response code, indicating the access mode the server granted. A tagged OK
|
|
215
|
+
// with no resp-text has no `attributes` property at all, and unlike the untagged
|
|
216
|
+
// handlers above this runs in the command body, where a throw would tear down the
|
|
217
|
+
// mailbox state the server has actually selected.
|
|
218
|
+
let okAttributes = (response.response && response.response.attributes) || [];
|
|
219
|
+
let section = okAttributes[0] && !okAttributes[0].value && okAttributes[0].section;
|
|
220
|
+
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
210
221
|
map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY';
|
|
211
222
|
}
|
|
212
223
|
|
|
@@ -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
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const Transform = require('stream').Transform;
|
|
4
4
|
const logger = require('../logger');
|
|
5
|
-
const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
|
|
5
|
+
const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
|
|
6
6
|
|
|
7
7
|
const LINE = 0x01;
|
|
8
8
|
const LITERAL = 0x02;
|
|
@@ -44,6 +44,16 @@ class ImapStream extends Transform {
|
|
|
44
44
|
* exactly at the limit is accepted. Exceeding it is terminal: the stream is destroyed with a
|
|
45
45
|
* `LiteralTooLarge` error, the marker line is not emitted, and no byte of the rejected
|
|
46
46
|
* literal body is parsed as protocol.
|
|
47
|
+
* @param {number} [options.maxResponseSize] - Maximum allowed total size (in bytes) of a
|
|
48
|
+
* single assembled response: every line segment and literal of one response combined.
|
|
49
|
+
* Defaults to MAX_RESPONSE_SIZE (2GB), which leaves room above the literal cap for a
|
|
50
|
+
* maximum-size literal plus its marker line. The per-line and per-literal caps alone
|
|
51
|
+
* cannot stop a server that spreads attacker-controlled bytes across an unbounded
|
|
52
|
+
* number of tokens of a single response. Declared literal sizes count when their
|
|
53
|
+
* marker is parsed, so an oversized total is rejected before the literal bytes arrive,
|
|
54
|
+
* and a line still being assembled counts against whatever budget is left.
|
|
55
|
+
* Exceeding the limit is terminal: the stream is destroyed with a `ResponseTooLarge`
|
|
56
|
+
* error and no further input is parsed.
|
|
47
57
|
*/
|
|
48
58
|
constructor(options) {
|
|
49
59
|
super({
|
|
@@ -73,6 +83,8 @@ class ImapStream extends Transform {
|
|
|
73
83
|
// announcing an oversized literal cannot exhaust memory.
|
|
74
84
|
this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
|
|
75
85
|
|
|
86
|
+
this.maxResponseSize = normalizeLimit(this.options.maxResponseSize, MAX_RESPONSE_SIZE);
|
|
87
|
+
|
|
76
88
|
this.state = LINE;
|
|
77
89
|
this.literalWaiting = 0;
|
|
78
90
|
this.inputBuffer = []; // lines
|
|
@@ -80,6 +92,7 @@ class ImapStream extends Transform {
|
|
|
80
92
|
this.lineBytes = 0; // bytes currently buffered for the in-progress line
|
|
81
93
|
this.literalBuffer = [];
|
|
82
94
|
this.literals = [];
|
|
95
|
+
this.responseBytes = 0; // bytes accumulated for the in-progress response (lines + declared literals)
|
|
83
96
|
|
|
84
97
|
this.compress = false;
|
|
85
98
|
this.secureConnection = this.options.secureConnection;
|
|
@@ -228,6 +241,33 @@ class ImapStream extends Transform {
|
|
|
228
241
|
return this.failStream(err);
|
|
229
242
|
}
|
|
230
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Enforces the configured per-response size cap: the cumulative bytes of every line
|
|
246
|
+
* segment and declared literal of the response currently being assembled. Counting
|
|
247
|
+
* declared literal sizes at marker time means an oversized total is rejected before
|
|
248
|
+
* the literal bytes even arrive. The counter is reset when a response is emitted.
|
|
249
|
+
*
|
|
250
|
+
* @param {number} additionalBytes - Bytes the next token would add to the response.
|
|
251
|
+
* @param {boolean} [peek] - Measure only, without committing the bytes to the counter.
|
|
252
|
+
* Used for a line that is still being assembled: its bytes are committed once, when the
|
|
253
|
+
* line completes.
|
|
254
|
+
* @returns {boolean} True if within the limit, false if the stream was failed.
|
|
255
|
+
*/
|
|
256
|
+
checkResponseSize(additionalBytes, peek) {
|
|
257
|
+
let total = this.responseBytes + additionalBytes;
|
|
258
|
+
if (total <= this.maxResponseSize) {
|
|
259
|
+
if (!peek) {
|
|
260
|
+
this.responseBytes = total;
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
const err = new Error(`Response size ${total} exceeds maximum allowed size of ${this.maxResponseSize} bytes`);
|
|
265
|
+
err.code = 'ResponseTooLarge';
|
|
266
|
+
err.responseSize = total;
|
|
267
|
+
err.maxSize = this.maxResponseSize;
|
|
268
|
+
return this.failStream(err);
|
|
269
|
+
}
|
|
270
|
+
|
|
231
271
|
/**
|
|
232
272
|
* Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
|
|
233
273
|
* lines and checks for literal markers. In LITERAL state, collects the expected number
|
|
@@ -273,6 +313,13 @@ class ImapStream extends Transform {
|
|
|
273
313
|
return;
|
|
274
314
|
}
|
|
275
315
|
|
|
316
|
+
// Count the line itself and, for a literal marker, the declared
|
|
317
|
+
// literal bytes against the cumulative per-response budget, so a
|
|
318
|
+
// response assembled from many tokens stays bounded as a whole
|
|
319
|
+
if (!this.checkResponseSize(line.length + (isLiteralMarker ? this.literalWaiting : 0))) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
276
323
|
this.inputBuffer.push(line);
|
|
277
324
|
|
|
278
325
|
if (isLiteralMarker) {
|
|
@@ -285,6 +332,7 @@ class ImapStream extends Transform {
|
|
|
285
332
|
let literals = this.literals;
|
|
286
333
|
this.inputBuffer = [];
|
|
287
334
|
this.literals = [];
|
|
335
|
+
this.responseBytes = 0;
|
|
288
336
|
|
|
289
337
|
if (payload.length) {
|
|
290
338
|
// remove final line terminator (\n or \r\n)
|
|
@@ -323,7 +371,12 @@ class ImapStream extends Transform {
|
|
|
323
371
|
// No line terminator was found in the remaining bytes; carry the tail over to
|
|
324
372
|
// the next chunk after measuring the line it belongs to.
|
|
325
373
|
let tail = chunk.slice(lineStart);
|
|
326
|
-
|
|
374
|
+
// The response counter is only committed when a line completes, so an
|
|
375
|
+
// in-progress line is measured against the remaining budget separately.
|
|
376
|
+
// Without this a response cap lowered to bound parser memory buys nothing
|
|
377
|
+
// while a server streams a line that never terminates - only the much
|
|
378
|
+
// larger line cap would hold it back.
|
|
379
|
+
if (!this.checkLineLength(this.lineBytes + tail.length) || !this.checkResponseSize(this.lineBytes + tail.length, true)) {
|
|
327
380
|
return;
|
|
328
381
|
}
|
|
329
382
|
this.lineBytes += tail.length;
|
|
@@ -453,6 +506,7 @@ class ImapStream extends Transform {
|
|
|
453
506
|
this.lineBytes = 0;
|
|
454
507
|
this.literalBuffer = [];
|
|
455
508
|
this.literals = [];
|
|
509
|
+
this.responseBytes = 0;
|
|
456
510
|
|
|
457
511
|
// Settle an in-flight push() wait so processInput() can unwind
|
|
458
512
|
if (typeof this.pendingPush === 'function') {
|
package/lib/handler/limits.js
CHANGED
|
@@ -12,16 +12,28 @@ const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
|
|
|
12
12
|
// only to stop a server that never sends a line terminator, not to constrain normal traffic.
|
|
13
13
|
const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
|
|
14
14
|
|
|
15
|
+
// Default maximum total size of a single assembled response: every line segment and literal of
|
|
16
|
+
// one response combined. The per-line and per-literal caps alone cannot stop a server that
|
|
17
|
+
// spreads attacker-controlled bytes across an unbounded number of tokens of a single response
|
|
18
|
+
// (e.g. one FETCH answer carrying many maximum-size literals).
|
|
19
|
+
//
|
|
20
|
+
// Deliberately above the literal cap: the response total also carries the literal's marker line
|
|
21
|
+
// and the rest of the response framing, so a cap equal to MAX_LITERAL_SIZE would make a literal
|
|
22
|
+
// of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
|
|
23
|
+
// the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
|
|
24
|
+
const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
|
|
17
|
-
* means "reject anything non-empty")
|
|
18
|
-
* not silently swallowed the way `value || DEFAULT` would
|
|
28
|
+
* means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
|
|
29
|
+
* to the default, so an explicit 0 is not silently swallowed the way `value || DEFAULT` would
|
|
30
|
+
* swallow it.
|
|
19
31
|
*
|
|
20
32
|
* @param {*} value - The configured value.
|
|
21
33
|
* @param {number} defaultValue - Fallback when the value is not a usable limit.
|
|
22
34
|
* @returns {number} The normalized limit.
|
|
23
35
|
*/
|
|
24
|
-
const normalizeLimit = (value, defaultValue) => (Number.isInteger(value) && value >= 0 ? value : defaultValue);
|
|
36
|
+
const normalizeLimit = (value, defaultValue) => ((Number.isInteger(value) || value === Infinity) && value >= 0 ? value : defaultValue);
|
|
25
37
|
|
|
26
38
|
/**
|
|
27
39
|
* Builds the `LiteralTooLarge` error. One shape for every place a literal is refused, so callers
|
|
@@ -40,4 +52,4 @@ const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
|
|
|
40
52
|
return err;
|
|
41
53
|
};
|
|
42
54
|
|
|
43
|
-
module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError };
|
|
55
|
+
module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError };
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -30,6 +30,17 @@ export interface ImapFlowOptions {
|
|
|
30
30
|
clientInfo?: IdInfoObject;
|
|
31
31
|
/** If true, then do not start IDLE when connection is established */
|
|
32
32
|
disableAutoIdle?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* How long (in ms) the connection has to be inactive before IDLE is started automatically.
|
|
35
|
+
* Keep it above the pause your own code usually leaves between two commands, otherwise every
|
|
36
|
+
* command is followed by an IDLE that the next command has to break, costing two extra
|
|
37
|
+
* round-trips per command. To turn auto-IDLE off use `disableAutoIdle` rather than a very
|
|
38
|
+
* large delay: the value is capped below `socketTimeout`, because auto-IDLE has to start
|
|
39
|
+
* before the inactivity watchdog fires. On servers without IDLE support this controls when
|
|
40
|
+
* the polling fallback starts, not how often it polls - the poll interval is `maxIdleTime`,
|
|
41
|
+
* capped at 2 minutes. Default: 15000 ms.
|
|
42
|
+
*/
|
|
43
|
+
autoIdleDelay?: number;
|
|
33
44
|
/** Additional TLS options (see Node.js TLS documentation) */
|
|
34
45
|
tls?: ConnectionOptions;
|
|
35
46
|
/** Custom logger instance. Set to false to disable logging */
|
|
@@ -85,18 +96,34 @@ export interface ImapFlowOptions {
|
|
|
85
96
|
* Maximum allowed length in bytes of a single response line (a response without a literal).
|
|
86
97
|
* Guards against a malicious or broken server that never sends a line terminator. Defaults to
|
|
87
98
|
* 1GB. The line terminator counts towards the limit and a line exactly at the limit is
|
|
88
|
-
* accepted.
|
|
99
|
+
* accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
|
|
100
|
+
* whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
|
|
101
|
+
* Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
|
|
89
102
|
* no further input is parsed.
|
|
90
103
|
*/
|
|
91
104
|
maxLineLength?: number;
|
|
92
105
|
/**
|
|
93
106
|
* Maximum allowed size in bytes of a single IMAP literal block. Bounds peak memory allocation
|
|
94
107
|
* against a malicious or broken server announcing an oversized literal. Defaults to 1GB. A
|
|
95
|
-
* literal exactly at the limit is accepted
|
|
108
|
+
* literal exactly at the limit is accepted, provided `maxResponseSize` leaves room for the
|
|
109
|
+
* marker line as the defaults do. `Infinity` disables the limit. Exceeding it is terminal: the connection fails
|
|
96
110
|
* with error code `LiteralTooLarge`, and neither the marker line nor any byte of the rejected
|
|
97
111
|
* literal is interpreted as protocol.
|
|
98
112
|
*/
|
|
99
113
|
maxLiteralSize?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Maximum allowed total size in bytes of a single assembled IMAP response (every line
|
|
116
|
+
* segment and literal of one response combined). Bounds peak memory allocation against
|
|
117
|
+
* a malicious or broken server that spreads response data across an unbounded number of
|
|
118
|
+
* tokens, which the per-line and per-literal caps alone cannot stop. Defaults to 2GB,
|
|
119
|
+
* which is above the default literal cap on purpose: the total also carries the literal
|
|
120
|
+
* marker line and the rest of the response framing, so a value equal to `maxLiteralSize`
|
|
121
|
+
* would make a literal of exactly the maximum permitted size impossible to receive. Set
|
|
122
|
+
* this above `maxLiteralSize` when configuring both. `Infinity` disables the limit.
|
|
123
|
+
* Exceeding it is terminal: the connection fails with error code `ResponseTooLarge` and
|
|
124
|
+
* no further input is parsed.
|
|
125
|
+
*/
|
|
126
|
+
maxResponseSize?: number;
|
|
100
127
|
/**
|
|
101
128
|
* Threshold in milliseconds for warning that a mailbox lock has been held
|
|
102
129
|
* for a long time (diagnostic for forgotten release() calls). Defaults to
|
|
@@ -145,6 +172,10 @@ export interface MailboxObject {
|
|
|
145
172
|
uidNext: number;
|
|
146
173
|
/** Messages in this folder */
|
|
147
174
|
exists: number;
|
|
175
|
+
/** Sequence number of the first unseen message, if the server reported [UNSEEN] on SELECT. Not a count of unseen messages - use mailboxStatus() with {unseen: true} for that */
|
|
176
|
+
unseen?: number;
|
|
177
|
+
/** Largest message size in octets the server accepts for APPEND into this mailbox, if it reported [APPENDLIMIT] (RFC 7889) */
|
|
178
|
+
appendlimit?: number;
|
|
148
179
|
/** Read-only state */
|
|
149
180
|
readOnly?: boolean;
|
|
150
181
|
}
|