wildduck/imap-core/lib/imap-command.js
Andris Reinman 2a24ff9331 v1.26.3
2020-05-27 11:55:36 +03:00

399 lines
16 KiB
JavaScript

'use strict';
const imapHandler = require('./handler/imap-handler');
const errors = require('../../lib/errors.js');
const MAX_MESSAGE_SIZE = 1 * 1024 * 1024;
const MAX_BAD_COMMANDS = 50;
const commands = new Map([
/*eslint-disable global-require*/
// require must normally be on top of the module
['NOOP', require('./commands/noop')],
['CAPABILITY', require('./commands/capability')],
['LOGOUT', require('./commands/logout')],
['ID', require('./commands/id')],
['STARTTLS', require('./commands/starttls')],
['LOGIN', require('./commands/login')],
['AUTHENTICATE PLAIN', require('./commands/authenticate-plain')],
['AUTHENTICATE PLAIN-CLIENTTOKEN', require('./commands/authenticate-plain')],
['NAMESPACE', require('./commands/namespace')],
['LIST', require('./commands/list')],
['XLIST', require('./commands/list')],
['LSUB', require('./commands/lsub')],
['SUBSCRIBE', require('./commands/subscribe')],
['UNSUBSCRIBE', require('./commands/unsubscribe')],
['CREATE', require('./commands/create')],
['DELETE', require('./commands/delete')],
['RENAME', require('./commands/rename')],
['SELECT', require('./commands/select')],
['EXAMINE', require('./commands/select')],
['IDLE', require('./commands/idle')],
['CHECK', require('./commands/check')],
['STATUS', require('./commands/status')],
['APPEND', require('./commands/append')],
['STORE', require('./commands/store')],
['UID STORE', require('./commands/uid-store')],
['EXPUNGE', require('./commands/expunge')],
['UID EXPUNGE', require('./commands/uid-expunge')],
['CLOSE', require('./commands/close')],
['UNSELECT', require('./commands/unselect')],
['COPY', require('./commands/copy')],
['UID COPY', require('./commands/copy')],
['MOVE', require('./commands/move')],
['UID MOVE', require('./commands/move')],
['FETCH', require('./commands/fetch')],
['UID FETCH', require('./commands/fetch')],
['SEARCH', require('./commands/search')],
['UID SEARCH', require('./commands/search')],
['ENABLE', require('./commands/enable')],
['GETQUOTAROOT', require('./commands/getquotaroot')],
['SETQUOTA', require('./commands/setquota')],
['GETQUOTA', require('./commands/getquota')],
['COMPRESS', require('./commands/compress')]
/*eslint-enable global-require*/
]);
class IMAPCommand {
constructor(connection) {
this.connection = connection;
this.payload = '';
this.literals = [];
this.first = true;
this.connection._badCount = this.connection._badCount || 0;
}
append(command, callback) {
let chunks = [];
let chunklen = 0;
this.payload += command.value;
if (this.first) {
// fetch tag and command name
this.first = false;
// only check payload if it is a regular command, not input for something else
if (typeof this.connection._nextHandler !== 'function') {
let match = /^([^\s]+)(?:\s+((?:AUTHENTICATE |UID )?[^\s]+)|$)/i.exec(command.value) || [];
this.tag = match[1];
this.command = (match[2] || '').trim().toUpperCase();
if (!this.command || !this.tag) {
let err = new Error('Invalid tag');
err.code = 'InvalidTag';
if (this.payload) {
// no payload means empty line
errors.notifyConnection(this.connection, err, {
payload: this.payload.length < 256 ? this.payload : this.payload.toString().substr(0, 150) + '...'
});
}
this.connection.send('* BAD Invalid tag');
return callback(err);
}
if (!commands.has(this.command)) {
let err = new Error('Unknown command');
err.code = 'UnknownCommand';
errors.notifyConnection(this.connection, err, {
payload: this.payload ? (this.payload.length < 256 ? this.payload : this.payload.toString().substr(0, 150) + '...') : false
});
this.connection.send(this.tag + ' BAD Unknown command: ' + this.command);
return callback(err);
}
}
}
if (command.literal) {
// check if the literal size is in acceptable bounds
if (isNaN(command.expecting) || isNaN(command.expecting) < 0 || command.expecting > Number.MAX_SAFE_INTEGER) {
let err = new Error('Invalid literal size');
err.code = 'InvalidLiteralSize';
errors.notifyConnection(this.connection, err, {
command: {
expecting: command.expecting
}
});
this.connection.send(this.tag + ' BAD Invalid literal size');
this.payload = '';
this.literals = [];
this.first = true;
return callback(err);
}
let maxAllowed = Math.max(Number(this.connection._server.options.maxMessage) || 0, MAX_MESSAGE_SIZE);
if (
// Allow large literals for selected commands only
(!['APPEND'].includes(this.command) && command.expecting > 1024) ||
// Deny all literals bigger than maxMessage
command.expecting > maxAllowed
) {
this.connection.logger.debug(
{
tnx: 'client',
cid: this.connection.id
},
'[%s] C:',
this.connection.id,
this.payload
);
this.payload = ''; // reset payload
this.literals = [];
if (command.expecting > maxAllowed) {
// APPENDLIMIT response for too large messages
// TOOBIG: https://tools.ietf.org/html/rfc4469#section-4.2
this.connection.send(this.tag + ' NO [TOOBIG] Literal too large');
} else {
this.connection.send(this.tag + ' NO Literal too large');
}
let err = new Error('Literal too large');
err.code = 'InvalidLiteralSize';
return callback(err);
}
// Accept literal input
this.connection.send('+ Go ahead');
// currently the stream is buffered into a large string and thats it.
// in the future we might consider some kind of actual stream usage
command.literal.on('data', chunk => {
chunks.push(chunk);
chunklen += chunk.length;
});
command.literal.on('end', () => {
this.payload += '\r\n'; // + Buffer.concat(chunks, chunklen).toString('binary');
this.literals.push(Buffer.concat(chunks, chunklen));
command.readyCallback(); // call this once stream is fully processed and ready to accept next data
});
}
callback();
}
end(command, callback) {
let callbackSent = false;
let next = err => {
if (!callbackSent) {
callbackSent = true;
return callback(err);
}
};
this.append(command, err => {
if (err) {
this.connection.logger.debug(
{
err,
tnx: 'client',
cid: this.connection.id
},
'[%s] C: %s',
this.connection.id,
this.payload || ''
);
if (!this.countBadResponses()) {
// stop processing
return;
}
return next(err);
}
// check if the payload needs to be directed to a preset handler
if (typeof this.connection._nextHandler === 'function') {
this.connection.logger.debug(
{
tnx: 'client',
cid: this.connection.id
},
'[%s] C: <%s bytes of data>',
this.connection.id,
(this.payload && this.payload.length) || 0
);
return this.connection._nextHandler(this.payload, next);
}
try {
this.parsed = imapHandler.parser(this.payload, { literals: this.literals });
} catch (E) {
errors.notifyConnection(this.connection, E, {
payload: this.payload ? (this.payload.length < 256 ? this.payload : this.payload.toString().substr(0, 150) + '...') : false
});
this.connection.logger.debug(
{
err: E,
tnx: 'client',
cid: this.connection.id
},
'[%s] C:',
this.connection.id,
this.payload
);
this.connection.send(this.tag + ' BAD ' + E.message);
if (!this.countBadResponses()) {
// stop processing
return;
}
return next();
}
let handler = commands.get(this.command);
if (/^(AUTHENTICATE|LOGIN)/.test(this.command) && Array.isArray(this.parsed.attributes)) {
this.parsed.attributes.forEach(attr => {
if (attr && typeof attr === 'object' && attr.value) {
attr.sensitive = true;
}
});
}
if (!this.connection.session.commandCounters[this.command]) {
this.connection.session.commandCounters[this.command] = 1;
} else {
this.connection.session.commandCounters[this.command]++;
}
this.connection.logger.debug(
{
tnx: 'client',
cid: this.connection.id
},
'[%s] C:',
this.connection.id,
imapHandler.compiler(this.parsed, false, true)
);
this.validateCommand(this.parsed, handler, err => {
if (err) {
let payload = imapHandler.compiler(this.parsed, false, true);
errors.notifyConnection(this.connection, err, {
payload: payload ? (payload.length < 256 ? payload : payload.toString().substr(0, 150) + '...') : false
});
this.connection.send(this.tag + ' ' + (err.response || 'BAD') + ' ' + err.message);
if (!this.countBadResponses()) {
// stop processing
return;
}
return next(err);
}
if (typeof handler.handler === 'function') {
handler.handler.call(
this.connection,
this.parsed,
(err, response) => {
if (err) {
let payload = imapHandler.compiler(this.parsed, false, true);
errors.notifyConnection(this.connection, err, {
payload: payload ? (payload.length < 256 ? payload : payload.toString().substr(0, 150) + '...') : false
});
this.connection.send(this.tag + ' ' + (err.response || 'BAD') + ' ' + err.message);
if (!err.response || err.response === 'BAD') {
if (!this.countBadResponses()) {
// stop processing
return;
}
}
return next(err);
}
// send EXPUNGE, EXISTS etc queued notices
this.sendNotifications(handler, () => {
// send command ready response
this.connection.writeStream.write({
tag: this.tag,
command: response.response,
attributes: []
.concat(
response.code
? {
type: 'SECTION',
section: [
{
type: 'TEXT',
value: response.code
}
]
}
: []
)
.concat({
type: 'TEXT',
value: response.message || this.command + ' completed'
})
});
next();
});
},
next
);
} else {
this.connection.send(this.tag + ' NO Not implemented: ' + this.command);
return next();
}
});
});
}
sendNotifications(handler, callback) {
if (this.connection.state !== 'Selected' || !!handler.disableNotifications) {
// nothing to advertise if not in Selected state
return callback();
}
this.connection.emitNotifications();
return callback();
}
validateCommand(parsed, handler, callback) {
let schema = handler.schema || [];
let maxArgs = schema.length;
let minArgs = schema.filter(item => !item.optional).length;
// Check if the command can be run in current state
if (handler.state && [].concat(handler.state || []).indexOf(this.connection.state) < 0) {
let err = new Error(parsed.command.toUpperCase() + ' not allowed now');
err.code = 'InvalidState';
return callback(err);
}
if (handler.schema === false) {
//schema check is disabled
return callback();
}
// Deny commands with too many arguments
if (parsed.attributes && parsed.attributes.length > maxArgs) {
let err = new Error('Too many arguments provided');
err.code = 'InvalidArguments';
return callback(err);
}
// Deny commands with too little arguments
if (((parsed.attributes && parsed.attributes.length) || 0) < minArgs) {
let err = new Error('Not enough arguments provided');
err.code = 'InvalidArguments';
return callback(err);
}
callback();
}
countBadResponses() {
this.connection._badCount++;
if (this.connection._badCount > MAX_BAD_COMMANDS) {
this.clearNotificationListener();
this.connection.send('* BYE Too many protocol errors');
setImmediate(() => this.connection.close(true));
return false;
}
return true;
}
}
module.exports.IMAPCommand = IMAPCommand;