imapflow
Version:
IMAP Client for Node
182 lines (154 loc) • 7.17 kB
JavaScript
;
const net = require('net');
const { ImapFlow } = require('../lib/imap-flow');
const { ImapStream } = require('../lib/handler/imap-stream');
// Minimal pre-STARTTLS IMAP server. Sends the greeting, advertises STARTTLS, and on the
// STARTTLS command invokes `onStartTls(socket, tag)` so each test controls what (if
// anything) is written after the tagged OK. It never performs a real TLS handshake.
const createStartTlsServer = onStartTls =>
net.createServer(socket => {
socket.on('error', () => {});
socket.write('* OK [CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED] ready\r\n');
let buf = '';
socket.on('data', data => {
buf += data.toString('binary');
let idx;
while ((idx = buf.indexOf('\r\n')) >= 0) {
let line = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let parts = line.split(' ');
let tag = parts[0];
let command = (parts[1] || '').toUpperCase();
if (command === 'CAPABILITY') {
socket.write('* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\n');
socket.write(`${tag} OK CAPABILITY done\r\n`);
} else if (command === 'STARTTLS') {
onStartTls(socket, tag);
}
}
});
});
const makeClient = port =>
new ImapFlow({
host: '127.0.0.1',
port,
secure: false,
doSTARTTLS: true,
servername: 'localhost',
tls: { rejectUnauthorized: false },
disableAutoIdle: true,
logger: false,
auth: { user: 'test', pass: 'test' }
});
// A MITM injects a response in the SAME segment as the STARTTLS OK, before the handshake.
// This is caught by the parser-level trailing-data flag (Check 1 in upgradeToSTARTTLS).
// Injection that instead arrives after the OK in a separate segment is caught by the
// socket-level read after unpipe (Check 2) or, failing that, by TLS handshake corruption;
// those fragmented cases are timing-dependent and not asserted deterministically here.
module.exports['STARTTLS: rejects same-segment plaintext injection'] = async test => {
let server = createStartTlsServer((socket, tag) => {
// OK and an injected untagged CAPABILITY in a single write, no TLS handshake.
socket.write(`${tag} OK Begin TLS\r\n* CAPABILITY IMAP4rev1 INJECTED\r\n`);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let port = server.address().port;
let client = makeClient(port);
client.on('error', () => {});
let connectErr = null;
try {
await client.connect();
test.ok(false, 'connect() must reject when data is injected after the STARTTLS OK');
} catch (err) {
connectErr = err;
}
test.ok(connectErr, 'connect() rejected');
test.equal(connectErr.code, 'STARTTLS_INJECTION', 'rejection is flagged as an injection');
test.ok(connectErr.tlsFailed, 'rejection is treated as a TLS failure');
// The connection must fail closed: it never becomes usable and never authenticates.
// (The injected untagged response may be parsed transiently before teardown, so asserting
// it never reaches the capability set would be a timing-fragile false assurance.)
test.ok(!client.usable, 'the connection did not become usable (failed closed)');
test.ok(!client.authenticated, 'the connection never authenticated');
client.close();
server.close();
test.done();
};
// A compliant server stays silent after the STARTTLS OK. The injection guard must NOT
// fire (no false positive); the upgrade proceeds to the TLS handshake, which then fails
// here only because this stub server never speaks TLS — proving the guard let it through.
module.exports['STARTTLS: clean OK is not flagged as injection'] = async test => {
let server = createStartTlsServer((socket, tag) => {
// Only the tagged OK, nothing after it, then drop the connection.
socket.write(`${tag} OK Begin TLS\r\n`);
setImmediate(() => socket.destroy());
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let port = server.address().port;
let client = makeClient(port);
client.on('error', () => {});
let connectErr = null;
try {
await client.connect();
test.ok(false, 'connect() rejects because the stub never completes the TLS handshake');
} catch (err) {
connectErr = err;
}
test.ok(connectErr, 'connect() rejected (no TLS handshake on the stub server)');
test.notEqual(connectErr.code, 'STARTTLS_INJECTION', 'a clean OK must not be flagged as injection');
client.close();
server.close();
test.done();
};
// A STARTTLS handshake failure must reject connect() through a single error path: it must
// not also emit an 'error' event (which would double-report and crash a listener-less client).
module.exports['STARTTLS: handshake failure has a single error path'] = async test => {
let server = createStartTlsServer((socket, tag) => {
// Acknowledge STARTTLS, then drop the connection instead of doing TLS.
socket.write(`${tag} OK Begin TLS\r\n`);
setImmediate(() => socket.destroy());
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let port = server.address().port;
let client = makeClient(port);
// A second error path would emit here (and crash a listener-less client).
let errorEvents = 0;
client.on('error', () => {
errorEvents++;
});
let connectErr = null;
try {
await client.connect();
test.ok(false, 'connect() should reject on STARTTLS handshake failure');
} catch (err) {
connectErr = err;
}
// Give any stray async error handler a chance to (wrongly) fire.
await new Promise(r => setTimeout(r, 50));
test.ok(connectErr, 'connect() rejected');
test.ok(connectErr.tlsFailed, 'rejection is flagged as a TLS failure');
test.equal(errorEvents, 0, 'no duplicate error event emitted (single error path)');
client.close();
server.close();
test.done();
};
// Unit-level check of the per-command trailing flag the guard relies on: each pushed
// command records whether more input followed it, independently of later commands.
module.exports['STARTTLS: parser flags trailing data per command'] = test => {
const stream = new ImapStream({ cid: 'test' });
let flags = [];
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
flags.push(cmd.trailingAfterLine);
cmd.next();
}
});
stream.on('error', err => test.ifError(err));
stream.on('end', () => {
// First command had a second command after it -> true; the last one -> false.
test.deepEqual(flags, [true, false], 'trailingAfterLine is per-command and not overwritten');
test.done();
});
// Two complete commands in a single write.
stream.end(Buffer.from('A OK first\r\nB OK second\r\n'));
};