# Copyright (c) 2022 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/logger' require 'contrast/components/scope' require 'contrast/utils/request_utils' 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. # # @attr_reader rack_request [Rack::Request] The passed to the Agent RackRequest to be wrapped. # @attr_accessor route [Contrast::Api::Dtm::RouteCoverage] the route, used for findings, of this request # @attr_accessor observed_route [Contrast::Api::Dtm::ObservedRoute] the route, used for coverage of this request # @attr_accessor new_observed_route [Contrast::Agent::Reporting::ObservedRoute] the route, used for coverage, of # this request class Request include Contrast::Utils::RequestUtils include Contrast::Components::Logger::InstanceMethods include Contrast::Components::Scope::InstanceMethods extend Forwardable INNER_REST_TOKEN = %r{/\d+/}.cs__freeze LAST_REST_TOKEN = %r{/\d+$}.cs__freeze INNER_NUMBER_MARKER = '/{n}/' LAST_NUMBER_MARKER = '/{n}' 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 attr_reader :rack_request attr_accessor :route, :observed_route, :new_observed_route # Delegate calls to the following methods to the attribute @rack_request def_delegators :@rack_request, :base_url, :cookies, :env, :ip, :media_type, :path, :port, :query_string, :request_method, :scheme, :url, :user_agent # Initialize new Contrast Request # # @param rack_request [Rack::Request] The passed to the Agent RackRequest to be wrapped. def initialize rack_request @rack_request = rack_request end # The HTTP version of this request # @return [String] def version env = rack_request.env return '1.1' unless env version = env['HTTP_VERSION'] return '1.1' unless version version.split('/')[-1] 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. # # @return uri [String] def normalized_uri @_normalized_uri ||= begin path = rack_request.path_info 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 # Returns the request file type # # @return type [Symbol<:XML, :JSON, :NORMAL>] def document_type @_document_type ||= if /xml/i.match?(media_type) || body&.start_with?('<?xml') :XML elsif /json/i.match?(media_type) || body&.match?(/\s*[{\[]/) :JSON else :NORMAL end end # Header keys upcased and any underscores replaced with dashes # # @return headers [Hash] def headers @_headers ||= with_contrast_scope do hash = {} env.each do |key, value| next unless key name = key.to_s next unless name.start_with?(Contrast::Utils::ObjectShare::HTTP_SCORE) hash[Contrast::Utils::StringUtils.normalized_key(name)] = value end hash end end # Try and read the body and return memorized object of the body. # If body contains file, do not parse it, otherwiseBody might be nil # # @return @_body [String, nil] The body of the Rack::Request def body # Memoize a flag indicating whether we've tried to read the body or not # (can't use body because it might be nil) @_body_read ||= begin body = rack_request.body if defined?(Rack::Multipart) && defined?(Rack::Multipart::UploadedFile) && body.is_a?(Rack::Multipart::UploadedFile) logger.trace('not parsing uploaded file body', file_name: body.original_filename, content_type: body.content_type) @_body = nil else logger.trace('parsing body from request', body_type: body.cs__class.cs__name) @_body = Contrast::Utils::StringUtils.force_utf8(read_body(body)) end true end # Return memoized body (which might be nil) @_body end # Unlike most of our translation, which is called where needed for each # message and forgotten, we'll leave this method to call the build as we # don't want to pay to reconstruct the DTM for this Request multiple # times. # # @return [Contrast::Api::Dtm::HttpRequest] the SpeedRacer compatible # form of this Request def dtm @_dtm ||= Contrast::Api::Dtm::HttpRequest.build(self) end # flattened hash of request params # # @return parameters [Hash] def parameters @_parameters ||= with_contrast_scope { normalize_params(rack_request.params) } end # returns multipart filenames # # @return file_names [Hash] 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 # returns or fenerates the hash checksum for the request # # @return @_hash_id [String] Contrast::Utils::HashDigest generated string checksum def hash_id @_hash_id ||= Contrast::Utils::HashDigest.generate_request_hash(self) end # 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 end end end