packages/fxa-profile-server/lib/server/web.js (245 lines of code) (raw):

/* 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/. */ const config = require('../config').getProperties(); const logger = require('../logging')('server.web'); const Hapi = require('@hapi/hapi'); const Sentry = require('@sentry/node'); const cloneDeep = require('lodash/cloneDeep'); const ScopeSet = require('fxa-shared').oauth.scopes; const AppError = require('../error'); const request = require('../request'); const summary = require('../logging/summary'); function trimLocale(header) { if (!header) { return header; } if (header.length < 256) { return header.trim(); } var parts = header.split(','); var str = parts[0]; if (str.length >= 255) { return null; } for (var i = 1; i < parts.length && str.length + parts[i].length < 255; i++) { str += ',' + parts[i]; } return str.trim(); } // This is the webserver. It's what the outside always talks to. It // handles the whole Profile API. exports.create = async function createServer() { var useRedis = config.serverCache.useRedis; var cacheProvider = { constructor: useRedis ? require('@hapi/catbox-redis') : require('@hapi/catbox-memory'), options: {}, }; if (useRedis) { cacheProvider.options.host = config.serverCache.redis.host; cacheProvider.options.port = config.serverCache.redis.port; cacheProvider.options.partition = config.serverCache.redis.keyPrefix; cacheProvider.options.password = config.serverCache.redis.password; } var isProd = config.env === 'production'; var server = new Hapi.Server({ cache: { provider: cacheProvider, }, debug: false, host: config.server.host, port: config.server.port, routes: { cors: { additionalExposedHeaders: ['Timestamp', 'Accept-Language'], additionalHeaders: ['sentry-trace', 'baggage'], // If we're accepting CORS from any origin then use Hapi's "ignore" mode, // which is more forgiving of missing Origin header. origin: ['*'], }, security: { hsts: { maxAge: 31536000, includeSubdomains: true, }, xframe: true, xss: true, noOpen: false, noSniff: true, }, validate: { options: { stripUnknown: true, }, failAction: async (request, h, err) => { // Starting with Hapi 17, the framework hides the validation info // We want the full validation information and use it in `onPreResponse` below // See: https://github.com/hapijs/hapi/issues/3706#issuecomment-349765943 throw err; }, }, }, }); server.validator(require('joi')); // configure Sentry if (config.sentry && config.sentry.dsn) { // Attach a new Sentry scope to the request for breadcrumbs/tags/extras server.ext({ type: 'onRequest', method(request, h) { request.sentryScope = new Sentry.Scope(); // Make a transaction per request so we can get performance monitoring. There are // some limitations to this approach, and distributed tracing will be off due to // hapi's architecture. // // See https://github.com/getsentry/sentry-javascript/issues/2172 for more into. It // looks like there might be some other solutions that are more complex, but would work // with hapi and distributed tracing. // const transaction = Sentry.startInactiveSpan({ op: 'profile-server', name: `${request.method.toUpperCase()} ${request.path}`, forceTransaction: true, request: Sentry.extractRequestData(request.raw.req), }); request.app.sentry = { transaction, }; return h.continue; }, }); // Handle sentry errors server.events.on( { name: 'request', channels: 'error' }, (_request, event) => { const err = (event && event.error) || null; let exception = ''; if (err && err.stack) { try { exception = err.stack.split('\n')[0]; } catch (e) { // ignore bad stack frames } } Sentry.captureException(err, { extra: { exception: exception, }, }); } ); } server.auth.scheme('oauth', function () { return { authenticate: async function (req, h) { var auth = req.headers.authorization; var url = config.oauth.url + '/verify'; logger.debug('auth', auth); if (!auth || auth.indexOf('Bearer') !== 0) { throw AppError.unauthorized('Bearer token not provided'); } var token = auth.split(' ')[1]; function makeReq() { return new Promise((resolve, reject) => { request.post( { url: url, json: { token: token, }, }, function (err, resp, body) { if (err || resp.statusCode >= 500) { err = err || resp.statusMessage || 'unknown'; logger.error('oauth.error', err); return reject(AppError.oauthError(err)); } if (body == null) { logger.error('oauth.error', 'no response body'); return reject(AppError.oauthError('no response body')); } if (body.code >= 400) { logger.debug('unauthorized', body); return reject(AppError.unauthorized(body.message)); } logger.debug('auth.valid', body); body.token = token; return resolve(body); } ); }); } return makeReq().then((body) => { return h.authenticated({ credentials: body, }); }); }, }; }); server.auth.strategy('oauth', 'oauth'); server.auth.scheme('secretBearerToken', function () { return { authenticate: async function (req, h) { // HACK: get fresh copy of secretBearerToken from config because tests change it. var expectedToken = require('../config').get('secretBearerToken'); var auth = req.headers.authorization; logger.debug('auth', auth); if (!auth || auth.indexOf('Bearer') !== 0) { throw new AppError.unauthorized('Bearer token not provided'); } var token = auth.split(' ')[1]; if (token === expectedToken) { return h.authenticated({ credentials: token }); } else { throw new AppError.unauthorized(); } }, }; }); server.auth.strategy('secretBearerToken', 'secretBearerToken'); // server method for caching profile await server.register({ plugin: { name: 'profileCache', version: '1.0.0', register: require('../profileCache'), }, options: config.serverCache, }); var routes = require('../routing'); if (isProd) { logger.info('production', 'Disabling response schema validation'); routes.forEach(function (route) { delete route.config.response; }); } // Expand the scope list on each route to include all super-scopes, // so that Hapi can easily check them via simple string comparison. routes = routes .map(function (routeDefinition) { // create a copy of the route definition to avoid cross-unit test // contamination since we make local changes to the definition object. const route = cloneDeep(routeDefinition); var scope = route.config.auth && route.config.auth.scope; if (scope) { route.config.auth.scope = ScopeSet.fromArray(scope).getImplicantValues(); } return route; }) .map(function (route) { if (route.config.cache === undefined) { route.config.cache = { otherwise: 'private, no-cache, no-store, must-revalidate', }; } return route; }); await server.route(routes); server.ext('onPreAuth', function (request, h) { // Construct source-ip-address chain for logging. var xff = (request.headers['x-forwarded-for'] || '').split(/\s*,\s*/); xff.push(request.info.remoteAddress); // Remove empty items from the list, in case of badly-formed header. xff = xff.filter(function (x) { return x; }); // Skip over entries for our own infra, loadbalancers, etc. var clientAddressIndex = xff.length - (config.clientAddressDepth || 1); if (clientAddressIndex < 0) { clientAddressIndex = 0; } request.app.remoteAddressChain = xff; request.app.clientAddress = xff[clientAddressIndex]; request.app.acceptLanguage = trimLocale(request.headers['accept-language']); if (request.headers.authorization) { // Log some helpful details for debugging authentication problems. logger.debug('server.onPreAuth'); logger.debug('rid', request.id); logger.debug('path', request.path); logger.debug('auth', request.headers.authorization); logger.debug('type', request.headers['content-type'] || ''); } return h.continue; }); server.ext('onPreResponse', (request) => { var response = request.response; if (response.isBoom) { response = AppError.translate(response); } summary(request, response); return response; }); return server; };