/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const _ = require('lodash');
const { INPUT_TYPES, SINK_TYPES, PATCH_TYPES } = require('../../constants');
const BaseFramework = require('./base');
const moduleHook = require('../require');
const patcher = require('../../hooks/patcher');
const logger = require('../../core/logger')(
  'contrast:hooks:sources:Hapi16Framework'
);
const Hapi16SessionHandler = require('../hapi-16-session');
const common = require('./common');
const Boom = require('../../protect/hapi/error-handler');
const { AsyncStorage } = require('../../core/async-storage');
const HapiRequest = require('../../reporter/models/frameworks/hapi-request');
const { Scopes } = require('../../core/async-storage/scopes');
const {
  constants: { EVENTS }
} = require('../../core/hapi');

const ID = 'hapi';
const version = '<17';

class Hapi16Framework extends BaseFramework {
  constructor(agent) {
    super({ id: ID, agent });
    this.assessMode = agent.isInAssessMode();
    this.sessionHandler = new Hapi16SessionHandler(this.assessMode);
    new Boom();
    this.hookHapi();
  }

  hookServer(hapi) {
    // hack, will not need this when hapi16 is moved to new IA
    this.emitter.emit(EVENTS.HAPI_EXPORT, hapi);

    // These are the session hooks which depend on other files being required,
    // such as hapi/lib/reply.
    this.sessionHandler.hookState();

    // These are the session hooks which depend on hapi itself being loaded;
    // because this only executes after all of hapi's sub-dependencies are required,
    // we cannot wait to hook those until we have access to the hapi module.
    this.sessionHandler.hookConfigs(hapi);

    const self = this;
    patcher.patch(hapi, 'Server', {
      name: 'hapi.Server',
      patchType: PATCH_TYPES.FRAMEWORK,
      alwaysRun: true,
      post(data) {
        const server = data.result;

        if (server) {
          Hapi16Framework.instrumentServer(server, self);
        }
      }
    });
  }

  hookRequest(Generator) {
    const self = this;
    patcher.patch(Generator.prototype, 'request', {
      name: 'Generator.prototype.request',
      patchType: PATCH_TYPES.ASYNC_CONTEXT,
      alwaysRun: true,
      post(wrapCtx) {
        if (!_.get(wrapCtx, 'result.domain')) {
          return;
        }
        const {
          result: { _protect }
        } = wrapCtx;
        const origRun = _protect.run.bind(_protect);
        const wrappedBoundRun = AsyncStorage.bind(
          self.createDomainRunWrapper(origRun)
        );
        _protect.run = wrappedBoundRun;
      }
    });
  }

  /**
   * Run the function in a scope that allows instrumentation. Otherwise, the
   * scope of the function will be the scope in which patcher runs pre/posts
   * (NO_INSTRUMENTATION).
   * @param {Function} orig
   * @returns {Function}
   */
  createDomainRunWrapper(orig) {
    return function(...args) {
      return Scopes.runInAllowAllScope(() => orig(...args));
    };
  }

  /**
   * Hook the Server constructor and immediately apply server extensions
   * so that we can see when the server is started and be the first to
   * add request extensions for the server.
   */
  hookHapi() {
    moduleHook.resolve({ name: 'hapi', version }, this.hookServer.bind(this));
    moduleHook.resolve(
      { name: '@hapi/hapi', version },
      this.hookServer.bind(this)
    );

    // For async caching, We want to bind all callbacks in the
    // request's lifecycle. Hapi uses its request's _protect instance
    // to wrap all extention-point functions; we can just wrap it.
    moduleHook.resolve(
      { name: 'hapi', file: 'lib/request', version },
      this.hookRequest.bind(this)
    );
    moduleHook.resolve(
      {
        name: '@hapi/hapi',
        file: 'lib/request',
        version
      },
      this.hookRequest.bind(this)
    );
  }

  /**
   * @param {hapiServer} server The server object (returned by Hapi.Server())
   * @param {Hapi16Framework} self Framework sensor on which to emit hapi's source events
   */
  static instrumentServer(server, self) {
    server.ext('onPreStart', function onPreStart(server, next) {
      logger.info(`hapi version ${server.version}`);
      Hapi16Framework.addReqExtensions(server, self);
      next();
    });
  }

