# frozen_string_literal: true module HTTPX InsecureRedirectError = Class.new(Error) module Plugins # # This plugin adds support for automatically following redirect (status 30X) responses. # # It has a default upper bound of followed redirects (see *MAX_REDIRECTS* and the *max_redirects* option), # after which it will return the last redirect response. It will **not** raise an exception. # # It doesn't follow insecure redirects (https -> http) by default (see *follow_insecure_redirects*). # # It doesn't propagate authorization related headers to requests redirecting to different origins # (see *allow_auth_to_other_origins*) to override. # # It allows customization of when to redirect via the *redirect_on* callback option). # # https://gitlab.com/os85/httpx/wikis/Follow-Redirects # module FollowRedirects MAX_REDIRECTS = 3 REDIRECT_STATUS = (300..399).freeze REQUEST_BODY_HEADERS = %w[transfer-encoding content-encoding content-type content-length content-language content-md5 trailer].freeze using URIExtensions # adds support for the following options: # # :max_redirects :: max number of times a request will be redirected (defaults to 3). # :follow_insecure_redirects :: whether redirects to an "http://" URI, when coming from an "https//", are allowed # (defaults to false). # :allow_auth_to_other_origins :: whether auth-related headers, such as "Authorization", are propagated on redirection # (defaults to false). # :redirect_on :: optional callback which receives the redirect location and can halt the redirect chain if it returns false. module OptionsMethods def option_max_redirects(value) num = Integer(value) raise TypeError, ":max_redirects must be positive" if num.negative? num end def option_follow_insecure_redirects(value) value end def option_allow_auth_to_other_origins(value) value end def option_redirect_on(value) raise TypeError, ":redirect_on must be callable" unless value.respond_to?(:call) value end end module InstanceMethods # returns a session with the *max_redirects* option set to +n+ def max_redirects(n) with(max_redirects: n.to_i) end private def fetch_response(request, connections, options) redirect_request = request.redirect_request response = super(redirect_request, connections, options) return unless response max_redirects = redirect_request.max_redirects return response unless response.is_a?(Response) return response unless REDIRECT_STATUS.include?(response.status) && response.headers.key?("location") return response unless max_redirects.positive? redirect_uri = __get_location_from_response(response) if options.redirect_on redirect_allowed = options.redirect_on.call(redirect_uri) return response unless redirect_allowed end # build redirect request request_body = redirect_request.body redirect_method = "GET" redirect_params = {} if response.status == 305 && options.respond_to?(:proxy) request_body.rewind # The requested resource MUST be accessed through the proxy given by # the Location field. The Location field gives the URI of the proxy. redirect_options = options.merge(headers: redirect_request.headers, proxy: { uri: redirect_uri }, max_redirects: max_redirects - 1) redirect_params[:body] = request_body redirect_uri = redirect_request.uri options = redirect_options else redirect_headers = redirect_request_headers(redirect_request.uri, redirect_uri, request.headers, options) redirect_opts = Hash[options] redirect_params[:max_redirects] = max_redirects - 1 unless request_body.empty? if response.status == 307 # The method and the body of the original request are reused to perform the redirected request. redirect_method = redirect_request.verb request_body.rewind redirect_params[:body] = request_body else # redirects are **ALWAYS** GET, so remove body-related headers REQUEST_BODY_HEADERS.each do |h| redirect_headers.delete(h) end redirect_params[:body] = nil end end options = options.class.new(redirect_opts.merge(headers: redirect_headers.to_h)) end redirect_uri = Utils.to_uri(redirect_uri) if !options.follow_insecure_redirects && response.uri.scheme == "https" && redirect_uri.scheme == "http" error = InsecureRedirectError.new(redirect_uri.to_s) error.set_backtrace(caller) return ErrorResponse.new(request, error) end retry_request = build_request(redirect_method, redirect_uri, redirect_params, options) request.redirect_request = retry_request redirect_after = response.headers["retry-after"] if redirect_after # Servers send the "Retry-After" header field to indicate how long the # user agent ought to wait before making a follow-up request. # When sent with any 3xx (Redirection) response, Retry-After indicates # the minimum time that the user agent is asked to wait before issuing # the redirected request. # redirect_after = Utils.parse_retry_after(redirect_after) log { "redirecting after #{redirect_after} secs..." } deactivate_connection(request, connections, options) pool.after(redirect_after) do if request.response # request has terminated abruptly meanwhile retry_request.emit(:response, request.response) else send_request(retry_request, connections, options) end end else send_request(retry_request, connections, options) end nil end # :nodoc: def redirect_request_headers(original_uri, redirect_uri, headers, options) headers = headers.dup return headers if options.allow_auth_to_other_origins return headers unless headers.key?("authorization") return headers if original_uri.origin == redirect_uri.origin headers.delete("authorization") headers end # :nodoc: def __get_location_from_response(response) # @type var location_uri: http_uri location_uri = URI(response.headers["location"]) location_uri = response.uri.merge(location_uri) if location_uri.relative? location_uri end end module RequestMethods # returns the top-most original HTTPX::Request from the redirect chain attr_accessor :root_request # returns the follow-up redirect request, or itself def redirect_request @redirect_request || self end # sets the follow-up redirect request def redirect_request=(req) @redirect_request = req req.root_request = @root_request || self @response = nil end def response return super unless @redirect_request && @response.nil? @redirect_request.response end def max_redirects @options.max_redirects || MAX_REDIRECTS end end module ConnectionMethods private def set_request_request_timeout(request) return unless request.root_request.nil? super end end end register_plugin :follow_redirects, FollowRedirects end end