Merge pull request #200 from mozilla/pushonempty-rejection

fix(settings): pushOnMissing no longer updates on unexpected errors
This commit is contained in:
Sean McArthur 2017-04-25 15:38:03 -07:00 коммит произвёл GitHub
Родитель e2f206cda1 a720749931
Коммит 3f03e431f3
12 изменённых файлов: 417 добавлений и 335 удалений

Просмотреть файл

@ -1,68 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
module.exports = function (config, mc, log) {
var pollInterval = null
function AllowedEmailDomains(domains) {
this.setAll(domains)
}
AllowedEmailDomains.prototype.isAllowed = function (email) {
var match = /^.+@(.+)$/.exec(email)
return match ? this.domains[match[1]] : false
}
AllowedEmailDomains.prototype.setAll = function (domains) {
this.domains = {}
for (var i = 0; i < domains.length; i++) {
this.domains[domains[i]] = true
}
return Object.keys(this.domains)
}
AllowedEmailDomains.prototype.push = function () {
log.info({ op: 'allowedEmailDomains.push' })
return mc.setAsync('allowedEmailDomains', Object.keys(this.domains), 0)
.then(this.refresh.bind(this))
}
AllowedEmailDomains.prototype.refresh = function (options) {
log.info({ op: 'allowedEmailDomains.refresh' })
var result = mc.getAsync('allowedEmailDomains').then(validate)
if (options && options.pushOnMissing) {
result = result.catch(this.push.bind(this))
}
return result.then(
this.setAll.bind(this),
function (err) {
log.error({ op: 'allowedEmailDomains.refresh', err: err })
}
)
}
AllowedEmailDomains.prototype.pollForUpdates = function () {
this.stopPolling()
pollInterval = setInterval(this.refresh.bind(this), config.updatePollIntervalSeconds * 1000)
pollInterval.unref()
}
AllowedEmailDomains.prototype.stopPolling = function () {
clearInterval(pollInterval)
}
function validate(domains) {
if (!Array.isArray(domains)) {
log.error({ op: 'allowedEmailDomains.validate.invalid', data: domains })
throw new Error('invalid allowedEmailDomains from memcache')
}
return domains
}
var allowedEmailDomains = new AllowedEmailDomains(config.allowedEmailDomains || [])
return allowedEmailDomains
}

Просмотреть файл

@ -1,70 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var net = require('net')
module.exports = function (config, mc, log) {
var pollInterval = null
function AllowedIPs(ips) {
this.setAll(ips)
}
AllowedIPs.prototype.setAll = function (ips) {
this.ips = {}
for (var i = 0; i < ips.length; i++) {
this.ips[ips[i]] = true
}
return Object.keys(this.ips)
}
AllowedIPs.prototype.push = function () {
log.info({ op: 'allowedIPs.push' })
return mc.setAsync('allowedIPs', Object.keys(this.ips), 0)
.then(this.refresh.bind(this))
}
AllowedIPs.prototype.refresh = function (options) {
log.info({ op: 'allowedIPs.refresh' })
var result = mc.getAsync('allowedIPs').then(validate)
if (options && options.pushOnMissing) {
result = result.catch(this.push.bind(this))
}
return result.then(
this.setAll.bind(this),
function (err) {
log.error({ op: 'allowedIPs.refresh', err: err })
}
)
}
AllowedIPs.prototype.pollForUpdates = function () {
this.stopPolling()
pollInterval = setInterval(this.refresh.bind(this), config.updatePollIntervalSeconds * 1000)
pollInterval.unref()
}
AllowedIPs.prototype.stopPolling = function () {
clearInterval(pollInterval)
}
function validate(ips) {
if (!Array.isArray(ips)) {
log.error({ op: 'allowedIPs.validate.invalid', data: ips })
throw new Error('invalid allowedIPs from memcache')
}
return ips.filter(function (ip) {
var is = net.isIPv4(ip)
if (!is) {
log.error({ op: 'allowedIPs.validate.err', ip: ip })
}
return is
})
}
var allowedIPs = new AllowedIPs(config.allowedIPs || [])
return allowedIPs
}

Просмотреть файл

