# frozen_string_literal: true # :markup: markdown module ActionController module Redirecting extend ActiveSupport::Concern include AbstractController::Logger include ActionController::UrlFor class UnsafeRedirectError < StandardError; end ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/ included do mattr_accessor :raise_on_open_redirects, default: false end # Redirects the browser to the target specified in `options`. This parameter can # be any one of: # # * `Hash` - The URL will be generated by calling url_for with the `options`. # * `Record` - The URL will be generated by calling url_for with the # `options`, which will reference a named URL for that record. # * `String` starting with `protocol://` (like `http://`) or a protocol # relative reference (like `//`) - Is passed straight through as the target # for redirection. # * `String` not containing a protocol - The current protocol and host is # prepended to the string. # * `Proc` - A block that will be executed in the controller's context. Should # return any option accepted by `redirect_to`. # # # ### Examples # # redirect_to action: "show", id: 5 # redirect_to @post # redirect_to "http://www.rubyonrails.org" # redirect_to "/images/screenshot.jpg" # redirect_to posts_url # redirect_to proc { edit_post_url(@post) } # # The redirection happens as a `302 Found` header unless otherwise specified # using the `:status` option: # # redirect_to post_url(@post), status: :found # redirect_to action: 'atom', status: :moved_permanently # redirect_to post_url(@post), status: 301 # redirect_to action: 'atom', status: 302 # # The status code can either be a standard [HTTP Status # code](https://www.iana.org/assignments/http-status-codes) as an integer, or a # symbol representing the downcased, underscored and symbolized description. # Note that the status code must be a 3xx HTTP code, or redirection will not # occur. # # If you are using XHR requests other than GET or POST and redirecting after the # request then some browsers will follow the redirect using the original request # method. This may lead to undesirable behavior such as a double DELETE. To work # around this you can return a `303 See Other` status code which will be # followed using a GET request. # # redirect_to posts_url, status: :see_other # redirect_to action: 'index', status: 303 # # It is also possible to assign a flash message as part of the redirection. # There are two special accessors for the commonly used flash names `alert` and # `notice` as well as a general purpose `flash` bucket. # # redirect_to post_url(@post), alert: "Watch it, mister!" # redirect_to post_url(@post), status: :found, notice: "Pay attention to the road" # redirect_to post_url(@post), status: 301, flash: { updated_post_id: @post.id } # redirect_to({ action: 'atom' }, alert: "Something serious happened") # # Statements after `redirect_to` in our controller get executed, so # `redirect_to` doesn't stop the execution of the function. To terminate the # execution of the function immediately after the `redirect_to`, use return. # # redirect_to post_url(@post) and return # # ### Open Redirect protection # # By default, Rails protects against redirecting to external hosts for your # app's safety, so called open redirects. Note: this was a new default in Rails # 7.0, after upgrading opt-in by uncommenting the line with # `raise_on_open_redirects` in # `config/initializers/new_framework_defaults_7_0.rb` # # Here #redirect_to automatically validates the potentially-unsafe URL: # # redirect_to params[:redirect_url] # # Raises UnsafeRedirectError in the case of an unsafe redirect. # # To allow any external redirects pass `allow_other_host: true`, though using a # user-provided param in that case is unsafe. # # redirect_to "https://rubyonrails.org", allow_other_host: true # # See #url_from for more information on what an internal and safe URL is, or how # to fall back to an alternate redirect URL in the unsafe case. def redirect_to(options = {}, response_options = {}) raise ActionControllerError.new("Cannot redirect to nil!") unless options raise AbstractController::DoubleRenderError if response_body allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host } self.status = _extract_redirect_to_status(options, response_options) redirect_to_location = _compute_redirect_to_location(request, options) _ensure_url_is_http_header_safe(redirect_to_location) self.location = _enforce_open_redirect_protection(redirect_to_location, allow_other_host: allow_other_host) self.response_body = "" end # Soft deprecated alias for #redirect_back_or_to where the `fallback_location` # location is supplied as a keyword argument instead of the first positional # argument. def redirect_back(fallback_location:, allow_other_host: _allow_other_host, **args) redirect_back_or_to fallback_location, allow_other_host: allow_other_host, **args end # Redirects the browser to the page that issued the request (the referrer) if # possible, otherwise redirects to the provided default fallback location. # # The referrer information is pulled from the HTTP `Referer` (sic) header on the # request. This is an optional header and its presence on the request is subject # to browser security settings and user preferences. If the request is missing # this header, the `fallback_location` will be used. # # redirect_back_or_to({ action: "show", id: 5 }) # redirect_back_or_to @post # redirect_back_or_to "http://www.rubyonrails.org" # redirect_back_or_to "/images/screenshot.jpg" # redirect_back_or_to posts_url # redirect_back_or_to proc { edit_post_url(@post) } # redirect_back_or_to '/', allow_other_host: false # # #### Options # * `:allow_other_host` - Allow or disallow redirection to the host that is # different to the current host, defaults to true. # # # All other options that can be passed to #redirect_to are accepted as options, # and the behavior is identical. def redirect_back_or_to(fallback_location, allow_other_host: _allow_other_host, **options) if request.referer && (allow_other_host || _url_host_allowed?(request.referer)) redirect_to request.referer, allow_other_host: allow_other_host, **options else # The method level `allow_other_host` doesn't apply in the fallback case, omit # and let the `redirect_to` handling take over. redirect_to fallback_location, **options end end def _compute_redirect_to_location(request, options) # :nodoc: case options # The scheme name consist of a letter followed by any combination of letters, # digits, and the plus ("+"), period ("."), or hyphen ("-") characters; and is # terminated by a colon (":"). See # https://tools.ietf.org/html/rfc3986#section-3.1 The protocol relative scheme # starts with a double slash "//". when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i options.to_str when String request.protocol + request.host_with_port + options when Proc _compute_redirect_to_location request, instance_eval(&options) else url_for(options) end.delete("\0\r\n") end module_function :_compute_redirect_to_location public :_compute_redirect_to_location # Verifies the passed `location` is an internal URL that's safe to redirect to # and returns it, or nil if not. Useful to wrap a params provided redirect URL # and fall back to an alternate URL to redirect to: # # redirect_to url_from(params[:redirect_url]) || root_url # # The `location` is considered internal, and safe, if it's on the same host as # `request.host`: # # # If request.host is example.com: # url_from("https://example.com/profile") # => "https://example.com/profile" # url_from("http://example.com/profile") # => "http://example.com/profile" # url_from("http://evil.com/profile") # => nil # # Subdomains are considered part of the host: # # # If request.host is on https://example.com or https://app.example.com, you'd get: # url_from("https://dev.example.com/profile") # => nil # # NOTE: there's a similarity with # [url_for](rdoc-ref:ActionDispatch::Routing::UrlFor#url_for), which generates # an internal URL from various options from within the app, e.g. # `url_for(@post)`. However, #url_from is meant to take an external parameter to # verify as in `url_from(params[:redirect_url])`. def url_from(location) location = location.presence location if location && _url_host_allowed?(location) end private def _allow_other_host !raise_on_open_redirects end def _extract_redirect_to_status(options, response_options) if options.is_a?(Hash) && options.key?(:status) Rack::Utils.status_code(options.delete(:status)) elsif response_options.key?(:status) Rack::Utils.status_code(response_options[:status]) else 302 end end def _enforce_open_redirect_protection(location, allow_other_host:) if allow_other_host || _url_host_allowed?(location) location else raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway." end end def _url_host_allowed?(url) host = URI(url.to_s).host return true if host == request.host return false unless host.nil? return false unless url.to_s.start_with?("/") !url.to_s.start_with?("//") rescue ArgumentError, URI::Error false end def _ensure_url_is_http_header_safe(url) # Attempt to comply with the set of valid token characters defined for an HTTP # header value in https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 if url.match?(ILLEGAL_HEADER_VALUE_REGEX) msg = "The redirect URL #{url} contains one or more illegal HTTP header field character. " \ "Set of legal characters defined in https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6" raise UnsafeRedirectError, msg end end end end