# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true cs__scoped_require 'contrast/agent/assess/rule/response_watcher' module Contrast module Agent module Assess module Rule class Csrf # Watchers are how those Rules which do not act on dataflow function. # This one is used by the CSRF rule to determine if a request was # made in a way that is susceptible to a CSRF attack. class Watcher < Contrast::Agent::Assess::Rule::ResponseWatcher def supports? context return false unless super(context) return false unless user_agent?(context) return false unless form_content_type?(context) return false unless form_method?(context) return false if empty_get?(context) true end X_REQUESTED_WITH = 'X-REQUESTED-WITH' # ignore this request if X-Requested-With is set def x_requested_with? context !context.request.normalized_request_headers[X_REQUESTED_WITH].nil? end USER_AGENT = 'USER-AGENT' # ignore this request if User-Agent is NOT set # since that indicates this isn't a browser interaction def user_agent? context !context.request.normalized_request_headers[USER_AGENT].nil? end CONTENT_TYPE = 'CONTENT-TYPE' FORM_TYPES = %w[ text/plain multipart/form-data application/x-www-form-urlencoded ].cs__freeze # ignore this request if the Content-Type is NOT set # to a form content type def form_content_type? context type = context.request.content_type # We need to account for the charset being part of the header. It doesn't # affect how we determine CSRF-ability or not type = type.split(Contrast::Utils::ObjectShare::SEMICOLON)[0] if type FORM_TYPES.include?(type) end FORM_METHODS = %w[GET POST].cs__freeze # ignore this request if the request method # is not one that can be set with forms def form_method? context FORM_METHODS.include?(context.request.request_method) end GET = 'GET' # ignore this request if the method is get # and the query string is empty def empty_get? context request = context.request request.request_method == GET && (request.query_string.nil? || request.query_string.empty?) end # If a parameter contains 'csrf' or 'token', we consider # it to be a guard against CSRF and therefore determine # that this request is not vulnerable. CSRF_PATTERN = /csrf/i.cs__freeze TOKEN_PATTERN = /token/i.cs__freeze def vulnerable? context # having the property 'csrf.token.checked' indicates # that a CSRF check was preformed at some point during # this request, so we consider it to not be vulnerable return false if context.get_property(CHECKED) params = context.request.parameters params.each_key do |key| return false if key.match?(CSRF_PATTERN) return false if key.match?(TOKEN_PATTERN) && looks_like_token?(params[key]) end # having no actions means there's no vulnerability return false unless context.get_property(STATE_CHANGING_ACTIONS_KEY)&.any? true end MINIMUM_CSRF_TOKEN_SIZE = 8 MAXIMUM_CSRF_TOKEN_SIZE = 24 TOKEN_REGEXP = /^[A-Za-z0-9]*$/.cs__freeze def looks_like_token? value return false unless value values = [value] values.each do |parameter| next unless parameter length = parameter.length next if length < MINIMUM_CSRF_TOKEN_SIZE next if length > MAXIMUM_CSRF_TOKEN_SIZE return true if parameter.match?(TOKEN_REGEXP) end false end DATA_KEY = 'actions' def build_finding context actions = context.get_property(STATE_CHANGING_ACTIONS_KEY) return if actions.nil? || actions.empty? string = actions.map(&:to_h).to_json finding = Contrast::Api::Dtm::Finding.new finding.rule_id = Contrast::Agent::Assess::Rule::Csrf::NAME finding.session_id = Contrast::Agent::FeatureState.instance.current_session_id hash_code = Contrast::Utils::HashDigest.generate_response_hash(finding) finding.hash_code = Contrast::Utils::StringUtils.force_utf8(hash_code) finding.properties[DATA_KEY] = Contrast::Utils::StringUtils.force_utf8(string) finding rescue StandardError => e logger.error(e, 'Unable to build a finding for CSRF') end end end end end end end