src/derails/action_controller/metal/redirecting.rb
# 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
      mattr_accessor :action_on_path_relative_redirect, default: :log
      class_attribute :_allowed_redirect_hosts, :allowed_redirect_hosts_permissions, instance_accessor: false, instance_predicate: false
      singleton_class.alias_method :allowed_redirect_hosts, :_allowed_redirect_hosts
    end

    module ClassMethods # :nodoc:
      def allowed_redirect_hosts=(hosts)
        hosts = hosts.dup.freeze
        self._allowed_redirect_hosts = hosts
        self.allowed_redirect_hosts_permissions = if hosts.present?
          ActionDispatch::HostAuthorization::Permissions.new(hosts)
        end
      end
    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.
    #
    # ### Path Relative URL Redirect Protection
    #
    # Rails also protects against potentially unsafe path relative URL redirects that don't
    # start with a leading slash. These can create security vulnerabilities:
    #
    #     redirect_to "example.com"     # Creates http://yourdomain.comexample.com
    #     redirect_to "@attacker.com"   # Creates http://yourdomain.com@attacker.com
    #                                   # which browsers interpret as user@host
    #
    # You can configure how Rails handles these cases using:
    #
    #     config.action_controller.action_on_path_relative_redirect = :log    # default
    #     config.action_controller.action_on_path_relative_redirect = :notify
    #     config.action_controller.action_on_path_relative_redirect = :raise
    #
    # * `:log` - Logs a warning but allows the redirect
    # * `:notify` - Sends an ActiveSupport notification but allows the redirect
    #   (includes stack trace to help identify the source)
    # * `:raise` - Raises an UnsafeRedirectError
    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 }

      proposed_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 = ""
      self.status        = proposed_status
    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
        if !options.start_with?("/", "?") && !options.empty?
          _handle_path_relative_redirect(options)
        end

        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)
          ActionDispatch::Response.rack_status_code(options.delete(:status))
        elsif response_options.key?(:status)
          ActionDispatch::Response.rack_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)
        url_to_s = url.to_s
        host = URI(url_to_s).host

        if host.nil?
          url_to_s.start_with?("/") && !url_to_s.start_with?("//")
        else
          host == request.host || self.class.allowed_redirect_hosts_permissions&.allows?(host)
        end
      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

      def _handle_path_relative_redirect(url)
        message = "Path relative URL redirect detected: #{url.inspect}"

        case action_on_path_relative_redirect
        when :log
          logger&.warn message
        when :notify
          ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller",
            url: url,
            message: message,
            stack_trace: caller
          )
        when :raise
          raise UnsafeRedirectError, message
        end
      end
  end
end
