module ActionDispatch
# This middleware is added to the stack when `config.force_ssl = true`.
# It does three jobs to enforce secure HTTP requests:
#
# 1. TLS redirect. http:// requests are permanently redirected to https://
# with the same URL host, path, etc. Pass `:host` and/or `:port` to
# modify the destination URL. This is always enabled.
#
# 2. Secure cookies. Sets the `secure` flag on cookies to tell browsers they
# mustn't be sent along with http:// requests. This is always enabled.
#
# 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. Pass `hsts: false` to disable.
#
# Configure HSTS with `hsts: { … }`:
# * `expires`: How long, in seconds, these settings will stick. Defaults to
# `180.days` (recommended). The minimum required to qualify for browser
# preload lists is `18.weeks`.
# * `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 `false`.
# * `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.appspot.com to submit your site for inclusion.
#
# Disabling HSTS: 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
# Default to 180 days, the low end for https://www.ssllabs.com/ssltest/
# and greater than the 18-week requirement for browser preload lists.
HSTS_EXPIRES_IN = 15552000
def self.default_hsts_options
{ expires: HSTS_EXPIRES_IN, subdomains: false, preload: false }
end
def initialize(app, redirect: {}, hsts: {}, **options)
@app = app
if options[:host] || options[:port]
ActiveSupport::Deprecation.warn <<-end_warning.strip_heredoc
The `:host` and `:port` options are moving within `:redirect`:
`config.ssl_options = { redirect: { host: …, port: … }}`.
end_warning
@redirect = options.slice(:host, :port)
else
@redirect = redirect
end
@hsts_header = build_hsts_header(normalize_hsts_options(hsts))
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
end
else
redirect_to_https request
end
end
private
def set_hsts_header!(headers)
headers['Strict-Transport-Security'.freeze] ||= @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
# http://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)
if cookies = headers['Set-Cookie'.freeze]
cookies = cookies.split("\n".freeze)
headers['Set-Cookie'.freeze] = cookies.map { |cookie|
if cookie !~ /;\s*secure\s*(;|$)/i
"#{cookie}; secure"
else
cookie
end
}.join("\n".freeze)
end
end
def redirect_to_https(request)
[ @redirect.fetch(:status, 301),
{ 'Content-Type' => 'text/html',
'Location' => https_location_for(request) },
@redirect.fetch(:body, []) ]
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