oaeproject/Hilary

View on GitHub
packages/oae-util/lib/server.js

Summary

Maintainability
A
25 mins
Test Coverage
A
94%
/*!
 * Copyright 2014 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

import http from 'node:http';
import { format } from 'node:util';
import _ from 'underscore';
import { nth, split, compose, not, indexOf, equals, either } from 'ramda';
import bodyParser from 'body-parser';
import express from 'express';

import { logger } from 'oae-logger';

import * as TelemetryAPI from 'oae-telemetry';
import OaeEmitter from './emitter.js';

import multipart from './middleware/multipart.js';
import * as Shutdown from './internal/shutdown.js';

const log = logger('oae-server');

const notExists = compose(not, Boolean);
const SLASH = '/';
const PROTOCOL_SEPARATOR = '://';
const isValidReferer = compose(not, equals(0), indexOf(SLASH));

const isGET = equals('GET');
const isHEAD = equals('HEAD');

// The main OAE config
let config = null;

// Maintains a list of paths that are safe from CSRF attacks
const safePathPrefixes = [];

/**
 * Starts an express server on the specified port. This will be done for the global admin server, as well
 * as for the tenant server.
 *
 * @param  {Number}     port        The port on which the express server should be started
 * @param  {Object}     config      JSON object containing configuration values for Cassandra, Redis, logging and telemetry
 * @return {Express}                The created express server
 */
const setupServer = function (port, _config) {
  // Cache the config
  config = _config;

  // Create the express server
  const app = express();

  // Expose the HTTP server on the express app server so other modules can hook into it
  app.httpServer = http.createServer(app);

  // Start listening for requests
  app.httpServer.listen(port);

  // Don't output pretty JSON,
  app.set('json spaces', 0);

  _applyAvailabilityHandling(app.httpServer, app, port);

  /*!
   * We support the following type of request encodings:
   *
   *  * urlencoded (regular POST requests)
   *  * application/json
   *  * multipart (file uploads)
   *
   * A maximum limit of 250kb is imposed for `urlencoded` and `application/json` requests.
   * This limit only applies to the *incoming request data*.
   * If the client needs to send more than 250kb, it should consider
   * using a proper multipart form request.
   */
  app.use(bodyParser.urlencoded({ limit: '250kb', extended: true }));
  app.use(bodyParser.json({ limit: '250kb' }));
  app.use(multipart(config.files));

  // Add telemetry before we do anything else
  app.use((request, response, next) => {
    TelemetryAPI.request(request, response);
    return next();
  });

  // Add CORS headers, cookies won't be passed in so all cross domain requests will be anonymous
  app.use((request, response, next) => {
    response.header('Access-Control-Allow-Origin', '*');
    response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
    return next();
  });

  return app;
};

/**
 * Aggregates the routes for the express server so that we can bind them after setting up
 * all the middleware as registering the first route puts the router onto the middleware
 * stack
 *
 * @param  {Express}       The express server these routes belong to
 * @return {Router}        An object for associating routes to the server
 */
const setupRouter = function (app) {
  const that = {};
  that.routes = [];

  /**
   * Setup a route on the associated server
   *
   * @param  {String}               method          The http method for the route
   * @param  {String|RegEx}         route           The path for the route
   * @param  {Function|Function[]}  handler         The function to handle requests to this route
   * @param  {String}               [telemetryUrl]  The string to use for telemetry tracking
   * @throws {Error}                                Error thrown when arguments aren't of the proper type
   */
  that.on = function (method, route, handler, telemetryUrl) {
    const isRouteValid = _.isString(route) || _.isRegExp(route);
    const isHandlerValid = _.isFunction(handler) || _.isArray(handler);
    if (!_.isString(method)) {
      throw new TypeError(
        format('Invalid type for request method "%s" when binding route "%s" to OAE Router', method, route.toString())
      );
    } else if (!isRouteValid) {
      throw new Error(format('Invalid route path "%s" while binding route to OAE Router', route.toString()));
    } else if (!isHandlerValid) {
      throw new Error(
        format('Invalid method handler given for route "%s" while binding to OAE Router', route.toString())
      );
    }

    that.routes.push({
      method,
      route,
      handler,
      telemetryUrl
    });
  };

  /**
   * Bind all the routes, this should only be called once by the server initialization
   */
  that.bind = function () {
    _.each(that.routes, (route) => {
      // Add a telemetry handler
      const handlers = [
        function (request, response, next) {
          request.telemetryUrl = route.telemetryUrl || route.route.replace(/:/, '');
          next();
        }
      ];

      app[route.method](route.route, [...handlers, route.handler]);
    });
  };

  return that;
};

/**
 * Add a path to the list of safe paths. Paths added here will not be protected against CSRF
 * attacks. This is common for endpoints that have other verification mechanisms such as Shibboleth.
 *
 * @param  {String}     pathPrefix  A path prefix that will not be validated against CSRF attacks
 */
const addSafePathPrefix = function (pathPrefix) {
  log().info('Adding %s to list of paths that are not CSRF-protected.', pathPrefix);
  safePathPrefixes.push(pathPrefix);
};

/**
 * This method is used to bind server functionality after all modules have had an opportunity to do so. This can be useful for things such
 * as:
 *
 * Response code logging / telemetry
 * Default "catch-all" error handling
 *
 * @param  {Express}    app     The express app for which the initialized should be finalized
 */
