# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'resolv' require 'timeout' require 'contrast/utils/object_share' require 'contrast/utils/string_utils' require 'contrast/utils/hash_digest' require 'contrast/components/interface' module Contrast module Agent # This class is the Contrast representation of the Rack::Request object. It # provides access to the original Rack::Request object as well as extracts # data in a format that the Agent expects, caching those transformations in # order to avoid repeatedly creating Strings & thrashing GC. class Request include Contrast::Components::Interface access_component :agent, :logging, :scope extend Forwardable INNER_REST_TOKEN = %r{/\d+/}.cs__freeze LAST_REST_TOKEN = %r{/\d+$}.cs__freeze INNER_NUMBER_MARKER = '/{n}/' LAST_NUMBER_MARKER = '/{n}' attr_reader :rack_request # Delegate calls to the following methods to the attribute @rack_request def_delegators :@rack_request, :base_url, :content_type, :cookies, :env, :ip, :path, :port, :query_string, :request_method, :scheme, :url, :user_agent def initialize rack_request @rack_request = rack_request end # Returns a normalized form of the URI. In "normal" URIs # this will return an unchanged String, but in REST-y # URIs this will normalize the digit path tokens, e.g.: # # /accounts/5/view # ...becomes: # /accounts/{n}/view # # Should also handle the ;jsessionid. def normalized_uri @_normalized_uri ||= begin path = rack_request.path uri = path.split(Contrast::Utils::ObjectShare::SEMICOLON)[0] # remove ;jsessionid uri = uri.split(Contrast::Utils::ObjectShare::QUESTION_MARK)[0] # remove ?query_string= uri.gsub(INNER_REST_TOKEN, INNER_NUMBER_MARKER) # replace interior tokens uri.gsub(LAST_REST_TOKEN, LAST_NUMBER_MARKER) # replace last token end end def document_type @_document_type ||= begin if /xml/i.match?(content_type) || body&.start_with?(' _e logger.warn('Unable to parse multipart request!') {} end end def hash_id @_hash_id ||= Contrast::Utils::HashDigest.generate_request_hash(self) end STATIC_SUFFIXES = /\.(?:js|css|jpeg|jpg|gif|png|ico|woff|svg|pdf|eot|ttf|jar)$/i.cs__freeze MEDIA_TYPE_MARKERS = %w[image/ text/css text/javascript].cs__freeze # Utility method for checking if a request is for a static resource. # @return [Boolean] true, if the request is for a well-known static # type as determined by the request suffix or the accept header. def static? return true if normalized_uri&.match?(STATIC_SUFFIXES) accepts = Array(headers['ACCEPT'])&.first&.to_s return false unless accepts return false if accepts.start_with?('*/*') accepts.start_with?(*MEDIA_TYPE_MARKERS) end private # Return a flattened hash of params with realized paths for keys, in # addition to a separate, valueless, entry for each nest key. # See RUBY-621 for more details. # { key : { nested_key : ['x','y','z' ] } } # becomes # { # key[nested_key][0] : 'x' # key[nested_key][1] : 'y' # key[nested_key][2] : 'z' # key : '' # nested_key : '' # } def normalize_params val, prefix: nil # In non-recursive invocations, val should always be a Hash # (rather than breaking this out into two methods) case val when Tempfile # Skip if it's the auto-generated value from rails when it handles # file uploads. The file name will still be sent to SR for analysis. {} when Hash res = val.each_with_object({}) do |(k, v), hash| k = Contrast::Utils::StringUtils.force_utf8(k) nested_prefix = prefix.nil? ? k : "#{ prefix }[#{ k }]" hash[k] = Contrast::Utils::ObjectShare::EMPTY_STRING hash.merge! normalize_params(v, prefix: nested_prefix) end res[prefix] = Contrast::Utils::ObjectShare::EMPTY_STRING if prefix res when Enumerable res = val.each_with_index.each_with_object({}) do |(v, i), hash| hash.merge! normalize_params(v, prefix: "#{ prefix }[#{ i }]") end res[prefix] = Contrast::Utils::ObjectShare::EMPTY_STRING if prefix res else { prefix => Contrast::Utils::StringUtils.force_utf8(val) } end end def read_body body return body if body.is_a?(String) begin can_rewind = Contrast::Utils::DuckUtils.quacks_to?(body, :rewind) # if we are after a middleware that failed to rewind body.rewind if can_rewind body.read rescue StandardError => e logger.error('Error in attempt to read body', message: e.message) logger.trace('With Stack', e) body.to_s ensure # be a good citizen and rewind body.rewind if can_rewind end end def traverse_parsed_multipart multipart_data, current_names return current_names unless multipart_data multipart_data.each_value do |data_value| next unless data_value.is_a?(Hash) tempfile = data_value[:tempfile] if tempfile.nil? traverse_parsed_multipart(data_value, current_names) else name = data_value[:name].to_s file_name = data_value[:filename].to_s current_names[name] = file_name end end current_names end end end end