/**
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 requireHook = require('../hooks/require');
const ApplicationContext = require('./models/application-context');
const AsyncStorage = require('../core/async-storage');

const LOGGER_NS = 'contrast:hooks:http:input-analysis';

class InputAnalysisSensor {
  constructor({ agent, protectService, logger }) {
    this.agent = agent;
    this.maxSize = this.agent.config.agent.node.unsafe.sr_max_body_size;
    this.logger = logger(LOGGER_NS);
    this.service = protectService;
    this.requestMeta = new WeakMap();
    this.installed = false;
  }

  /**
   * After checking whether the sensor is enabled, will set up `require` hooks
   * for instrumenting both `http` and `https` modules when they load.
   */
  install() {
    if (this.installed) {
      return;
    }
    this.installed = true;
    this.hookHttp();
    this.hookHttps();
  }

  /**
   * Sets hooks to instrument `http.Server.prototype`.
   */
  hookHttp() {
    this.logger.debug('hooking http library');
    requireHook.resolve({ name: 'http' }, this.hookServer.bind(this));
  }

  /**
   * Sets hooks to instrument `https.Server.prototype`.
   */
  hookHttps() {
    this.logger.debug('hooking https library');
    requireHook.resolve({ name: 'https' }, this.hookServer.bind(this));
  }

  /**
   * When an IncomingMessage is first handled by the server, this will
   * initialize metadata state that will be used during subsequent event
   * handling operations.
   * @param {IncomingMessage} req
   */
  initMetadata(req) {
    /*
     * Save request context so it's available when processing the body
     *
     * Why do we do this - shouldn't our AsyncStorage layer handle this for us?
     * You would think, but not in this case.
     *
     * At this point cls-hooked has monkey-patched the req EventEmitter so that
     * when emit is called it restores the async context.  We then monkey patch
     * emit to process the body.  We can think of monkey patching like a stack
     * the last patch installed is the first to run.  So when our patch runs
     * cls-hooked hasn't done it's thing to restore context, and the request
     * context might not be available.  So we explicitly save a reference to it.
     */
    const asyncStorageContext = AsyncStorage.getContext();
    const meta = {
      draining: false,
      drained: false,
      hasEmittedData: false,
      chunks: [],
      bodyLength: 0,
      asyncStorageContext
    };
    this.requestMeta.set(req, meta);
    return meta;
  }

  /**
   * Instruments the `Server` prototype from `http(s)`. This patches `emit` and
   * calls into the protect service to do analysis when appropriate.
   * @param {Object} xport The http(s) module export
   */
  hookServer(xport) {
    const self = this;

    const {
      Server: {
        prototype: { emit }
      }
    } = xport;

    xport.Server.prototype.emit = function(...args) {
      const [type] = args;

      if (type !== 'request') {
        return emit.call(this, ...args);
      }

      const context = { instance: this, method: emit, args };
      self.processRequestAndEmit(context);

      return !!this._events[type];
    };
  }

  /**
   * Sends connection/header/uri info to service for input analysis.
   * @param {Object} context Function invokation context
   */
  async processRequestAndEmit(context) {
    const {
      instance,
      method,
      args,
      args: [, req, res]
    } = context;

    const meta = this.initMetadata(req);
    this.hookRequestListenerArgs(req, res);

    let permit = true;
    try {
      const appContext = InputAnalysisSensor.makeApplicationContext(method);
      permit = await this.service.analyzeRequest({
        meta,
        req,
        res,
        appContext
      });
    } catch (error) {
      this.logger.error('Error during input analysis %o', error);
    }

    if (!permit) {
      return;
    }

    setImmediate(() => method.call(instance, ...args));
  }

  /**
   * Instruments the IncomingMessage instance as it's handled as the server
   * publishes the 'request' event.
   * @param {IncomingMessage} req
   * @param {ServerResponse} res
   */
  hookRequestListenerArgs(req, res) {
    const self = this;

    const { emit } = req;
    req.emit = function(...args) {
      const [type] = args;
      const orig = () => emit.call(this, ...args);

      const meta = self.requestMeta.get(req);

      if (!meta || self.skipInputAnalysis(type, meta)) {
        return orig();
      }

      self.cacheChunk(meta, args);
      this._readableState.autoDestroy = false;
      const context = { instance: this, method: emit, args };

      // The request exceeeds the max length SR can process
      // so we must emit the data events that we have been
      // deferring until SR input analysis could be done
      if (self.requestTooLarge(meta) && !meta.draining) {
        self.logger.debug(
          'request is too large, draining deferred data that was already emitted'
        );
        meta.draining = true;
        meta.drained = false;
        InputAnalysisSensor.emitEvents({ ...context, meta });
      } else if (!self.requestTooLarge(meta)) {
        self.processBodyAndEmit(meta, context, req, res);
      } else {
        // do nothing the chunk was already added to the metadata above so it is being emitted
        // I am not putting a log message because I worry about perf of the llog event
      }

      return !!this._events[type];
    };
  }

  /**
   * Checks if the request exceeds the agent.node.unsafe.sr_max_body_size
   * SR cannot handle requests >= 16 MBs
   *
   * Also checks if end event and empty body
   * Lastly checks if the event is not end or data
   *
   * @param {string} type of emit on request
   * @param {object} meta keep track of all chunks emitted and size of said chunks
   */
  skipInputAnalysis(type, meta) {
    return (
      (this.requestTooLarge(meta) && meta.drained) ||
      (type === 'end' && !meta.hasEmittedData) ||
      (type !== 'end' && type !== 'data')
    );
  }

  /**
   * Keeps track of chunks in an array
   * Also keeps track of total size of chunks emitted
   *
   * @param {object} meta keep track of all chunks emitted and size of said chunks
   * @param {array} args args to req.emit
   */
  cacheChunk(meta, args) {
    const [type, chunk] = args;
    if (type === 'data') {
      meta.chunks.push(chunk);
      meta.hasEmittedData = true;
      meta.bodyLength += chunk.length;
    }
  }

  /**
   * Check if the request message size is bigger than the largest that speedracer
   * can support.
   * @param   {Number} msgSize size of message to send to speedracer
   * @returns {Boolean}        is it larger than the config value of max?
   */
  requestTooLarge(meta) {
    const msgSize = meta.bodyLength;
    const sizeInMb = msgSize / 1024 / 1024;
    return sizeInMb >= this.maxSize;
  }

  /**
   * We defer calling the original req.emit of data chunks
   * and end until SR analyzes the request body.
   *
   * If the request is done and permit is false we prevent
   * calling the original data and end emits
   *
   * If the request is done and permit is true we will
   * call the original data and end events in order within
   * a setImmediate to properly simulate the original emit events
   */
  async processBodyAndEmit(meta, context, req, res) {
    const { args, method } = context;
    // the request is done when the end event is emitted
    const done = args[0] === 'end';

    let permit = true;

    try {
      let appContext;

      if (done) {
        appContext = InputAnalysisSensor.makeApplicationContext(method);

        permit = await this.service.analyzeRequestStream({
          meta,
          req,
          res,
          appContext
        });
      }
    } catch (error) {
      this.logger.error('Error during input analysis %o', error);
    }

    if (!permit) {
      return;
    }

    if (done) {
      InputAnalysisSensor.emitEvents({ ...context, meta });
    }
  }

  /**
   * This emits the original data and end events that were deferred
   * until SR input analysis was done or if the request is too large
   */
  static emitEvents({ instance, method, args, meta }) {
    setImmediate(InputAnalysisSensor.emitOne, {
      instance,
      method,
      args,
      index: 0,
      meta
    });
  }

  /**
   * Recursively calls the emit events with the proper chunks
   * in a setImmediate
   */
  static emitOne({ instance, method, args, index, meta }) {
    const { chunks } = meta;
    if (index === chunks.length) {
      InputAnalysisSensor.endEvent({ instance, method, args, meta });
    } else {
      // emit each chunk and recurse to process next chunk
      method.call(instance, 'data', chunks[index]);
      setImmediate(InputAnalysisSensor.emitOne, {
        instance,
        method,
        args,
        index: ++index,
        meta
      });
    }
  }

  /**
   * We have finished flushing the data events
   * Depending on the state of the stream we either have to resume or emit
   */
  static endEvent({ instance, method, args, meta }) {
    meta.draining = false;
    meta.drained = true;
    // stream is finished, call end manually
    if (args[0] === 'data' && instance.readableEnded) {
      method.call(instance, 'end');
      // stream is done flushing but events are still being emitted, resume stream
    } else if (args[0] === 'data' && !instance.ReadableEnded) {
      instance.resume();
      // this is the end event in the case that SR processed
      // the body and there were no input analysis vectors
    } else {
      method.call(instance, ...args);
    }
  }

  /**
   * Makes a new app context and sets the stack with
   * the proper request handler
   */
  static makeApplicationContext(handle) {
    const appContext = new ApplicationContext();
    appContext.setStack(handle);
    return appContext;
  }
}

module.exports = InputAnalysisSensor;
