src/derails/action_dispatch/middleware/ssl.rb
# frozen_string_literal: true

# :markup: markdown

module ActionDispatch
  # # Action Dispatch SSL
  #
  # This middleware is added to the stack when `config.force_ssl = true`, and is
  # passed the options set in `config.ssl_options`. It does three jobs to enforce
  # secure HTTP requests:
  #
  # 1.  **TLS redirect**: Permanently redirects `http://` requests to `https://`
  #     with the same URL host, path, etc. Enabled by default. Set
  #     `config.ssl_options` to modify the destination URL:
  #
  #         config.ssl_options = { redirect: { host: "secure.widgets.com", port: 8080 }`
  #
  #     Or set `redirect: false` to disable redirection.
  #
  #     Requests can opt-out of redirection with `exclude`:
  #
  #         config.ssl_options = { redirect: { exclude: -> request { request.path == "/up" } } }
  #
  #     Cookies will not be flagged as secure for excluded requests.
  #
  #     When proxying through a load balancer that terminates SSL, the forwarded
  #     request will appear as though it's HTTP instead of HTTPS to the application.
  #     This makes redirects and cookie security target HTTP instead of HTTPS.
  #     To make the server assume that the proxy already terminated SSL, and
  #     that the request really is HTTPS, set `config.assume_ssl` to `true`:
  #
  #         config.assume_ssl = true
  #
  # 2.  **Secure cookies**: Sets the `secure` flag on cookies to tell browsers
  #     they must not be sent along with `http://` requests. Enabled by default.
  #     Set `config.ssl_options` with `secure_cookies: false` to disable this
  #     feature.
  #
  # 3.  **HTTP Strict Transport Security (HSTS)**: Tells the browser to remember
  #     this site as TLS-only and automatically redirect non-TLS requests. Enabled
  #     by default. Configure `config.ssl_options` with `hsts: false` to disable.
  #
  #     Set `config.ssl_options` with `hsts: { ... }` to configure HSTS:
  #
  #     *   `expires`: How long, in seconds, these settings will stick. The
  #         minimum required to qualify for browser preload lists is 1 year.
  #         Defaults to 2 years (recommended).
  #
  #     *   `subdomains`: Set to `true` to tell the browser to apply these
  #         settings to all subdomains. This protects your cookies from
  #         interception by a vulnerable site on a subdomain. Defaults to `true`.
  #
  #     *   `preload`: Advertise that this site may be included in browsers'
  #         preloaded HSTS lists. HSTS protects your site on every visit *except
  #         the first visit* since it hasn't seen your HSTS header yet. To close
  #         this gap, browser vendors include a baked-in list of HSTS-enabled
  #         sites. Go to https://hstspreload.org to submit your site for
  #         inclusion. Defaults to `false`.
  #
  #
  #     To turn off HSTS, omitting the header is not enough. Browsers will
  #     remember the original HSTS directive until it expires. Instead, use the
  #     header to tell browsers to expire HSTS immediately. Setting `hsts: false`
  #     is a shortcut for `hsts: { expires: 0 }`.
  #
  class SSL
    # :stopdoc: Default to 2 years as recommended on hstspreload.org.
    HSTS_EXPIRES_IN = 63072000

    PERMANENT_REDIRECT_REQUEST_METHODS = %w[GET HEAD] # :nodoc:

    def self.default_hsts_options
      { expires: HSTS_EXPIRES_IN, subdomains: true, preload: false }
    end

    def initialize(app, redirect: {}, hsts: {}, secure_cookies: true, ssl_default_redirect_status: nil)
      @app = app

      @redirect = redirect

      @exclude = @redirect && @redirect[:exclude] || proc { !@redirect }
      @secure_cookies = secure_cookies

      @hsts_header = build_hsts_header(normalize_hsts_options(hsts))
      @ssl_default_redirect_status = ssl_default_redirect_status
    end

    def call(env)
      request = Request.new env

      if request.ssl?
        @app.call(env).tap do |status, headers, body|
          set_hsts_header! headers
          flag_cookies_as_secure! headers if @secure_cookies && !@exclude.call(request)
        end
      else
        return redirect_to_https request unless @exclude.call(request)
        @app.call(env)
      end
    end

    private
      def set_hsts_header!(headers)
        headers[Constants::STRICT_TRANSPORT_SECURITY] ||= @hsts_header
      end

      def normalize_hsts_options(options)
        case options
        # Explicitly disabling HSTS clears the existing setting from browsers by setting
        # expiry to 0.
        when false
          self.class.default_hsts_options.merge(expires: 0)
        # Default to enabled, with default options.
        when nil, true
          self.class.default_hsts_options
        else
          self.class.default_hsts_options.merge(options)
        end
      end

      # https://tools.ietf.org/html/rfc6797#section-6.1
      def build_hsts_header(hsts)
        value = +"max-age=#{hsts[:expires].to_i}"
        value << "; includeSubDomains" if hsts[:subdomains]
        value << "; preload" if hsts[:preload]
        value
      end

      def flag_cookies_as_secure!(headers)
        cookies = headers[Rack::SET_COOKIE]
        return unless cookies

        if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3")
          cookies = cookies.split("\n")
          headers[Rack::SET_COOKIE] = cookies.map { |cookie|
            if !/;\s*secure\s*(;|$)/i.match?(cookie)
              "#{cookie}; secure"
            else
              cookie
            end
          }.join("\n")
        else
          headers[Rack::SET_COOKIE] = Array(cookies).map do |cookie|
            if !/;\s*secure\s*(;|$)/i.match?(cookie)
              "#{cookie}; secure"
            else
              cookie
            end
          end
        end
      end

      def redirect_to_https(request)
        [ @redirect.fetch(:status, redirection_status(request)),
          { Rack::CONTENT_TYPE => "text/html; charset=utf-8",
            Constants::LOCATION => https_location_for(request) },
          (@redirect[:body] || []) ]
      end

      def redirection_status(request)
        if PERMANENT_REDIRECT_REQUEST_METHODS.include?(request.raw_request_method)
          301 # Issue a permanent redirect via a GET request.
        elsif @ssl_default_redirect_status
          @ssl_default_redirect_status
        else
          307 # Issue a fresh request redirect to preserve the HTTP method.
        end
      end

      def https_location_for(request)
        host = @redirect[:host] || request.host
        port = @redirect[:port] || request.port

        location = +"https://#{host}"
        location << ":#{port}" if port != 80 && port != 443
        location << request.fullpath
        location
      end
  end
end