@ -1,99 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var deepEqual = require('deep-equal')
module.exports = function (config, mc, log) {
var pollInterval = null
function Limits(settings) {
this.setAll(settings)
}
Limits.prototype.setAll = function (settings) {
this.blockIntervalSeconds = settings.blockIntervalSeconds
this.blockIntervalMs = settings.blockIntervalSeconds * 1000
this.rateLimitIntervalSeconds = settings.rateLimitIntervalSeconds
this.rateLimitIntervalMs = settings.rateLimitIntervalSeconds * 1000
this.maxEmails = settings.maxEmails
this.maxBadLogins = settings.maxBadLogins
this.maxBadLoginsPerIp = settings.maxBadLoginsPerIp
this.maxUnblockAttempts = settings.maxUnblockAttempts
this.maxVerifyCodes = settings.maxVerifyCodes
this.ipRateLimitIntervalSeconds = settings.ipRateLimitIntervalSeconds
this.ipRateLimitIntervalMs = settings.ipRateLimitIntervalSeconds * 1000
this.ipRateLimitBanDurationSeconds = settings.ipRateLimitBanDurationSeconds
this.ipRateLimitBanDurationMs = settings.ipRateLimitBanDurationSeconds * 1000
this.maxAccountStatusCheck = settings.maxAccountStatusCheck
this.badLoginErrnoWeights = settings.badLoginErrnoWeights || {}
this.uidRateLimit = settings.uidRateLimit || {}
this.maxChecksPerUid = this.uidRateLimit.maxChecks
this.uidRateLimitBanDurationMs = this.uidRateLimit.banDurationSeconds * 1000
this.uidRateLimitIntervalMs = this.uidRateLimit.limitIntervalSeconds * 1000
this.smsRateLimit = settings.smsRateLimit || {}
this.maxSms = settings.smsRateLimit.maxSms
this.smsRateLimitIntervalSeconds = this.smsRateLimit.limitIntervalSeconds
this.smsRateLimitIntervalMs = this.smsRateLimitIntervalSeconds * 1000
return this
}
Limits.prototype.push = function () {
log.info({ op: 'limits.push' })
return mc.setAsync('limits', this, 0)
.then(this.refresh.bind(this))
}
Limits.prototype.refresh = function (options) {
log.info({ op: 'limits.refresh' })
var result = mc.getAsync('limits').then(validate)
if (options && options.pushOnMissing) {
result = result.catch(this.push.bind(this))
}
return result.then(
this.setAll.bind(this),
function (err) {
log.error({ op: 'limits.refresh', err: err })
}
)
}
Limits.prototype.pollForUpdates = function () {
this.stopPolling()
pollInterval = setInterval(this.refresh.bind(this), config.updatePollIntervalSeconds * 1000)
pollInterval.unref()
}
Limits.prototype.stopPolling = function () {
clearInterval(pollInterval)
}
var limits = new Limits(config.limits)
function validate(settings) {
if (typeof(settings) !== 'object') {
log.error({ op: 'limits.validate.invalid', data: settings })
throw new Error('invalid limits from memcache')
}
var keys = Object.keys(config.limits)
for (var i = 0; i < keys.length; i++) {
var key = keys[i]
var current = limits[key]
var future = settings[key]
if (typeof(current) !== typeof(future)) {
log.error({ op: 'limits.validate.err', key: key, message: 'types do not match'})
settings[key] = current
}
else if (!deepEqual(current, future)) {
log.info({ op: 'limits.validate.changed', key: key, current: current, future: future })
}
}
return settings
}
return limits
}

Просмотреть файл

@ -1,82 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var deepEqual = require('deep-equal')
module.exports = function (config, mc, log) {
var requestChecks = null
var pollInterval = null
function RequestChecks(settings) {
this.setAll(settings)
}
RequestChecks.prototype.setAll = function (settings) {
this.treatEveryoneWithSuspicion = settings.treatEveryoneWithSuspicion
// The private branch puts some additional private config here.
return this
}
RequestChecks.prototype.push = function () {
log.info({ op: 'requestChecks.push' })
return mc.setAsync('requestChecks', this, 0)
.then(this.refresh.bind(this))
}
RequestChecks.prototype.refresh = function (options) {
log.info({ op: 'requestChecks.refresh' })
var result = mc.getAsync('requestChecks').then(validateAndMerge)
if (options && options.pushOnMissing) {
result = result.catch(this.push.bind(this))
}
return result.then(
this.setAll.bind(this),
function (err) {
log.error({ op: 'requestChecks.refresh', err: err })
}
)
}
RequestChecks.prototype.pollForUpdates = function () {
this.stopPolling()
pollInterval = setInterval(this.refresh.bind(this), config.updatePollIntervalSeconds * 1000)
pollInterval.unref()
}
RequestChecks.prototype.stopPolling = function () {
clearInterval(pollInterval)
}
// Type-checks updates to the settings, and merges them
// with the current values, modying its argument in-place.
function validateAndMerge(settings) {
if (typeof(settings) !== 'object') {
log.error({ op: 'requestChecks.validate.invalid', data: settings })
throw new Error('invalid requestChecks from memcache')
}
var keys = Object.keys(config.requestChecks)
for (var i = 0; i < keys.length; i++) {
var key = keys[i]
var current = requestChecks[key]
var future = settings[key]
if (typeof(future) === 'undefined') {
settings[key] = current
}
else if (typeof(current) !== typeof(future)) {
log.error({ op: 'requestChecks.validate.err', key: key, message: 'types do not match' })
settings[key] = current
}
else if (!deepEqual(current, future)) {
log.info({ op: 'requestChecks.validate.changed', key: key, current: current, future: future })
}
}
return settings
}
requestChecks = new RequestChecks(config.requestChecks)
return requestChecks
}

