# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'contrast/agent/protect/rule/xxe/xxe'
require 'contrast/agent/protect/policy/rule_applicator'
require 'contrast/utils/object_share'

module Contrast
  module Agent
    module Protect
      module Policy
        # This Module is how we apply the XXE rule. It is called from our patches
        # of the targeted methods in which XML parsing and entity resolution
        # occurs. It is responsible for deciding if the infilter methods of the
        # rule should be invoked.
        module AppliesXxeRule
          extend Contrast::Agent::Protect::Policy::RuleApplicator

          class << self
            def apply_rule method, _exception, _properties, object, args
              xml = args[0]
              xxe_check(method, xml, object)
            end

            # IO is tricky. If we can't rewind it, we can't fix it back to the
            # original state. To be safe, we'll skip non-rewindable IO objects.
            def apply_rule__io method, _exception, _properties, object, args
              need_rewind = false
              potential_xml = args[0]
              return unless potential_xml.cs__respond_to?(:rewind)

              xml = potential_xml.read
              need_rewind = true
              xxe_check(method, xml, object)
            ensure
              potential_xml.rewind if need_rewind
            end

            # Oga's Lexer is a special case b/c the information we need is on the
            # object itself -- specifically in the @data instance variable
            def apply_rule__lexer method, _exception, _properties, object, _args
              return unless valid_data_input?(object)

              data = object.instance_variable_get(DATA_KEY)
              xxe_check(method, data, object)
            ensure
              data.rewind if data&.cs__respond_to?(:rewind)
            end

            protected

            def rule_name
              Contrast::Agent::Protect::Rule::Xxe::NAME
            end

            private

            DATA_KEY = :@data
            def valid_data_input? object
              object.instance_variable_defined?(DATA_KEY) && object.instance_variable_get(DATA_KEY)
            end

            NOKOGIRI_MARKER = 'Nokogiri::'
            PARSER_NOKOGIRI = 'Nokogiri'
            OX_MARKER = 'Ox' # breaks marker pattern b/c Ox is entire classname
            PARSER_OX = 'Ox'
            OGA_MARKER = 'Oga::'
            PARSER_OGA = 'Oga'
            # Given an object, determine the XML parser type that it represents.
            #
            # @param object[Object] the parsing instance or Module
            # @return [String] the name of the parser
            def determine_parser object
              clazz = object.is_a?(Module) ? object : object.cs__class
              name = clazz.cs__name

              if name.start_with?(NOKOGIRI_MARKER)
                PARSER_NOKOGIRI
              elsif name.start_with?(OX_MARKER)
                PARSER_OX
              elsif name.start_with?(OGA_MARKER)
                PARSER_OGA
              end
            end

            # Given an xml, convert it to a String and pass it to the rule for
            # analysis.
            #
            # @param method [Symbol] the name of the method doing this work.
            # @param xml [Object] the container of the XML to be checked.
            # @param potential_parser [Object] the entity that may be an XML
            #   parser.
            # @raise [Contrast::SecurityException] Security exception if an XXE
            #   attack is found and the rule is in block mode.
            def xxe_check method, xml, potential_parser
              return if skip_analysis?
              return unless xml

              parser = determine_parser(potential_parser)
              return unless parser

              if xml.cs__is_a?(String)
                rule.infilter(Contrast::Agent::REQUEST_TRACKER.current, parser, xml)
              elsif xml.cs__respond_to?(:each_line)
                xml.each_line do |line|
                  rule.infilter(Contrast::Agent::REQUEST_TRACKER.current, parser, line)
                end
              elsif xml.cs__respond_to?(:each)
                xml.each do |chunk|
                  rule.infilter(Contrast::Agent::REQUEST_TRACKER.current, parser, chunk)
                end
              end
            rescue Contrast::SecurityException => e
              raise(e)
            rescue StandardError => e
              parser ||= Contrast::Utils::ObjectShare::UNKNOWN
              logger.error('Error applying xxe', e, module: potential_parser.cs__class.cs__name, method: method,
                                                    parser: parser)
            end
          end
        end
      end
    end
  end
end