/**
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 moduleHook = require('../../../hooks/require');
const patcher = require('../../../hooks/patcher');
const { Signature, CallContext } = require('../../models');
const policy = require('../../policy');
const { get } = require('lodash');
const agentEmitter = require('../../../agent-emitter');
const {
  constants: { EVENTS, ORIG_FUNC, LIFECYLE_METHODS }
} = require('../../../core/hapi');
const stackFactory = require('../../../core/stacktrace').singleton;
const { AsyncStorage, KEYS } = require('../../../core/async-storage');
const semver = require('semver');
const { funcInfo } = require('@contrast/fn-inspect');
const { PATCH_TYPES } = require('../../../constants');

class HapiXssSink {
  constructor(agent) {
    this.common = require('../../sinks/common')(agent);
    this.rules = get(policy, 'rules');
    this.ruleId = 'reflected-xss';
    this.signature = new Signature({
      moduleName: 'h',
      methodName: 'response',
      isModule: true
    });

    this.instrumentToolkit();
    this.registerRouteHandler();
    this.registerXssHandler();
  }

  /**
   * checks if an assess rule is enabled in policy
   */
  get enabled() {
    return get(this.rules, `${this.ruleId}.enabled`);
  }

  /**
   * Adds a listeners for when `router.add` is called to get access to the
   * route handler, to save that function reference to report a more accurate
   * stack trace for xss
   */
  registerRouteHandler() {
    /**
     * @param {Route} route hapi route
     */
    agentEmitter.on(EVENTS.HAPI_ADD_ROUTE, (route) => {
      const { handler } = route.settings;
      Object.defineProperty(route.settings.handler, ORIG_FUNC, {
        value: handler
      });
    });
  }

  /**
   * Implements a koa `onRoute` hook to wrap the handler
   * if it is a promise
   */
  instrumentToolkit() {
    moduleHook.resolve(
      { name: 'hapi', file: 'lib/toolkit' },
      this.registerExecuteHandler.bind(this)
    );
    moduleHook.resolve(
      { name: '@hapi/hapi', file: 'lib/toolkit' },
      this.registerExecuteHandler.bind(this)
    );
  }

  /**
   * Sets both the route handler and stack
   * in AsyncStorage
   *
   * @param {Toolkit} Toolkit module
   * @param {Object} pkg meta for hapi
   */
  registerExecuteHandler(Toolkit, meta) {
    // Hapi 19 exports this on Manager now
    if (semver.gte(meta.version, '19.0.0')) {
      Toolkit = Toolkit.Manager;
    }

    patcher.patch(Toolkit.prototype, 'execute', {
      name: 'hapi.toolkit.execute',
      patchType: PATCH_TYPES.FRAMEWORK,
      alwaysRun: true,
      pre(data) {
        let handler = data.args[0];

        if (!LIFECYLE_METHODS.includes(handler.name)) {
          // if route coverage is enabled we put the original function
          // on the wrap in a Symbol, use that if it exists
          handler = handler[ORIG_FUNC] || handler;
          const topFrame = funcInfo(handler);
          const stacktrace = stackFactory.createSnapshot({
            constructorOpt: data.hooked,
            prependFrames: [topFrame]
          });
          AsyncStorage.set(KEYS.HAPI_CALLER, { handler, stack: stacktrace });
        }
      }
    });
  }

  hasViewEngineSource(source) {
    if (typeof source === 'object' && source.manager && source.context) {
      return true;
    }

    return false;
  }

  /**
   * Checks the response in a `onPreResponse` hook for xss vulns
   */
  registerXssHandler() {
    /**
     * @param {Request} request Hapi request object
     */
    agentEmitter.on(EVENTS.HAPI_PRE_RES, (request) => {
      if (!this.enabled) {
        return;
      }
      const { ruleId, signature } = this;
      const {
        response: { source }
      } = request;
      const {
        report,
        isVulnerable,
        xss: { disallowedTags },
        requiredTags
      } = this.common;

      const input = this.hasViewEngineSource(source) ? source.context : source;

      if (
        isVulnerable({
          input,
          disallowedTags,
          requiredTags,
          searchDepth: typeof input === 'string' ? 0 : Infinity,
          ruleId
        })
      ) {
        const { handler, stack: stacktrace } = AsyncStorage.get(
          KEYS.HAPI_CALLER
        );
        const ctxt = CallContext.create({
          obj: handler,
          args: [source],
          result: source,
          stacktrace
        });

        report({ ruleId, signature, input: source, ctxt });
      }
    });
  }
}

module.exports = HapiXssSink;