Просмотреть файл

@ -4,6 +4,8 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
var Memcached = require('memcached')
var restify = require('restify')
var safeJsonFormatter = require('restify-safe-json-formatter')
@ -40,20 +42,22 @@ module.exports = function createServer(config, log) {
)
var reputationService = require('./reputationService')(config, log)
var limits = require('./limits')(config, mc, log)
var allowedIPs = require('./allowed_ips')(config, mc, log)
var allowedEmailDomains = require('./allowed_email_domains')(config, mc, log)
var requestChecks = require('./requestChecks')(config, mc, log)
const Settings = require('./settings/settings')(config, mc, log)
var limits = require('./settings/limits')(config, Settings, log)
var allowedIPs = require('./settings/allowed_ips')(config, Settings, log)
var allowedEmailDomains = require('./settings/allowed_email_domains')(config, Settings, log)
var requestChecks = require('./settings/requestChecks')(config, Settings, log)
if (config.updatePollIntervalSeconds) {
limits.refresh({ pushOnMissing: true })
limits.pollForUpdates()
allowedIPs.refresh({ pushOnMissing: true })
allowedIPs.pollForUpdates()
allowedEmailDomains.refresh({ pushOnMissing: true })
allowedEmailDomains.pollForUpdates()
requestChecks.refresh({ pushOnMissing: true })
requestChecks.pollForUpdates()
[
allowedEmailDomains,
allowedIPs,
limits,
requestChecks
].forEach(settings => {
settings.refresh({ pushOnMissing: true }).catch(() => {})
settings.pollForUpdates()
})
}
var IpEmailRecord = require('./ip_email_record')(limits)

Просмотреть файл

@ -0,0 +1,43 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
module.exports = (config, Settings, log) => {
class AllowedEmailDomains extends Settings {
constructor(domains) {
super('allowedEmailDomains')
this.setAll(domains)
}
isAllowed(email) {
var match = /^.+@(.+)$/.exec(email)
return match ? this.domains[match[1]] : false
}
setAll(domains) {
this.domains = {}
for (var i = 0; i < domains.length; i++) {
this.domains[domains[i]] = true
}
return Object.keys(this.domains)
}
validate(domains) {
if (!Array.isArray(domains)) {
log.error({ op: 'allowedEmailDomains.validate.invalid', data: domains })
throw new Settings.Missing('invalid allowedEmailDomains from memcache')
}
return domains
}
toJSON() {
return Object.keys(this.domains)
}
}
return new AllowedEmailDomains(config.allowedEmailDomains || [])
}

Просмотреть файл

@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const net = require('net')
module.exports = (config, Settings, log) => {
class AllowedIPs extends Settings {
constructor(ips) {
super('allowedIPs')
this.setAll(ips)
}
setAll(ips) {
this.ips = {}
for (var i = 0; i < ips.length; i++) {
this.ips[ips[i]] = true
}
return Object.keys(this.ips)
}
validate(ips) {
if (!Array.isArray(ips)) {
log.error({ op: 'allowedIPs.validate.invalid', data: ips })
throw new Settings.Missing('invalid allowedIPs from memcache')
}
return ips.filter(function (ip) {
var is = net.isIPv4(ip)
if (!is) {
log.error({ op: 'allowedIPs.validate.err', ip: ip })
}
return is
})
}
toJSON() {
return Object.keys(this.ips)
}
}
return new AllowedIPs(config.allowedIPs || [])
}

70
lib/settings/limits.js Normal file
Просмотреть файл

