diff --git a/bin/customs_server.js b/bin/customs_server.js index 1454144..67fa8c3 100644 --- a/bin/customs_server.js +++ b/bin/customs_server.js @@ -13,7 +13,7 @@ var BLOCK_INTERVAL_MS = config.blockIntervalSeconds * 1000 var RATE_LIMIT_INTERVAL_MS = config.rateLimitIntervalSeconds * 1000 var IpEmailRecord = require('../ip_email_record')(RATE_LIMIT_INTERVAL_MS, config.maxBadLogins) -var EmailRecord = require('../email_record')(RATE_LIMIT_INTERVAL_MS, config.maxEmails) +var EmailRecord = require('../email_record')(RATE_LIMIT_INTERVAL_MS, BLOCK_INTERVAL_MS, config.maxEmails) var IpRecord = require('../ip_record')(BLOCK_INTERVAL_MS) var P = require('bluebird') @@ -190,6 +190,39 @@ api.post( } ) +api.post( + '/blockEmail', + function (req, res, next) { + var email = req.body.email + if (!email) { + var err = {code: 'MissingParameters', message: 'email is required'} + log.error({ op: 'request.blockEmail', email: email, err: err }) + res.send(500, err) + next() + } + + mc.getAsync(email) + .then(EmailRecord.parse, EmailRecord.parse) + .then( + function (emailRecord) { + emailRecord.block() + return mc.setAsync(email, emailRecord, LIFETIME).caught(ignore) + } + ) + .then( + function () { + log.info({ op: 'request.blockEmail', email: email }) + res.send({}) + }, + function (err) { + log.error({ op: 'request.blockEmail', email: email, err: err }) + res.send(500, err) + } + ) + .done(next, next) + } +) + api.get( '/', function (req, res, next) { diff --git a/email_record.js b/email_record.js index 34fd3af..e6a5536 100644 --- a/email_record.js +++ b/email_record.js @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // Keep track of events tied to just email addresses -module.exports = function (RATE_LIMIT_INTERVAL_MS, MAX_EMAILS, now) { +module.exports = function (RATE_LIMIT_INTERVAL_MS, BLOCK_INTERVAL_MS, MAX_EMAILS, now) { now = now || Date.now @@ -21,6 +21,7 @@ module.exports = function (RATE_LIMIT_INTERVAL_MS, MAX_EMAILS, now) { EmailRecord.parse = function (object) { var rec = new EmailRecord() object = object || {} + rec.bk = object.bk // timestamp when the account was banned rec.rl = object.rl // timestamp when the account was rate-limited rec.xs = object.xs || [] // timestamps when emails were sent rec.pr = object.pr // timestamp of the last password reset @@ -52,7 +53,13 @@ module.exports = function (RATE_LIMIT_INTERVAL_MS, MAX_EMAILS, now) { } EmailRecord.prototype.isBlocked = function () { - return !!(this.rl && (now() - this.rl < RATE_LIMIT_INTERVAL_MS)) + var rateLimited = !!(this.rl && (now() - this.rl < RATE_LIMIT_INTERVAL_MS)) + var banned = !!(this.bk && (now() - this.bk < BLOCK_INTERVAL_MS)) + return rateLimited || banned + } + + EmailRecord.prototype.block = function () { + this.bk = now() } EmailRecord.prototype.rateLimit = function () { @@ -65,7 +72,9 @@ module.exports = function (RATE_LIMIT_INTERVAL_MS, MAX_EMAILS, now) { } EmailRecord.prototype.retryAfter = function () { - return Math.max(0, Math.floor(((this.rl || 0) + RATE_LIMIT_INTERVAL_MS - now()) / 1000)) + var rateLimitAfter = Math.floor(((this.rl || 0) + RATE_LIMIT_INTERVAL_MS - now()) / 1000) + var banAfter = Math.floor(((this.bk || 0) + BLOCK_INTERVAL_MS - now()) / 1000) + return Math.max(0, rateLimitAfter, banAfter) } EmailRecord.prototype.update = function (action) { diff --git a/test/local/email_record_tests.js b/test/local/email_record_tests.js index 3b0910b..0c3ca41 100644 --- a/test/local/email_record_tests.js +++ b/test/local/email_record_tests.js @@ -10,7 +10,7 @@ function now() { } function simpleEmailRecord() { - return new (emailRecord(500, 2, now))() + return new (emailRecord(500, 800, 2, now))() } test( @@ -23,6 +23,13 @@ test( t.equal(er.isBlocked(), false, 'blockedAt is older than rate-limit interval') er.rl = 501 t.equal(er.isBlocked(), true, 'blockedAt is within the rate-limit interval') + delete er.rl + t.equal(er.isBlocked(), false, 'record is no longer blocked') + + er.bk = 199 + t.equal(er.isBlocked(), false, 'blockedAt is older than block interval') + er.bk = 201 + t.equal(er.isBlocked(), true, 'blockedAt is within the block interval') t.end() } ) @@ -110,18 +117,26 @@ test( test( 'retryAfter works', function (t) { - var er = simpleEmailRecord() - er.now = function () { + var er = new (emailRecord(5000, 8000, 2, function () { return 10000 - } + }))() t.equal(er.retryAfter(), 0, 'unblocked records can be retried now') - er.rl = 100 + er.rl = 1000 t.equal(er.retryAfter(), 0, 'long expired blocks can be retried immediately') - er.rl = 500 + er.rl = 5000 t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately') er.rl = 6000 - t.equal(er.retryAfter(), 5, 'unexpired blocks can be retried in a bit') + t.equal(er.retryAfter(), 1, 'unexpired blocks can be retried in a bit') + + delete er.rl + t.equal(er.retryAfter(), 0, 'unblocked records can be retried now') + er.bk = 1000 + t.equal(er.retryAfter(), 0, 'long expired blocks can be retried immediately') + er.bk = 2000 + t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately') + er.bk = 6000 + t.equal(er.retryAfter(), 4, 'unexpired blocks can be retried in a bit') // TODO? t.end() } ) @@ -145,7 +160,7 @@ test( t.equal(er.isBlocked(), false, 'original object is not blocked') t.equal(er.xs.length, 0, 'original object has no hits') - var erCopy1 = (emailRecord(50, 2, now)).parse(er) + var erCopy1 = (emailRecord(50, 50, 2, now)).parse(er) t.equal(erCopy1.isBlocked(), false, 'copied object is not blocked') t.equal(erCopy1.xs.length, 0, 'copied object has no hits') @@ -154,7 +169,7 @@ test( t.equal(er.isBlocked(), true, 'original object is now blocked') t.equal(er.xs.length, 1, 'original object now has one hit') - var erCopy2 = (emailRecord(50, 2, now)).parse(er) + var erCopy2 = (emailRecord(50, 50, 2, now)).parse(er) t.equal(erCopy2.isBlocked(), true, 'copied object is blocked') t.equal(erCopy2.xs.length, 1, 'copied object has one hit') t.end() diff --git a/test/remote/block_email_tests.js b/test/remote/block_email_tests.js new file mode 100644 index 0000000..42afa23 --- /dev/null +++ b/test/remote/block_email_tests.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var test = require('tap').test +var restify = require('restify') +var TestServer = require('../test_server') + +var TEST_EMAIL = 'test@example.com' + +var config = { + port: 7000 +} +var testServer = new TestServer(config) + +test( + 'startup', + function (t) { + testServer.start(function (err) { + t.type(testServer.server, 'object', 'test server was started') + t.notOk(err, 'no errors were returned') + t.end() + }) + } +) + +var client = restify.createJsonClient({ + url: 'http://127.0.0.1:' + config.port +}); + +test( + 'well-formed request', + function (t) { + client.post('/blockEmail', { email: TEST_EMAIL }, + function (err, req, res, obj) { + t.notOk(err, 'good request is successful') + t.equal(res.statusCode, 200, 'good request returns a 200') + t.end() + } + ) + } +) + +test( + 'missing email', + function (t) { + client.post('/blockEmail', {}, + function (err, req, res, obj) { + t.equal(res.statusCode, 500, 'bad request returns a 500') + t.type(obj.code, 'string', 'bad request returns an error code') + t.type(obj.message, 'string', 'bad request returns an error message') + t.end() + } + ) + } +) + +test( + 'teardown', + function (t) { + testServer.stop() + t.equal(testServer.server.killed, true, 'test server has been killed') + t.end() + } +) diff --git a/test/remote/never_blocked.js b/test/remote/never_blocked.js index 01344ae..34b1c87 100644 --- a/test/remote/never_blocked.js +++ b/test/remote/never_blocked.js @@ -12,12 +12,13 @@ var TEST_IP = '192.0.2.1' var config = { port: 7000, memcached: '127.0.0.1:11211', + blockIntervalSeconds: 1, rateLimitIntervalSeconds: 1, maxEmails: 3, maxBadLogins: 2 } -var EmailRecord = require('../../email_record')(config.rateLimitIntervalSeconds * 1000, config.maxEmails) +var EmailRecord = require('../../email_record')(config.rateLimitIntervalSeconds * 1000, config.blockIntervalSeconds * 1000, config.maxEmails) var IpEmailRecord = require('../../ip_email_record')(config.rateLimitIntervalSeconds * 1000, config.maxBadLogins) var testServer = new TestServer(config) diff --git a/test/remote/too_many_emails.js b/test/remote/too_many_emails.js index c8b778e..35b0228 100644 --- a/test/remote/too_many_emails.js +++ b/test/remote/too_many_emails.js @@ -12,11 +12,12 @@ var TEST_IP = '192.0.2.1' var config = { port: 7000, memcached: '127.0.0.1:11211', + blockIntervalSeconds: 1, rateLimitIntervalSeconds: 1, maxEmails: 3 } -var EmailRecord = require('../../email_record')(config.rateLimitIntervalSeconds * 1000, config.maxEmails) +var EmailRecord = require('../../email_record')(config.rateLimitIntervalSeconds * 1000, config.blockIntervalSeconds * 1000, config.maxEmails) var testServer = new TestServer(config) var mc = new Memcached(