/**
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.
*/
/**
 * @module lib/hooks/http
 */
'use strict';

const moduleHook = require('./require');
const {
  INPUT_TYPES,
  SINK_TYPES,
  HTTP_RESPONSE_HOOKED_METHOD_KEYS: { END, WRITE_HEAD }
} = require('../constants');
const agentEmitter = require('../agent-emitter');
const { AsyncStorage, KEYS } = require('../core/async-storage');
const scopes = require('../core/async-storage/scopes');
const {
  emitSourceEvent,
  emitSinkEvent,
  createSourceEvent
} = require('./frameworks/common');
const logger = require('../core/logger')('contrast:hooks');
const patcher = require('./patcher');
const { PATCH_TYPES } = require('../constants');

const flatten = (ary) => Array.prototype.join.apply(ary, [',']);

/**
 * Logs stack for every res.[end, writeHead, write] call
 *
 * @param {ServerResponse} response
 * @param {string} method method to hook
 */
const logHook = (response, method) => {
  patcher.patch(response, method, {
    alwaysRun: true,
    name: 'req-log',
    patchType: PATCH_TYPES.MISC,
    post(data) {
      const { stack } = new Error();
      logger.info('res.%s(%s):%o', method, flatten(data.args), stack);
    }
  });
};

/**
 * Hooks the Server prototype's <code>emit</code> method.
 * @param {Server} server The Server prototype
 */
const hookServer = (server, agent, id) => {
  patcher.patch(server, 'listen', {
    alwaysRun: true,
    name: 'server-listen-log-time',
    patchType: PATCH_TYPES.MISC,
    pre() {
      logger.info(`${process.pid}: http listen after ${process.uptime()}`);
    }
  });

  // Wrap the request emit in a contrast domain to track non-dataflow hooks, and for storage.
  const { emit } = server;
  server.emit = function newEmit(...args) {
    const self = this;
    const [event, req, res] = args;

    if (event !== 'request') {
      return emit.apply(self, args);
    }

    logger.debug('Received request %s, method %s', req.url, req.method);
    return scopes.runInRequestScope({
      agent,
      req,
      res,
      hookResponse(response, agent) {
        const { writeHead, end } = response;
        // XXX(ehden): we keep the original method available to call when handling
        // blocked requests because of MethodTampering or other rules whose sink
        // type is RESPONSE_STATUS and which can block at perimeter. We call the original
        // when we block to prevent recursing through blocks by setting our own 403.
        response[WRITE_HEAD] = writeHead;
        response[END] = end;

        /**
         * Patch writeHead to emit a sink event before calling writeHead
         *
         */
        patcher.patch(response, 'writeHead', {
          alwaysRun: true,
          name: 'write-head',
          patchType: PATCH_TYPES.PROTECT_SINK,
          pre(data) {
            const [statusCode, reason] = data.args;
            let [, , obj] = data.args;
            let contentType;
            if (typeof reason !== 'string') {
              obj = reason;
            }
            if (obj && typeof obj === 'object') {
              for (const [key, value] of Object.entries(obj)) {
                if (key.toLowerCase() === 'content-type') {
                  contentType = value;
                }
              }
            }
            if (contentType) {
              AsyncStorage.set(KEYS.RESPONSE_CONTENT_TYPE, contentType);
            }
            emitSinkEvent(statusCode, SINK_TYPES.RESPONSE_STATUS, id, false);
          }
        });

        patcher.patch(response, 'setHeader', {
          alwaysRun: true,
          name: 'set-header',
          patchType: PATCH_TYPES.ASYNC_CONTEXT,
          pre(data) {
            const [name = '', value] = data.args;
            if (name.toLowerCase() === 'content-type' && value) {
              AsyncStorage.set(KEYS.RESPONSE_CONTENT_TYPE, value);
            }
          }
        });

        // special HTTP logging for responses.
        if (agent.config.agent.logger.log_outbound_http) {
          ['end', 'writeHead', 'write'].forEach((method) => {
            logHook(response, method);
          });
        }
      },
      callback() {
        const ipEvent = createSourceEvent(
          req,
          req,
          res,
          'socket.remoteAddress',
          INPUT_TYPES.IP,
          id
        );
        agentEmitter.emit('http.requestStart', req, res, ipEvent);

        emitSourceEvent(req, req, res, 'method', INPUT_TYPES.METHOD, id);
        emitSourceEvent(req, req, res, 'headers', INPUT_TYPES.HEADER, id);
        emitSourceEvent(req, req, res, 'url', INPUT_TYPES.URL, id);

        const rv = emit.apply(self, args);

        agentEmitter.emit('http.requestEnd', req, res);
        return rv;
      }
    });
  };
};

module.exports = (agent) => {
  logger.info('applying non-policy hook: http');

  moduleHook.resolve({ name: 'http' }, (http) => {
    hookServer(http.Server.prototype, agent, 'http');
  });

  moduleHook.resolve({ name: 'https' }, (https) => {
    hookServer(https.Server.prototype, agent, 'https');
  });

  moduleHook.resolve({ name: 'http2' }, (http2) => {
    patcher.patch(http2, 'createServer', {
      name: `create-server-http2-hooks`,
      patchType: PATCH_TYPES.MISC,
      alwaysRun: true,
      post({ result }) {
        hookServer(result, agent);
      }
    });

    patcher.patch(http2, 'createSecureServer', {
      name: `create-secure-server-http2-hooks`,
      patchType: PATCH_TYPES.MISC,
      alwaysRun: true,
      post({ result }) {
        hookServer(result, agent);
      }
    });
  });

  moduleHook.resolve({ name: 'spdy' }, (spdy) => {
    patcher.patch(spdy, 'createServer', {
      name: `create-server-spdy-hooks`,
      patchType: PATCH_TYPES.MISC,
      alwaysRun: true,
      post({ result }) {
        hookServer(result, agent);
      }
    });
  });
};
module.exports.hookServer = hookServer;