  /**
   * Add request extension points to the server
   *
   * @see {@link https://hapijs.com/api#request-lifecycle|request extension points}
   * @param {hapiServer} server The server object (returned by Hapi.Server())
   * @param {Hapi16Framework} self Framework sensor on which to emit hapi's source events
   */
  static addReqExtensions(server, self) {
    Hapi16Framework.addOnRequestExtension(server);
    Hapi16Framework.addOnPreAuthExtension(server, self);
    Hapi16Framework.addOnPreHandlerExtension(server, self);
    Hapi16Framework.addOnPostHandlerExtension(server, self);
    Hapi16Framework.addOnPreResponseExtension(server, self);
  }

  static addOnRequestExtension(server) {
    server.ext('onRequest', function(request, reply) {
      // Set the framework-specific request model in our first hook point
      Hapi16Framework.setFrameworkRequest(request, HapiRequest);
      Hapi16Framework.resume(reply);
    });
  }

  static addOnPreAuthExtension(server, self) {
    server.ext('onPreAuth', function onPreAuth(request, reply) {
      common.emitSourceEvent(
        request,
        common.getRawRequest(request),
        common.getRawResponse(reply),
        'state',
        INPUT_TYPES.COOKIE_VALUE,
        ID
      );

      Hapi16Framework.resume(reply);
    });
  }

  static addOnPreHandlerExtension(server, self) {
    server.ext('onPreHandler', function onPreHandler(request, reply) {
      // Update the framework request model of the request with body and params since it has been
      // fully parsed and validated
      self.decorateRequest({
        normalizedUri: _.get(request, 'route.path'),
        body: request.payload,
        parameters: request.params
      });

      common.emitWatchableObjects(request);

      // We can get at all of this data sooner, however this is the first
      // extension point called after validation. The default validation behavior
      // is to return a 400. We are not sure until after validation whether any of these
      // values will be used.
      common.emitSourceEvent(
        request,
        common.getRawRequest(request),
        common.getRawResponse(reply),
        'payload',
        INPUT_TYPES.BODY,
        ID
      );

      common.emitSourceEvent(
        request,
        common.getRawRequest(request),
        common.getRawResponse(reply),
        'query',
        INPUT_TYPES.QUERYSTRING,
        ID
      );
      common.emitSourceEvent(
        request,
        common.getRawRequest(request),
        common.getRawResponse(reply),
        'params',
        INPUT_TYPES.URL_PARAMETER,
        ID
      );

      Hapi16Framework.resume(reply);
    });
  }

  static addOnPostHandlerExtension(server, self) {
    server.ext('onPostHandler', function onPostHandler(request, reply) {
      const { response } = request;
      // if the response was ended before we got here, we may not get a response that looks like
      // what we expect it to
      if (response && !response.isBoom) {
        response.on('finish', function() {
          self.sessionHandler.checkCookies(request);

          const body = _.get(response, '_payload._data');
          const encoding = _.get(response, '_payload._encoding');
          // XXX: is this right?
          if (
            encoding === 'utf8' ||
            encoding === 'ascii' ||
            encoding === 'utf16'
          ) {
            common.emitSendEvent(body);
          }
        });
      }
      Hapi16Framework.resume(reply);
    });
  }

  static addOnPreResponseExtension(server, self) {
    server.ext('onPreResponse', function onPreResponse(request, reply) {
      const { response } = request;
      const { source } = response;
      const res = common.getRawResponse(request);

      // HAPI uses 'Boom' as their error handler, sometimes the response will create a JSON output
      // that will be sent as a response instead of a usual response. Attach this output just in case,
      // which can be used in rules (such as Method Tampering)
      res._cs_output = _.get(response, 'output');
      self.emitter.emit('response.data', request, res, source);
      self.emitter.emit('http.requestEnd', request, res);

      common.emitSinkEvent(source, SINK_TYPES.RESPONSE_BODY, ID);

      Hapi16Framework.resume(reply);
    });
  }

  /**
   * Check of the reply interfaces has already been replied with.
   * If it has been, calling continue can result in an internal error.
   *
   * @param {hapi.Reply} reply
   */
  static resume(reply) {
    const _replied = _.get(reply, '_replied');
    if (!_replied) {
      return reply.continue();
    }
  }
}

module.exports = Hapi16Framework;