@ -0,0 +1,70 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
var deepEqual = require('deep-equal')
module.exports = (config, Settings, log) => {
class Limits extends Settings {
constructor(settings) {
super('limits')
this.setAll(settings)
}
setAll(settings) {
this.blockIntervalSeconds = settings.blockIntervalSeconds
this.blockIntervalMs = settings.blockIntervalSeconds * 1000
this.rateLimitIntervalSeconds = settings.rateLimitIntervalSeconds
this.rateLimitIntervalMs = settings.rateLimitIntervalSeconds * 1000
this.maxEmails = settings.maxEmails
this.maxBadLogins = settings.maxBadLogins
this.maxBadLoginsPerIp = settings.maxBadLoginsPerIp
this.maxUnblockAttempts = settings.maxUnblockAttempts
this.maxVerifyCodes = settings.maxVerifyCodes
this.ipRateLimitIntervalSeconds = settings.ipRateLimitIntervalSeconds
this.ipRateLimitIntervalMs = settings.ipRateLimitIntervalSeconds * 1000
this.ipRateLimitBanDurationSeconds = settings.ipRateLimitBanDurationSeconds
this.ipRateLimitBanDurationMs = settings.ipRateLimitBanDurationSeconds * 1000
this.maxAccountStatusCheck = settings.maxAccountStatusCheck
this.badLoginErrnoWeights = settings.badLoginErrnoWeights || {}
this.uidRateLimit = settings.uidRateLimit || {}
this.maxChecksPerUid = this.uidRateLimit.maxChecks
this.uidRateLimitBanDurationMs = this.uidRateLimit.banDurationSeconds * 1000
this.uidRateLimitIntervalMs = this.uidRateLimit.limitIntervalSeconds * 1000
this.smsRateLimit = settings.smsRateLimit || {}
this.maxSms = settings.smsRateLimit.maxSms
this.smsRateLimitIntervalSeconds = this.smsRateLimit.limitIntervalSeconds
this.smsRateLimitIntervalMs = this.smsRateLimitIntervalSeconds * 1000
return this
}
validate(settings) {
if (typeof settings !== 'object') {
log.error({ op: 'limits.validate.invalid', data: settings })
throw new Settings.Missing('invalid limits from memcache')
}
var keys = Object.keys(config.limits)
for (var i = 0; i < keys.length; i++) {
var key = keys[i]
var current = this[key]
var future = settings[key]
if (typeof(current) !== typeof(future)) {
log.error({ op: 'limits.validate.err', key: key, message: 'types do not match'})
settings[key] = current
}
else if (!deepEqual(current, future)) {
log.info({ op: 'limits.validate.changed', key: key, current: current, future: future })
}
}
return settings
}
}
return new Limits(config.limits)
}

Просмотреть файл

@ -0,0 +1,52 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const deepEqual = require('deep-equal')
module.exports = (config, Settings, log) => {
class RequestChecks extends Settings {
constructor(settings) {
super('requestChecks')
this.setAll(settings)
}
setAll(settings) {
this.treatEveryoneWithSuspicion = settings.treatEveryoneWithSuspicion
// The private branch puts some additional private config here.
return this
}
// Type-checks updates to the settings, and merges them
// with the current values, modying its argument in-place.
validate(settings) {
if (typeof settings !== 'object') {
log.error({ op: 'requestChecks.validate.invalid', data: settings })
throw new Settings.Missing('invalid requestChecks from memcache')
}
const keys = Object.keys(config.requestChecks)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const current = this[key]
const future = settings[key]
if (typeof future === 'undefined') {
settings[key] = current
}
else if (typeof current !== typeof future) {
log.error({ op: 'requestChecks.validate.err', key: key, message: 'types do not match' })
settings[key] = current
}
else if (!deepEqual(current, future)) {
log.info({ op: 'requestChecks.validate.changed', key: key, current: current, future: future })
}
}
return settings
}
}
return new RequestChecks(config.requestChecks)
}

91
lib/settings/settings.js Normal file
Просмотреть файл

