smtp-server
Advanced tools
Comparing version 1.1.1 to 1.2.0
# Changelog | ||
## v1.2.0 2015-03-11 | ||
* Do not allow HTTP requests. If the client tries to send a command that looks like a HTTP request, then disconnect | ||
* Close connection after 10 unrecognized commands | ||
* Close connection after 10 unauthenticated commands | ||
* Close all pending connections after `server.close()` has been called. Default delay to wait is 30 sec. Can be changed with `closeTimeout` option | ||
## v1.1.1 2015-03-11 | ||
@@ -4,0 +11,0 @@ |
@@ -65,2 +65,8 @@ 'use strict'; | ||
// Error counter - if too many commands in non-authenticated state are used, then disconnect | ||
this._unauthenticatedCommands = 0; | ||
// Error counter - if too many invalid commands are used, then disconnect | ||
this._unrecognizedCommands = 0; | ||
// Server hostname for the greegins | ||
@@ -75,12 +81,18 @@ this.name = this._server.options.name || os.hostname(); | ||
// Setup event handlers for the socket | ||
this._setListeners(); | ||
// increment connection count | ||
this._server.connections++; | ||
this._closing = false; | ||
this._closed = false; | ||
} | ||
util.inherits(SMTPConnection, EventEmitter); | ||
/** | ||
* Initiates the connection. Checks connection limits and reverse resolves client hostname. The client | ||
* is not allowed to send anything before init has finished otherwise 'You talk too soon' error is returned | ||
*/ | ||
SMTPConnection.prototype.init = function() { | ||
// Setup event handlers for the socket | ||
this._setListeners(); | ||
// Check that connection limit is not exceeded | ||
if (this._server.options.maxClients && this._server.connections > this._server.options.maxClients) { | ||
if (this._server.options.maxClients && this._server.connections.size > this._server.options.maxClients) { | ||
this.send(421, this.name + ' Too many connected clients, try again in a moment'); | ||
@@ -90,18 +102,19 @@ return this.close(); | ||
// Resolve hostname for the remote IP | ||
dns.reverse(this.remoteAddress, function(err, hostnames) { | ||
if (this._closing || this._closed) { | ||
return; | ||
} | ||
// Resolve hostname for the remote IP, keep a small delay for detecting early talkers | ||
setTimeout(function() { | ||
dns.reverse(this.remoteAddress, function(err, hostnames) { | ||
if (this._closing || this._closed) { | ||
return; | ||
} | ||
this.clientHostname = hostnames && hostnames.shift() || '[' + this.remoteAddress + ']'; | ||
this.clientHostname = hostnames && hostnames.shift() || '[' + this.remoteAddress + ']'; | ||
this._startSession(); | ||
this._startSession(); | ||
this._ready = true; // Start accepting data from input | ||
this._server.logger.info('[%s] Connection from %s', this._id, this.clientHostname); | ||
this.send(220, this.name + ' ESMTP' + (this._server.options.banner ? ' ' + this._server.options.banner : '')); | ||
}.bind(this)); | ||
} | ||
util.inherits(SMTPConnection, EventEmitter); | ||
this._ready = true; // Start accepting data from input | ||
this._server.logger.info('[%s] Connection from %s', this._id, this.clientHostname); | ||
this.send(220, this.name + ' ESMTP' + (this._server.options.banner ? ' ' + this._server.options.banner : '')); | ||
}.bind(this)); | ||
}.bind(this), 100); | ||
}; | ||
@@ -139,5 +152,3 @@ /** | ||
if (!this._closing && !this._closed) { | ||
this._server.connections--; | ||
} | ||
this._server.connections.delete(this); | ||
@@ -171,5 +182,3 @@ this._closing = true; | ||
if (!this._closing && !this._closed) { | ||
this._server.connections--; | ||
} | ||
this._server.connections.delete(this); | ||
@@ -221,2 +230,3 @@ if (this._closed) { | ||
// block spammers that send payloads before server greeting | ||
if (!this._ready) { | ||
@@ -227,2 +237,8 @@ this.send(421, this.name + ' You talk too soon'); | ||
// block malicious web pages that try to make SMTP calls from an AJAX request | ||
if (/^(OPTIONS|GET|HEAD|POST|PUT|DELETE|TRACE|CONNECT) \/.* HTTP\/\d\.\d$/i.test(command)) { | ||
this.send(554, 'HTTP requests not allowed'); | ||
return this.close(); | ||
} | ||
callback = callback || function() {}; | ||
@@ -251,2 +267,9 @@ | ||
if (!handler) { | ||
// if the user makes more | ||
this._unrecognizedCommands++; | ||
if (this._unrecognizedCommands >= 10) { | ||
this.send(554, 'Error: too many unrecognized commands'); | ||
return this.close(); | ||
} | ||
this.send(500, 'Error: command not recognized'); | ||
@@ -256,2 +279,11 @@ return setImmediate(callback); | ||
// block users that try to fiddle around without logging in | ||
if (!this.session.user && this._isSupported('AUTH') && commandName !== 'AUTH') { | ||
this._unauthenticatedCommands++; | ||
if (this._unauthenticatedCommands >= 10) { | ||
this.send(554, 'Error: too many unauthenticated commands'); | ||
return this.close(); | ||
} | ||
} | ||
if (!this.hostNameAppearsAs && commandName && | ||
@@ -639,2 +671,3 @@ ['MAIL', 'RCPT', 'DATA', 'AUTH'].indexOf(commandName) >= 0) { | ||
this._unrecognizedCommands = 0; // reset unrecognized commands counter | ||
this._startSession(); // reset session state | ||
@@ -641,0 +674,0 @@ this._parser.continue(); |
@@ -10,2 +10,4 @@ 'use strict'; | ||
var CLOSE_TIMEOUT = 30 * 1000; // how much to wait until pending connections are terminated | ||
// Expose to the world | ||
@@ -46,3 +48,3 @@ module.exports.SMTPServer = SMTPServer; | ||
if ('logger' in this.options) { | ||
// use provided logger or use vanity logger | ||
// use provided logger or use vanity logger if option is set to false | ||
this.logger = this.options.logger || { | ||
@@ -58,2 +60,3 @@ info: function() {}, | ||
// apply shorthand handlers | ||
['onAuth', 'onMailFrom', 'onRcptTo', 'onData'].forEach(function(handler) { | ||
@@ -65,7 +68,18 @@ if (typeof this.options[handler] === 'function') { | ||
this.connections = 0; | ||
/** | ||
* Timeout after close has been called until pending connections are forcibly closed | ||
*/ | ||
this._closeTimeout = false; | ||
/** | ||
* A set of all currently open connections | ||
*/ | ||
this.connections = new Set(); | ||
// setup server listener and connection handler | ||
this.server = (this.options.secure ? tls : net).createServer(this.options, function(socket) { | ||
var connection = new SMTPConnection(this, socket); | ||
this.connections.add(connection); | ||
connection.on('error', this._onError.bind(this)); | ||
connection.init(); | ||
}.bind(this)); | ||
@@ -90,3 +104,27 @@ | ||
SMTPServer.prototype.close = function(callback) { | ||
this.server.close(callback); | ||
var connections = this.connections.size; | ||
var timeout = this.options.closeTimeout || CLOSE_TIMEOUT; | ||
// stop accepting new connections | ||
this.server.close(function() { | ||
clearTimeout(this._closeTimeout); | ||
callback(); | ||
}.bind(this)); | ||
// close active connections | ||
if (connections) { | ||
this.logger.info('Server closing with %s pending connection%s, waiting %s seconds before terminating', connections, connections !== 1 ? 's' : '', timeout / 1000); | ||
} | ||
this._closeTimeout = setTimeout(function() { | ||
connections = this.connections.size; | ||
if (connections) { | ||
this.logger.info('Closing %s pending connection%s to close the server', connections, connections !== 1 ? 's' : ''); | ||
this.connections.forEach(function(connection) { | ||
connection.send(421, 'Server shutting down'); | ||
connection.close(); | ||
}.bind(this)); | ||
} | ||
}.bind(this), timeout); | ||
}; | ||
@@ -93,0 +131,0 @@ |
{ | ||
"name": "smtp-server", | ||
"version": "1.1.1", | ||
"version": "1.2.0", | ||
"description": "Create custom SMTP servers on the fly", | ||
@@ -5,0 +5,0 @@ "main": "lib/smtp-server.js", |
@@ -44,2 +44,3 @@ # smtp-server | ||
* **options.socketTimeout** how many milliseconds of inactivity to allow before disconnecting the client (defaults to 1 minute) | ||
* **options.closeTimeout** how many millisceonds to wait before disconnecting pending connections once server.close() has been called (defaults to 30 seconds) | ||
* **onAuth** is the callback to handle authentications (see details [here](#handling-authentication)) | ||
@@ -46,0 +47,0 @@ * **onMailFrom** is the callback to validate MAIL FROM commands (see details [here](#validating-sender-addresses)) |
@@ -7,2 +7,3 @@ 'use strict'; | ||
var SMTPConnection = require('../lib/smtp-connection').SMTPConnection; | ||
var net = require('net'); | ||
@@ -21,4 +22,7 @@ var expect = chai.expect; | ||
it('should parse MAIL FROM/RCPT TO', function() { | ||
var conn = new SMTPConnection({ | ||
options: {} | ||
}, {}); | ||
expect(SMTPConnection.prototype._parseAddressCommand('MAIL FROM', 'MAIL FROM:<test@example.com>')).to.deep.equal({ | ||
expect(conn._parseAddressCommand('MAIL FROM', 'MAIL FROM:<test@example.com>')).to.deep.equal({ | ||
address: 'test@example.com', | ||
@@ -28,3 +32,3 @@ args: false | ||
expect(SMTPConnection.prototype._parseAddressCommand('MAIL FROM', 'MAIL FROM:<sender@example.com> SIZE=12345 RET=HDRS ')).to.deep.equal({ | ||
expect(conn._parseAddressCommand('MAIL FROM', 'MAIL FROM:<sender@example.com> SIZE=12345 RET=HDRS ')).to.deep.equal({ | ||
address: 'sender@example.com', | ||
@@ -37,3 +41,3 @@ args: { | ||
expect(SMTPConnection.prototype._parseAddressCommand('MAIL FROM', 'MAIL FROM : <test@example.com>')).to.deep.equal({ | ||
expect(conn._parseAddressCommand('MAIL FROM', 'MAIL FROM : <test@example.com>')).to.deep.equal({ | ||
address: 'test@example.com', | ||
@@ -43,3 +47,3 @@ args: false | ||
expect(SMTPConnection.prototype._parseAddressCommand('MAIL TO', 'MAIL FROM:<test@example.com>')).to.be.false; | ||
expect(conn._parseAddressCommand('MAIL TO', 'MAIL FROM:<test@example.com>')).to.be.false; | ||
}); | ||
@@ -241,3 +245,73 @@ }); | ||
}); | ||
}); | ||
describe('Plaintext server with no connection limit', function() { | ||
this.timeout(60 * 1000); | ||
var PORT = 1336; | ||
var server = new SMTPServer({ | ||
logger: false, | ||
socketTimeout: 100 * 1000, | ||
closeTimeout: 6 * 1000 | ||
}); | ||
beforeEach(function(done) { | ||
server.listen(PORT, '127.0.0.1', done); | ||
}); | ||
it('open multiple connections and close all at once', function(done) { | ||
var limit = 100; | ||
var cleanClose = 4; | ||
var disconnected = 0; | ||
var connected = 0; | ||
var connections = []; | ||
var createConnection = function(callback) { | ||
var connection = new Client({ | ||
port: PORT, | ||
host: '127.0.0.1', | ||
tls: { | ||
rejectUnauthorized: false | ||
} | ||
}); | ||
connection.on('error', function(err) { | ||
expect(err.responseCode).to.equal(421); // Server shutting down | ||
}); | ||
connection.on('end', function() { | ||
disconnected++; | ||
if (disconnected >= limit) { | ||
done(); | ||
} | ||
}); | ||
connection.connect(function() { | ||
connected++; | ||
callback(null, connection); | ||
}); | ||
}; | ||
var connCb = function(err, conn) { | ||
expect(err).to.not.exist; | ||
connections.push(conn); | ||
if (connected >= limit) { | ||
server.close(); | ||
setTimeout(function() { | ||
for (var i = 0; i < cleanClose; i++) { | ||
connections[i].quit(); | ||
} | ||
}, 1000); | ||
} else { | ||
createConnection(connCb); | ||
} | ||
}; | ||
createConnection(connCb); | ||
}); | ||
}); | ||
@@ -303,3 +377,14 @@ | ||
logger: false, | ||
socketTimeout: 2 * 1000 | ||
socketTimeout: 2 * 1000, | ||
onAuth: function(auth, session, callback) { | ||
if (auth.username === 'testuser' && auth.password === 'testpass') { | ||
callback(null, { | ||
user: 'userdata' | ||
}); | ||
} else { | ||
callback(null, { | ||
message: 'Authentication failed' | ||
}); | ||
} | ||
} | ||
}); | ||
@@ -356,2 +441,93 @@ | ||
}); | ||
it('should close after too many unauthenticated commands', function(done) { | ||
var connection = new Client({ | ||
port: PORT, | ||
host: '127.0.0.1', | ||
ignoreTLS: true | ||
}); | ||
connection.on('error', function(err) { | ||
expect(err).to.exist; | ||
}); | ||
connection.on('end', done); | ||
connection.connect(function() { | ||
var looper = function() { | ||
connection._currentAction = function() { | ||
looper(); | ||
}; | ||
connection._sendCommand('NOOP'); | ||
}; | ||
looper(); | ||
}); | ||
}); | ||
it('should close after too many unrecognized commands', function(done) { | ||
var connection = new Client({ | ||
port: PORT, | ||
host: '127.0.0.1', | ||
ignoreTLS: true | ||
}); | ||
connection.on('error', function(err) { | ||
expect(err).to.exist; | ||
}); | ||
connection.on('end', done); | ||
connection.connect(function() { | ||
connection.login({ | ||
user: 'testuser', | ||
pass: 'testpass' | ||
}, function(err) { | ||
expect(err).to.not.exist; | ||
var looper = function() { | ||
connection._currentAction = function() { | ||
looper(); | ||
}; | ||
connection._sendCommand('ZOOP'); | ||
}; | ||
looper(); | ||
}); | ||
}); | ||
}); | ||
it('should reject early talker', function(done) { | ||
var socket = net.connect(PORT, '127.0.0.1', function() { | ||
var buffers = []; | ||
socket.on('data', function(chunk) { | ||
buffers.push(chunk); | ||
}); | ||
socket.on('end', function() { | ||
var data = Buffer.concat(buffers).toString(); | ||
expect(/^421 /.test(data)).to.be.true; | ||
done(); | ||
}); | ||
socket.write('EHLO FOO\r\n'); | ||
}); | ||
}); | ||
it('should reject HTTP requests', function(done) { | ||
var socket = net.connect(PORT, '127.0.0.1', function() { | ||
var buffers = []; | ||
var started = false; | ||
socket.on('data', function(chunk) { | ||
buffers.push(chunk); | ||
if (!started) { | ||
started = true; | ||
socket.write('GET /path/file.html HTTP/1.0\r\nHost: www.example.com\r\n\r\n'); | ||
} | ||
}); | ||
socket.on('end', function() { | ||
var data = Buffer.concat(buffers).toString(); | ||
expect(/^554 /m.test(data)).to.be.true; | ||
done(); | ||
}); | ||
}); | ||
}); | ||
}); | ||
@@ -401,8 +577,19 @@ | ||
logger: false, | ||
authMethods: ['PLAIN', 'LOGIN', 'XOAUTH2'] | ||
}); | ||
server.onAuth = function(auth, session, callback) { | ||
if (auth.method === 'XOAUTH2') { | ||
if (auth.username === 'testuser' && auth.accessToken === 'testtoken') { | ||
authMethods: ['PLAIN', 'LOGIN', 'XOAUTH2'], | ||
onAuth: function(auth, session, callback) { | ||
if (auth.method === 'XOAUTH2') { | ||
if (auth.username === 'testuser' && auth.accessToken === 'testtoken') { | ||
callback(null, { | ||
user: 'userdata' | ||
}); | ||
} else { | ||
callback(null, { | ||
data: { | ||
status: '401', | ||
schemes: 'bearer mac', | ||
scope: 'https://mail.google.com/' | ||
} | ||
}); | ||
} | ||
} else if (auth.username === 'testuser' && auth.password === 'testpass') { | ||
callback(null, { | ||
@@ -413,19 +600,7 @@ user: 'userdata' | ||
callback(null, { | ||
data: { | ||
status: '401', | ||
schemes: 'bearer mac', | ||
scope: 'https://mail.google.com/' | ||
} | ||
message: 'Authentication failed' | ||
}); | ||
} | ||
} else if (auth.username === 'testuser' && auth.password === 'testpass') { | ||
callback(null, { | ||
user: 'userdata' | ||
}); | ||
} else { | ||
callback(null, { | ||
message: 'Authentication failed' | ||
}); | ||
} | ||
}; | ||
}); | ||
@@ -432,0 +607,0 @@ beforeEach(function(done) { |
99245
2176
321
5