packages/fxa-customs-server/lib/server.js (701 lines of code) (raw):

#!/usr/bin/env node /* 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 Hapi = require('@hapi/hapi'); const HapiSwagger = require('hapi-swagger'); const swaggerOptions = require('../docs/swagger/swagger-options'); const API_DOCS = require('../docs/swagger/customs-server-api'); const packageJson = require('../package.json'); const blockReasons = require('./block_reasons'); const P = require('bluebird'); const { configureSentry } = require('./sentry'); const dataflow = require('./dataflow'); const { StatsD } = require('hot-shots'); const Cache = require('./cache'); module.exports = async function createServer(config, log) { var startupDefers = []; // Setup blocklist manager if (config.ipBlocklist.enable) { var IPBlocklistManager = require('./ip_blocklist_manager')(log, config); var blockListManager = new IPBlocklistManager(); startupDefers.push( blockListManager.load( config.ipBlocklist.lists, config.ipBlocklist.logOnlyLists ) ); blockListManager.pollForUpdates(); } const statsd = config.statsd.enabled ? new StatsD({ ...config.statsd, errorHandler: (err) => { // eslint-disable-next-line no-use-before-define log.error('statsd.error', err); }, }) : { timing: () => {}, }; const mc = new Cache(config); var reputationService = require('./reputationService')(config, 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 ); const allowedPhoneNumbers = require('./settings/allowed_phone_numbers')( config, Settings, log ); var requestChecks = require('./settings/requestChecks')( config, Settings, log ); const { fetchRecord, fetchRecords, setRecords, setRecord } = require('./records')( mc, reputationService, limits, config.cache.recordLifetimeSeconds, statsd ); const { checkUserDefinedRateLimitRulesByIpEmail, checkUserDefinedRateLimitRulesByUid, } = require('./user_defined_rules')(config, fetchRecord, setRecords); dataflow(config, log, fetchRecords, setRecord); if (config.updatePollIntervalSeconds) { [ allowedEmailDomains, allowedIPs, allowedPhoneNumbers, limits, requestChecks, ].forEach((settings) => { settings.refresh({ pushOnMissing: true }).catch(() => {}); settings.pollForUpdates(); }); } const handleBan = require('./bans/handler')(fetchRecords, setRecords, log); const api = Hapi.server({ port: config.listen.port, host: config.listen.host, // To reduce connection timeouts, increase the server and socket timeouts routes: { timeout: { server: 4 * 60 * 1000, socket: 8 * 60 * 1000, }, }, }); // Allow Keep-Alive connections from the auth-server to be idle up to two // minutes before closing the connection. If this is not set, the default // idle-time is 5 seconds. This can cause a lot of unneeded churn in server // connections. Setting this to 120s makes node8 behave more like node6. - // https://nodejs.org/docs/latest-v8.x/api/http.html#http_server_keepalivetimeout api.listener.keepAliveTimeout = 2 * 60 * 1000; await configureSentry(api, config, log); function logError(err) { log.error({ op: 'cacheError', err: err }); throw err; } function normalizePath(path) { path = path.indexOf('/') === 0 ? path.substr(1) : path; return path.replace(/\//g, '_'); } function reportMetrics(request) { const path = normalizePath(request._route.path); const statusCode = request.response.isBoom ? request.response.output.statusCode : request.response.statusCode; const errno = request.response.errno || (request.response.source && request.response.source.errno) || 0; statsd.timing( 'url_request', request.info.completed - request.info.received, 1, { path, method: request.method.toUpperCase(), statusCode, errno, } ); } api.events.on('response', (request) => { reportMetrics(request); }); function isAllowed(ip, email, phoneNumber) { if (allowedIPs.isAllowed(ip)) { return true; } if (allowedEmailDomains.isAllowed(email)) { return true; } if (allowedPhoneNumbers.isAllowed(phoneNumber)) { return true; } return false; } function checkAllowlist(result, ip, email, phoneNumber) { // Regardless of any preceding checks, there are some IPs, emails, // and phone numbers that we won't block. These are typically for // Mozilla QA purposes. They should be checked after everything // else so as not to pay the overhead of checking the many requests // that are *not* QA-related. if (result.block || result.suspect) { if (isAllowed(ip, email, phoneNumber)) { log.info({ op: 'request.check.allowed', ip: ip, block: result.block, suspect: result.suspect, }); result.block = false; result.suspect = false; } } } function optionallyReportIp(result, ip, action) { if (result.block && result.blockReason !== blockReasons.IP_BAD_REPUTATION) { reputationService.report(ip, `fxa:request.check.block.${action}`); } } function max(prev, cur) { return Math.max(prev, cur); } function normalizedEmail(rawEmail) { const lowercaseEmail = rawEmail.toLowerCase(); if (/@gmail\.com$/.test(lowercaseEmail)) { // gmail addresses have some special rules: // Everything from `+` to the end of the local part is ignored. // All periods are ignored. // Remove these optional portions so that these emails are treated the same: // attacker@gmail.com // attacker+20190507@gmail.com // a.ttack.e.r+is+a+goon@gmail.com const [local, domain] = lowercaseEmail.split('@'); return `${local.replace(/\+.*$/, '').replace(/\./g, '')}@${domain}`; } return lowercaseEmail; } api.register([ { plugin: HapiSwagger, options: swaggerOptions, }, ]); api.route({ method: 'POST', path: '/check', handler: async (req, h) => { let email = req.payload.email; const { ip, action } = req.payload; const headers = req.headers || {}; const payload = req.payload.payload || {}; if (!email || !ip || !action) { const err = { code: 'MissingParameters', message: 'email, ip and action are all required', }; log.error({ op: 'request.check', email, ip, action, err }); return h.response(err).code(400); } email = normalizedEmail(email); // Phone number is optional let phoneNumber; if (payload.phoneNumber) { phoneNumber = payload.phoneNumber; } async function checkRecords({ ipRecord, reputation, emailRecord, ipEmailRecord, smsRecord, }) { if (ipRecord.isBlocked() || ipRecord.isDisabled()) { // a blocked ip should just be ignored completely // it's malicious, it shouldn't penalize emails or allow // (most) escape hatches. just abort! return { block: true, retryAfter: ipRecord.retryAfter(), }; } // Check each record type to see if a retryAfter has been set const wantsUnblock = payload.unblockCode; const blockEmail = emailRecord.update(action, !!wantsUnblock); let blockIpEmail = ipEmailRecord.update(action); const blockIp = ipRecord.update(action, email); let blockSMS = 0; if (smsRecord) { blockSMS = smsRecord.update(action); } if (blockIpEmail && ipEmailRecord.unblockIfReset(emailRecord.pr)) { blockIpEmail = 0; } let retryAfter = [blockEmail, blockIpEmail, blockIp, blockSMS].reduce( max ); let block = retryAfter > 0; let suspect = false; let blockReason = null; if (block) { blockReason = blockReasons.OTHER; } if ( requestChecks.treatEveryoneWithSuspicion || reputationService.isSuspectBelow(reputation) || ipRecord.isSuspected() || emailRecord.isSuspected() ) { suspect = true; } if (!block && action === 'accountLogin') { // All login requests should include a valid flowId. if (!payload.metricsContext || !payload.metricsContext.flowId) { // Unless they're legacy user-agents that we know will not include it. var isExemptUA = false; var userAgent = headers['user-agent']; isExemptUA = requestChecks.flowIdExemptUserAgentCompiledREs.some( function (re) { return re.test(userAgent); } ); // Or unless it's for non-signin-related reasons, e.g. changing password. // We know these requests will not include it. var isExemptRequest = false; if (payload.reason && payload.reason !== 'signin') { isExemptRequest = true; } if (!isExemptUA && !isExemptRequest) { // By default we just treat a missing flowId as suspicious, // but config can change this to a hard block. suspect = true; if (requestChecks.flowIdRequiredOnLogin) { block = true; } } } } const canUnblock = emailRecord.canUnblock(); // IP's that are in blocklist should be blocked // and not return a retryAfter because it is not known // when they would be removed from blocklist if (config.ipBlocklist.enable && blockListManager.contains(ip)) { block = true; blockReason = blockReasons.IP_IN_BLOCKLIST; retryAfter = 0; } if (reputationService.isBlockBelow(reputation)) { block = true; retryAfter = ipRecord.retryAfter(); blockReason = blockReasons.IP_BAD_REPUTATION; } // smsRecord is optional, trying to save an undefined record results in an error const recordsToSave = [ ipRecord, emailRecord, ipEmailRecord, smsRecord, ].filter((record) => !!record); await setRecords(...recordsToSave); return { block, blockReason, retryAfter, unblock: canUnblock, suspect, }; } function createResponse(result) { const { block, unblock, suspect, blockReason } = result; checkAllowlist(result, ip, email, phoneNumber); log.info({ op: 'request.check', email, ip, action, block, unblock, suspect, }); const response = { block: result.block, retryAfter: result.retryAfter, unblock: result.unblock, suspect: result.suspect, }; if (blockReason) { response['blockReason'] = blockReason; } optionallyReportIp(result, ip, action); return response; } function handleError(err) { log.error({ op: 'request.check', email: email, ip: ip, action: action, err: err, }); // Default is to block request on any server based error return { block: true, retryAfter: limits.rateLimitIntervalSeconds, unblock: false, }; } return fetchRecords({ ip, email, phoneNumber }) .then(checkRecords) .then((result) => { return checkUserDefinedRateLimitRulesByIpEmail( result, action, email, ip ); }) .then((result) => { return result; }) .then(createResponse, handleError); }, options: { ...API_DOCS.CHECK_POST, }, }); api.route({ method: 'POST', path: '/checkAuthenticated', handler: async (req, h) => { var action = req.payload.action; var ip = req.payload.ip; var uid = req.payload.uid; if (!action || !ip || !uid) { var err = { code: 'MissingParameters', message: 'action, ip and uid are all required', }; log.error({ op: 'request.checkAuthenticated', action: action, ip: ip, uid: uid, err: err, }); return h.response(err).code(400); } return fetchRecords({ uid }) .then(async ({ uidRecord }) => { var result = uidRecord.addCount(action, uid); const { retryAfter } = await checkUserDefinedRateLimitRulesByUid( { retryAfter: result, }, action, uid ); return setRecords(uidRecord).then(function () { return { block: retryAfter > 0, retryAfter: retryAfter, }; }); }) .then( function (result) { log.info({ op: 'request.checkAuthenticated', block: result.block, }); if (result.block) { reputationService.report( ip, 'fxa:request.checkAuthenticated.block.' + action ); } return result; }, function (err) { log.error({ op: 'request.checkAuthenticated', err: err }); // Default is to block request on any server based error reputationService.report( ip, 'fxa:request.checkAuthenticated.block.' + action ); return { block: true, retryAfter: limits.blockIntervalSeconds, }; } ); }, options: { ...API_DOCS.CHECK_AUTHENTICATED_POST, }, }); api.route({ method: 'POST', path: '/checkIpOnly', handler: async (req, h) => { const action = req.payload.action; const ip = req.payload.ip; if (!action || !ip) { const err = { code: 'MissingParameters', message: 'action and ip are both required', }; log.error({ op: 'request.checkIpOnly', action: action, ip: ip, err: err, }); return h.response(err).code(400); } return fetchRecords({ ip }) .then(({ ipRecord, reputation }) => { if (ipRecord.isBlocked() || ipRecord.isDisabled()) { return { block: true, retryAfter: ipRecord.retryAfter() }; } const suspect = requestChecks.treatEveryoneWithSuspicion || reputationService.isSuspectBelow(reputation); let retryAfter = ipRecord.update(action); let block = retryAfter > 0; let blockReason; if (block) { blockReason = blockReasons.OTHER; } if (config.ipBlocklist.enable && blockListManager.contains(ip)) { block = true; blockReason = blockReasons.IP_IN_BLOCKLIST; retryAfter = 0; } if (reputationService.isBlockBelow(reputation)) { block = true; retryAfter = ipRecord.retryAfter(); blockReason = blockReasons.IP_BAD_REPUTATION; } return setRecords(ipRecord).then(() => ({ block, blockReason, retryAfter, suspect, })); }) .then( (result) => { checkAllowlist(result, ip); log.info({ op: 'request.checkIpOnly', ip, action, block: result.block, blockReason: result.blockReason, suspect: result.suspect, }); const response = { block: result.block, retryAfter: result.retryAfter, suspect: result.suspect, }; if (result.blockReason) { response['blockReason'] = result.blockReason; } optionallyReportIp(result, ip, action); return response; }, (err) => { log.error({ op: 'request.checkIpOnly', ip: ip, action: action, err: err, }); return { block: true, retryAfter: limits.ipRateLimitIntervalSeconds, }; } ); }, options: { ...API_DOCS.CHECKIPONLY_POST, }, }); api.route({ method: 'POST', path: '/failedLoginAttempt', handler: async (req, h) => { let email = req.payload.email; const ip = req.payload.ip; const errno = Number(req.payload.errno) || 999; if (!email || !ip) { const err = { code: 'MissingParameters', message: 'email and ip are both required', }; log.error({ op: 'request.failedLoginAttempt', email: email, ip: ip, err: err, }); return h.response(err).code(400); } email = normalizedEmail(email); return fetchRecords({ ip, email }) .then(function ({ ipRecord, emailRecord, ipEmailRecord }) { ipRecord.addBadLogin({ email: email, errno: errno }); ipEmailRecord.addBadLogin(); emailRecord.addBadLogin(); if (ipRecord.isOverBadLogins()) { reputationService.report( ip, 'fxa:request.failedLoginAttempt.isOverBadLogins' ); } return setRecords(ipRecord, emailRecord, ipEmailRecord).then( function () { return {}; } ); }) .then( function (result) { log.info({ op: 'request.failedLoginAttempt', email: email, ip: ip, errno: errno, }); return result; }, function (err) { log.error({ op: 'request.failedLoginAttempt', email: email, ip: ip, err: err, }); return h.response(err).code(500); } ); }, options: { ...API_DOCS.FAILEDLOGINATTEMPT_POST, }, }); api.route({ method: 'POST', path: '/passwordReset', handler: async (req, h) => { const ip = req.payload.ip; var email = req.payload.email; if (!email) { const err = { code: 'MissingParameters', message: 'email is required', }; log.error({ op: 'request.passwordReset', email: email, err: err }); return h.response(err).code(400); } email = normalizedEmail(email); const { ipRecord, emailRecord } = await fetchRecords({ ip, email }); ipRecord.passwordReset(); emailRecord.passwordReset(); try { await setRecords(ipRecord, emailRecord); return {}; } catch (err) { logError(err); return h.response(err).code(500); } }, options: { ...API_DOCS.PASSWORDRESET_POST, }, }); api.route({ method: 'POST', path: '/blockEmail', handler: async (req, h) => { var email = req.payload.email; if (!email) { const err = { code: 'MissingParameters', message: 'email is required', }; log.error({ op: 'request.blockEmail', email: email, err: err }); return h.response(err).code(400); } email = normalizedEmail(email); return handleBan({ ban: { email: email } }) .then(function () { log.info({ op: 'request.blockEmail', email: email }); return {}; }) .catch(function (err) { log.error({ op: 'request.blockEmail', email: email, err: err }); return h.response(err).code(500); }); }, options: { ...API_DOCS.BLOCKEMAIL_POST, }, }); api.route({ method: 'POST', path: '/blockIp', handler: async (req, h) => { var ip = req.payload.ip; if (!ip) { const err = { code: 'MissingParameters', message: 'ip is required' }; log.error({ op: 'request.blockIp', ip: ip, err: err }); return h.response(err).code(400); } try { await handleBan({ ban: { ip: ip } }); reputationService.report(ip, 'fxa:request.blockIp'); log.info({ op: 'request.blockIp', ip: ip }); return {}; } catch (err) { log.error({ op: 'request.blockIp', ip: ip, err: err }); return h.response(err).code(500); } }, options: { ...API_DOCS.BLOCKIP_POST, }, }); api.route({ method: 'GET', path: '/', handler: async () => { return { version: packageJson.version }; }, options: { ...API_DOCS.GET, }, }); api.route({ method: 'GET', path: '/limits', handler: async () => { return limits; }, options: { ...API_DOCS.LIMITS_GET, }, }); api.route({ method: 'GET', path: '/allowedIPs', handler: async () => { return Object.keys(allowedIPs.ips); }, options: { ...API_DOCS.ALLOWED_IPS_GET, }, }); api.route({ method: 'GET', path: '/allowedEmailDomains', handler: async () => { return Object.keys(allowedEmailDomains.domains); }, options: { ...API_DOCS.ALLOWED_EMAILDOMAINS_GET, }, }); api.route({ method: 'GET', path: '/allowedPhoneNumbers', handler: async () => { return allowedPhoneNumbers.toJSON(); }, options: { ...API_DOCS.ALLOWED_PHONENUMBERS_GET, }, }); return P.all(startupDefers).then(function () { return api; }); };