@ -0,0 +1,91 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const assert = require('assert')
const KEY = Symbol()
const POLL_INTERVAL = Symbol()
module.exports = (config, mc, log) => {
// A sentinel error for signaling that the result was invalid/missing,
// and we should try to push our own representation to memcached.
class Missing extends Error {}
// An abstract class that stores options in memcached.
//
// Others extend this class to hotload options from memcached.
class Settings {
constructor(key) {
assert(typeof key === 'string')
this[KEY] = key
}
// subclasses should provide their own implementation
setAll(value) {
return this
}
// subclasses should provide their own implementation
validate(value) {
if (value == null) {
throw new Missing('value was undefined or null')
}
return value
}
// Pushes `this` as JSON to `key`
//
// Customize what is pushed with a toJSON method.
push() {
log.info({ op: this[KEY] + '.push' })
return mc.setAsync(this[KEY], this, 0)
.then(() => this.refresh())
}
refresh(options) {
log.info({ op: this[KEY] + '.refresh' })
let result = mc.getAsync(this[KEY]).then(value => this.validate(value))
if (options && options.pushOnMissing) {
result = result.catch(err => {
if (err instanceof Missing) {
log.info({ op: this[KEY] + '.refresh.pushOnMissing' })
return this.push()
} else {
throw err
}
})
}
return result.then(
value => this.setAll(value),
err => {
log.error({ op: this[KEY] + '.refresh', err: err })
throw err
}
)
}
pollForUpdates() {
this.stopPolling()
this[POLL_INTERVAL] = setInterval(() => {
this.refresh()
// refresh error should just log, but nothing else
.catch(() => {})
}, config.updatePollIntervalSeconds * 1000)
this[POLL_INTERVAL].unref()
}
stopPolling() {
clearInterval(this[POLL_INTERVAL])
}
}
Settings.Missing = Missing
return Settings
}

Просмотреть файл

@ -0,0 +1,93 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict'
const P = require('bluebird')
const test = require('tap').test
const config = {}
const mc = {}
const log = {
info() {},
error() {}
}
const Settings = require('../../lib/settings/settings')(config, mc, log)
class TestSettings extends Settings {
constructor() {
super('tests')
}
setAll(settings) {
this.testOption = !!settings.testOption
return this
}
validate(other) {
if (!other) {
throw new Settings.Missing()
}
return other
}
}
test(
'refresh without pushOnMissing does not call push',
t => {
let pushed
mc.getAsync = () => P.resolve(pushed)
mc.setAsync = (key, val) => {
pushed = val
return P.resolve(val)
}
const settings = new TestSettings()
settings.setAll({ testOption: true })
return settings.refresh()
.then(
t.fail,
err => {
t.equal(pushed, undefined)
t.ok(err instanceof Settings.Missing)
}
)
}
)
test(
'refresh pushOnMissing works on Missing error',
t => {
let pushed
mc.getAsync = () => P.resolve(pushed)
mc.setAsync = (key, val) => {
pushed = val
return P.resolve(val)
}
const settings = new TestSettings()
settings.setAll({ testOption: true })
return settings.refresh({ pushOnMissing: true })
.then(() => {
t.deepEqual(pushed, { testOption: true })
}, t.fail)
}
)
test(
'refresh pushOnMissing returns other Errors',
t => {
const mcError = new Error('memcached error')
mc.getAsync = () => P.reject(mcError)
mc.setAsync = (key, val) => {
return P.reject(new Error('setAsync should not have been called'))
}
const settings = new TestSettings()
settings.setAll({ testOption: true })
return settings.refresh({ pushOnMissing: true })
.then(
t.fail,
err => {
t.equal(err, mcError)
}
)
}
)

Просмотреть файл

@ -2,6 +2,8 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
var P = require('bluebird')
var Memcached = require('memcached')
P.promisifyAll(Memcached.prototype)
@ -52,10 +54,11 @@ module.exports.mc = mc
var TEST_EMAIL = 'test@example.com'
var TEST_IP = '192.0.2.1'
var limits = require('../lib/limits')(config, mc, console)
var allowedIPs = require('../lib/allowed_ips')(config, mc, console)
var allowedEmailDomains = require('../lib/allowed_email_domains')(config, mc, console)
var requestChecks = require('../lib/requestChecks')(config, mc, console)
const Settings = require('../lib/settings/settings')(config, mc, console)
var limits = require('../lib/settings/limits')(config, Settings, console)
var allowedIPs = require('../lib/settings/allowed_ips')(config, Settings, console)
var allowedEmailDomains = require('../lib/settings/allowed_email_domains')(config, Settings, console)
var requestChecks = require('../lib/settings/requestChecks')(config, Settings, console)
var EmailRecord = require('../lib/email_record')(limits)
var IpEmailRecord = require('../lib/ip_email_record')(limits)
var IpRecord = require('../lib/ip_record')(limits)