# Copyright (c) 2023 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' require 'contrast/utils/duck_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. class Request include Contrast::Utils::RequestUtils include Contrast::Components::Logger::InstanceMethods include Contrast::Components::Scope::InstanceMethods extend Forwardable EMPTY_PATH = '/' # @return [Rack::Request] The passed to the Agent RackRequest to be wrapped. attr_reader :rack_request # @return [Contrast::Agent::Reporting::ObservedRoute] the route, used for coverage, of this request attr_accessor :observed_route # @return [Contrast::Agent::Reporting::RouteDiscovery] attr_accessor :discovered_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 || rack_request.path.to_s path = EMPTY_PATH if Contrast::Utils::DuckUtils.empty_duck?(path) # /foo/bar;jsessionid=123 => /foo/bar uri = path.split(Contrast::Utils::ObjectShare::SEMICOLON)[0] # /foo/bar?query_string=123 => /foo/bar uri = uri.split(Contrast::Utils::ObjectShare::QUESTION_MARK)[0] # Replace with tokens: # NUM_ => '/{n}/' # ID_ => '{ID}' # # replace UUIDs: /123e4567-e89b-42d3-a456-556642440000/ => /{ID}/ uri.gsub!(UUID_PATTERN, ID_) # replace hash patterns: /6f1ed002ab5595859014ebf0951522d9/ => /{ID}/ uri.gsub!(HASH_PATTERN, ID_) # replace windows SID: /S-1-5-21-1843332746-572796286-2118856591-1000/ => /{ID}/ uri.gsub!(WIN_PATTERN, ID_) # replace interior number tokens: /123/ => /{n}/ uri.gsub!(NUM_PATTERN, NUM_) # replace last number tokens: /123 => /{n} uri.gsub!(END_PATTERN, NUM_[0..-2]) uri rescue StandardError => e logger.error('error normalizing uri', error: e, backtrace: e.backtrace) EMPTY_PATH 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?(' _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