actionpack/lib/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 (e.g. `redirect: {
# host: "secure.widgets.com", port: 8080 }`), or set `redirect: false` to
# disable this feature.
#
# Requests can opt-out of redirection with `exclude`:
#
# config.ssl_options = { redirect: { exclude: -> request { /healthcheck/.match?(request.path) } } }
#
# Cookies will not be flagged as secure for excluded requests.
#
# 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