const postInitializeServer = function (app, router) {
  /*!
   * Referer-based CSRF protection. If the request is not safe (e.g., POST, DELETE) and the origin of the request (as
   * specified by the HTTP Referer header) does not match the target host of the request (as specified by the HTTP
   * Host header), then the request will result in a 500 error.
   *
   * While referer-based protection is not highly recommended due to spoofing possibilities in insecure environments,
   * it currently offers the best trade-off between ease of use (e.g., for cURL interoperability), effort and security
   * against CSRF attacks.
   *
   * Middleware that gets called earlier, can force the CSRF check to be skipped by setting `_checkCSRF` on the request.
   *
   * If using a utility such as `curl` to POST requests to the API, you can bypass this by just setting the referer
   * header to "/":
   *
   * curl -X POST -e / http://my.oae.com/api/auth/login
   *
   * More information about CSRF attacks: http://en.wikipedia.org/wiki/Cross-site_request_forgery
   */
  app.use((request, response, next) => {
    // If earlier middleware determined that CSRF is not required, we can skip the check
    if (request._checkCSRF === false) {
      return next();
    }

    if (!_isSafeMethod(request.method) && !_isSafePath(request) && !_isSameOrigin(request)) {
      log().warn(
        {
          method: request.method,
          host: request.headers.host,
          referer: request.headers.referer,
          targetPath: request.path
        },
        'CSRF validation failed: attempted to execute unsafe operation from untrusted origin'
      );
      return _abort(response, 500, 'CSRF validation failed: attempted to execute unsafe method from untrusted origin');
    }

    return next();
  });

  // Bind routes
  router.bind();

  // Catch-all error handler
  const appTelemetry = TelemetryAPI.telemetry('server');
  // eslint-disable-next-line no-unused-vars
  app.use((error, request, response, next) => {
    appTelemetry.incr('error.count');
    log(request.ctx).error(
      {
        err: error,
        req: request,
        res: response
      },
      'Unhandled error in the request chain, caught at the default error handler'
    );
    response.status(500).send('An unexpected error occurred');
  });
};

/**
 * Whether or not the server is running behind HTTPs.
 *
 * @return {Boolean}   Whether or not the server is running behind https.
 */
const useHttps = function () {
  return config.servers.useHttps;
};

/**
 * Apply the logic and request handling required to gracefully start up and shut down the web server. This entails both:
 *
 *  * Gracefully rejecting web requests until the container has fully initialized
 *  * Gracefully rejecting web requests while the container is in the process of shutting down services
 *
 * @param  {Server}         server  The node.js http server object
 * @param  {Application}    app     The Express `app` object
 * @param  {Number}         port    The port on which the server is listening
 * @api private
 */
const _applyAvailabilityHandling = function (server, app, port) {
  let isAvailable = false;

  OaeEmitter.on('ready', () => {
    // The container is initialized, start accepting web requests
    isAvailable = true;
  });

  // Register a pre-shutdown handler that will close this express server to stop receiving requests
  Shutdown.registerPreShutdownHandler('express-server-' + port, null, (callback) => {
    log().info('Beginning shutdown.');

    // Stop accepting web requests
    isAvailable = false;

    server.close(() => {
      log().info('Express is now shut down.');
      callback();
    });
  });

  // Notify the front-end proxy that we are unable to accept requests if isAvailable is false
  app.use((request, response, next) => {
    if (!isAvailable) {
      log().info({ path: request.path }, 'Rejecting request during shutdown with 502 error');
      response.setHeader('Connection', 'close');
      return response.status(502).send('Server is in the process of restarting');
    }

    return next();
  });
};

/**
 * Abort a request with a given code and response message.
 *
 * @param  {Response}   res     The express response object
 * @param  {Number}     code    The HTTP response code
 * @param  {String}     message The message body to provide as a reason for aborting the request
 * @api private
 */
const _abort = function (response, code, message) {
  response.setHeader('Connection', 'Close');
  return response.status(code).send(message);
};

/**
 * Determines if the target path for a request is considered "safe" from CSRF attacks.
 *
 * @param  {Request}    req     The express request object
 * @return {Boolean}            `true` if the path is safe from CSRF attacks, `false` otherwise
 * @api private
 */
const _isSafePath = function (request) {
  const { path } = request;
  const matchingPaths = _.filter(safePathPrefixes, (safePathPrefix) => path.indexOf(safePathPrefix) === 0);
  return matchingPaths.length > 0;
};

/**
 * Determine whether or not the given request method is considered "safe"
 *
 * @param  {String}     method  The request method
 * @return {Boolean}            `true` if the request method is safe (e.g., GET, HEAD), `false` otherwise
 * @api private
 */
const _isSafeMethod = (method) => either(isGET, isHEAD)(method);

/**
 * Determine whether or not the origin host of the given request is the same as the target host.
 *
 * @param  {Request}    req     The express request object to test
 * @return {Boolean}            `true` if the request is of the same origin as the target host, `false` otherwise
 * @api private
 */
const _isSameOrigin = function (request) {
  const { host, referer } = request.headers;

  const isSameAsHost = equals(host);
  const getHostPortion = compose(nth(1), split(PROTOCOL_SEPARATOR));
  const getHostFirstToken = compose(nth(0), split(SLASH));
  const isNotSameOrigin = compose(not, isSameAsHost, getHostFirstToken);

  if (notExists(referer)) return false;

  if (isValidReferer(referer)) {
    // Verify the host portion against the host header
    const hostPortionOfReferer = getHostPortion(referer);

    /**
     * If there is nothing after the protocol (e.g., "http://") or the host before
     * the first slash does not match we deem it not to be the same origin.
     */
    if (either(notExists, isNotSameOrigin)(hostPortionOfReferer)) return false;
  }

  return true;
};

export { setupServer, setupRouter, addSafePathPrefix, postInitializeServer, useHttps };