├── .gitignore ├── .awsbox.json ├── test ├── customs_server_stub.js ├── remote │ ├── root_tests.js │ ├── password_reset_tests.js │ ├── failed_login_attempt_tests.js │ ├── check_tests.js │ ├── block_ip_tests.js │ ├── block_email_tests.js │ ├── password_reset_clears_bad_logins.js │ ├── too_many_emails.js │ ├── never_blocked.js │ ├── email_normalization.js │ └── too_many_bad_logins.js ├── test_server.js ├── local │ ├── ip_record_tests.js │ ├── ban_tests.js │ ├── ip_email_record_tests.js │ └── email_record_tests.js └── memcache-helper.js ├── scripts ├── test-local.sh └── tap-coverage.js ├── config ├── index.js └── config.js ├── .jshintrc ├── .travis.yml ├── bans ├── index.js ├── sqs.js └── handler.js ├── CHANGELOG ├── ip_record.js ├── log.js ├── package.json ├── Gruntfile.js ├── CONTRIBUTING.md ├── ip_email_record.js ├── README.md ├── email_record.js ├── docs └── api.md ├── bin └── customs_server.js ├── LICENSE └── npm-shrinkwrap.json /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.html 2 | node_modules 3 | -------------------------------------------------------------------------------- /.awsbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "processes": [ 3 | "bin/customs_server.js" 4 | ], 5 | "packages": [ 6 | "memcached" 7 | ], 8 | "ssl": "disable" 9 | } 10 | -------------------------------------------------------------------------------- /test/customs_server_stub.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | require('../bin/customs_server') 7 | -------------------------------------------------------------------------------- /scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | node_modules/.bin/grunt || exit 1 8 | node scripts/tap-coverage.js test/local test/remote || exit 1 9 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var fs = require('fs') 6 | var path = require('path') 7 | var url = require('url') 8 | var convict = require('convict') 9 | 10 | module.exports = require('./config')(fs, path, url, convict) 11 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": true, 3 | "bitwise": true, 4 | "camelcase": true, 5 | "curly": true, 6 | "eqeqeq": true, 7 | "forin": true, 8 | "freeze": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "nonbsp": true, 15 | "nonew": true, 16 | "quotmark": "single", 17 | "undef": true, 18 | "unused": true, 19 | "strict": false, 20 | 21 | "node": true 22 | } 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | # workaround for obsolete `temp` module 0.6 4 | env: 5 | global: 6 | - TMPDIR=/tmp 7 | 8 | node_js: 9 | - 0.10 10 | 11 | services: 12 | - memcached 13 | 14 | notifications: 15 | irc: 16 | channels: 17 | - "irc.mozilla.org#fxa" 18 | use_notice: false 19 | skip_join: false 20 | 21 | before_install: 22 | - npm config set spin false 23 | 24 | script: 25 | - npm run outdated 26 | - ./node_modules/.bin/grunt validate-shrinkwrap 27 | - npm test 28 | -------------------------------------------------------------------------------- /bans/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var handleBans = require('./handler') 6 | 7 | module.exports = function (log) { 8 | 9 | var BanQueue = require('./sqs')(log) 10 | 11 | return function start(config, mc) { 12 | var banQueue = new BanQueue(config) 13 | banQueue.on('data', handleBans(mc, log)) 14 | banQueue.start() 15 | return banQueue 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.5.0 2 | * Block all actions for emails that are explicitly banned - #70 3 | 4 | 0.4.0 5 | * Validation errors should return 400 errors, not 500 - #68 6 | * Document the current blocking and rate-limiting policies - #63 7 | 8 | 0.3.0 9 | * Add support for account lockout on excessive login attempts - #58, #60 10 | * normalize email addresses (compare the lower case values) - #59, #62 11 | 12 | 0.2.0 13 | * update request and restify for new qs module 14 | * update ass version 15 | * use npm shrinkwrap 16 | 17 | 0.1.1 18 | * Remove redundant memcache.host and memcache.port settings 19 | * expose all configuration settings to the environment; add option memcache.address to work with previous puppet settings 20 | * removing npm spinner from travis logs 21 | 22 | 0.1.0 23 | * init 24 | -------------------------------------------------------------------------------- /ip_record.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | // Keep track of events related to just IP addresses 6 | module.exports = function (BLOCK_INTERVAL_MS, now) { 7 | 8 | now = now || Date.now 9 | 10 | function IpRecord() {} 11 | 12 | IpRecord.parse = function (object) { 13 | var rec = new IpRecord() 14 | object = object || {} 15 | rec.bk = object.bk // timestamp when the account was blocked 16 | return rec 17 | } 18 | 19 | IpRecord.prototype.shouldBlock = function () { 20 | return this.isBlocked() 21 | } 22 | 23 | IpRecord.prototype.isBlocked = function () { 24 | return !!(this.bk && (now() - this.bk < BLOCK_INTERVAL_MS)) 25 | } 26 | 27 | IpRecord.prototype.block = function () { 28 | this.bk = now() 29 | } 30 | 31 | IpRecord.prototype.retryAfter = function () { 32 | return Math.max(0, Math.floor(((this.bk || 0) + BLOCK_INTERVAL_MS - now()) / 1000)) 33 | } 34 | 35 | IpRecord.prototype.update = function () { 36 | return this.retryAfter() 37 | } 38 | 39 | return IpRecord 40 | } 41 | -------------------------------------------------------------------------------- /scripts/tap-coverage.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | if (!process.env.NO_COVERAGE) { 8 | var ass = require('ass').enable( { 9 | exclude: [ '/test' ] 10 | }) 11 | } 12 | 13 | var path = require('path'), 14 | spawn = require('child_process').spawn, 15 | fs = require('fs') 16 | 17 | var p = spawn(path.join(path.dirname(__dirname), 'node_modules', '.bin', 'tap'), 18 | process.argv.slice(2), { stdio: 'inherit' }) 19 | 20 | p.on('close', function(code) { 21 | if (!process.env.NO_COVERAGE) { 22 | ass.report('json', function(err, r) { 23 | console.log('code coverage:', r.percent + '%') 24 | process.stdout.write('generating coverage.html: ') 25 | var start = new Date() 26 | ass.report('html', function(err, html) { 27 | fs.writeFileSync(path.join(path.dirname(__dirname), 'coverage.html'), 28 | html) 29 | process.stdout.write('complete in ' + 30 | ((new Date() - start) / 1000.0).toFixed(1) + 's\n') 31 | process.exit(code) 32 | }) 33 | }) 34 | } else { 35 | process.exit(code) 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var util = require('util') 6 | var Logger = require('bunyan') 7 | 8 | function Overdrive(options) { 9 | Logger.call(this, options) 10 | } 11 | util.inherits(Overdrive, Logger) 12 | 13 | 14 | Overdrive.prototype.stat = function (stats) { 15 | stats.op = 'stat' 16 | this.info(stats) 17 | } 18 | 19 | module.exports = function (level, name) { 20 | var logStreams = [{ stream: process.stderr, level: level }] 21 | name = name || 'fxa-auth-server' 22 | 23 | var log = new Overdrive( 24 | { 25 | name: name, 26 | streams: logStreams 27 | } 28 | ) 29 | 30 | process.stdout.on( 31 | 'error', 32 | function (err) { 33 | if (err.code === 'EPIPE') { 34 | log.emit('error', err) 35 | } 36 | } 37 | ) 38 | 39 | Object.keys(console).forEach( 40 | function (key) { 41 | console[key] = function () { 42 | var json = { op: 'console', message: util.format.apply(null, arguments) } 43 | if(log[key]) { 44 | log[key](json) 45 | } 46 | else { 47 | log.warn(json) 48 | } 49 | } 50 | } 51 | ) 52 | 53 | return log 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxa-customs-server", 3 | "version": "0.5.0", 4 | "description": "Firefox Accounts Customs Server", 5 | "author": "Mozilla (https://mozilla.org/)", 6 | "license": "MPL 2.0", 7 | "keywords": [], 8 | "repository": { 9 | "type": "git", 10 | "url": "git@github.com:mozilla/fxa-customs-server.git" 11 | }, 12 | "homepage": "https://github.com/mozilla/fxa-customs-server/", 13 | "bugs": "https://github.com/mozilla/fxa-customs-server/issues/", 14 | "scripts": { 15 | "start": "node bin/customs_server.js", 16 | "test": "scripts/test-local.sh", 17 | "outdated": "npm outdated --depth 0", 18 | "shrinkwrap": "npm shrinkwrap && grunt validate-shrinkwrap" 19 | }, 20 | "dependencies": { 21 | "aws-sdk": "2.0.0-rc.20", 22 | "bluebird": "1.2.2", 23 | "bunyan": "0.23.1", 24 | "convict": "0.6.1", 25 | "memcached": "0.2.8", 26 | "restify": "2.8.2" 27 | }, 28 | "devDependencies": { 29 | "ass": "0.0.4", 30 | "grunt": "0.4.5", 31 | "grunt-cli": "0.1.13", 32 | "grunt-contrib-jshint": "0.10.0", 33 | "grunt-copyright": "0.1.0", 34 | "grunt-nsp-shrinkwrap": "0.0.3", 35 | "jshint": "2.5.x", 36 | "jshint-stylish": "0.4.0", 37 | "request": "2.40.0", 38 | "tap": "0.4.12", 39 | "walk": "2.3.x" 40 | }, 41 | "engines": { 42 | "node": ">=0.10.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/remote/root_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var packageJson = require('../../package.json') 8 | 9 | var config = { 10 | listen: { 11 | port: 7000 12 | } 13 | } 14 | var testServer = new TestServer(config) 15 | 16 | test( 17 | 'startup', 18 | function (t) { 19 | testServer.start(function (err) { 20 | t.type(testServer.server, 'object', 'test server was started') 21 | t.notOk(err, 'no errors were returned') 22 | t.end() 23 | }) 24 | } 25 | ) 26 | 27 | var client = restify.createJsonClient({ 28 | url: 'http://127.0.0.1:' + config.listen.port 29 | }); 30 | 31 | test( 32 | 'version check', 33 | function (t) { 34 | client.get('/', 35 | function (err, req, res, obj) { 36 | t.notOk(err, 'good request is successful') 37 | t.equal(res.statusCode, 200, 'good request returns a 200') 38 | t.equal(obj.version, packageJson.version, 'returns the correct version number') 39 | t.end() 40 | } 41 | ) 42 | } 43 | ) 44 | 45 | test( 46 | 'teardown', 47 | function (t) { 48 | testServer.stop() 49 | t.equal(testServer.server.killed, true, 'test server has been killed') 50 | t.end() 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.loadNpmTasks('grunt-copyright') 9 | grunt.loadNpmTasks('grunt-contrib-jshint') 10 | grunt.loadNpmTasks('grunt-nsp-shrinkwrap') 11 | 12 | grunt.initConfig({ 13 | pkg: grunt.file.readJSON('package.json'), 14 | 15 | copyright: { 16 | app: { 17 | options: { 18 | pattern: 'This Source Code Form is subject to the terms of the Mozilla Public' 19 | }, 20 | src: [ 21 | '{,config/}*.js', 22 | '{bans/,bin/,scripts/,test/}*' 23 | ] 24 | }, 25 | tests: { 26 | options: { 27 | pattern: 'Any copyright is dedicated to the Public Domain.' 28 | }, 29 | src: [ 30 | 'test/{remote,local}/*.js' 31 | ] 32 | } 33 | }, 34 | 35 | jshint: { 36 | options: { 37 | jshintrc: '.jshintrc', 38 | reporter: require('jshint-stylish') 39 | }, 40 | app: [ 41 | '{,bans/,bin/,config/,scripts/,test/}*.js' 42 | ] 43 | } 44 | }) 45 | 46 | grunt.registerTask('default', ['lint', 'copyright', 'validate-shrinkwrap']) 47 | grunt.registerTask('lint', ['jshint']) 48 | } 49 | -------------------------------------------------------------------------------- /test/test_server.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var cp = require('child_process') 6 | var request = require('request') 7 | 8 | function TestServer(config) { 9 | this.url = 'http://127.0.0.1:' + config.listen.port 10 | this.server = null 11 | } 12 | 13 | function waitLoop(testServer, url, cb) { 14 | request( 15 | url + '/', 16 | function (err, res/*, body*/) { 17 | if (err) { 18 | if (err.errno !== 'ECONNREFUSED') { 19 | console.log('ERROR: unexpected result from ' + url) 20 | console.log(err) 21 | return cb(err) 22 | } 23 | return setTimeout(waitLoop.bind(null, testServer, url, cb), 100) 24 | } 25 | if (res.statusCode !== 200) { 26 | console.log('ERROR: bad status code: ' + res.statusCode) 27 | return cb(res.statusCode) 28 | } 29 | return cb() 30 | } 31 | ) 32 | } 33 | 34 | TestServer.prototype.start = function (cb) { 35 | if (!this.server) { 36 | this.server = cp.spawn( 37 | 'node', 38 | ['./customs_server_stub.js'], 39 | { 40 | cwd: __dirname, 41 | stdio: 'ignore' 42 | } 43 | ) 44 | } 45 | 46 | waitLoop(this, this.url, function (err) { 47 | if (err) { 48 | cb(err) 49 | } else { 50 | cb(null) 51 | } 52 | }) 53 | } 54 | 55 | TestServer.prototype.stop = function () { 56 | if (this.server) { 57 | this.server.kill('SIGINT') 58 | } 59 | } 60 | 61 | module.exports = TestServer 62 | -------------------------------------------------------------------------------- /test/remote/password_reset_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | 8 | var TEST_EMAIL = 'test@example.com' 9 | 10 | var config = { 11 | listen: { 12 | port: 7000 13 | } 14 | } 15 | var testServer = new TestServer(config) 16 | 17 | test( 18 | 'startup', 19 | function (t) { 20 | testServer.start(function (err) { 21 | t.type(testServer.server, 'object', 'test server was started') 22 | t.notOk(err, 'no errors were returned') 23 | t.end() 24 | }) 25 | } 26 | ) 27 | 28 | var client = restify.createJsonClient({ 29 | url: 'http://127.0.0.1:' + config.listen.port 30 | }); 31 | 32 | test( 33 | 'well-formed request', 34 | function (t) { 35 | client.post('/passwordReset', { email: TEST_EMAIL }, 36 | function (err, req, res, obj) { 37 | t.notOk(err, 'good request is successful') 38 | t.equal(res.statusCode, 200, 'good request returns a 200') 39 | t.end() 40 | } 41 | ) 42 | } 43 | ) 44 | 45 | test( 46 | 'missing email', 47 | function (t) { 48 | client.post('/passwordReset', {}, 49 | function (err, req, res, obj) { 50 | t.equal(res.statusCode, 400, 'bad request returns a 400') 51 | t.type(obj.code, 'string', 'bad request returns an error code') 52 | t.type(obj.message, 'string', 'bad request returns an error message') 53 | t.end() 54 | } 55 | ) 56 | } 57 | ) 58 | 59 | test( 60 | 'teardown', 61 | function (t) { 62 | testServer.stop() 63 | t.equal(testServer.server.killed, true, 'test server has been killed') 64 | t.end() 65 | } 66 | ) 67 | -------------------------------------------------------------------------------- /test/remote/failed_login_attempt_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | 8 | var TEST_EMAIL = 'test@example.com' 9 | var TEST_IP = '192.0.2.1' 10 | 11 | var config = { 12 | listen: { 13 | port: 7000 14 | } 15 | } 16 | var testServer = new TestServer(config) 17 | 18 | test( 19 | 'startup', 20 | function (t) { 21 | testServer.start(function (err) { 22 | t.type(testServer.server, 'object', 'test server was started') 23 | t.notOk(err, 'no errors were returned') 24 | t.end() 25 | }) 26 | } 27 | ) 28 | 29 | var client = restify.createJsonClient({ 30 | url: 'http://127.0.0.1:' + config.listen.port 31 | }); 32 | 33 | test( 34 | 'well-formed request', 35 | function (t) { 36 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 37 | function (err, req, res, obj) { 38 | t.notOk(err, 'good request is successful') 39 | t.equal(res.statusCode, 200, 'good request returns a 200') 40 | t.end() 41 | } 42 | ) 43 | } 44 | ) 45 | 46 | test( 47 | 'missing ip', 48 | function (t) { 49 | client.post('/failedLoginAttempt', { email: TEST_EMAIL }, 50 | function (err, req, res, obj) { 51 | t.equal(res.statusCode, 400, 'bad request returns a 400') 52 | t.type(obj.code, 'string', 'bad request returns an error code') 53 | t.type(obj.message, 'string', 'bad request returns an error message') 54 | t.end() 55 | } 56 | ) 57 | } 58 | ) 59 | 60 | test( 61 | 'missing email and ip', 62 | function (t) { 63 | client.post('/failedLoginAttempt', {}, 64 | function (err, req, res, obj) { 65 | t.equal(res.statusCode, 400, 'bad request returns a 400') 66 | t.type(obj.code, 'string', 'bad request returns an error code') 67 | t.type(obj.message, 'string', 'bad request returns an error message') 68 | t.end() 69 | } 70 | ) 71 | } 72 | ) 73 | 74 | test( 75 | 'teardown', 76 | function (t) { 77 | testServer.stop() 78 | t.equal(testServer.server.killed, true, 'test server has been killed') 79 | t.end() 80 | } 81 | ) 82 | -------------------------------------------------------------------------------- /bans/sqs.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var AWS = require('aws-sdk') 6 | var inherits = require('util').inherits 7 | var EventEmitter = require('events').EventEmitter 8 | 9 | module.exports = function (log) { 10 | 11 | function SQSBanQueue(config) { 12 | this.sqs = new AWS.SQS({ region : config.region }) 13 | this.queueUrl = config.queueUrl 14 | EventEmitter.call(this) 15 | } 16 | inherits(SQSBanQueue, EventEmitter) 17 | 18 | function checkDeleteError(err) { 19 | if (err) { 20 | log.error({ op: 'sqs.deleteMessage', err: err }) 21 | } 22 | } 23 | 24 | SQSBanQueue.prototype.fetch = function (url) { 25 | var errTimer = null 26 | this.sqs.receiveMessage( 27 | { 28 | QueueUrl: url, 29 | AttributeNames: [], 30 | MaxNumberOfMessages: 10, 31 | WaitTimeSeconds: 20 32 | }, 33 | function (err, data) { 34 | if (err) { 35 | log.error({ op: 'sqs.fetch', url: url, err: err }) 36 | if (!errTimer) { 37 | // unacceptable! this aws lib will call the callback 38 | // more than once with different errors. 39 | errTimer = setTimeout(this.fetch.bind(this, url), 2000) 40 | } 41 | return 42 | } 43 | data.Messages = data.Messages || [] 44 | for (var i = 0; i < data.Messages.length; i++) { 45 | var msg = data.Messages[i] 46 | this.sqs.deleteMessage( 47 | { 48 | QueueUrl: url, 49 | ReceiptHandle: msg.ReceiptHandle 50 | }, 51 | checkDeleteError 52 | ) 53 | try { 54 | var body = JSON.parse(msg.Body) 55 | var message = JSON.parse(body.Message) 56 | this.emit('data', message) 57 | } 58 | catch (e) { 59 | log.error({ op: 'sqs.fetch.parse', message: msg.Body, err: e }) 60 | } 61 | } 62 | this.fetch(url) 63 | }.bind(this) 64 | ) 65 | } 66 | 67 | SQSBanQueue.prototype.start = function () { 68 | this.fetch(this.queueUrl) 69 | } 70 | 71 | return SQSBanQueue 72 | } 73 | -------------------------------------------------------------------------------- /test/remote/check_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | 8 | var TEST_EMAIL = 'test@example.com' 9 | var TEST_IP = '192.0.2.1' 10 | 11 | var config = { 12 | listen: { 13 | port: 7000 14 | } 15 | } 16 | var testServer = new TestServer(config) 17 | 18 | test( 19 | 'startup', 20 | function (t) { 21 | testServer.start(function (err) { 22 | t.type(testServer.server, 'object', 'test server was started') 23 | t.notOk(err, 'no errors were returned') 24 | t.end() 25 | }) 26 | } 27 | ) 28 | 29 | var client = restify.createJsonClient({ 30 | url: 'http://127.0.0.1:' + config.listen.port 31 | }); 32 | 33 | ['accountCreate', 'accountLogin', 'passwordChange'].forEach(function (action) { 34 | test( 35 | 'normal ' + action, 36 | function (t) { 37 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: action }, 38 | function (err, req, res, obj) { 39 | t.notOk(err, 'good request is successful') 40 | t.equal(res.statusCode, 200, 'good request returns a 200') 41 | t.end() 42 | } 43 | ) 44 | } 45 | ) 46 | }) 47 | 48 | test( 49 | 'missing action', 50 | function (t) { 51 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP }, 52 | function (err, req, res, obj) { 53 | t.equal(res.statusCode, 400, 'bad request returns a 400') 54 | t.type(obj.code, 'string', 'bad request returns an error code') 55 | t.type(obj.message, 'string', 'bad request returns an error message') 56 | t.end() 57 | } 58 | ) 59 | } 60 | ) 61 | 62 | test( 63 | 'missing email, ip and action', 64 | function (t) { 65 | client.post('/check', {}, 66 | function (err, req, res, obj) { 67 | t.equal(res.statusCode, 400, 'bad request returns a 400') 68 | t.type(obj.code, 'string', 'bad request returns an error code') 69 | t.type(obj.message, 'string', 'bad request returns an error message') 70 | t.end() 71 | } 72 | ) 73 | } 74 | ) 75 | 76 | test( 77 | 'teardown', 78 | function (t) { 79 | testServer.stop() 80 | t.equal(testServer.server.killed, true, 'test server has been killed') 81 | t.end() 82 | } 83 | ) 84 | -------------------------------------------------------------------------------- /test/local/ip_record_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | require('ass') 5 | var test = require('tap').test 6 | var ipRecord = require('../../ip_record') 7 | 8 | function now() { 9 | return 240*1000 // old school 10 | } 11 | 12 | function simpleIpRecord() { 13 | return new (ipRecord(120*1000, now))() 14 | } 15 | 16 | test( 17 | 'shouldBlock works', 18 | function (t) { 19 | var ir = simpleIpRecord() 20 | 21 | t.equal(ir.shouldBlock(), false, 'record has never been blocked') 22 | ir.bk = now() 23 | t.equal(ir.shouldBlock(), true, 'record is blocked') 24 | ir.bk = now() - 60*1000; 25 | t.equal(ir.shouldBlock(), true, 'record is still blocked') 26 | ir.bk = now() - 120*1000; // blockInterval 27 | t.equal(ir.shouldBlock(), false, 'record is no longer blocked') 28 | t.end() 29 | } 30 | ) 31 | 32 | test( 33 | 'block works', 34 | function (t) { 35 | var ir = simpleIpRecord() 36 | 37 | t.equal(ir.shouldBlock(), false, 'record has never been blocked') 38 | ir.block() 39 | t.equal(ir.shouldBlock(), true, 'record is blocked') 40 | t.end() 41 | } 42 | ) 43 | 44 | test( 45 | 'retryAfter works', 46 | function (t) { 47 | var ir = simpleIpRecord() 48 | 49 | t.equal(ir.retryAfter(), 0, 'unblocked records can be retried now') 50 | ir.bk = now() - 180*1000 51 | t.equal(ir.retryAfter(), 0, 'long expired blocks can be retried immediately') 52 | ir.bk = now() - 120*1000 53 | t.equal(ir.retryAfter(), 0, 'just expired blocks can be retried immediately') 54 | ir.bk = now() - 60*1000 55 | t.equal(ir.retryAfter(), 60, 'unexpired blocks can be retried in a bit') 56 | t.end() 57 | } 58 | ) 59 | 60 | test( 61 | 'parse works', 62 | function (t) { 63 | var ir = simpleIpRecord() 64 | t.equal(ir.shouldBlock(), false, 'original object is not blocked') 65 | var irCopy1 = (ipRecord(120*1000, now)).parse(ir) 66 | t.equal(irCopy1.shouldBlock(), false, 'copied object is not blocked') 67 | 68 | ir.block() 69 | t.equal(ir.shouldBlock(), true, 'original object is now blocked') 70 | var irCopy2 = (ipRecord(120*1000, now)).parse(ir) 71 | t.equal(irCopy2.shouldBlock(), true, 'copied object is blocked') 72 | t.end() 73 | } 74 | ) 75 | 76 | test( 77 | 'update works', 78 | function (t) { 79 | var ir = simpleIpRecord() 80 | t.equal(ir.update(), 0, 'update does nothing') 81 | t.end() 82 | } 83 | ) 84 | -------------------------------------------------------------------------------- /bans/handler.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var config = require('../config').root() 6 | 7 | var LIFETIME = config.memcache.recordLifetimeSeconds 8 | var BLOCK_INTERVAL_MS = config.limits.blockIntervalSeconds * 1000 9 | var RATE_LIMIT_INTERVAL_MS = config.limits.rateLimitIntervalSeconds * 1000 10 | 11 | var EmailRecord = require('../email_record')(RATE_LIMIT_INTERVAL_MS, BLOCK_INTERVAL_MS, config.limits.maxEmails) 12 | var IpRecord = require('../ip_record')(BLOCK_INTERVAL_MS) 13 | 14 | module.exports = function (mc, log) { 15 | 16 | function blockIp(ip, cb) { 17 | mc.get(ip, 18 | function (err, data) { 19 | if (err) { 20 | log.error({ op: 'handleBan.blockIp', ip: ip, err: err }) 21 | return cb(err) 22 | } 23 | 24 | log.info({ op: 'handleBan.blockIp', ip: ip }) 25 | var ir = IpRecord.parse(data) 26 | ir.block() 27 | mc.set(ip, ir, LIFETIME, 28 | function (err) { 29 | if (err) { 30 | log.error({ op: 'memcachedError', err: err }) 31 | return cb(err) 32 | } 33 | mc.end() 34 | cb(null) 35 | } 36 | ) 37 | } 38 | ) 39 | } 40 | 41 | function blockEmail(email, cb) { 42 | mc.get(email, 43 | function (err, data) { 44 | if (err) { 45 | log.error({ op: 'handleBan.blockEmail', email: email, err: err }) 46 | return cb(err) 47 | } 48 | 49 | log.info({ op: 'handleBan.blockEmail', email: email }) 50 | var er = EmailRecord.parse(data) 51 | er.block() 52 | mc.set(email, er, LIFETIME, 53 | function (err) { 54 | if (err) { 55 | log.error({ op: 'memcachedError', err: err }) 56 | return cb(err) 57 | } 58 | mc.end() 59 | cb(null) 60 | } 61 | ) 62 | } 63 | ) 64 | } 65 | 66 | function handleBan(message, cb) { 67 | if (!cb) { 68 | cb = function () {} 69 | } 70 | if (message.ban && message.ban.ip) { 71 | blockIp(message.ban.ip, cb) 72 | } 73 | else if (message.ban && message.ban.email) { 74 | blockEmail(message.ban.email, cb) 75 | } 76 | else { 77 | log.error({ op: 'handleBan', ban: !!message.ban }) 78 | cb('invalid message') 79 | } 80 | } 81 | 82 | return handleBan 83 | } 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Anyone is welcome to help with Firefox Accounts. Feel free to get in touch with other community members on IRC, the 4 | mailing list or through issues here on GitHub. 5 | 6 | - IRC: `#fxa` on `irc.mozilla.org` 7 | - Mailing list: 8 | - and of course, [the bug tracker](https://github.com/mozilla/fxa-customs-server/issues) 9 | 10 | ## Bug Reports ## 11 | 12 | You can file issues here on GitHub. Please try to include as much information as you can and under what conditions 13 | you saw the issue. 14 | 15 | ## Sending Pull Requests ## 16 | 17 | Patches should be submitted as pull requests. When submitting patches as PRs: 18 | 19 | - You agree to license your code under the project's open source license ([MPL 2.0](/LICENSE)). 20 | - Base your branch off the current `master` (see below for an example workflow). 21 | - Add both your code and new tests if relevant. 22 | - Run `npm test` to make sure all tests still pass. 23 | - Please do not include merge commits in pull requests; include only commits with the new relevant code. 24 | 25 | See the main [README.md](/README.md) for information on prerequisites, installing, running and testing. 26 | 27 | ## Example Workflow ## 28 | 29 | This is an example workflow to make it easier to submit Pull Requests. Imagine your username is `user1`: 30 | 31 | 1. Fork this repository via the GitHub interface 32 | 33 | 2. The clone the upstream (as origin) and add your own repo as a remote: 34 | 35 | ```sh 36 | $ git clone https://github.com/mozilla/fxa-customs-server.git 37 | $ cd fxa-customs-server 38 | $ git remote add user1 git@github.com:user1/fxa-customs-server.git 39 | ``` 40 | 41 | 3. Create a branch for your fix/feature and make sure it's your currently checked-out branch: 42 | 43 | ```sh 44 | $ git checkout -b add-new-feature 45 | ``` 46 | 47 | 4. Add/fix code, add tests then commit and push this branch to your repo: 48 | 49 | ```sh 50 | $ git add 51 | $ git commit 52 | $ git push user1 add-new-feature 53 | ``` 54 | 55 | 5. From the GitHub interface for your repo, click the `Review Changes and Pull Request` which appears next to your new branch. 56 | 57 | 6. Click `Send pull request`. 58 | 59 | ### Keeping up to Date ### 60 | 61 | The main reason for creating a new branch for each feature or fix is so that you can track master correctly. If you need 62 | to fetch the latest code for a new fix, try the following: 63 | 64 | ```sh 65 | $ git checkout master 66 | $ git pull 67 | ``` 68 | 69 | Now you're ready to branch again for your new feature (from step 3 above). 70 | -------------------------------------------------------------------------------- /test/remote/block_ip_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | } 16 | } 17 | var testServer = new TestServer(config) 18 | 19 | test( 20 | 'startup', 21 | function (t) { 22 | testServer.start(function (err) { 23 | t.type(testServer.server, 'object', 'test server was started') 24 | t.notOk(err, 'no errors were returned') 25 | t.end() 26 | }) 27 | } 28 | ) 29 | 30 | test( 31 | 'clear everything', 32 | function (t) { 33 | mcHelper.clearEverything( 34 | function (err) { 35 | t.notOk(err, 'no errors were returned') 36 | t.end() 37 | } 38 | ) 39 | } 40 | ) 41 | 42 | var client = restify.createJsonClient({ 43 | url: 'http://127.0.0.1:' + config.listen.port 44 | }); 45 | 46 | test( 47 | 'missing ip', 48 | function (t) { 49 | client.post('/blockIp', {}, 50 | function (err, req, res, obj) { 51 | t.equal(res.statusCode, 400, 'bad request returns a 400') 52 | t.type(obj.code, 'string', 'bad request returns an error code') 53 | t.type(obj.message, 'string', 'bad request returns an error message') 54 | t.end() 55 | } 56 | ) 57 | } 58 | ) 59 | 60 | test( 61 | 'well-formed request', 62 | function (t) { 63 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 64 | function (err, req, res, obj) { 65 | t.equal(res.statusCode, 200, 'check worked') 66 | t.equal(obj.block, false, 'request was not blocked') 67 | 68 | client.post('/blockIp', { ip: TEST_IP }, 69 | function (err, req, res, obj) { 70 | t.notOk(err, 'block request is successful') 71 | t.equal(res.statusCode, 200, 'block request returns a 200') 72 | 73 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 74 | function (err, req, res, obj) { 75 | t.equal(res.statusCode, 200, 'check worked') 76 | t.equal(obj.block, true, 'request was blocked') 77 | t.end() 78 | } 79 | ) 80 | } 81 | ) 82 | } 83 | ) 84 | } 85 | ) 86 | 87 | test( 88 | 'teardown', 89 | function (t) { 90 | testServer.stop() 91 | t.equal(testServer.server.killed, true, 'test server has been killed') 92 | t.end() 93 | } 94 | ) 95 | -------------------------------------------------------------------------------- /test/remote/block_email_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | } 16 | } 17 | var testServer = new TestServer(config) 18 | 19 | test( 20 | 'startup', 21 | function (t) { 22 | testServer.start(function (err) { 23 | t.type(testServer.server, 'object', 'test server was started') 24 | t.notOk(err, 'no errors were returned') 25 | t.end() 26 | }) 27 | } 28 | ) 29 | 30 | test( 31 | 'clear everything', 32 | function (t) { 33 | mcHelper.clearEverything( 34 | function (err) { 35 | t.notOk(err, 'no errors were returned') 36 | t.end() 37 | } 38 | ) 39 | } 40 | ) 41 | 42 | var client = restify.createJsonClient({ 43 | url: 'http://127.0.0.1:' + config.listen.port 44 | }); 45 | 46 | test( 47 | 'missing email', 48 | function (t) { 49 | client.post('/blockEmail', {}, 50 | function (err, req, res, obj) { 51 | t.equal(res.statusCode, 400, 'bad request returns a 400') 52 | t.type(obj.code, 'string', 'bad request returns an error code') 53 | t.type(obj.message, 'string', 'bad request returns an error message') 54 | t.end() 55 | } 56 | ) 57 | } 58 | ) 59 | 60 | test( 61 | 'well-formed request', 62 | function (t) { 63 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountCreate' }, 64 | function (err, req, res, obj) { 65 | t.equal(res.statusCode, 200, 'check worked') 66 | t.equal(obj.block, false, 'request was not blocked') 67 | 68 | client.post('/blockEmail', { email: TEST_EMAIL }, 69 | function (err, req, res, obj) { 70 | t.notOk(err, 'block request is successful') 71 | t.equal(res.statusCode, 200, 'block request returns a 200') 72 | 73 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountCreate' }, 74 | function (err, req, res, obj) { 75 | t.equal(res.statusCode, 200, 'check worked') 76 | t.equal(obj.block, true, 'request was blocked') 77 | t.end() 78 | } 79 | ) 80 | } 81 | ) 82 | } 83 | ) 84 | } 85 | ) 86 | 87 | test( 88 | 'teardown', 89 | function (t) { 90 | testServer.stop() 91 | t.equal(testServer.server.killed, true, 'test server has been killed') 92 | t.end() 93 | } 94 | ) 95 | -------------------------------------------------------------------------------- /test/remote/password_reset_clears_bad_logins.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | } 16 | } 17 | 18 | var testServer = new TestServer(config) 19 | 20 | test( 21 | 'startup', 22 | function (t) { 23 | testServer.start(function (err) { 24 | t.type(testServer.server, 'object', 'test server was started') 25 | t.notOk(err, 'no errors were returned') 26 | t.end() 27 | }) 28 | } 29 | ) 30 | 31 | test( 32 | 'clear everything', 33 | function (t) { 34 | mcHelper.clearEverything( 35 | function (err) { 36 | t.notOk(err, 'no errors were returned') 37 | t.end() 38 | } 39 | ) 40 | } 41 | ) 42 | 43 | var client = restify.createJsonClient({ 44 | url: 'http://127.0.0.1:' + config.listen.port 45 | }); 46 | 47 | test( 48 | 'too many failed logins', 49 | function (t) { 50 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 51 | function (err, req, res, obj) { 52 | t.equal(res.statusCode, 200, 'first login attempt noted') 53 | 54 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 55 | function (err, req, res, obj) { 56 | t.equal(res.statusCode, 200, 'second login attempt noted') 57 | 58 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 59 | function (err, req, res, obj) { 60 | t.equal(res.statusCode, 200, 'third login attempt noted') 61 | 62 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 63 | function (err, req, res, obj) { 64 | t.equal(res.statusCode, 200, 'login check succeeds') 65 | t.equal(obj.block, true, 'login is blocked') 66 | t.end() 67 | } 68 | ) 69 | } 70 | ) 71 | } 72 | ) 73 | } 74 | ) 75 | } 76 | ) 77 | 78 | test( 79 | 'failed logins are cleared', 80 | function (t) { 81 | client.post('/passwordReset', { email: TEST_EMAIL }, 82 | function (err, req, res, obj) { 83 | t.notOk(err, 'request is successful') 84 | t.equal(res.statusCode, 200, 'request returns a 200') 85 | 86 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 87 | function (err, req, res, obj) { 88 | t.equal(res.statusCode, 200, 'login check succeeds') 89 | t.equal(obj.block, false, 'login is no longer blocked') 90 | t.end() 91 | } 92 | ) 93 | } 94 | ) 95 | } 96 | ) 97 | 98 | test( 99 | 'teardown', 100 | function (t) { 101 | testServer.stop() 102 | t.equal(testServer.server.killed, true, 'test server has been killed') 103 | t.end() 104 | } 105 | ) 106 | -------------------------------------------------------------------------------- /ip_email_record.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | // Keep track of events tied to both email and IP addresses 6 | module.exports = function (RATE_LIMIT_INTERVAL_MS, MAX_BAD_LOGINS, now) { 7 | 8 | now = now || Date.now 9 | 10 | var IP_EMAIL_ACTION = { 11 | accountLogin : true, 12 | accountDestroy : true, 13 | passwordChange : true, 14 | } 15 | 16 | function isIpEmailAction(action) { 17 | return IP_EMAIL_ACTION[action] 18 | } 19 | 20 | function IpEmailRecord() { 21 | this.lf = [] 22 | } 23 | 24 | IpEmailRecord.parse = function (object) { 25 | var rec = new IpEmailRecord() 26 | object = object || {} 27 | rec.rl = object.rl // timestamp when the account was rate-limited 28 | rec.lf = object.lf || [] // timestamps when a login failure occurred 29 | return rec 30 | } 31 | 32 | IpEmailRecord.prototype.isOverBadLogins = function () { 33 | this.trimBadLogins(now()) 34 | return this.lf.length > MAX_BAD_LOGINS 35 | } 36 | 37 | IpEmailRecord.prototype.addBadLogin = function () { 38 | this.trimBadLogins(now()) 39 | this.lf.push(now()) 40 | } 41 | 42 | IpEmailRecord.prototype.trimBadLogins = function (now) { 43 | if (this.lf.length === 0) { return } 44 | // lf is naturally ordered from oldest to newest 45 | // and we only need to keep up to MAX_BAD_LOGINS + 1 46 | 47 | var i = this.lf.length - 1 48 | var n = 0 49 | var login = this.lf[i] 50 | while (login > (now - RATE_LIMIT_INTERVAL_MS) && n <= MAX_BAD_LOGINS) { 51 | login = this.lf[--i] 52 | n++ 53 | } 54 | this.lf = this.lf.slice(i + 1) 55 | } 56 | 57 | IpEmailRecord.prototype.shouldBlock = function () { 58 | return this.isRateLimited() 59 | } 60 | 61 | IpEmailRecord.prototype.isRateLimited = function () { 62 | return !!(this.rl && (now() - this.rl < RATE_LIMIT_INTERVAL_MS)) 63 | } 64 | 65 | IpEmailRecord.prototype.rateLimit = function () { 66 | this.rl = now() 67 | this.lf = [] 68 | } 69 | 70 | IpEmailRecord.prototype.unblockIfReset = function (resetAt) { 71 | if (resetAt > this.rl) { 72 | this.lf = [] 73 | delete this.rl 74 | return true 75 | } 76 | return false 77 | } 78 | 79 | IpEmailRecord.prototype.retryAfter = function () { 80 | return Math.max(0, Math.floor(((this.rl || 0) + RATE_LIMIT_INTERVAL_MS - now()) / 1000)) 81 | } 82 | 83 | IpEmailRecord.prototype.update = function (action) { 84 | // if this is not an Ip/Email Action, then all ok (no block) 85 | if ( !isIpEmailAction(action) ) { 86 | return 0 87 | } 88 | 89 | if ( this.shouldBlock() ) { 90 | // if already blocked, then return a block 91 | return this.retryAfter() 92 | } 93 | 94 | // if over the bad logins, rate limit them and return the block 95 | if (this.isOverBadLogins()) { 96 | this.rateLimit() 97 | return this.retryAfter() 98 | } 99 | 100 | // no block, not yet over limit 101 | return 0 102 | } 103 | 104 | return IpEmailRecord 105 | } 106 | -------------------------------------------------------------------------------- /test/local/ban_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | require('ass') 5 | var test = require('tap').test 6 | var banHandler = require('../../bans/handler') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var log = { 10 | info: function () {}, 11 | error: function () {} 12 | } 13 | 14 | var config = { 15 | limits: { 16 | blockIntervalSeconds: 1 17 | } 18 | } 19 | 20 | var TEST_IP = '192.0.2.1' 21 | var TEST_EMAIL = 'test@example.com' 22 | 23 | test( 24 | 'clear everything', 25 | function (t) { 26 | mcHelper.clearEverything( 27 | function (err) { 28 | t.notOk(err, 'no errors were returned') 29 | t.end() 30 | } 31 | ) 32 | } 33 | ) 34 | 35 | test( 36 | 'well-formed ip blocking request', 37 | function (t) { 38 | var message = { 39 | ban: { 40 | ip: TEST_IP 41 | } 42 | } 43 | banHandler(mcHelper.mc, log)(message, 44 | function (err) { 45 | t.notOk(err, 'no errors were returned') 46 | 47 | mcHelper.blockedIpCheck( 48 | function (isBlocked) { 49 | t.equal(isBlocked, true, 'ip is blocked') 50 | t.end() 51 | } 52 | ) 53 | } 54 | ) 55 | } 56 | ) 57 | 58 | test( 59 | 'ip block has expired', 60 | function (t) { 61 | setTimeout( 62 | function () { 63 | mcHelper.blockedIpCheck( 64 | function (isBlocked) { 65 | t.equal(isBlocked, false, 'ip is not blocked') 66 | t.end() 67 | } 68 | ) 69 | }, 70 | config.limits.blockIntervalSeconds * 1000 71 | ) 72 | } 73 | ) 74 | 75 | test( 76 | 'well-formed email blocking request', 77 | function (t) { 78 | var message = { 79 | ban: { 80 | email: TEST_EMAIL 81 | } 82 | } 83 | banHandler(mcHelper.mc, log)(message, 84 | function (err) { 85 | t.notOk(err, 'no errors were returned') 86 | 87 | mcHelper.blockedEmailCheck( 88 | function (isBlocked) { 89 | t.equal(isBlocked, true, 'email is blocked') 90 | t.end() 91 | } 92 | ) 93 | } 94 | ) 95 | } 96 | ) 97 | 98 | test( 99 | 'email block has expired', 100 | function (t) { 101 | setTimeout( 102 | function () { 103 | mcHelper.blockedEmailCheck( 104 | function (isBlocked) { 105 | t.equal(isBlocked, false, 'email is not blocked') 106 | t.end() 107 | } 108 | ) 109 | }, 110 | config.limits.blockIntervalSeconds * 1000 111 | ) 112 | } 113 | ) 114 | 115 | test( 116 | 'missing ip and email', 117 | function (t) { 118 | var message = { 119 | ban: { 120 | } 121 | } 122 | banHandler(mcHelper.mc, log)(message, 123 | function (err) { 124 | t.equal(err, 'invalid message') 125 | t.end() 126 | } 127 | ) 128 | } 129 | ) 130 | 131 | test( 132 | 'missing ban', 133 | function (t) { 134 | var message = { 135 | } 136 | banHandler(mcHelper.mc, log)(message, 137 | function (err) { 138 | t.equal(err, 'invalid message') 139 | t.end() 140 | } 141 | ) 142 | } 143 | ) 144 | -------------------------------------------------------------------------------- /test/remote/too_many_emails.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | }, 16 | limits: { 17 | rateLimitIntervalSeconds: 1 18 | } 19 | } 20 | 21 | var testServer = new TestServer(config) 22 | 23 | test( 24 | 'startup', 25 | function (t) { 26 | testServer.start(function (err) { 27 | t.type(testServer.server, 'object', 'test server was started') 28 | t.notOk(err, 'no errors were returned') 29 | t.end() 30 | }) 31 | } 32 | ) 33 | 34 | test( 35 | 'clear everything', 36 | function (t) { 37 | mcHelper.clearEverything( 38 | function (err) { 39 | t.notOk(err, 'no errors were returned') 40 | t.end() 41 | } 42 | ) 43 | } 44 | ) 45 | 46 | var client = restify.createJsonClient({ 47 | url: 'http://127.0.0.1:' + config.listen.port 48 | }); 49 | 50 | test( 51 | 'too many sent emails', 52 | function (t) { 53 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 54 | function (err, req, res, obj) { 55 | t.equal(res.statusCode, 200, 'first email attempt') 56 | t.equal(obj.block, false, 'resending the code') 57 | 58 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 59 | function (err, req, res, obj) { 60 | t.equal(res.statusCode, 200, 'second email attempt') 61 | t.equal(obj.block, false, 'resending the code') 62 | 63 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 64 | function (err, req, res, obj) { 65 | t.equal(res.statusCode, 200, 'third email attempt') 66 | t.equal(obj.block, false, 'resending the code') 67 | 68 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 69 | function (err, req, res, obj) { 70 | t.equal(res.statusCode, 200, 'fourth email attempt') 71 | t.equal(obj.block, true, 'operation blocked') 72 | 73 | mcHelper.blockedEmailCheck( 74 | function (isBlocked) { 75 | t.equal(isBlocked, true, 'account is blocked') 76 | t.end() 77 | } 78 | ) 79 | } 80 | ) 81 | } 82 | ) 83 | } 84 | ) 85 | } 86 | ) 87 | } 88 | ) 89 | 90 | test( 91 | 'failed logins expire', 92 | function (t) { 93 | setTimeout( 94 | function () { 95 | mcHelper.blockedEmailCheck( 96 | function (isBlocked) { 97 | t.equal(isBlocked, false, 'account no longer blocked') 98 | t.end() 99 | } 100 | ) 101 | }, 102 | config.limits.rateLimitIntervalSeconds * 1000 103 | ) 104 | } 105 | ) 106 | 107 | test( 108 | 'teardown', 109 | function (t) { 110 | testServer.stop() 111 | t.equal(testServer.server.killed, true, 'test server has been killed') 112 | t.end() 113 | } 114 | ) 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Firefox Accounts Customs Server 2 | ======================= 3 | 4 | [![Build Status](https://travis-ci.org/mozilla/fxa-customs-server.svg?branch=master)](https://travis-ci.org/mozilla/fxa-customs-server) 5 | 6 | This project is used by the [Firefox Accounts Auth Server](https://github.com/mozilla/fxa-auth-server) to detect and deter [fraud and abuse](https://wiki.mozilla.org/Identity/Firefox_Accounts/Fraud_and_abuse). 7 | 8 | ## Install 9 | 10 | You'll need node 0.10.x or higher and npm to run the server. 11 | 12 | Clone the git repository and install dependencies: 13 | 14 | git clone git://github.com/mozilla/fxa-customs-server.git 15 | cd fxa-customs-server 16 | npm install 17 | 18 | To start the server, run: 19 | 20 | npm start 21 | 22 | It will listen on http://127.0.0.1:7000 by default. 23 | 24 | ## Testing 25 | 26 | Run tests with: 27 | 28 | npm test 29 | 30 | ## Code 31 | 32 | Here are the main components of this project: 33 | 34 | - `bans/`: code implementing temporary bans of specific email or IP addresses and listening on the SQS API for requests 35 | - `bin/customs_server.js`: process listening on the network and responding to HTTP API calls 36 | - `config/config.js`: where all of the configuration options are defined 37 | - `email_record.js`, `ip_email_record.js` and `ip_record.js`: code implementing the various blocking and rate-limiting policies 38 | - `scripts`: helper scripts only used for development/testing 39 | - `test/local`: unit tests 40 | - `test/remote`: tests exercising the HTTP API 41 | 42 | ### API 43 | 44 | See our [detailed API spec](/docs/api.md). 45 | 46 | ### Policies 47 | 48 | There are three types of policies: 49 | 50 | * rate-limiting: slows down attackers by temporarily blocking requests for 15 minutes (see `config.limits.rateLimitIntervalSeconds`) 51 | * block / ban: stops attacks by temporarily blocking requests for 24 hours (see `config.limits.blockIntervalSeconds`) 52 | * lockout: stops password-guessing attacks by permanently blocking password-authenticated requests until the user reconfirms their email address by clicking a link 53 | 54 | We currently have the following policies in place: 55 | 56 | * rate-limiting when too many emails (`config.limits.maxEmails` defaults to 3) have been sent to the same email address in a given time period (`config.limits.rateLimitIntervalSeconds` defaults to 15 minutes) 57 | * rate-limiting when too many failed login attempts (`config.limits.maxBadLogins` defaults to 2) have occurred for a given account and IP address, in a given time period (`config.limits.rateLimitIntervalSeconds` defaults to 15 minutes) 58 | * lockout when too many failed login attempts (`config.limits.badLoginLockout` defaults to 20) have occurred for a given account regardless of the IP address, in a given time period (`config.limits.rateLimitIntervalSeconds` defaults to 15 minutes) 59 | * manual blocking of an account (see `/blockEmail` API call) 60 | * manual blocking of an IP address (see `/blockIp` API call) 61 | 62 | The data that these policies are based on is stored in a memcache instance (keyed by `email`, `ip` or `ip + email` depending on the policy) and the code that implements them is split across these three files: 63 | 64 | * `email_record.js` handles blocking and rate-limiting based only on the email address 65 | * `ip_email_record.js` handles rate-limiting based on both the email and IP address of the request 66 | * `ip_record.js` handles blocking based only on the IP address 67 | 68 | The rate-limiting and blocking policies are conveyed to the auth server via the `block` property in the response to `/check` wheres the `lockout` policies are conveyed via the response to `/failedLoginAttempt`. 69 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (fs, path, url, convict) { 6 | 7 | var conf = convict({ 8 | env: { 9 | doc: 'The current node.js environment', 10 | default: 'prod', 11 | format: [ 'dev', 'test', 'stage', 'prod' ], 12 | env: 'NODE_ENV' 13 | }, 14 | log: { 15 | level: { 16 | default: 'trace', 17 | env: 'LOG_LEVEL' 18 | } 19 | }, 20 | publicUrl: { 21 | format: 'url', 22 | // the real url is set by awsbox 23 | default: 'http://127.0.0.1:7000', 24 | env: 'PUBLIC_URL' 25 | }, 26 | listen: { 27 | host: { 28 | doc: 'The ip address the server should bind', 29 | default: '127.0.0.1', 30 | format: 'ipaddress', 31 | env: 'IP_ADDRESS' 32 | }, 33 | port: { 34 | doc: 'The port the server should bind', 35 | default: 7000, 36 | format: 'port', 37 | env: 'PORT' 38 | } 39 | }, 40 | limits: { 41 | blockIntervalSeconds: { 42 | doc: 'Duration of a manual ban', 43 | default: 60 * 60 * 24, 44 | format: 'nat', 45 | env: 'BLOCK_INTERVAL_SECONDS' 46 | }, 47 | rateLimitIntervalSeconds: { 48 | doc: 'Duration of automatic throttling', 49 | default: 60 * 15, 50 | format: 'nat', 51 | env: 'RATE_LIMIT_INTERVAL_SECONDS' 52 | }, 53 | maxEmails: { 54 | doc: 'Number of emails sent within rateLimitIntervalSeconds before throttling', 55 | default: 3, 56 | format: 'nat', 57 | env: 'MAX_EMAILS' 58 | }, 59 | maxBadLogins: { 60 | doc: 'Number failed login attempts within rateLimitIntervalSeconds before throttling', 61 | default: 2, 62 | format: 'nat', 63 | env: 'MAX_BAD_LOGINS' 64 | }, 65 | badLoginLockout: { 66 | doc: 'Number failed login attempts within rateLimitIntervalSeconds before forcing locking the account', 67 | default: 20, 68 | format: 'nat', 69 | env: 'BAD_LOGIN_LOCKOUT' 70 | } 71 | }, 72 | memcache: { 73 | address: { 74 | doc: 'Hostname/IP:Port of the memcache server', 75 | default: '127.0.0.1:11211', 76 | env: 'MEMCACHE_ADDRESS' 77 | }, 78 | recordLifetimeSeconds: { 79 | doc: 'Memcache record expiry', 80 | default: 900, 81 | format: 'nat', 82 | env: 'RECORD_LIFETIME_SECONDS' 83 | } 84 | }, 85 | bans: { 86 | region: { 87 | doc: 'The region where the queues live, most likely the same region we are sending email e.g. us-east-1, us-west-2, ap-southeast-2', 88 | format: String, 89 | default: '', 90 | env: 'BANS_REGION' 91 | }, 92 | queueUrl: { 93 | doc: 'The bounce queue URL to use (should include https://sqs..amazonaws.com//)', 94 | format: String, 95 | default: '', 96 | env: 'BANS_QUEUE_URL' 97 | } 98 | } 99 | }) 100 | 101 | // handle configuration files. you can specify a CSV list of configuration 102 | // files to process, which will be overlayed in order, in the CONFIG_FILES 103 | // environment variable. By default, the ./config/.json file is loaded. 104 | 105 | var envConfig = path.join(__dirname, conf.get('env') + '.json') 106 | var files = (envConfig + ',' + process.env.CONFIG_FILES) 107 | .split(',').filter(fs.existsSync) 108 | conf.loadFile(files) 109 | conf.validate() 110 | 111 | return conf 112 | } 113 | -------------------------------------------------------------------------------- /test/remote/never_blocked.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | } 16 | } 17 | 18 | var testServer = new TestServer(config) 19 | 20 | test( 21 | 'startup', 22 | function (t) { 23 | testServer.start(function (err) { 24 | t.type(testServer.server, 'object', 'test server was started') 25 | t.notOk(err, 'no errors were returned') 26 | t.end() 27 | }) 28 | } 29 | ) 30 | 31 | test( 32 | 'clear everything', 33 | function (t) { 34 | mcHelper.clearEverything( 35 | function (err) { 36 | t.notOk(err, 'no errors were returned') 37 | t.end() 38 | } 39 | ) 40 | } 41 | ) 42 | 43 | var client = restify.createJsonClient({ 44 | url: 'http://127.0.0.1:' + config.listen.port 45 | }); 46 | 47 | test( 48 | 'maximum number of emails', 49 | function (t) { 50 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountCreate' }, 51 | function (err, req, res, obj) { 52 | t.equal(res.statusCode, 200, 'first email attempt') 53 | t.equal(obj.block, false, 'creating the account') 54 | 55 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 56 | function (err, req, res, obj) { 57 | t.equal(res.statusCode, 200, 'second email attempt') 58 | t.equal(obj.block, false, 'resending the code') 59 | 60 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'recoveryEmailResendCode' }, 61 | function (err, req, res, obj) { 62 | t.equal(res.statusCode, 200, 'third email attempt') 63 | t.equal(obj.block, false, 'resending the code') 64 | 65 | mcHelper.blockedEmailCheck( 66 | function (isBlocked) { 67 | t.equal(isBlocked, false, 'account is still not blocked') 68 | t.end() 69 | } 70 | ) 71 | } 72 | ) 73 | } 74 | ) 75 | } 76 | ) 77 | } 78 | ) 79 | 80 | test( 81 | 'maximum failed logins', 82 | function (t) { 83 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 84 | function (err, req, res, obj) { 85 | t.equal(res.statusCode, 200, 'first login attempt noted') 86 | 87 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 88 | function (err, req, res, obj) { 89 | t.equal(res.statusCode, 200, 'second login attempt noted') 90 | 91 | mcHelper.badLoginCheck( 92 | function (isOverBadLogins, isWayOverBadLogins) { 93 | t.equal(isOverBadLogins, false, 'is still not over bad logins') 94 | t.equal(isWayOverBadLogins, false, 'is still not locked out') 95 | 96 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 97 | function (err, req, res, obj) { 98 | t.equal(res.statusCode, 200, 'attempting to login') 99 | t.equal(obj.block, false, 'login is not blocked') 100 | t.end() 101 | } 102 | ) 103 | } 104 | ) 105 | } 106 | ) 107 | } 108 | ) 109 | } 110 | ) 111 | 112 | test( 113 | 'teardown', 114 | function (t) { 115 | testServer.stop() 116 | t.equal(testServer.server.killed, true, 'test server has been killed') 117 | t.end() 118 | } 119 | ) 120 | -------------------------------------------------------------------------------- /test/memcache-helper.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var Memcached = require('memcached') 6 | 7 | var config = { 8 | memcache: { 9 | host: '127.0.0.1', 10 | port: '11211' 11 | }, 12 | limits: { 13 | blockIntervalSeconds: 1, 14 | rateLimitIntervalSeconds: 1, 15 | maxEmails: 3, 16 | maxBadLogins: 2, 17 | badLoginLockout: 3 18 | } 19 | } 20 | 21 | var mc = new Memcached( 22 | config.memcache.address, 23 | { 24 | timeout: 500, 25 | retries: 1, 26 | retry: 1000, 27 | reconnect: 1000, 28 | idle: 30000, 29 | namespace: 'fxa~' 30 | } 31 | ) 32 | 33 | module.exports.mc = mc 34 | 35 | var TEST_EMAIL = 'test@example.com' 36 | var TEST_IP = '192.0.2.1' 37 | 38 | var EmailRecord = require('../email_record')(config.limits.rateLimitIntervalSeconds * 1000, config.limits.blockIntervalSeconds * 1000, config.limits.maxEmails, config.limits.badLoginLockout) 39 | var IpEmailRecord = require('../ip_email_record')(config.limits.rateLimitIntervalSeconds * 1000, config.limits.maxBadLogins) 40 | var IpRecord = require('../ip_record')(config.limits.blockIntervalSeconds * 1000) 41 | 42 | function blockedEmailCheck(cb) { 43 | setTimeout( // give memcache time to flush the writes 44 | function () { 45 | mc.get(TEST_EMAIL, 46 | function (err, data) { 47 | var er = EmailRecord.parse(data) 48 | mc.end() 49 | cb(er.shouldBlock()) 50 | } 51 | ) 52 | } 53 | ) 54 | } 55 | 56 | module.exports.blockedEmailCheck = blockedEmailCheck 57 | 58 | function blockedIpCheck(cb) { 59 | setTimeout( // give memcache time to flush the writes 60 | function () { 61 | mc.get(TEST_IP, 62 | function (err, data) { 63 | var ir = IpRecord.parse(data) 64 | mc.end() 65 | cb(ir.shouldBlock()) 66 | } 67 | ) 68 | } 69 | ) 70 | } 71 | 72 | module.exports.blockedIpCheck = blockedIpCheck 73 | 74 | function badLoginCheck(cb) { 75 | setTimeout( // give memcache time to flush the writes 76 | function () { 77 | mc.get(TEST_IP + TEST_EMAIL, 78 | function (err, data1) { 79 | var ier = IpEmailRecord.parse(data1) 80 | 81 | mc.get(TEST_EMAIL, 82 | function (err, data2) { 83 | var er = EmailRecord.parse(data2) 84 | mc.end() 85 | cb(ier.isOverBadLogins(), er.isWayOverBadLogins()) 86 | } 87 | ) 88 | } 89 | ) 90 | } 91 | ) 92 | } 93 | 94 | module.exports.badLoginCheck = badLoginCheck 95 | 96 | function clearEverything(cb) { 97 | mc.del(TEST_EMAIL, 98 | function (err) { 99 | if (err) { 100 | return cb(err) 101 | } 102 | 103 | blockedEmailCheck( 104 | function (isBlocked) { 105 | if (isBlocked) { 106 | return cb('email was not unblocked') 107 | } 108 | 109 | mc.del(TEST_IP + TEST_EMAIL, 110 | function (err) { 111 | if (err) { 112 | return cb(err) 113 | } 114 | 115 | badLoginCheck( 116 | function (isOverBadLogins, isWayOverBadLogins) { 117 | if (isOverBadLogins || isWayOverBadLogins) { 118 | return cb('there are still some bad logins') 119 | } 120 | 121 | mc.del(TEST_IP, 122 | function (err) { 123 | if (err) { 124 | return cb(err) 125 | } 126 | 127 | blockedIpCheck( 128 | function (isBlocked) { 129 | if (isBlocked) { 130 | return cb('IP was not unblocked') 131 | } 132 | 133 | return cb(null) 134 | } 135 | ) 136 | } 137 | ) 138 | } 139 | ) 140 | } 141 | ) 142 | } 143 | ) 144 | } 145 | ) 146 | } 147 | 148 | module.exports.clearEverything = clearEverything 149 | -------------------------------------------------------------------------------- /test/remote/email_normalization.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | } 16 | } 17 | 18 | var testServer = new TestServer(config) 19 | 20 | test( 21 | 'startup', 22 | function (t) { 23 | testServer.start(function (err) { 24 | t.type(testServer.server, 'object', 'test server was started') 25 | t.notOk(err, 'no errors were returned') 26 | t.end() 27 | }) 28 | } 29 | ) 30 | 31 | test( 32 | 'clear everything', 33 | function (t) { 34 | mcHelper.clearEverything( 35 | function (err) { 36 | t.notOk(err, 'no errors were returned') 37 | t.end() 38 | } 39 | ) 40 | } 41 | ) 42 | 43 | var client = restify.createJsonClient({ 44 | url: 'http://127.0.0.1:' + config.listen.port 45 | }); 46 | 47 | test( 48 | 'too many failed logins using different capitalizations', 49 | function (t) { 50 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 51 | function (err, req, res, obj) { 52 | t.equal(res.statusCode, 200, 'first login attempt noted') 53 | 54 | client.post('/failedLoginAttempt', { email: 'TEST@example.com', ip: TEST_IP }, 55 | function (err, req, res, obj) { 56 | t.equal(res.statusCode, 200, 'second login attempt noted') 57 | 58 | client.post('/failedLoginAttempt', { email: 'test@Example.Com', ip: TEST_IP }, 59 | function (err, req, res, obj) { 60 | t.equal(res.statusCode, 200, 'third login attempt noted') 61 | 62 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 63 | function (err, req, res, obj) { 64 | t.equal(res.statusCode, 200, 'login check succeeds') 65 | t.equal(obj.block, true, 'login with exact email address is blocked') 66 | 67 | client.post('/check', { email: 'tEST@eXaMpLe.CoM', ip: TEST_IP, action: 'accountLogin' }, 68 | function (err, req, res, obj) { 69 | t.equal(res.statusCode, 200, 'login check succeeds') 70 | t.equal(obj.block, true, 'login with weird caps is blocked') 71 | t.end() 72 | } 73 | ) 74 | } 75 | ) 76 | } 77 | ) 78 | } 79 | ) 80 | } 81 | ) 82 | } 83 | ) 84 | 85 | test( 86 | 'failed logins are cleared', 87 | function (t) { 88 | client.post('/passwordReset', { email: 'tEst@example.com' }, 89 | function (err, req, res, obj) { 90 | t.notOk(err, 'request is successful') 91 | t.equal(res.statusCode, 200, 'request returns a 200') 92 | 93 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountLogin' }, 94 | function (err, req, res, obj) { 95 | t.equal(res.statusCode, 200, 'login check succeeds') 96 | t.equal(obj.block, false, 'login is no longer blocked') 97 | t.end() 98 | } 99 | ) 100 | } 101 | ) 102 | } 103 | ) 104 | 105 | test( 106 | 'blocking an email using weird caps', 107 | function (t) { 108 | client.post('/blockEmail', { email: 'test@EXAMPLE.COM' }, 109 | function (err, req, res, obj) { 110 | t.notOk(err, 'block request is successful') 111 | t.equal(res.statusCode, 200, 'block request returns a 200') 112 | 113 | client.post('/check', { email: TEST_EMAIL, ip: TEST_IP, action: 'accountCreate' }, 114 | function (err, req, res, obj) { 115 | t.equal(res.statusCode, 200, 'check worked') 116 | t.equal(obj.block, true, 'request was blocked') 117 | t.end() 118 | } 119 | ) 120 | } 121 | ) 122 | } 123 | ) 124 | 125 | test( 126 | 'teardown', 127 | function (t) { 128 | testServer.stop() 129 | t.equal(testServer.server.killed, true, 'test server has been killed') 130 | t.end() 131 | } 132 | ) 133 | -------------------------------------------------------------------------------- /email_record.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | // Keep track of events tied to just email addresses 6 | module.exports = function (RATE_LIMIT_INTERVAL_MS, BLOCK_INTERVAL_MS, MAX_EMAILS, BAD_LOGIN_LOCKOUT, now) { 7 | 8 | now = now || Date.now 9 | 10 | var EMAIL_ACTION = { 11 | accountCreate : true, 12 | recoveryEmailResendCode : true, 13 | passwordForgotSendCode : true, 14 | passwordForgotResendCode : true, 15 | } 16 | 17 | function isEmailAction(action) { 18 | return EMAIL_ACTION[action] 19 | } 20 | 21 | function EmailRecord() { 22 | this.xs = [] 23 | this.lf = [] 24 | } 25 | 26 | EmailRecord.parse = function (object) { 27 | var rec = new EmailRecord() 28 | object = object || {} 29 | rec.bk = object.bk // timestamp when the account was banned 30 | rec.rl = object.rl // timestamp when the account was rate-limited 31 | rec.xs = object.xs || [] // timestamps when emails were sent 32 | rec.lf = object.lf || [] // timestamps when a login failure occurred 33 | rec.pr = object.pr // timestamp of the last password reset 34 | return rec 35 | } 36 | 37 | EmailRecord.prototype.isOverEmailLimit = function () { 38 | this.trimHits(now()) 39 | return this.xs.length > MAX_EMAILS 40 | } 41 | 42 | EmailRecord.prototype.isWayOverBadLogins = function () { 43 | this.trimBadLogins(now()) 44 | return this.lf.length > BAD_LOGIN_LOCKOUT 45 | } 46 | 47 | EmailRecord.prototype.trimHits = function (now) { 48 | if (this.xs.length === 0) { return } 49 | // xs is naturally ordered from oldest to newest 50 | // and we only need to keep up to MAX_EMAILS + 1 51 | 52 | var i = this.xs.length - 1 53 | var n = 0 54 | var hit = this.xs[i] 55 | while (hit > (now - RATE_LIMIT_INTERVAL_MS) && n <= MAX_EMAILS) { 56 | hit = this.xs[--i] 57 | n++ 58 | } 59 | this.xs = this.xs.slice(i + 1) 60 | } 61 | 62 | EmailRecord.prototype.trimBadLogins = function (now) { 63 | if (this.lf.length === 0) { return } 64 | // lf is naturally ordered from oldest to newest 65 | // and we only need to keep up to BAD_LOGIN_LOCKOUT + 1 66 | 67 | var i = this.lf.length - 1 68 | var n = 0 69 | var login = this.lf[i] 70 | while (login > (now - RATE_LIMIT_INTERVAL_MS) && n <= BAD_LOGIN_LOCKOUT) { 71 | login = this.lf[--i] 72 | n++ 73 | } 74 | this.lf = this.lf.slice(i + 1) 75 | } 76 | 77 | EmailRecord.prototype.addHit = function () { 78 | this.xs.push(now()) 79 | } 80 | 81 | EmailRecord.prototype.addBadLogin = function () { 82 | this.trimBadLogins(now()) 83 | this.lf.push(now()) 84 | } 85 | 86 | EmailRecord.prototype.shouldBlock = function () { 87 | return this.isRateLimited() || this.isBlocked() 88 | } 89 | 90 | EmailRecord.prototype.isRateLimited = function () { 91 | return !!(this.rl && (now() - this.rl < RATE_LIMIT_INTERVAL_MS)) 92 | } 93 | 94 | EmailRecord.prototype.isBlocked = function () { 95 | return !!(this.bk && (now() - this.bk < BLOCK_INTERVAL_MS)) 96 | } 97 | 98 | EmailRecord.prototype.block = function () { 99 | this.bk = now() 100 | } 101 | 102 | EmailRecord.prototype.rateLimit = function () { 103 | this.rl = now() 104 | this.xs = [] 105 | } 106 | 107 | EmailRecord.prototype.passwordReset = function () { 108 | this.pr = now() 109 | } 110 | 111 | EmailRecord.prototype.retryAfter = function () { 112 | var rateLimitAfter = Math.floor(((this.rl || 0) + RATE_LIMIT_INTERVAL_MS - now()) / 1000) 113 | var banAfter = Math.floor(((this.bk || 0) + BLOCK_INTERVAL_MS - now()) / 1000) 114 | return Math.max(0, rateLimitAfter, banAfter) 115 | } 116 | 117 | EmailRecord.prototype.update = function (action) { 118 | // if this user is not yet blocked 119 | // and if this is NOT an email action, then no block 120 | if ( !this.isBlocked() && !isEmailAction(action) ) { 121 | return 0 122 | } 123 | 124 | // check if this is already blocked, don't count any more hits and tell them to retry 125 | if (this.shouldBlock()) { 126 | return this.retryAfter() 127 | } 128 | 129 | this.addHit() 130 | 131 | if (this.isOverEmailLimit()) { 132 | // rate limit this email if now over the limit and tell them to retry 133 | this.rateLimit() 134 | return this.retryAfter() 135 | } 136 | 137 | // no block, not yet over limit 138 | return 0 139 | } 140 | 141 | return EmailRecord 142 | } 143 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Firefox Accounts Customs Server API 2 | 3 | # Overview 4 | 5 | ## Request Format 6 | 7 | None of the requests are authenticated. The customs server is an 8 | internal service that is running on the same machine as the service 9 | that uses it (currently only the auth server) and is listening on 10 | localhost. 11 | 12 | ## Response Format 13 | 14 | All successful requests will produce a response with HTTP status code 15 | of "200" and content-type of "application/json". The structure of the 16 | response body will depend on the endpoint in question. 17 | 18 | Failures due to invalid behavior from the client will produce a 19 | response with HTTP status code of "400" and content-type of 20 | "application/json". Failures due to an unexpected situation on the 21 | server will produce a response with HTTP status code of "500" and 22 | content-type of "application/json". 23 | 24 | # API Endpoints 25 | 26 | * [POST /blockEmail](#post-blockemail) 27 | * [POST /blockIp](#post-blockip) 28 | * [POST /check](#post-check) 29 | * [POST /failedLoginAttempt](#post-failedloginattempt) 30 | * [POST /passwordReset](#post-passwordreset) 31 | 32 | ## POST /blockEmail 33 | 34 | *Not currently used by anyone.* 35 | 36 | Used by internal services to temporarily ban requests associated with a given email address. These bans last for `config.limits.blockIntervalSeconds` (default: 24 hours). 37 | 38 | ___Parameters___ 39 | 40 | * email - the email address associated with the account to ban 41 | 42 | ### Request 43 | 44 | ```sh 45 | curl -v \ 46 | -H "Content-Type: application/json" \ 47 | "http://127.0.0.1:7000/blockEmail" \ 48 | -d '{ 49 | "email": "me@example.com" 50 | }' 51 | ``` 52 | 53 | ### Response 54 | 55 | Successful requests will produce a "200 OK" response with an empty JSON object as the body. 56 | 57 | ```json 58 | { 59 | } 60 | ``` 61 | 62 | Failing requests may be due to the following errors: 63 | 64 | * status code 400, code MissingParameters: email is required 65 | 66 | ## POST /blockIp 67 | 68 | *Not currently used by anyone.* 69 | 70 | Used by internal services to temporarily ban requests associated with a given IP address. These bans last for `config.limits.blockIntervalSeconds` (default: 24 hours). 71 | 72 | ___Parameters___ 73 | 74 | * ip - the IP address to ban 75 | 76 | ### Request 77 | 78 | ```sh 79 | curl -v \ 80 | -H "Content-Type: application/json" \ 81 | "http://127.0.0.1:7000/blockIp" \ 82 | -d '{ 83 | "ip": "192.0.2.1" 84 | }' 85 | ``` 86 | 87 | ### Response 88 | 89 | Successful requests will produce a "200 OK" response with an empty JSON object as the body. 90 | 91 | ```json 92 | { 93 | } 94 | ``` 95 | 96 | Failing requests may be due to the following errors: 97 | 98 | * status code 400, code MissingParameters: ip is required 99 | 100 | 101 | ## POST /check 102 | 103 | Called by the auth server before performing an action on its end to 104 | check whether or not the action should be blocked. 105 | 106 | ___Parameters___ 107 | 108 | * email - the email address associated with the account 109 | * ip - the IP address where the request originates 110 | * action - the name of the action under consideration 111 | 112 | ### Request 113 | 114 | ```sh 115 | curl -v \ 116 | -H "Content-Type: application/json" \ 117 | "http://127.0.0.1:7000/check" \ 118 | -d '{ 119 | "email": "me@example.com", 120 | "ip": "192.0.2.1", 121 | "action": "accountCreate" 122 | }' 123 | ``` 124 | 125 | ### Response 126 | 127 | Successful requests will produce a "200 OK" response with the blocking 128 | advice in the JSON body: 129 | 130 | ```json 131 | { 132 | "block": true, 133 | "retryAfter": 86396 134 | } 135 | ``` 136 | 137 | `block` indicates whether or not the action should be blocked and 138 | `retryAfter` tells the client how long it should wait (in seconds) 139 | before attempting this action again. 140 | 141 | Failing requests may be due to the following errors: 142 | 143 | * status code 400, code MissingParameters: email, ip and action are all required 144 | 145 | ## POST /failedLoginAttempt 146 | 147 | Called by the auth server to signal to the customs server that a 148 | failed login attempt has occured. 149 | 150 | This information is stored by the customs server to enforce some of 151 | its policies. 152 | 153 | ___Parameters___ 154 | 155 | * email - the email address associated with the account 156 | * ip - the IP address where the request originates 157 | 158 | ### Request 159 | 160 | ```sh 161 | curl -v \ 162 | -H "Content-Type: application/json" \ 163 | "http://127.0.0.1:7000/failedLoginAttempt" \ 164 | -d '{ 165 | "email": "me@example.com", 166 | "ip": "192.0.2.1" 167 | }' 168 | ``` 169 | 170 | ### Response 171 | 172 | Successful requests will produce a "200 OK" response with the lockout 173 | advice in the JSON body: 174 | 175 | ```json 176 | { 177 | "lockout": false 178 | } 179 | ``` 180 | 181 | `lockout` indicates whether or not the account should be locked out. 182 | 183 | Failing requests may be due to the following errors: 184 | 185 | * status code 400, code MissingParameters: email and ip are both required 186 | 187 | ## POST /passwordReset 188 | 189 | Called by the auth server to signal to the customs server that the 190 | password on the account has been successfully reset. 191 | 192 | The customs server uses this information to update its state (expiring 193 | bad logins for example). 194 | 195 | ___Parameters___ 196 | 197 | * email - the email address associated with the account 198 | 199 | ### Request 200 | 201 | ```sh 202 | curl -v \ 203 | -H "Content-Type: application/json" \ 204 | "http://127.0.0.1:7000/passwordReset" \ 205 | -d '{ 206 | "email": "me@example.com" 207 | }' 208 | ``` 209 | 210 | ### Response 211 | 212 | Successful requests will produce a "200 OK" response with an empty JSON object as the body. 213 | 214 | ```json 215 | { 216 | } 217 | ``` 218 | 219 | Failing requests may be due to the following errors: 220 | 221 | * status code 400, code MissingParameters: email is required 222 | -------------------------------------------------------------------------------- /test/local/ip_email_record_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | require('ass') 5 | var test = require('tap').test 6 | var ipEmailRecord = require('../../ip_email_record') 7 | 8 | function now() { 9 | return 1000 // old school 10 | } 11 | 12 | function simpleIpEmailRecord() { 13 | return new (ipEmailRecord(500, 2, now))() 14 | } 15 | 16 | test( 17 | 'shouldBlock works', 18 | function (t) { 19 | var ier = simpleIpEmailRecord() 20 | 21 | t.equal(ier.shouldBlock(), false, 'record has never been blocked') 22 | ier.rl = 499 23 | t.equal(ier.shouldBlock(), false, 'blockedAt is older than rate-limit interval') 24 | ier.rl = 501 25 | t.equal(ier.shouldBlock(), true, 'blockedAt is within the rate-limit interval') 26 | t.end() 27 | } 28 | ) 29 | 30 | test( 31 | 'addBadLogin works', 32 | function (t) { 33 | var ier = simpleIpEmailRecord() 34 | 35 | t.equal(ier.lf.length, 0, 'record has never had a bad login') 36 | ier.addBadLogin() 37 | t.equal(ier.lf.length, 1, 'record has had one bad login') 38 | ier.addBadLogin() 39 | ier.addBadLogin() 40 | t.equal(ier.lf.length, 3, 'record has three bad logins') 41 | t.end() 42 | } 43 | ) 44 | 45 | test( 46 | 'rateLimit works', 47 | function (t) { 48 | var ier = simpleIpEmailRecord() 49 | 50 | ier.addBadLogin() 51 | t.equal(ier.shouldBlock(), false, 'record is not blocked') 52 | t.equal(ier.lf.length, 1, 'record has been emailed once') 53 | ier.rateLimit() 54 | t.equal(ier.shouldBlock(), true, 'record is blocked') 55 | t.equal(ier.lf.length, 0, 'record has an empty list of emails') 56 | t.end() 57 | } 58 | ) 59 | 60 | test( 61 | 'trimBadLogins enforces the bad login limit', 62 | function (t) { 63 | var ier = simpleIpEmailRecord() 64 | 65 | t.equal(ier.lf.length, 0, 'record has nothing to trim') 66 | ier.addBadLogin() 67 | ier.addBadLogin() 68 | ier.addBadLogin() 69 | ier.addBadLogin() 70 | t.equal(ier.lf.length, 4, 'record contains too many bad logins') 71 | ier.trimBadLogins(now()) 72 | t.equal(ier.lf.length, 3, 'record has trimmed excess bad logins') 73 | t.end() 74 | } 75 | ) 76 | 77 | test( 78 | 'trimBadLogins evicts expired entries', 79 | function (t) { 80 | var ier = simpleIpEmailRecord() 81 | 82 | t.equal(ier.lf.length, 0, 'record has nothing to trim') 83 | ier.trimBadLogins(now()) 84 | t.equal(ier.lf.length, 0, 'trimming did not do anything') 85 | ier.lf.push(400) 86 | ier.lf.push(400) 87 | ier.lf.push(now()) 88 | t.equal(ier.lf.length, 3, 'record contains expired and fresh logins') 89 | ier.trimBadLogins(now()) 90 | t.equal(ier.lf.length, 1, 'record has trimmed expired bad logins') 91 | t.end() 92 | } 93 | ) 94 | 95 | test( 96 | 'isOverBadLogins works', 97 | function (t) { 98 | var ier = simpleIpEmailRecord() 99 | 100 | t.equal(ier.isOverBadLogins(), false, 'record has never seen bad logins') 101 | ier.addBadLogin() 102 | t.equal(ier.isOverBadLogins(), false, 'record has not reached the bad login limit') 103 | ier.addBadLogin() 104 | ier.addBadLogin() 105 | t.equal(ier.isOverBadLogins(), true, 'record has reached the bad login limit') 106 | t.end() 107 | } 108 | ) 109 | 110 | test( 111 | 'retryAfter works', 112 | function (t) { 113 | var ier = simpleIpEmailRecord() 114 | ier.now = function () { 115 | return 10000 116 | } 117 | 118 | t.equal(ier.retryAfter(), 0, 'unblocked records can be retried now') 119 | ier.rl = 100 120 | t.equal(ier.retryAfter(), 0, 'long expired blocks can be retried immediately') 121 | ier.rl = 500 122 | t.equal(ier.retryAfter(), 0, 'just expired blocks can be retried immediately') 123 | ier.rl = 6000 124 | t.equal(ier.retryAfter(), 5, 'unexpired blocks can be retried in a bit') 125 | t.end() 126 | } 127 | ) 128 | 129 | test( 130 | 'unblockIfReset works', 131 | function (t) { 132 | var ier = simpleIpEmailRecord() 133 | 134 | t.equal(ier.lf.length, 0, 'record does not have any bad logins') 135 | t.equal(ier.rl, undefined, 'record is not blocked') 136 | ier.unblockIfReset(now()) 137 | t.equal(ier.lf.length, 0, 'record still does not have any bad logins') 138 | t.equal(ier.rl, undefined, 'record is still not blocked') 139 | ier.rateLimit() 140 | ier.addBadLogin() 141 | t.equal(ier.lf.length, 1, 'record has one bad login') 142 | t.equal(ier.rl, now(), 'record is blocked') 143 | ier.unblockIfReset(500) 144 | t.equal(ier.lf.length, 1, 'bad logins are not cleared when resetting prior to blocking') 145 | t.equal(ier.rl, now(), 'record is not unblocked when resetting prior to blocking') 146 | ier.unblockIfReset(2000) 147 | t.equal(ier.lf.length, 0, 'bad logins are cleared when resetting after blocking') 148 | t.equal(ier.rl, undefined, 'record is unblocked when resetting after blocking') 149 | t.end() 150 | } 151 | ) 152 | 153 | test( 154 | 'parse works', 155 | function (t) { 156 | var ier = simpleIpEmailRecord() 157 | t.equal(ier.shouldBlock(), false, 'original object is not blocked') 158 | t.equal(ier.lf.length, 0, 'original object has no bad logins') 159 | 160 | var ierCopy1 = (ipEmailRecord(50, 2, now)).parse(ier) 161 | t.equal(ierCopy1.shouldBlock(), false, 'copied object is not blocked') 162 | t.equal(ierCopy1.lf.length, 0, 'copied object has no bad logins') 163 | 164 | ier.rateLimit() 165 | ier.addBadLogin() 166 | t.equal(ier.shouldBlock(), true, 'original object is now blocked') 167 | t.equal(ier.lf.length, 1, 'original object now has one bad login') 168 | 169 | var ierCopy2 = (ipEmailRecord(50, 2, now)).parse(ier) 170 | t.equal(ierCopy2.shouldBlock(), true, 'copied object is blocked') 171 | t.equal(ierCopy2.lf.length, 1, 'copied object has one bad login') 172 | t.end() 173 | } 174 | ) 175 | 176 | test( 177 | 'update works', 178 | function (t) { 179 | var ier = simpleIpEmailRecord() 180 | 181 | t.equal(ier.update(), 0, 'undefined action does nothing') 182 | t.equal(ier.update('bogusAction'), 0, 'bogus action does nothing') 183 | t.equal(ier.update('accountLogin'), 0, 'login action in a clean account') 184 | ier.addBadLogin() 185 | ier.addBadLogin() 186 | ier.addBadLogin() 187 | t.equal(ier.shouldBlock(), false, 'account is not blocked') 188 | t.equal(ier.update('accountLogin'), 0, 'action above the login limit') 189 | t.equal(ier.shouldBlock(), true, 'account is now blocked') 190 | t.equal(ier.update('accountLogin'), 0, 'login action in a blocked account') 191 | t.end() 192 | } 193 | ) 194 | -------------------------------------------------------------------------------- /test/local/email_record_tests.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | require('ass') 5 | var test = require('tap').test 6 | var emailRecord = require('../../email_record') 7 | 8 | function now() { 9 | return 1000 // old school 10 | } 11 | 12 | function simpleEmailRecord() { 13 | return new (emailRecord(500, 800, 2, 5, now))() 14 | } 15 | 16 | test( 17 | 'shouldBlock works', 18 | function (t) { 19 | var er = simpleEmailRecord() 20 | 21 | t.equal(er.shouldBlock(), false, 'record has never been blocked') 22 | er.rl = 499 23 | t.equal(er.shouldBlock(), false, 'blockedAt is older than rate-limit interval') 24 | er.rl = 501 25 | t.equal(er.shouldBlock(), true, 'blockedAt is within the rate-limit interval') 26 | delete er.rl 27 | t.equal(er.shouldBlock(), false, 'record is no longer blocked') 28 | 29 | er.bk = 199 30 | t.equal(er.shouldBlock(), false, 'blockedAt is older than block interval') 31 | er.bk = 201 32 | t.equal(er.shouldBlock(), true, 'blockedAt is within the block interval') 33 | t.end() 34 | } 35 | ) 36 | 37 | test( 38 | 'addHit works', 39 | function (t) { 40 | var er = simpleEmailRecord() 41 | 42 | t.equal(er.xs.length, 0, 'record has never been emailed') 43 | er.addHit() 44 | t.equal(er.xs.length, 1, 'record has been emailed once') 45 | er.addHit() 46 | er.addHit() 47 | t.equal(er.xs.length, 3, 'record has been emailed three times') 48 | t.end() 49 | } 50 | ) 51 | 52 | test( 53 | 'rateLimit works', 54 | function (t) { 55 | var er = simpleEmailRecord() 56 | 57 | er.addHit() 58 | t.equal(er.shouldBlock(), false, 'record is not blocked') 59 | t.equal(er.xs.length, 1, 'record has been emailed once') 60 | er.rateLimit() 61 | t.equal(er.shouldBlock(), true, 'record is blocked') 62 | t.equal(er.xs.length, 0, 'record has an empty list of emails') 63 | t.end() 64 | } 65 | ) 66 | 67 | test( 68 | 'trimHits enforces the email limit', 69 | function (t) { 70 | var er = simpleEmailRecord() 71 | 72 | t.equal(er.xs.length, 0, 'record has nothing to trim') 73 | er.addHit() 74 | er.addHit() 75 | er.addHit() 76 | er.addHit() 77 | t.equal(er.xs.length, 4, 'record contains too many emails') 78 | er.trimHits(now()) 79 | t.equal(er.xs.length, 3, 'record has trimmed excess emails') 80 | t.end() 81 | } 82 | ) 83 | 84 | test( 85 | 'trimHits evicts expired entries', 86 | function (t) { 87 | var er = simpleEmailRecord() 88 | 89 | t.equal(er.xs.length, 0, 'record has nothing to trim') 90 | er.trimHits(now()) 91 | t.equal(er.xs.length, 0, 'trimming did not do anything') 92 | er.xs.push(400) 93 | er.xs.push(400) 94 | er.xs.push(now()) 95 | t.equal(er.xs.length, 3, 'record contains expired and fresh emails') 96 | er.trimHits(now()) 97 | t.equal(er.xs.length, 1, 'record has trimmed expired emails') 98 | t.end() 99 | } 100 | ) 101 | 102 | test( 103 | 'isOverEmailLimit works', 104 | function (t) { 105 | var er = simpleEmailRecord() 106 | 107 | t.equal(er.isOverEmailLimit(), false, 'record has never been emailed') 108 | er.addHit() 109 | t.equal(er.isOverEmailLimit(), false, 'record has not reached the email limit') 110 | er.addHit() 111 | er.addHit() 112 | t.equal(er.isOverEmailLimit(), true, 'record has reached the email limit') 113 | t.end() 114 | } 115 | ) 116 | 117 | test( 118 | 'isWayOverBadLogins works', 119 | function (t) { 120 | var er = simpleEmailRecord() 121 | 122 | t.equal(er.isWayOverBadLogins(), false, 'record has never seen a bad login') 123 | er.addBadLogin() 124 | er.addBadLogin() 125 | er.addBadLogin() 126 | er.addBadLogin() 127 | t.equal(er.isWayOverBadLogins(), false, 'record has not reached the bad login limit') 128 | er.addBadLogin() 129 | er.addBadLogin() 130 | t.equal(er.isWayOverBadLogins(), true, 'record has reached the bad login limit') 131 | t.end() 132 | } 133 | ) 134 | 135 | test( 136 | 'retryAfter works', 137 | function (t) { 138 | var er = new (emailRecord(5000, 8000, 2, 5, function () { 139 | return 10000 140 | }))() 141 | 142 | t.equal(er.retryAfter(), 0, 'unblocked records can be retried now') 143 | er.rl = 1000 144 | t.equal(er.retryAfter(), 0, 'long expired blocks can be retried immediately') 145 | er.rl = 5000 146 | t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately') 147 | er.rl = 6000 148 | t.equal(er.retryAfter(), 1, 'unexpired blocks can be retried in a bit') 149 | 150 | delete er.rl 151 | t.equal(er.retryAfter(), 0, 'unblocked records can be retried now') 152 | er.bk = 1000 153 | t.equal(er.retryAfter(), 0, 'long expired blocks can be retried immediately') 154 | er.bk = 2000 155 | t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately') 156 | er.bk = 6000 157 | t.equal(er.retryAfter(), 4, 'unexpired blocks can be retried in a bit') // TODO? 158 | t.end() 159 | } 160 | ) 161 | 162 | test( 163 | 'passwordReset works', 164 | function (t) { 165 | var er = simpleEmailRecord() 166 | 167 | t.equal(er.pr, undefined, 'password is not marked as reset yet') 168 | er.passwordReset() 169 | t.equal(er.pr, 1000, 'password is marked as reset now') 170 | t.end() 171 | } 172 | ) 173 | 174 | test( 175 | 'parse works', 176 | function (t) { 177 | var er = simpleEmailRecord() 178 | t.equal(er.shouldBlock(), false, 'original object is not blocked') 179 | t.equal(er.xs.length, 0, 'original object has no hits') 180 | 181 | var erCopy1 = (emailRecord(50, 50, 2, 5, now)).parse(er) 182 | t.equal(erCopy1.shouldBlock(), false, 'copied object is not blocked') 183 | t.equal(erCopy1.xs.length, 0, 'copied object has no hits') 184 | 185 | er.rateLimit() 186 | er.addHit() 187 | t.equal(er.shouldBlock(), true, 'original object is now blocked') 188 | t.equal(er.xs.length, 1, 'original object now has one hit') 189 | 190 | var erCopy2 = (emailRecord(50, 50, 2, 5, now)).parse(er) 191 | t.equal(erCopy2.shouldBlock(), true, 'copied object is blocked') 192 | t.equal(erCopy2.xs.length, 1, 'copied object has one hit') 193 | t.end() 194 | } 195 | ) 196 | 197 | test( 198 | 'update works', 199 | function (t) { 200 | var er = simpleEmailRecord() 201 | 202 | t.equal(er.update('bogusAction'), 0, 'bogus email actions does nothing') 203 | t.equal(er.update('accountCreate'), 0, 'email action in a clean account') 204 | er.addHit() 205 | er.addHit() 206 | er.addHit() 207 | t.equal(er.shouldBlock(), false, 'account is not blocked') 208 | t.equal(er.update('accountCreate'), 0, 'email action above the email limit') 209 | t.equal(er.shouldBlock(), true, 'account is now blocked') 210 | t.equal(er.update('accountCreate'), 0, 'email action in a blocked account') 211 | 212 | er.rl = 2000 213 | t.equal(er.shouldBlock(), true, 'account is blocked due to rate limiting') 214 | t.equal(er.isBlocked(), false, 'account is not outright banned') 215 | t.equal(er.isRateLimited(), true, 'account is rate limited') 216 | t.equal(er.update('accountCreate'), 1, 'email action is blocked') 217 | t.equal(er.update('accountLogin'), 0, 'non-email action is not blocked') 218 | er.rl = 0 219 | er.bk = 2000 220 | t.equal(er.shouldBlock(), true, 'account is blocked due to being outright blocked') 221 | t.equal(er.isBlocked(), true, 'account is outright banned') 222 | t.equal(er.isRateLimited(), false, 'account is not rate limited') 223 | t.equal(er.update('accountCreate'), 1, 'email action is blocked') 224 | t.equal(er.update('accountLogin'), 1, 'non-email action is blocked') 225 | 226 | t.end() 227 | } 228 | ) 229 | -------------------------------------------------------------------------------- /bin/customs_server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | var Memcached = require('memcached') 8 | var restify = require('restify') 9 | var config = require('../config').root() 10 | var log = require('../log')(config.log.level, 'customs-server') 11 | var packageJson = require('../package.json') 12 | 13 | var LIFETIME = config.memcache.recordLifetimeSeconds 14 | var BLOCK_INTERVAL_MS = config.limits.blockIntervalSeconds * 1000 15 | var RATE_LIMIT_INTERVAL_MS = config.limits.rateLimitIntervalSeconds * 1000 16 | 17 | var IpEmailRecord = require('../ip_email_record')(RATE_LIMIT_INTERVAL_MS, config.limits.maxBadLogins) 18 | var EmailRecord = require('../email_record')(RATE_LIMIT_INTERVAL_MS, BLOCK_INTERVAL_MS, config.limits.maxEmails, config.limits.badLoginLockout) 19 | var IpRecord = require('../ip_record')(BLOCK_INTERVAL_MS) 20 | 21 | var P = require('bluebird') 22 | P.promisifyAll(Memcached.prototype) 23 | 24 | function shutdown() { 25 | process.nextTick(process.exit) 26 | } 27 | 28 | if (process.env.ASS_CODE_COVERAGE) { 29 | process.on('SIGINT', shutdown) 30 | } 31 | 32 | var mc = new Memcached( 33 | config.memcache.address, 34 | { 35 | timeout: 500, 36 | retries: 1, 37 | retry: 1000, 38 | reconnect: 1000, 39 | idle: 30000, 40 | namespace: 'fxa~' 41 | } 42 | ) 43 | 44 | var handleBan = P.promisify(require('../bans/handler')(mc, log)) 45 | 46 | // optional SQS-based IP/email banning API 47 | if (config.bans.region && config.bans.queueUrl) { 48 | var bans = require('../bans')(log) 49 | bans(config.bans, mc) 50 | log.info({ op: 'listeningSQS', sqsRegion: config.bans.region, sqsQueueUrl: config.bans.queueUrl }) 51 | } 52 | 53 | var api = restify.createServer() 54 | api.use(restify.bodyParser()) 55 | 56 | function ignore(err) { 57 | log.error({ op: 'memcachedError', err: err }) 58 | } 59 | 60 | function fetchRecords(email, ip) { 61 | return P.all( 62 | [ 63 | // get records and ignore errors by returning a new record 64 | mc.getAsync(email).then(EmailRecord.parse, EmailRecord.parse), 65 | mc.getAsync(ip).then(IpRecord.parse, IpRecord.parse), 66 | mc.getAsync(ip + email).then(IpEmailRecord.parse, IpEmailRecord.parse) 67 | ] 68 | ) 69 | } 70 | 71 | function setRecords(email, ip, emailRecord, ipRecord, ipEmailRecord) { 72 | return P.all( 73 | [ 74 | // store records ignoring errors 75 | mc.setAsync(email, emailRecord, LIFETIME).catch(ignore), 76 | mc.setAsync(ip, ipRecord, LIFETIME).catch(ignore), 77 | mc.setAsync(ip + email, ipEmailRecord, LIFETIME).catch(ignore) 78 | ] 79 | ) 80 | } 81 | 82 | function max(prev, cur) { 83 | return Math.max(prev, cur) 84 | } 85 | 86 | function normalizedEmail(rawEmail) { 87 | return rawEmail.toLowerCase() 88 | } 89 | 90 | api.post( 91 | '/check', 92 | function (req, res, next) { 93 | var email = req.body.email 94 | var ip = req.body.ip 95 | var action = req.body.action 96 | 97 | if (!email || !ip || !action) { 98 | var err = {code: 'MissingParameters', message: 'email, ip and action are all required'} 99 | log.error({ op: 'request.failedLoginAttempt', email: email, ip: ip, action: action, err: err }) 100 | res.send(400, err) 101 | return next() 102 | } 103 | email = normalizedEmail(email) 104 | 105 | fetchRecords(email, ip) 106 | .spread( 107 | function (emailRecord, ipRecord, ipEmailRecord) { 108 | var blockEmail = emailRecord.update(action) 109 | var blockIpEmail = ipEmailRecord.update(action) 110 | var blockIp = ipRecord.update() 111 | 112 | if (blockIpEmail && ipEmailRecord.unblockIfReset(emailRecord.pr)) { 113 | blockIpEmail = 0 114 | } 115 | var retryAfter = [blockEmail, blockIpEmail, blockIp].reduce(max) 116 | 117 | return setRecords(email, ip, emailRecord, ipRecord, ipEmailRecord) 118 | .then( 119 | function () { 120 | return { 121 | block: retryAfter > 0, 122 | retryAfter: retryAfter 123 | } 124 | } 125 | ) 126 | } 127 | ) 128 | .then( 129 | function (result) { 130 | log.info({ op: 'request.check', email: email, ip: ip, action: action, block: result.block }) 131 | res.send(result) 132 | }, 133 | function (err) { 134 | log.error({ op: 'request.check', email: email, ip: ip, action: action, err: err }) 135 | res.send(500, err) 136 | } 137 | ) 138 | .done(next, next) 139 | } 140 | ) 141 | 142 | api.post( 143 | '/failedLoginAttempt', 144 | function (req, res, next) { 145 | var email = req.body.email 146 | var ip = req.body.ip 147 | if (!email || !ip) { 148 | var err = {code: 'MissingParameters', message: 'email and ip are both required'} 149 | log.error({ op: 'request.failedLoginAttempt', email: email, ip: ip, err: err }) 150 | res.send(400, err) 151 | return next() 152 | } 153 | email = normalizedEmail(email) 154 | 155 | fetchRecords(email, ip) 156 | .spread( 157 | function (emailRecord, ipRecord, ipEmailRecord) { 158 | emailRecord.addBadLogin() 159 | ipEmailRecord.addBadLogin() 160 | return setRecords(email, ip, emailRecord, ipRecord, ipEmailRecord) 161 | .then( 162 | function () { 163 | return { 164 | lockout: emailRecord.isWayOverBadLogins() 165 | } 166 | } 167 | ) 168 | } 169 | ) 170 | .then( 171 | function (result) { 172 | log.info({ op: 'request.failedLoginAttempt', email: email, ip: ip }) 173 | res.send(result) 174 | }, 175 | function (err) { 176 | log.error({ op: 'request.failedLoginAttempt', email: email, ip: ip, err: err }) 177 | res.send(500, err) 178 | } 179 | ) 180 | .done(next, next) 181 | } 182 | ) 183 | 184 | api.post( 185 | '/passwordReset', 186 | function (req, res, next) { 187 | var email = req.body.email 188 | if (!email) { 189 | var err = {code: 'MissingParameters', message: 'email is required'} 190 | log.error({ op: 'request.passwordReset', email: email, err: err }) 191 | res.send(400, err) 192 | return next() 193 | } 194 | email = normalizedEmail(email) 195 | 196 | mc.getAsync(email) 197 | .then(EmailRecord.parse, EmailRecord.parse) 198 | .then( 199 | function (emailRecord) { 200 | emailRecord.passwordReset() 201 | return mc.setAsync(email, emailRecord, LIFETIME).catch(ignore) 202 | } 203 | ) 204 | .then( 205 | function () { 206 | log.info({ op: 'request.passwordReset', email: email }) 207 | res.send({}) 208 | }, 209 | function (err) { 210 | log.error({ op: 'request.passwordReset', email: email, err: err }) 211 | res.send(500, err) 212 | } 213 | ) 214 | .done(next, next) 215 | } 216 | ) 217 | 218 | api.post( 219 | '/blockEmail', 220 | function (req, res, next) { 221 | var email = req.body.email 222 | if (!email) { 223 | var err = {code: 'MissingParameters', message: 'email is required'} 224 | log.error({ op: 'request.blockEmail', email: email, err: err }) 225 | res.send(400, err) 226 | return next() 227 | } 228 | email = normalizedEmail(email) 229 | 230 | handleBan({ ban: { email: email } }) 231 | .then( 232 | function () { 233 | log.info({ op: 'request.blockEmail', email: email }) 234 | res.send({}) 235 | } 236 | ) 237 | .catch( 238 | function (err) { 239 | log.error({ op: 'request.blockEmail', email: email, err: err }) 240 | res.send(500, err) 241 | } 242 | ) 243 | .done(next, next) 244 | } 245 | ) 246 | 247 | api.post( 248 | '/blockIp', 249 | function (req, res, next) { 250 | var ip = req.body.ip 251 | if (!ip) { 252 | var err = {code: 'MissingParameters', message: 'ip is required'} 253 | log.error({ op: 'request.blockIp', ip: ip, err: err }) 254 | res.send(400, err) 255 | return next() 256 | } 257 | 258 | handleBan({ ban: { ip: ip } }) 259 | .then( 260 | function () { 261 | log.info({ op: 'request.blockIp', ip: ip }) 262 | res.send({}) 263 | } 264 | ) 265 | .catch( 266 | function (err) { 267 | log.error({ op: 'request.blockIp', ip: ip, err: err }) 268 | res.send(500, err) 269 | } 270 | ) 271 | .done(next, next) 272 | } 273 | ) 274 | 275 | api.get( 276 | '/', 277 | function (req, res, next) { 278 | res.send({ version: packageJson.version }) 279 | next() 280 | } 281 | ) 282 | 283 | api.listen( 284 | config.listen.port, 285 | config.listen.host, 286 | function () { 287 | log.info({ op: 'listening', host: config.listen.host, port: config.listen.port }) 288 | } 289 | ) 290 | -------------------------------------------------------------------------------- /test/remote/too_many_bad_logins.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var mcHelper = require('../memcache-helper') 8 | 9 | var TEST_EMAIL = 'test@example.com' 10 | var TEST_IP = '192.0.2.1' 11 | 12 | var config = { 13 | listen: { 14 | port: 7000 15 | }, 16 | limits: { 17 | rateLimitIntervalSeconds: 1 18 | } 19 | } 20 | 21 | var testServer = new TestServer(config) 22 | 23 | test( 24 | 'startup', 25 | function (t) { 26 | testServer.start(function (err) { 27 | t.type(testServer.server, 'object', 'test server was started') 28 | t.notOk(err, 'no errors were returned') 29 | t.end() 30 | }) 31 | } 32 | ) 33 | 34 | test( 35 | 'clear everything', 36 | function (t) { 37 | mcHelper.clearEverything( 38 | function (err) { 39 | t.notOk(err, 'no errors were returned') 40 | t.end() 41 | } 42 | ) 43 | } 44 | ) 45 | 46 | var client = restify.createJsonClient({ 47 | url: 'http://127.0.0.1:' + config.listen.port 48 | }); 49 | 50 | test( 51 | 'too many failed logins from the same IP', 52 | function (t) { 53 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 54 | function (err, req, res, obj) { 55 | t.equal(res.statusCode, 200, 'first login attempt noted') 56 | t.equal(obj.lockout, false, 'not locked out') 57 | 58 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 59 | function (err, req, res, obj) { 60 | t.equal(res.statusCode, 200, 'second login attempt noted') 61 | t.equal(obj.lockout, false, 'not locked out') 62 | 63 | mcHelper.badLoginCheck( 64 | function (isOverBadLogins, isWayOverBadLogins) { 65 | t.equal(isOverBadLogins, false, 'is not yet over bad logins') 66 | t.equal(isWayOverBadLogins, false, 'is not locked out') 67 | 68 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: TEST_IP }, 69 | function (err, req, res, obj) { 70 | t.equal(res.statusCode, 200, 'third login attempt noted') 71 | t.equal(obj.lockout, false, 'not locked out') 72 | 73 | mcHelper.badLoginCheck( 74 | function (isOverBadLogins, isWayOverBadLogins) { 75 | t.equal(isOverBadLogins, true, 'is now over bad logins') 76 | t.equal(isWayOverBadLogins, false, 'is still not locked out') 77 | t.end() 78 | } 79 | ) 80 | } 81 | ) 82 | } 83 | ) 84 | } 85 | ) 86 | } 87 | ) 88 | } 89 | ) 90 | 91 | test( 92 | 'failed logins expire', 93 | function (t) { 94 | setTimeout( 95 | function () { 96 | mcHelper.badLoginCheck( 97 | function (isOverBadLogins) { 98 | t.equal(isOverBadLogins, false, 'is no longer over bad logins') 99 | t.end() 100 | } 101 | ) 102 | }, 103 | config.limits.rateLimitIntervalSeconds * 1000 104 | ) 105 | } 106 | ) 107 | 108 | test( 109 | 'clear everything', 110 | function (t) { 111 | mcHelper.clearEverything( 112 | function (err) { 113 | t.notOk(err, 'no errors were returned') 114 | t.end() 115 | } 116 | ) 117 | } 118 | ) 119 | 120 | test( 121 | 'too many failed logins from different IPs', 122 | function (t) { 123 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.10' }, 124 | function (err, req, res, obj) { 125 | t.equal(res.statusCode, 200, 'failed login 1') 126 | t.equal(obj.lockout, false, 'not locked out') 127 | 128 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.11' }, 129 | function (err, req, res, obj) { 130 | t.equal(res.statusCode, 200, 'failed login 2') 131 | t.equal(obj.lockout, false, 'not locked out') 132 | 133 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.12' }, 134 | function (err, req, res, obj) { 135 | t.equal(res.statusCode, 200, 'failed login 3') 136 | t.equal(obj.lockout, false, 'not locked out') 137 | 138 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.13' }, 139 | function (err, req, res, obj) { 140 | t.equal(res.statusCode, 200, 'failed login 4') 141 | t.equal(obj.lockout, false, 'not locked out') 142 | 143 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.14' }, 144 | function (err, req, res, obj) { 145 | t.equal(res.statusCode, 200, 'failed login 5') 146 | t.equal(obj.lockout, false, 'locked out') 147 | 148 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.15' }, 149 | function (err, req, res, obj) { 150 | t.equal(res.statusCode, 200, 'failed login 6') 151 | t.equal(obj.lockout, false, 'locked out') 152 | 153 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.16' }, 154 | function (err, req, res, obj) { 155 | t.equal(res.statusCode, 200, 'failed login 7') 156 | t.equal(obj.lockout, false, 'not locked out') 157 | 158 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.17' }, 159 | function (err, req, res, obj) { 160 | t.equal(res.statusCode, 200, 'failed login 8') 161 | t.equal(obj.lockout, false, 'not locked out') 162 | 163 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.18' }, 164 | function (err, req, res, obj) { 165 | t.equal(res.statusCode, 200, 'failed login 9') 166 | t.equal(obj.lockout, false, 'not locked out') 167 | 168 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.19' }, 169 | function (err, req, res, obj) { 170 | t.equal(res.statusCode, 200, 'failed login 10') 171 | t.equal(obj.lockout, false, 'not locked out') 172 | 173 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.20' }, 174 | function (err, req, res, obj) { 175 | t.equal(res.statusCode, 200, 'failed login 11') 176 | t.equal(obj.lockout, false, 'locked out') 177 | 178 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.21' }, 179 | function (err, req, res, obj) { 180 | t.equal(res.statusCode, 200, 'failed login 12') 181 | t.equal(obj.lockout, false, 'locked out') 182 | 183 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.22' }, 184 | function (err, req, res, obj) { 185 | t.equal(res.statusCode, 200, 'failed login 13') 186 | t.equal(obj.lockout, false, 'locked out') 187 | 188 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.23' }, 189 | function (err, req, res, obj) { 190 | t.equal(res.statusCode, 200, 'failed login 14') 191 | t.equal(obj.lockout, false, 'not locked out') 192 | 193 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.24' }, 194 | function (err, req, res, obj) { 195 | t.equal(res.statusCode, 200, 'failed login 15') 196 | t.equal(obj.lockout, false, 'not locked out') 197 | 198 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.25' }, 199 | function (err, req, res, obj) { 200 | t.equal(res.statusCode, 200, 'failed login 16') 201 | t.equal(obj.lockout, false, 'not locked out') 202 | 203 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.26' }, 204 | function (err, req, res, obj) { 205 | t.equal(res.statusCode, 200, 'failed login 17') 206 | t.equal(obj.lockout, false, 'not locked out') 207 | 208 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.27' }, 209 | function (err, req, res, obj) { 210 | t.equal(res.statusCode, 200, 'failed login 18') 211 | t.equal(obj.lockout, false, 'locked out') 212 | 213 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.28' }, 214 | function (err, req, res, obj) { 215 | t.equal(res.statusCode, 200, 'failed login 19') 216 | t.equal(obj.lockout, false, 'locked out') 217 | 218 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.27' }, 219 | function (err, req, res, obj) { 220 | t.equal(res.statusCode, 200, 'failed login 20') 221 | t.equal(obj.lockout, false, 'locked out') 222 | 223 | client.post('/failedLoginAttempt', { email: TEST_EMAIL, ip: '192.0.2.28' }, 224 | function (err, req, res, obj) { 225 | t.equal(res.statusCode, 200, 'failed login 21') 226 | t.equal(obj.lockout, true, 'locked out') 227 | 228 | mcHelper.badLoginCheck( 229 | function (isOverBadLogins, isWayOverBadLogins) { 230 | t.equal(isOverBadLogins, false, 'is still not over bad logins') 231 | t.equal(isWayOverBadLogins, true, 'is now locked out') 232 | t.end() 233 | } 234 | ) 235 | } 236 | ) 237 | } 238 | ) 239 | } 240 | ) 241 | } 242 | ) 243 | } 244 | ) 245 | } 246 | ) 247 | } 248 | ) 249 | } 250 | ) 251 | } 252 | ) 253 | } 254 | ) 255 | } 256 | ) 257 | } 258 | ) 259 | } 260 | ) 261 | } 262 | ) 263 | } 264 | ) 265 | } 266 | ) 267 | } 268 | ) 269 | } 270 | ) 271 | } 272 | ) 273 | } 274 | ) 275 | } 276 | ) 277 | } 278 | ) 279 | 280 | test( 281 | 'teardown', 282 | function (t) { 283 | testServer.stop() 284 | t.equal(testServer.server.killed, true, 'test server has been killed') 285 | t.end() 286 | } 287 | ) 288 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxa-customs-server", 3 | "version": "0.5.0", 4 | "dependencies": { 5 | "ass": { 6 | "version": "0.0.4", 7 | "from": "ass@0.0.4", 8 | "resolved": "https://registry.npmjs.org/ass/-/ass-0.0.4.tgz", 9 | "dependencies": { 10 | "blanket": { 11 | "version": "1.1.6", 12 | "from": "blanket@~1.1.5", 13 | "resolved": "https://registry.npmjs.org/blanket/-/blanket-1.1.6.tgz", 14 | "dependencies": { 15 | "esprima": { 16 | "version": "1.0.4", 17 | "from": "esprima@~1.0.2", 18 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" 19 | }, 20 | "falafel": { 21 | "version": "0.1.6", 22 | "from": "falafel@~0.1.6", 23 | "resolved": "https://registry.npmjs.org/falafel/-/falafel-0.1.6.tgz" 24 | }, 25 | "xtend": { 26 | "version": "2.1.2", 27 | "from": "xtend@~2.1.1", 28 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", 29 | "dependencies": { 30 | "object-keys": { 31 | "version": "0.4.0", 32 | "from": "object-keys@~0.4.0", 33 | "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz" 34 | } 35 | } 36 | } 37 | } 38 | }, 39 | "temp": { 40 | "version": "0.6.0", 41 | "from": "temp@~0.6.0", 42 | "resolved": "https://registry.npmjs.org/temp/-/temp-0.6.0.tgz", 43 | "dependencies": { 44 | "rimraf": { 45 | "version": "2.1.4", 46 | "from": "rimraf@~2.1.4", 47 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.1.4.tgz", 48 | "dependencies": { 49 | "graceful-fs": { 50 | "version": "1.2.3", 51 | "from": "graceful-fs@~1", 52 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" 53 | } 54 | } 55 | }, 56 | "osenv": { 57 | "version": "0.0.3", 58 | "from": "osenv@0.0.3", 59 | "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.0.3.tgz" 60 | } 61 | } 62 | }, 63 | "async": { 64 | "version": "0.2.10", 65 | "from": "async@~0.2.9", 66 | "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" 67 | }, 68 | "cheerio": { 69 | "version": "0.12.4", 70 | "from": "cheerio@~0.12.4", 71 | "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.12.4.tgz", 72 | "dependencies": { 73 | "cheerio-select": { 74 | "version": "0.0.3", 75 | "from": "cheerio-select@*", 76 | "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-0.0.3.tgz", 77 | "dependencies": { 78 | "CSSselect": { 79 | "version": "0.7.0", 80 | "from": "CSSselect@0.x", 81 | "resolved": "https://registry.npmjs.org/CSSselect/-/CSSselect-0.7.0.tgz", 82 | "dependencies": { 83 | "CSSwhat": { 84 | "version": "0.4.7", 85 | "from": "CSSwhat@0.4", 86 | "resolved": "https://registry.npmjs.org/CSSwhat/-/CSSwhat-0.4.7.tgz" 87 | }, 88 | "domutils": { 89 | "version": "1.4.3", 90 | "from": "domutils@1.4", 91 | "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.4.3.tgz", 92 | "dependencies": { 93 | "domelementtype": { 94 | "version": "1.1.3", 95 | "from": "domelementtype@1", 96 | "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz" 97 | } 98 | } 99 | }, 100 | "boolbase": { 101 | "version": "1.0.0", 102 | "from": "boolbase@~1.0.0", 103 | "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" 104 | }, 105 | "nth-check": { 106 | "version": "1.0.0", 107 | "from": "nth-check@~1.0.0", 108 | "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.0.tgz" 109 | } 110 | } 111 | } 112 | } 113 | }, 114 | "htmlparser2": { 115 | "version": "3.1.4", 116 | "from": "htmlparser2@3.1.4", 117 | "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.1.4.tgz", 118 | "dependencies": { 119 | "domhandler": { 120 | "version": "2.0.3", 121 | "from": "domhandler@2.0", 122 | "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.0.3.tgz" 123 | }, 124 | "domutils": { 125 | "version": "1.1.6", 126 | "from": "domutils@1.1", 127 | "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.1.6.tgz" 128 | }, 129 | "domelementtype": { 130 | "version": "1.1.3", 131 | "from": "domelementtype@1", 132 | "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz" 133 | }, 134 | "readable-stream": { 135 | "version": "1.0.33", 136 | "from": "readable-stream@1.0", 137 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", 138 | "dependencies": { 139 | "core-util-is": { 140 | "version": "1.0.1", 141 | "from": "core-util-is@~1.0.0", 142 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" 143 | }, 144 | "isarray": { 145 | "version": "0.0.1", 146 | "from": "isarray@0.0.1", 147 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" 148 | }, 149 | "string_decoder": { 150 | "version": "0.10.31", 151 | "from": "string_decoder@~0.10.x", 152 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" 153 | }, 154 | "inherits": { 155 | "version": "2.0.1", 156 | "from": "inherits@~2.0.1", 157 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 158 | } 159 | } 160 | } 161 | } 162 | }, 163 | "underscore": { 164 | "version": "1.4.4", 165 | "from": "underscore@~1.4", 166 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz" 167 | }, 168 | "entities": { 169 | "version": "0.5.0", 170 | "from": "entities@0.x", 171 | "resolved": "https://registry.npmjs.org/entities/-/entities-0.5.0.tgz" 172 | } 173 | } 174 | } 175 | } 176 | }, 177 | "aws-sdk": { 178 | "version": "2.0.0-rc.20", 179 | "from": "aws-sdk@2.0.0-rc.20", 180 | "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.0.0-rc.20.tgz", 181 | "dependencies": { 182 | "aws-sdk-apis": { 183 | "version": "3.1.10", 184 | "from": "aws-sdk-apis@3.x", 185 | "resolved": "https://registry.npmjs.org/aws-sdk-apis/-/aws-sdk-apis-3.1.10.tgz" 186 | }, 187 | "xml2js": { 188 | "version": "0.2.6", 189 | "from": "xml2js@0.2.6", 190 | "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.6.tgz", 191 | "dependencies": { 192 | "sax": { 193 | "version": "0.4.2", 194 | "from": "sax@0.4.2", 195 | "resolved": "https://registry.npmjs.org/sax/-/sax-0.4.2.tgz" 196 | } 197 | } 198 | }, 199 | "xmlbuilder": { 200 | "version": "0.4.2", 201 | "from": "xmlbuilder@0.4.2", 202 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-0.4.2.tgz" 203 | } 204 | } 205 | }, 206 | "bluebird": { 207 | "version": "1.2.2", 208 | "from": "bluebird@1.2.2", 209 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-1.2.2.tgz" 210 | }, 211 | "bunyan": { 212 | "version": "0.23.1", 213 | "from": "bunyan@0.23.1", 214 | "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-0.23.1.tgz", 215 | "dependencies": { 216 | "mv": { 217 | "version": "2.0.3", 218 | "from": "mv@~2", 219 | "resolved": "https://registry.npmjs.org/mv/-/mv-2.0.3.tgz", 220 | "dependencies": { 221 | "mkdirp": { 222 | "version": "0.5.0", 223 | "from": "mkdirp@~0.5.0", 224 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz", 225 | "dependencies": { 226 | "minimist": { 227 | "version": "0.0.8", 228 | "from": "minimist@0.0.8", 229 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" 230 | } 231 | } 232 | }, 233 | "ncp": { 234 | "version": "0.6.0", 235 | "from": "ncp@~0.6.0", 236 | "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.6.0.tgz" 237 | }, 238 | "rimraf": { 239 | "version": "2.2.8", 240 | "from": "rimraf@~2.2.8", 241 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz" 242 | } 243 | } 244 | }, 245 | "dtrace-provider": { 246 | "version": "0.2.8", 247 | "from": "dtrace-provider@^0.2.8", 248 | "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz" 249 | } 250 | } 251 | }, 252 | "convict": { 253 | "version": "0.6.1", 254 | "from": "convict@0.6.1", 255 | "resolved": "https://registry.npmjs.org/convict/-/convict-0.6.1.tgz", 256 | "dependencies": { 257 | "cjson": { 258 | "version": "0.3.0", 259 | "from": "cjson@0.3.0", 260 | "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.0.tgz", 261 | "dependencies": { 262 | "jsonlint": { 263 | "version": "1.6.0", 264 | "from": "jsonlint@1.6.0", 265 | "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.0.tgz", 266 | "dependencies": { 267 | "nomnom": { 268 | "version": "1.8.1", 269 | "from": "nomnom@>= 1.5.x", 270 | "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.8.1.tgz", 271 | "dependencies": { 272 | "underscore": { 273 | "version": "1.6.0", 274 | "from": "underscore@~1.6.0", 275 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz" 276 | }, 277 | "chalk": { 278 | "version": "0.4.0", 279 | "from": "chalk@~0.4.0", 280 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz", 281 | "dependencies": { 282 | "has-color": { 283 | "version": "0.1.7", 284 | "from": "has-color@~0.1.0", 285 | "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz" 286 | }, 287 | "ansi-styles": { 288 | "version": "1.0.0", 289 | "from": "ansi-styles@~1.0.0", 290 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.0.0.tgz" 291 | }, 292 | "strip-ansi": { 293 | "version": "0.1.1", 294 | "from": "strip-ansi@~0.1.0", 295 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz" 296 | } 297 | } 298 | } 299 | } 300 | }, 301 | "JSV": { 302 | "version": "4.0.2", 303 | "from": "JSV@>= 4.0.x", 304 | "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz" 305 | } 306 | } 307 | } 308 | } 309 | }, 310 | "depd": { 311 | "version": "1.0.0", 312 | "from": "depd@1.0.0", 313 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.0.tgz" 314 | }, 315 | "moment": { 316 | "version": "2.8.4", 317 | "from": "moment@2.8.4", 318 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.8.4.tgz" 319 | }, 320 | "optimist": { 321 | "version": "0.6.1", 322 | "from": "optimist@0.6.1", 323 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 324 | "dependencies": { 325 | "wordwrap": { 326 | "version": "0.0.2", 327 | "from": "wordwrap@~0.0.2", 328 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" 329 | }, 330 | "minimist": { 331 | "version": "0.0.10", 332 | "from": "minimist@~0.0.1", 333 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" 334 | } 335 | } 336 | }, 337 | "validator": { 338 | "version": "3.26.0", 339 | "from": "validator@3.26.0", 340 | "resolved": "https://registry.npmjs.org/validator/-/validator-3.26.0.tgz" 341 | } 342 | } 343 | }, 344 | "grunt": { 345 | "version": "0.4.5", 346 | "from": "grunt@0.4.5", 347 | "resolved": "https://registry.npmjs.org/grunt/-/grunt-0.4.5.tgz", 348 | "dependencies": { 349 | "async": { 350 | "version": "0.1.22", 351 | "from": "async@~0.1.22", 352 | "resolved": "https://registry.npmjs.org/async/-/async-0.1.22.tgz" 353 | }, 354 | "coffee-script": { 355 | "version": "1.3.3", 356 | "from": "coffee-script@~1.3.3", 357 | "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.3.3.tgz" 358 | }, 359 | "colors": { 360 | "version": "0.6.2", 361 | "from": "colors@~0.6.2", 362 | "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz" 363 | }, 364 | "dateformat": { 365 | "version": "1.0.2-1.2.3", 366 | "from": "dateformat@1.0.2-1.2.3", 367 | "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.2-1.2.3.tgz" 368 | }, 369 | "eventemitter2": { 370 | "version": "0.4.14", 371 | "from": "eventemitter2@~0.4.13", 372 | "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz" 373 | }, 374 | "findup-sync": { 375 | "version": "0.1.3", 376 | "from": "findup-sync@~0.1.2", 377 | "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz", 378 | "dependencies": { 379 | "glob": { 380 | "version": "3.2.11", 381 | "from": "glob@~3.2.9", 382 | "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", 383 | "dependencies": { 384 | "inherits": { 385 | "version": "2.0.1", 386 | "from": "inherits@2", 387 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 388 | }, 389 | "minimatch": { 390 | "version": "0.3.0", 391 | "from": "minimatch@0.3", 392 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", 393 | "dependencies": { 394 | "lru-cache": { 395 | "version": "2.5.0", 396 | "from": "lru-cache@2", 397 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 398 | }, 399 | "sigmund": { 400 | "version": "1.0.0", 401 | "from": "sigmund@~1.0.0", 402 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 403 | } 404 | } 405 | } 406 | } 407 | }, 408 | "lodash": { 409 | "version": "2.4.1", 410 | "from": "lodash@~2.4.1", 411 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.1.tgz" 412 | } 413 | } 414 | }, 415 | "glob": { 416 | "version": "3.1.21", 417 | "from": "glob@~3.1.21", 418 | "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz", 419 | "dependencies": { 420 | "graceful-fs": { 421 | "version": "1.2.3", 422 | "from": "graceful-fs@~1", 423 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" 424 | }, 425 | "inherits": { 426 | "version": "1.0.0", 427 | "from": "inherits@1", 428 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.0.tgz" 429 | } 430 | } 431 | }, 432 | "hooker": { 433 | "version": "0.2.3", 434 | "from": "hooker@~0.2.3", 435 | "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz" 436 | }, 437 | "iconv-lite": { 438 | "version": "0.2.11", 439 | "from": "iconv-lite@~0.2.11", 440 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz" 441 | }, 442 | "minimatch": { 443 | "version": "0.2.14", 444 | "from": "minimatch@~0.2.12", 445 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz", 446 | "dependencies": { 447 | "lru-cache": { 448 | "version": "2.5.0", 449 | "from": "lru-cache@2", 450 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 451 | }, 452 | "sigmund": { 453 | "version": "1.0.0", 454 | "from": "sigmund@~1.0.0", 455 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 456 | } 457 | } 458 | }, 459 | "nopt": { 460 | "version": "1.0.10", 461 | "from": "nopt@~1.0.10", 462 | "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", 463 | "dependencies": { 464 | "abbrev": { 465 | "version": "1.0.5", 466 | "from": "abbrev@1", 467 | "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.5.tgz" 468 | } 469 | } 470 | }, 471 | "rimraf": { 472 | "version": "2.2.8", 473 | "from": "rimraf@~2.2.8", 474 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz" 475 | }, 476 | "lodash": { 477 | "version": "0.9.2", 478 | "from": "lodash@~0.9.2", 479 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-0.9.2.tgz" 480 | }, 481 | "underscore.string": { 482 | "version": "2.2.1", 483 | "from": "underscore.string@~2.2.1", 484 | "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.2.1.tgz" 485 | }, 486 | "which": { 487 | "version": "1.0.8", 488 | "from": "which@~1.0.5", 489 | "resolved": "https://registry.npmjs.org/which/-/which-1.0.8.tgz" 490 | }, 491 | "js-yaml": { 492 | "version": "2.0.5", 493 | "from": "js-yaml@~2.0.5", 494 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-2.0.5.tgz", 495 | "dependencies": { 496 | "argparse": { 497 | "version": "0.1.16", 498 | "from": "argparse@~ 0.1.11", 499 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz", 500 | "dependencies": { 501 | "underscore": { 502 | "version": "1.7.0", 503 | "from": "underscore@~1.7.0", 504 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz" 505 | }, 506 | "underscore.string": { 507 | "version": "2.4.0", 508 | "from": "underscore.string@~2.4.0", 509 | "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz" 510 | } 511 | } 512 | }, 513 | "esprima": { 514 | "version": "1.0.4", 515 | "from": "esprima@~ 1.0.2", 516 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" 517 | } 518 | } 519 | }, 520 | "exit": { 521 | "version": "0.1.2", 522 | "from": "exit@~0.1.1", 523 | "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz" 524 | }, 525 | "getobject": { 526 | "version": "0.1.0", 527 | "from": "getobject@~0.1.0", 528 | "resolved": "https://registry.npmjs.org/getobject/-/getobject-0.1.0.tgz" 529 | }, 530 | "grunt-legacy-util": { 531 | "version": "0.2.0", 532 | "from": "grunt-legacy-util@~0.2.0", 533 | "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-0.2.0.tgz" 534 | }, 535 | "grunt-legacy-log": { 536 | "version": "0.1.1", 537 | "from": "grunt-legacy-log@~0.1.0", 538 | "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-0.1.1.tgz", 539 | "dependencies": { 540 | "lodash": { 541 | "version": "2.4.1", 542 | "from": "lodash@~2.4.1", 543 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.1.tgz" 544 | }, 545 | "underscore.string": { 546 | "version": "2.3.3", 547 | "from": "underscore.string@~2.3.3", 548 | "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.3.3.tgz" 549 | } 550 | } 551 | } 552 | } 553 | }, 554 | "grunt-cli": { 555 | "version": "0.1.13", 556 | "from": "grunt-cli@0.1.13", 557 | "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-0.1.13.tgz", 558 | "dependencies": { 559 | "nopt": { 560 | "version": "1.0.10", 561 | "from": "nopt@~1.0.10", 562 | "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", 563 | "dependencies": { 564 | "abbrev": { 565 | "version": "1.0.5", 566 | "from": "abbrev@1", 567 | "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.5.tgz" 568 | } 569 | } 570 | }, 571 | "findup-sync": { 572 | "version": "0.1.3", 573 | "from": "findup-sync@~0.1.0", 574 | "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz", 575 | "dependencies": { 576 | "glob": { 577 | "version": "3.2.11", 578 | "from": "glob@~3.2.9", 579 | "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", 580 | "dependencies": { 581 | "inherits": { 582 | "version": "2.0.1", 583 | "from": "inherits@2", 584 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 585 | }, 586 | "minimatch": { 587 | "version": "0.3.0", 588 | "from": "minimatch@0.3", 589 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", 590 | "dependencies": { 591 | "lru-cache": { 592 | "version": "2.5.0", 593 | "from": "lru-cache@2", 594 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 595 | }, 596 | "sigmund": { 597 | "version": "1.0.0", 598 | "from": "sigmund@~1.0.0", 599 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 600 | } 601 | } 602 | } 603 | } 604 | }, 605 | "lodash": { 606 | "version": "2.4.1", 607 | "from": "lodash@~2.4.1", 608 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.1.tgz" 609 | } 610 | } 611 | }, 612 | "resolve": { 613 | "version": "0.3.1", 614 | "from": "resolve@~0.3.1", 615 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.3.1.tgz" 616 | } 617 | } 618 | }, 619 | "grunt-contrib-jshint": { 620 | "version": "0.10.0", 621 | "from": "grunt-contrib-jshint@0.10.0", 622 | "resolved": "https://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-0.10.0.tgz", 623 | "dependencies": { 624 | "hooker": { 625 | "version": "0.2.3", 626 | "from": "hooker@~0.2.3", 627 | "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz" 628 | } 629 | } 630 | }, 631 | "grunt-copyright": { 632 | "version": "0.1.0", 633 | "from": "grunt-copyright@0.1.0", 634 | "resolved": "https://registry.npmjs.org/grunt-copyright/-/grunt-copyright-0.1.0.tgz" 635 | }, 636 | "grunt-nsp-shrinkwrap": { 637 | "version": "0.0.3", 638 | "from": "grunt-nsp-shrinkwrap@0.0.3", 639 | "resolved": "https://registry.npmjs.org/grunt-nsp-shrinkwrap/-/grunt-nsp-shrinkwrap-0.0.3.tgz", 640 | "dependencies": { 641 | "cli-color": { 642 | "version": "0.2.3", 643 | "from": "cli-color@^0.2.3", 644 | "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-0.2.3.tgz", 645 | "dependencies": { 646 | "es5-ext": { 647 | "version": "0.9.2", 648 | "from": "es5-ext@~0.9.2", 649 | "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.9.2.tgz" 650 | }, 651 | "memoizee": { 652 | "version": "0.2.6", 653 | "from": "memoizee@~0.2.5", 654 | "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.2.6.tgz", 655 | "dependencies": { 656 | "event-emitter": { 657 | "version": "0.2.2", 658 | "from": "event-emitter@~0.2.2", 659 | "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.2.2.tgz" 660 | }, 661 | "next-tick": { 662 | "version": "0.1.0", 663 | "from": "next-tick@0.1.x", 664 | "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.1.0.tgz" 665 | } 666 | } 667 | } 668 | } 669 | }, 670 | "colors": { 671 | "version": "0.6.2", 672 | "from": "colors@^0.6.2", 673 | "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz" 674 | }, 675 | "text-table": { 676 | "version": "0.2.0", 677 | "from": "text-table@^0.2.0", 678 | "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" 679 | } 680 | } 681 | }, 682 | "jshint": { 683 | "version": "2.5.11", 684 | "from": "jshint@2.5.x", 685 | "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.5.11.tgz", 686 | "dependencies": { 687 | "cli": { 688 | "version": "0.6.5", 689 | "from": "cli@0.6.x", 690 | "resolved": "https://registry.npmjs.org/cli/-/cli-0.6.5.tgz", 691 | "dependencies": { 692 | "glob": { 693 | "version": "3.2.11", 694 | "from": "glob@~ 3.2.1", 695 | "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", 696 | "dependencies": { 697 | "inherits": { 698 | "version": "2.0.1", 699 | "from": "inherits@2", 700 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 701 | }, 702 | "minimatch": { 703 | "version": "0.3.0", 704 | "from": "minimatch@0.3", 705 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", 706 | "dependencies": { 707 | "lru-cache": { 708 | "version": "2.5.0", 709 | "from": "lru-cache@2", 710 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 711 | }, 712 | "sigmund": { 713 | "version": "1.0.0", 714 | "from": "sigmund@~1.0.0", 715 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 716 | } 717 | } 718 | } 719 | } 720 | } 721 | } 722 | }, 723 | "console-browserify": { 724 | "version": "1.1.0", 725 | "from": "console-browserify@1.1.x", 726 | "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", 727 | "dependencies": { 728 | "date-now": { 729 | "version": "0.1.4", 730 | "from": "date-now@^0.1.4", 731 | "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz" 732 | } 733 | } 734 | }, 735 | "exit": { 736 | "version": "0.1.2", 737 | "from": "exit@0.1.x", 738 | "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz" 739 | }, 740 | "htmlparser2": { 741 | "version": "3.8.2", 742 | "from": "htmlparser2@3.8.x", 743 | "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.2.tgz", 744 | "dependencies": { 745 | "domhandler": { 746 | "version": "2.3.0", 747 | "from": "domhandler@2.3", 748 | "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz" 749 | }, 750 | "domutils": { 751 | "version": "1.5.1", 752 | "from": "domutils@1.5", 753 | "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", 754 | "dependencies": { 755 | "dom-serializer": { 756 | "version": "0.0.1", 757 | "from": "dom-serializer@0", 758 | "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.0.1.tgz", 759 | "dependencies": { 760 | "entities": { 761 | "version": "1.1.1", 762 | "from": "entities@~1.1.1", 763 | "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz" 764 | } 765 | } 766 | } 767 | } 768 | }, 769 | "domelementtype": { 770 | "version": "1.1.3", 771 | "from": "domelementtype@1", 772 | "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz" 773 | }, 774 | "readable-stream": { 775 | "version": "1.1.13", 776 | "from": "readable-stream@1.1", 777 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", 778 | "dependencies": { 779 | "core-util-is": { 780 | "version": "1.0.1", 781 | "from": "core-util-is@~1.0.0", 782 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" 783 | }, 784 | "isarray": { 785 | "version": "0.0.1", 786 | "from": "isarray@0.0.1", 787 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" 788 | }, 789 | "string_decoder": { 790 | "version": "0.10.31", 791 | "from": "string_decoder@~0.10.x", 792 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" 793 | }, 794 | "inherits": { 795 | "version": "2.0.1", 796 | "from": "inherits@~2.0.1", 797 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 798 | } 799 | } 800 | }, 801 | "entities": { 802 | "version": "1.0.0", 803 | "from": "entities@1.0", 804 | "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz" 805 | } 806 | } 807 | }, 808 | "minimatch": { 809 | "version": "1.0.0", 810 | "from": "minimatch@1.0.x", 811 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-1.0.0.tgz", 812 | "dependencies": { 813 | "lru-cache": { 814 | "version": "2.5.0", 815 | "from": "lru-cache@2", 816 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 817 | }, 818 | "sigmund": { 819 | "version": "1.0.0", 820 | "from": "sigmund@~1.0.0", 821 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 822 | } 823 | } 824 | }, 825 | "shelljs": { 826 | "version": "0.3.0", 827 | "from": "shelljs@0.3.x", 828 | "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz" 829 | }, 830 | "strip-json-comments": { 831 | "version": "1.0.2", 832 | "from": "strip-json-comments@1.0.x", 833 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.2.tgz" 834 | }, 835 | "underscore": { 836 | "version": "1.6.0", 837 | "from": "underscore@1.6.x", 838 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz" 839 | } 840 | } 841 | }, 842 | "jshint-stylish": { 843 | "version": "0.4.0", 844 | "from": "jshint-stylish@0.4.0", 845 | "resolved": "https://registry.npmjs.org/jshint-stylish/-/jshint-stylish-0.4.0.tgz", 846 | "dependencies": { 847 | "chalk": { 848 | "version": "0.5.1", 849 | "from": "chalk@^0.5.1", 850 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz", 851 | "dependencies": { 852 | "ansi-styles": { 853 | "version": "1.1.0", 854 | "from": "ansi-styles@^1.1.0", 855 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz" 856 | }, 857 | "escape-string-regexp": { 858 | "version": "1.0.2", 859 | "from": "escape-string-regexp@^1.0.0", 860 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz" 861 | }, 862 | "has-ansi": { 863 | "version": "0.1.0", 864 | "from": "has-ansi@^0.1.0", 865 | "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz", 866 | "dependencies": { 867 | "ansi-regex": { 868 | "version": "0.2.1", 869 | "from": "ansi-regex@^0.2.0", 870 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" 871 | } 872 | } 873 | }, 874 | "strip-ansi": { 875 | "version": "0.3.0", 876 | "from": "strip-ansi@^0.3.0", 877 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz", 878 | "dependencies": { 879 | "ansi-regex": { 880 | "version": "0.2.1", 881 | "from": "ansi-regex@^0.2.0", 882 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" 883 | } 884 | } 885 | }, 886 | "supports-color": { 887 | "version": "0.2.0", 888 | "from": "supports-color@^0.2.0", 889 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz" 890 | } 891 | } 892 | }, 893 | "log-symbols": { 894 | "version": "1.0.1", 895 | "from": "log-symbols@^1.0.0", 896 | "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.1.tgz" 897 | }, 898 | "text-table": { 899 | "version": "0.2.0", 900 | "from": "text-table@^0.2.0", 901 | "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" 902 | } 903 | } 904 | }, 905 | "memcached": { 906 | "version": "0.2.8", 907 | "from": "memcached@0.2.8", 908 | "resolved": "https://registry.npmjs.org/memcached/-/memcached-0.2.8.tgz", 909 | "dependencies": { 910 | "hashring": { 911 | "version": "0.0.8", 912 | "from": "hashring@0.0.x", 913 | "resolved": "https://registry.npmjs.org/hashring/-/hashring-0.0.8.tgz", 914 | "dependencies": { 915 | "bisection": { 916 | "version": "0.0.3", 917 | "from": "bisection@", 918 | "resolved": "https://registry.npmjs.org/bisection/-/bisection-0.0.3.tgz" 919 | }, 920 | "simple-lru-cache": { 921 | "version": "0.0.1", 922 | "from": "simple-lru-cache@0.0.x", 923 | "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.1.tgz" 924 | } 925 | } 926 | }, 927 | "jackpot": { 928 | "version": "0.0.6", 929 | "from": "jackpot@>=0.0.6", 930 | "resolved": "https://registry.npmjs.org/jackpot/-/jackpot-0.0.6.tgz", 931 | "dependencies": { 932 | "retry": { 933 | "version": "0.6.0", 934 | "from": "retry@0.6.0", 935 | "resolved": "https://registry.npmjs.org/retry/-/retry-0.6.0.tgz" 936 | } 937 | } 938 | } 939 | } 940 | }, 941 | "request": { 942 | "version": "2.40.0", 943 | "from": "request@2.40.0", 944 | "resolved": "https://registry.npmjs.org/request/-/request-2.40.0.tgz", 945 | "dependencies": { 946 | "qs": { 947 | "version": "1.0.2", 948 | "from": "qs@~1.0.0", 949 | "resolved": "https://registry.npmjs.org/qs/-/qs-1.0.2.tgz" 950 | }, 951 | "json-stringify-safe": { 952 | "version": "5.0.0", 953 | "from": "json-stringify-safe@~5.0.0", 954 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.0.tgz" 955 | }, 956 | "mime-types": { 957 | "version": "1.0.2", 958 | "from": "mime-types@~1.0.1", 959 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-1.0.2.tgz" 960 | }, 961 | "forever-agent": { 962 | "version": "0.5.2", 963 | "from": "forever-agent@~0.5.0", 964 | "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.5.2.tgz" 965 | }, 966 | "node-uuid": { 967 | "version": "1.4.2", 968 | "from": "node-uuid@~1.4.0", 969 | "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.2.tgz" 970 | }, 971 | "tough-cookie": { 972 | "version": "0.12.1", 973 | "from": "tough-cookie@>=0.12.0", 974 | "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-0.12.1.tgz", 975 | "dependencies": { 976 | "punycode": { 977 | "version": "1.3.2", 978 | "from": "punycode@>=0.2.0", 979 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz" 980 | } 981 | } 982 | }, 983 | "form-data": { 984 | "version": "0.1.4", 985 | "from": "form-data@~0.1.0", 986 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-0.1.4.tgz", 987 | "dependencies": { 988 | "combined-stream": { 989 | "version": "0.0.7", 990 | "from": "combined-stream@~0.0.4", 991 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-0.0.7.tgz", 992 | "dependencies": { 993 | "delayed-stream": { 994 | "version": "0.0.5", 995 | "from": "delayed-stream@0.0.5", 996 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.5.tgz" 997 | } 998 | } 999 | }, 1000 | "mime": { 1001 | "version": "1.2.11", 1002 | "from": "mime@~1.2.11", 1003 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz" 1004 | }, 1005 | "async": { 1006 | "version": "0.9.0", 1007 | "from": "async@~0.9.0", 1008 | "resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz" 1009 | } 1010 | } 1011 | }, 1012 | "tunnel-agent": { 1013 | "version": "0.4.0", 1014 | "from": "tunnel-agent@~0.4.0", 1015 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.0.tgz" 1016 | }, 1017 | "http-signature": { 1018 | "version": "0.10.1", 1019 | "from": "http-signature@~0.10.0", 1020 | "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.10.1.tgz", 1021 | "dependencies": { 1022 | "assert-plus": { 1023 | "version": "0.1.5", 1024 | "from": "assert-plus@^0.1.5", 1025 | "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" 1026 | }, 1027 | "asn1": { 1028 | "version": "0.1.11", 1029 | "from": "asn1@0.1.11", 1030 | "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.1.11.tgz" 1031 | }, 1032 | "ctype": { 1033 | "version": "0.5.3", 1034 | "from": "ctype@0.5.3", 1035 | "resolved": "https://registry.npmjs.org/ctype/-/ctype-0.5.3.tgz" 1036 | } 1037 | } 1038 | }, 1039 | "oauth-sign": { 1040 | "version": "0.3.0", 1041 | "from": "oauth-sign@~0.3.0", 1042 | "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.3.0.tgz" 1043 | }, 1044 | "hawk": { 1045 | "version": "1.1.1", 1046 | "from": "hawk@1.1.1", 1047 | "resolved": "https://registry.npmjs.org/hawk/-/hawk-1.1.1.tgz", 1048 | "dependencies": { 1049 | "hoek": { 1050 | "version": "0.9.1", 1051 | "from": "hoek@0.9.x", 1052 | "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.9.1.tgz" 1053 | }, 1054 | "boom": { 1055 | "version": "0.4.2", 1056 | "from": "boom@0.4.x", 1057 | "resolved": "https://registry.npmjs.org/boom/-/boom-0.4.2.tgz" 1058 | }, 1059 | "cryptiles": { 1060 | "version": "0.2.2", 1061 | "from": "cryptiles@0.2.x", 1062 | "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-0.2.2.tgz" 1063 | }, 1064 | "sntp": { 1065 | "version": "0.2.4", 1066 | "from": "sntp@0.2.x", 1067 | "resolved": "https://registry.npmjs.org/sntp/-/sntp-0.2.4.tgz" 1068 | } 1069 | } 1070 | }, 1071 | "aws-sign2": { 1072 | "version": "0.5.0", 1073 | "from": "aws-sign2@~0.5.0", 1074 | "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.5.0.tgz" 1075 | }, 1076 | "stringstream": { 1077 | "version": "0.0.4", 1078 | "from": "stringstream@~0.0.4", 1079 | "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.4.tgz" 1080 | } 1081 | } 1082 | }, 1083 | "restify": { 1084 | "version": "2.8.2", 1085 | "from": "restify@2.8.2", 1086 | "resolved": "https://registry.npmjs.org/restify/-/restify-2.8.2.tgz", 1087 | "dependencies": { 1088 | "assert-plus": { 1089 | "version": "0.1.5", 1090 | "from": "assert-plus@^0.1.5", 1091 | "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" 1092 | }, 1093 | "backoff": { 1094 | "version": "2.4.1", 1095 | "from": "backoff@^2.3.0", 1096 | "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.4.1.tgz", 1097 | "dependencies": { 1098 | "precond": { 1099 | "version": "0.2.3", 1100 | "from": "precond@0.2", 1101 | "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz" 1102 | } 1103 | } 1104 | }, 1105 | "csv": { 1106 | "version": "0.4.1", 1107 | "from": "csv@^0.4.0", 1108 | "resolved": "https://registry.npmjs.org/csv/-/csv-0.4.1.tgz", 1109 | "dependencies": { 1110 | "csv-generate": { 1111 | "version": "0.0.4", 1112 | "from": "csv-generate@*", 1113 | "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-0.0.4.tgz" 1114 | }, 1115 | "csv-parse": { 1116 | "version": "0.0.6", 1117 | "from": "csv-parse@*", 1118 | "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-0.0.6.tgz" 1119 | }, 1120 | "stream-transform": { 1121 | "version": "0.0.6", 1122 | "from": "stream-transform@*", 1123 | "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-0.0.6.tgz" 1124 | }, 1125 | "csv-stringify": { 1126 | "version": "0.0.6", 1127 | "from": "csv-stringify@*", 1128 | "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-0.0.6.tgz" 1129 | } 1130 | } 1131 | }, 1132 | "deep-equal": { 1133 | "version": "0.2.2", 1134 | "from": "deep-equal@^0.2.1", 1135 | "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-0.2.2.tgz" 1136 | }, 1137 | "escape-regexp-component": { 1138 | "version": "1.0.2", 1139 | "from": "escape-regexp-component@^1.0.2", 1140 | "resolved": "https://registry.npmjs.org/escape-regexp-component/-/escape-regexp-component-1.0.2.tgz" 1141 | }, 1142 | "formidable": { 1143 | "version": "1.0.16", 1144 | "from": "formidable@^1.0.14", 1145 | "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.0.16.tgz" 1146 | }, 1147 | "http-signature": { 1148 | "version": "0.10.1", 1149 | "from": "http-signature@^0.10.0", 1150 | "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.10.1.tgz", 1151 | "dependencies": { 1152 | "asn1": { 1153 | "version": "0.1.11", 1154 | "from": "asn1@0.1.11", 1155 | "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.1.11.tgz" 1156 | }, 1157 | "ctype": { 1158 | "version": "0.5.3", 1159 | "from": "ctype@0.5.3", 1160 | "resolved": "https://registry.npmjs.org/ctype/-/ctype-0.5.3.tgz" 1161 | } 1162 | } 1163 | }, 1164 | "keep-alive-agent": { 1165 | "version": "0.0.1", 1166 | "from": "keep-alive-agent@^0.0.1", 1167 | "resolved": "https://registry.npmjs.org/keep-alive-agent/-/keep-alive-agent-0.0.1.tgz" 1168 | }, 1169 | "lru-cache": { 1170 | "version": "2.5.0", 1171 | "from": "lru-cache@^2.5.0", 1172 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 1173 | }, 1174 | "mime": { 1175 | "version": "1.3.4", 1176 | "from": "mime@^1.2.11", 1177 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" 1178 | }, 1179 | "negotiator": { 1180 | "version": "0.4.9", 1181 | "from": "negotiator@^0.4.5", 1182 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.4.9.tgz" 1183 | }, 1184 | "node-uuid": { 1185 | "version": "1.4.2", 1186 | "from": "node-uuid@^1.4.1", 1187 | "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.2.tgz" 1188 | }, 1189 | "once": { 1190 | "version": "1.3.1", 1191 | "from": "once@^1.3.0", 1192 | "resolved": "https://registry.npmjs.org/once/-/once-1.3.1.tgz", 1193 | "dependencies": { 1194 | "wrappy": { 1195 | "version": "1.0.1", 1196 | "from": "wrappy@1", 1197 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.1.tgz" 1198 | } 1199 | } 1200 | }, 1201 | "qs": { 1202 | "version": "1.2.2", 1203 | "from": "qs@^1.0.0", 1204 | "resolved": "https://registry.npmjs.org/qs/-/qs-1.2.2.tgz" 1205 | }, 1206 | "semver": { 1207 | "version": "2.3.2", 1208 | "from": "semver@^2.3.0", 1209 | "resolved": "https://registry.npmjs.org/semver/-/semver-2.3.2.tgz" 1210 | }, 1211 | "spdy": { 1212 | "version": "1.30.1", 1213 | "from": "spdy@^1.26.5", 1214 | "resolved": "https://registry.npmjs.org/spdy/-/spdy-1.30.1.tgz" 1215 | }, 1216 | "tunnel-agent": { 1217 | "version": "0.4.0", 1218 | "from": "tunnel-agent@^0.4.0", 1219 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.0.tgz" 1220 | }, 1221 | "verror": { 1222 | "version": "1.6.0", 1223 | "from": "verror@^1.4.0", 1224 | "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", 1225 | "dependencies": { 1226 | "extsprintf": { 1227 | "version": "1.2.0", 1228 | "from": "extsprintf@1.2.0", 1229 | "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz" 1230 | } 1231 | } 1232 | }, 1233 | "dtrace-provider": { 1234 | "version": "0.2.8", 1235 | "from": "dtrace-provider@^0.2.8", 1236 | "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz" 1237 | } 1238 | } 1239 | }, 1240 | "tap": { 1241 | "version": "0.4.12", 1242 | "from": "tap@0.4.12", 1243 | "resolved": "https://registry.npmjs.org/tap/-/tap-0.4.12.tgz", 1244 | "dependencies": { 1245 | "buffer-equal": { 1246 | "version": "0.0.1", 1247 | "from": "buffer-equal@~0.0.0", 1248 | "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz" 1249 | }, 1250 | "deep-equal": { 1251 | "version": "0.0.0", 1252 | "from": "deep-equal@~0.0.0", 1253 | "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-0.0.0.tgz" 1254 | }, 1255 | "difflet": { 1256 | "version": "0.2.6", 1257 | "from": "difflet@~0.2.0", 1258 | "resolved": "https://registry.npmjs.org/difflet/-/difflet-0.2.6.tgz", 1259 | "dependencies": { 1260 | "traverse": { 1261 | "version": "0.6.6", 1262 | "from": "traverse@0.6.x", 1263 | "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz" 1264 | }, 1265 | "charm": { 1266 | "version": "0.1.2", 1267 | "from": "charm@0.1.x", 1268 | "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz" 1269 | }, 1270 | "deep-is": { 1271 | "version": "0.1.3", 1272 | "from": "deep-is@0.1.x", 1273 | "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz" 1274 | } 1275 | } 1276 | }, 1277 | "glob": { 1278 | "version": "3.2.11", 1279 | "from": "glob@~3.2.1", 1280 | "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", 1281 | "dependencies": { 1282 | "inherits": { 1283 | "version": "2.0.1", 1284 | "from": "inherits@2", 1285 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 1286 | }, 1287 | "minimatch": { 1288 | "version": "0.3.0", 1289 | "from": "minimatch@0.3", 1290 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", 1291 | "dependencies": { 1292 | "lru-cache": { 1293 | "version": "2.5.0", 1294 | "from": "lru-cache@2", 1295 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.5.0.tgz" 1296 | }, 1297 | "sigmund": { 1298 | "version": "1.0.0", 1299 | "from": "sigmund@~1.0.0", 1300 | "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" 1301 | } 1302 | } 1303 | } 1304 | } 1305 | }, 1306 | "inherits": { 1307 | "version": "2.0.1", 1308 | "from": "inherits@*" 1309 | }, 1310 | "mkdirp": { 1311 | "version": "0.5.0", 1312 | "from": "mkdirp@~0.3 || 0.4 || 0.5", 1313 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz", 1314 | "dependencies": { 1315 | "minimist": { 1316 | "version": "0.0.8", 1317 | "from": "minimist@0.0.8", 1318 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" 1319 | } 1320 | } 1321 | }, 1322 | "nopt": { 1323 | "version": "2.2.1", 1324 | "from": "nopt@~2", 1325 | "resolved": "https://registry.npmjs.org/nopt/-/nopt-2.2.1.tgz", 1326 | "dependencies": { 1327 | "abbrev": { 1328 | "version": "1.0.5", 1329 | "from": "abbrev@1", 1330 | "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.5.tgz" 1331 | } 1332 | } 1333 | }, 1334 | "runforcover": { 1335 | "version": "0.0.2", 1336 | "from": "runforcover@~0.0.2", 1337 | "resolved": "https://registry.npmjs.org/runforcover/-/runforcover-0.0.2.tgz", 1338 | "dependencies": { 1339 | "bunker": { 1340 | "version": "0.1.2", 1341 | "from": "bunker@0.1.X", 1342 | "resolved": "https://registry.npmjs.org/bunker/-/bunker-0.1.2.tgz", 1343 | "dependencies": { 1344 | "burrito": { 1345 | "version": "0.2.12", 1346 | "from": "burrito@>=0.2.5 <0.3", 1347 | "resolved": "https://registry.npmjs.org/burrito/-/burrito-0.2.12.tgz", 1348 | "dependencies": { 1349 | "traverse": { 1350 | "version": "0.5.2", 1351 | "from": "traverse@~0.5.1", 1352 | "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.5.2.tgz" 1353 | }, 1354 | "uglify-js": { 1355 | "version": "1.1.1", 1356 | "from": "uglify-js@~1.1.1", 1357 | "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.1.1.tgz" 1358 | } 1359 | } 1360 | } 1361 | } 1362 | } 1363 | } 1364 | }, 1365 | "slide": { 1366 | "version": "1.1.6", 1367 | "from": "slide@*", 1368 | "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz" 1369 | }, 1370 | "yamlish": { 1371 | "version": "0.0.5", 1372 | "from": "yamlish@*" 1373 | } 1374 | } 1375 | }, 1376 | "walk": { 1377 | "version": "2.3.9", 1378 | "from": "walk@2.3.x", 1379 | "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz", 1380 | "dependencies": { 1381 | "foreachasync": { 1382 | "version": "3.0.0", 1383 | "from": "foreachasync@^3.0.0", 1384 | "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz" 1385 | } 1386 | } 1387 | } 1388 | } 1389 | } 1390 | --------------------------------------------------------------------------------