# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true cs__scoped_require 'resolv' cs__scoped_require 'timeout' cs__scoped_require 'contrast/utils/object_share' cs__scoped_require 'contrast/utils/string_utils' cs__scoped_require 'contrast/utils/comment_range' cs__scoped_require 'contrast/utils/hash_digest' cs__scoped_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 :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}' OMITTED_BODY = '{{body-omitted-by-contrast}}' attr_reader :rack_request def_delegators :@rack_request, :env, :query_string, :user_agent, :path, :base_url # receiver is memoized because it is the address/host/port of the server, once we # resolve this for the first time, it shouldn't change def self.receiver @_receiver ||= build_receiver end def initialize rack_request @rack_request = rack_request end ACCEPT = 'ACCEPT' def accept_headers accepts = Array(normalized_request_headers[ACCEPT]) accepts.any? ? accepts : nil end STATIC_SUFFIXES = /\.(?:js|css|jpeg|jpg|gif|png|ico|woff|svg|pdf|eot|ttf|jar)$/i.cs__freeze WILDCARD = '*/*' # 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, like the following, and false otherwise: .js, .css, .jpg, # .gif, .png, .ico def static_request? return true if trimmed_uri&.match?(STATIC_SUFFIXES) accepts = accept_headers if accepts return false if accepts[0].to_s.start_with?(WILDCARD) return true if media_content_type?(accepts[0]) end false end MEDIA_TYPE_MARKERS = %w[image/ text/css text/javascript].cs__freeze def media_content_type? str str = str.to_s str.start_with?(*MEDIA_TYPE_MARKERS) end def trimmed_uri @_trimmed_uri ||= begin raise ArgumentError, 'url was nil when attempting to trim' unless uri trimmed = uri.split(Contrast::Utils::ObjectShare::SEMICOLON).first # remove ;jsessionid trimmed.split(Contrast::Utils::ObjectShare::QUESTION_MARK).first # remove ?query_string= end 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 uri = trimmed_uri uri = uri.gsub(INNER_REST_TOKEN, INNER_NUMBER_MARKER) # replace interior tokens uri.gsub(LAST_REST_TOKEN, LAST_NUMBER_MARKER) # replace last token end end UNKNOWN_REQUEST_METHOD = 'UNKNOWN' def request_method rack_request.get_header(Rack::REQUEST_METHOD) rescue StandardError => e logger.warn("Unable to extract request method: #{ e }") UNKNOWN_REQUEST_METHOD end def document_type_from_header case content_type when /xml/i :XML when /json/i :JSON when /html/i :NORMAL end end def document_type @_document_type ||= begin type = document_type_from_header if type type elsif request_body_str.start_with?(' Contrast::Utils::StringUtils.force_utf8(val.path) } 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 request_body # Memoize a flag indicating whether we've tried to read the body or not # (can't use body because it might be nil) @_request_body_read ||= begin body = @rack_request.body if defined?(Rack::Multipart) if defined?(Rack::Multipart::UploadedFile) && body.is_a?(Rack::Multipart::UploadedFile) logger.debug("not parsing uploaded file body :: #{ body.original_filename }::#{ body.content_type }") @_request_body = nil else logger.debug("parsing body from request :: #{ body.cs__class.cs__name }") @_request_body = Contrast::Utils::StringUtils.force_utf8(read_body(body), logger) end else logger.debug('Rack before 1.3.x does not support Rack::Multipart') @_request_body = Contrast::Utils::StringUtils.force_utf8(read_body(body), logger) end true end # Return memoized body (which might be nil) @_request_body end def request_body_str request_body.to_s || Contrast::Utils::ObjectShare::EMPTY_STRING end # This will become to_protobuf # Expectation is that all data from a previous phase will be populated # before the subsequent one begins def dtm @_dtm ||= begin with_contrast_scope do http_request = Contrast::Api::Dtm::HttpRequest.new http_request.uuid = Contrast::Utils::StringUtils.force_utf8(__id__) http_request.timestamp_ms = Contrast::Utils::Timer.now_ms.to_i append_receiver(http_request) append_connection(http_request) append_params(http_request) append_headers(http_request) append_body(http_request) http_request end end end def append_receiver http_request r = cs__class.receiver r.port = port.to_i if port http_request.receiver = r unless r.nil? end def append_connection http_request http_request.sender = Contrast::Api::Dtm::Address.new http_request.sender.ip = Contrast::Utils::StringUtils.force_utf8(ip) http_request.protocol = Contrast::Utils::StringUtils.force_utf8(scheme) http_request.version = '1.1' # currently not in rack request; hard-coding http_request.method = Contrast::Utils::StringUtils.force_utf8(request_method) http_request.raw = Contrast::Utils::StringUtils.force_utf8(@rack_request.path_info) http_request.parsed_connection = false end def append_params http_request parameters.each do |k, v| next unless k && v next if v.is_a?(Hash) key = Contrast::Utils::StringUtils.force_utf8(k) val = Contrast::Utils::StringUtils.force_utf8(v) params = http_request.normalized_request_params params[key] = Contrast::Api::Dtm::Pair.new unless params[key].is_a?(Contrast::Api::Dtm::Pair) params[key].key = key params[key].values << val end end def append_headers http_request request_headers.each do |k, v| next unless k && v next if v.is_a?(Hash) key = Contrast::Utils::StringUtils.force_utf8(k) val = Contrast::Utils::StringUtils.force_utf8(v) http_request.request_headers[key] = val end http_request.parsed_request_headers = true normalized_request_headers.each do |k, v| next unless k && v next if v.is_a?(Hash) key = Contrast::Utils::StringUtils.force_utf8(k) val = Contrast::Utils::StringUtils.force_utf8(v) http_request.normalized_request_headers[key] ||= Contrast::Api::Dtm::Pair.new http_request.normalized_request_headers[key].key = key http_request.normalized_request_headers[key].values << val end request_cookies.each do |k, v| next unless k && v next if v.is_a?(Hash) key = Contrast::Utils::StringUtils.force_utf8(k) val = Contrast::Utils::StringUtils.force_utf8(v) http_request.normalized_cookies[key] ||= Contrast::Api::Dtm::Pair.new http_request.normalized_cookies[key].key = key http_request.normalized_cookies[key].values << val end end def append_body http_request http_request.document_type = Contrast::Utils::StringUtils.force_utf8(document_type) http_request.request_body = if omit_body? OMITTED_BODY else Contrast::Utils::StringUtils.force_utf8(request_body) end return if file_names.empty? file_names.each do |name, filename| pair = Contrast::Api::Dtm::SimplePair.new pair.key = Contrast::Utils::StringUtils.force_utf8(name) pair.value = Contrast::Utils::StringUtils.force_utf8(filename) http_request.multipart_headers << pair end end def omit_body? return true if Contrast::Agent::FeatureState.instance.omit_body? return false if document_type == :XML return false if document_type == :JSON content_type&.include?('multipart/form-data') end def self.build_receiver address = Contrast::Api::Dtm::Address.new address.host = 'localhost' address.ip = '127.0.0.1' begin Timeout.timeout(1) do address.host = Contrast::Utils::StringUtils.force_utf8(Socket.gethostname) address.ip = Contrast::Utils::StringUtils.force_utf8(Resolv.getaddress(address.host)) end rescue Timeout::Error logger.warn(nil, "Timeout resolving host or ip in #{ address }") rescue StandardError => e logger.warn(e, "Error resolving address for #{ address }") end address end # Memoized Rack Request Values def ip @_ip ||= @rack_request.ip end def scheme @_scheme ||= @rack_request.scheme end def port @_port ||= @rack_request.port end def uri @_uri ||= @rack_request.path end def url @_url ||= @rack_request.url end def content_type @_content_type ||= @rack_request.content_type end def request_cookies @_request_cookies ||= @rack_request.cookies end def parameters @_parameters ||= with_contrast_scope { normalize_params(@rack_request.params) } end # End of Rack Request memoized values def file_names @_file_names ||= begin names = {} parsed_data = Rack::Multipart.parse_multipart(@rack_request.env) traverse_parsed_multipart(parsed_data, names) rescue StandardError => _e logger.warn('Unable to parse multipart request!') {} end end def hash_id @_hash_id ||= Contrast::Utils::HashDigest.generate_request_hash(self) end # we just need to map length to a repeatable value # unlike Java, we hash with strings, so we'll use single character # strings for our purposes. CHARS = %w[a b c d e f g].cs__freeze def normalized_length_header chr chr = chr.to_s tmp = CHARS[Math.log10(chr.length).to_i] if chr tmp ||= CHARS[6] tmp end private HTTP_PREFIX = /^[Hh][Tt][Tt][Pp][_-]/i.cs__freeze def header_pairs env selected = env.select do |k, _v| k.to_s.start_with?(Contrast::Utils::ObjectShare::HTTP_SCORE) end selected.map do |k, v| name = k.to_s.sub(HTTP_PREFIX, Contrast::Utils::ObjectShare::EMPTY_STRING) [name, v] 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 :: #{ e.message }") logger.debug(e.backtrace.join(Contrast::Utils::ObjectShare::NEW_LINE)) 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