提交 a11571ce 编写于 作者: J Jeremy Daer (Kemper)

Merge pull request #21520 from jeremy/friendlier-force-ssl

Make `config.force_ssl` less dangerous to try and easier to disable
* Make it easier to opt in to `config.force_ssl` and `config.ssl_options` by
making them less dangerous to try and easier to disable.
SSL redirect:
* Move `:host` and `:port` options within `redirect: { … }`. Deprecate.
* Introduce `:status` and `:body` to customize the redirect response.
The 301 permanent default makes it difficult to test the redirect and
back out of it since browsers remember the 301. Test with a 302 or 307
instead, then switch to 301 once you're confident that all is well.
HTTP Strict Transport Security (HSTS):
* Shorter max-age. Shorten the default max-age from 1 year to 180 days,
the low end for https://www.ssllabs.com/ssltest/ grading and greater
than the 18-week minimum to qualify for browser preload lists.
* Disabling HSTS. Setting `hsts: false` now sets `hsts { expires: 0 }`
instead of omitting the header. Omitting does nothing to disable HSTS
since browsers hang on to your previous settings until they expire.
Sending `{ hsts: { expires: 0 }}` flushes out old browser settings and
actually disables HSTS:
http://tools.ietf.org/html/rfc6797#section-6.1.1
* HSTS Preload. Introduce `preload: true` to set the `preload` flag,
indicating that your site may be included in browser preload lists,
including Chrome, Firefox, Safari, IE11, and Edge. Submit your site:
https://hstspreload.appspot.com
*Jeremy Daer*
* Update `ActionController::TestSession#fetch` to behave more like * Update `ActionController::TestSession#fetch` to behave more like
`ActionDispatch::Request::Session#fetch` when using non-string keys. `ActionDispatch::Request::Session#fetch` when using non-string keys.
......
module ActionDispatch 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
# `18.weeks`, the minimum required to qualify for browser preload lists.
# * `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 class SSL
YEAR = 31536000 # 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 def self.default_hsts_options
{ :expires => YEAR, :subdomains => false } { expires: HSTS_EXPIRES_IN, subdomains: false, preload: false }
end end
def initialize(app, options = {}) def initialize(app, redirect: {}, hsts: {}, **options)
@app = app @app = app
@hsts = options.fetch(:hsts, {}) if options[:host] || options[:port]
@hsts = {} if @hsts == true ActiveSupport::Deprecation.warn <<-end_warning.strip_heredoc
@hsts = self.class.default_hsts_options.merge(@hsts) if @hsts 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
@host = options[:host] @hsts_header = build_hsts_header(normalize_hsts_options(hsts))
@port = options[:port]
end end
def call(env) def call(env)
request = Request.new(env) request = Request.new env
if request.ssl? if request.ssl?
status, headers, body = @app.call(env) @app.call(env).tap do |status, headers, body|
headers.reverse_merge!(hsts_headers) set_hsts_header! headers
flag_cookies_as_secure!(headers) flag_cookies_as_secure! headers
[status, headers, body] end
else else
redirect_to_https(request) redirect_to_https request
end end
end end
private private
def redirect_to_https(request) def set_hsts_header!(headers)
host = @host || request.host headers['Strict-Transport-Security'.freeze] ||= @hsts_header
port = @port || request.port
location = "https://#{host}"
location << ":#{port}" if port != 80
location << request.fullpath
headers = { 'Content-Type' => 'text/html', 'Location' => location }
[301, headers, []]
end end
# http://tools.ietf.org/html/draft-hodges-strict-transport-sec-02 def normalize_hsts_options(options)
def hsts_headers case options
if @hsts # Explicitly disabling HSTS clears the existing setting from browsers
value = "max-age=#{@hsts[:expires].to_i}" # by setting expiry to 0.
value += "; includeSubDomains" if @hsts[:subdomains] when false
{ 'Strict-Transport-Security' => value } self.class.default_hsts_options.merge(expires: 0)
# Default to enabled, with default options.
when nil, true
self.class.default_hsts_options
else else
{} self.class.default_hsts_options.merge(options)
end end
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) def flag_cookies_as_secure!(headers)
if cookies = headers['Set-Cookie'] if cookies = headers['Set-Cookie'.freeze]
cookies = cookies.split("\n") cookies = cookies.split("\n".freeze)
headers['Set-Cookie'] = cookies.map { |cookie| headers['Set-Cookie'.freeze] = cookies.map { |cookie|
if cookie !~ /;\s*secure\s*(;|$)/i if cookie !~ /;\s*secure\s*(;|$)/i
"#{cookie}; secure" "#{cookie}; secure"
else else
cookie cookie
end end
}.join("\n") }.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 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 end
end end
require 'abstract_unit' require 'abstract_unit'
class SSLTest < ActionDispatch::IntegrationTest class SSLTest < ActionDispatch::IntegrationTest
def default_app HEADERS = Rack::Utils::HeaderHash.new 'Content-Type' => 'text/html'
lambda { |env|
headers = {'Content-Type' => "text/html"} attr_accessor :app
headers['Set-Cookie'] = "id=1; path=/\ntoken=abc; path=/; secure; HttpOnly"
[200, headers, ["OK"]] def build_app(headers: {}, ssl_options: {})
headers = HEADERS.merge(headers)
ActionDispatch::SSL.new lambda { |env| [200, headers, []] }, ssl_options
end
end
class RedirectSSLTest < SSLTest
def assert_not_redirected(url, headers: {})
self.app = build_app
get url, headers: headers
assert_response :ok
end
def assert_redirected(host: nil, port: nil, status: 301, body: [],
deprecated_host: nil, deprecated_port: nil,
from: 'http://a/b?c=d', to: from.sub('http', 'https'))
self.app = build_app ssl_options: {
redirect: { host: host, port: port, status: status, body: body },
host: deprecated_host, port: deprecated_port
} }
get from
assert_response status
assert_redirected_to to
assert_equal body.join, @response.body
end end
def app test 'https is not redirected' do
@app ||= ActionDispatch::SSL.new(default_app) assert_not_redirected 'https://example.org'
end end
attr_writer :app
def test_allows_https_url test 'proxied https is not redirected' do
get "https://example.org/path?key=value" assert_not_redirected 'http://example.org', headers: { 'HTTP_X_FORWARDED_PROTO' => 'https' }
assert_response :success
end end
def test_allows_https_proxy_header_url test 'http is redirected to https' do
get "http://example.org/", headers: { 'HTTP_X_FORWARDED_PROTO' => "https" } assert_redirected
assert_response :success
end end
def test_redirects_http_to_https test 'redirect with non-301 status' do
get "http://example.org/path?key=value" assert_redirected status: 307
assert_response :redirect
assert_equal "https://example.org/path?key=value",
response.headers['Location']
end end
def test_hsts_header_by_default test 'redirect with custom body' do
get "https://example.org/" assert_redirected body: ['foo']
assert_equal "max-age=31536000",
response.headers['Strict-Transport-Security']
end end
def test_no_hsts_with_insecure_connection test 'redirect to specific host' do
get "http://example.org/" assert_redirected host: 'ssl', to: 'https://ssl/b?c=d'
assert_not response.headers['Strict-Transport-Security']
end end
def test_hsts_header test 'redirect to default port' do
self.app = ActionDispatch::SSL.new(default_app, :hsts => true) assert_redirected port: 443
get "https://example.org/"
assert_equal "max-age=31536000",
response.headers['Strict-Transport-Security']
end end
def test_disable_hsts_header test 'redirect to non-default port' do
self.app = ActionDispatch::SSL.new(default_app, :hsts => false) assert_redirected port: 8443, to: 'https://a:8443/b?c=d'
get "https://example.org/"
assert_not response.headers['Strict-Transport-Security']
end end
def test_hsts_expires test 'redirect to different host and non-default port' do
self.app = ActionDispatch::SSL.new(default_app, :hsts => { :expires => 500 }) assert_redirected host: 'ssl', port: 8443, to: 'https://ssl:8443/b?c=d'
get "https://example.org/"
assert_equal "max-age=500",
response.headers['Strict-Transport-Security']
end end
def test_hsts_expires_with_duration test 'redirect to different host including port' do
self.app = ActionDispatch::SSL.new(default_app, :hsts => { :expires => 1.year }) assert_redirected host: 'ssl:443', to: 'https://ssl:443/b?c=d'
get "https://example.org/"
assert_equal "max-age=31557600",
response.headers['Strict-Transport-Security']
end end
def test_hsts_include_subdomains test ':host is deprecated, moved within redirect: { host: … }' do
self.app = ActionDispatch::SSL.new(default_app, :hsts => { :subdomains => true }) assert_deprecated do
get "https://example.org/" assert_redirected deprecated_host: 'foo', to: 'https://foo/b?c=d'
assert_equal "max-age=31536000; includeSubDomains", end
response.headers['Strict-Transport-Security']
end end
def test_flag_cookies_as_secure test ':port is deprecated, moved within redirect: { port: … }' do
get "https://example.org/" assert_deprecated do
assert_equal ["id=1; path=/; secure", "token=abc; path=/; secure; HttpOnly" ], assert_redirected deprecated_port: 1, to: 'https://a:1/b?c=d'
response.headers['Set-Cookie'].split("\n") end
end end
end
def test_flag_cookies_as_secure_at_end_of_line class StrictTransportSecurityTest < SSLTest
self.app = ActionDispatch::SSL.new(lambda { |env| EXPECTED = 'max-age=15552000'
headers = {
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/; HttpOnly; secure"
}
[200, headers, ["OK"]]
})
get "https://example.org/" def assert_hsts(expected, url: 'https://example.org', hsts: {}, headers: {})
assert_equal ["problem=def; path=/; HttpOnly; secure"], self.app = build_app ssl_options: { hsts: hsts }, headers: headers
response.headers['Set-Cookie'].split("\n") get url
assert_equal expected, response.headers['Strict-Transport-Security']
end end
def test_flag_cookies_as_secure_with_more_spaces_before test 'enabled by default' do
self.app = ActionDispatch::SSL.new(lambda { |env| assert_hsts EXPECTED
headers = { end
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/; HttpOnly; secure"
}
[200, headers, ["OK"]]
})
get "https://example.org/" test 'not sent with http:// responses' do
assert_equal ["problem=def; path=/; HttpOnly; secure"], assert_hsts nil, url: 'http://example.org'
response.headers['Set-Cookie'].split("\n")
end end
def test_flag_cookies_as_secure_with_more_spaces_after test 'defers to app-provided header' do
self.app = ActionDispatch::SSL.new(lambda { |env| assert_hsts 'app-provided', headers: { 'Strict-Transport-Security' => 'app-provided' }
headers = { end
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/; secure; HttpOnly"
}
[200, headers, ["OK"]]
})
get "https://example.org/" test 'hsts: true enables default settings' do
assert_equal ["problem=def; path=/; secure; HttpOnly"], assert_hsts EXPECTED, hsts: true
response.headers['Set-Cookie'].split("\n")
end end
test 'hsts: false sets max-age to zero, clearing browser HSTS settings' do
assert_hsts 'max-age=0', hsts: false
end
def test_flag_cookies_as_secure_with_has_not_spaces_before test ':expires sets max-age' do
self.app = ActionDispatch::SSL.new(lambda { |env| assert_hsts 'max-age=500', hsts: { expires: 500 }
headers = { end
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/;secure; HttpOnly"
}
[200, headers, ["OK"]]
})
get "https://example.org/" test ':expires supports AS::Duration arguments' do
assert_equal ["problem=def; path=/;secure; HttpOnly"], assert_hsts 'max-age=31557600', hsts: { expires: 1.year }
response.headers['Set-Cookie'].split("\n")
end end
def test_flag_cookies_as_secure_with_has_not_spaces_after test 'include subdomains' do
self.app = ActionDispatch::SSL.new(lambda { |env| assert_hsts "#{EXPECTED}; includeSubDomains", hsts: { subdomains: true }
headers = { end
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/; secure;HttpOnly"
}
[200, headers, ["OK"]]
})
get "https://example.org/" test 'exclude subdomains' do
assert_equal ["problem=def; path=/; secure;HttpOnly"], assert_hsts EXPECTED, hsts: { subdomains: false }
response.headers['Set-Cookie'].split("\n")
end end
def test_flag_cookies_as_secure_with_ignore_case test 'opt in to browser preload lists' do
self.app = ActionDispatch::SSL.new(lambda { |env| assert_hsts "#{EXPECTED}; preload", hsts: { preload: true }
headers = { end
'Content-Type' => "text/html",
'Set-Cookie' => "problem=def; path=/; Secure; HttpOnly"
}
[200, headers, ["OK"]]
})
get "https://example.org/" test 'opt out of browser preload lists' do
assert_equal ["problem=def; path=/; Secure; HttpOnly"], assert_hsts EXPECTED, hsts: { preload: false }
response.headers['Set-Cookie'].split("\n")
end end
end
def test_no_cookies class SecureCookiesTest < SSLTest
self.app = ActionDispatch::SSL.new(lambda { |env| DEFAULT = %(id=1; path=/\ntoken=abc; path=/; secure; HttpOnly)
[200, {'Content-Type' => "text/html"}, ["OK"]]
}) def get(**options)
get "https://example.org/" self.app = build_app(**options)
assert !response.headers['Set-Cookie'] super 'https://example.org'
end
def assert_cookies(*expected)
assert_equal expected, response.headers['Set-Cookie'].split("\n")
end end
def test_redirect_to_host def test_flag_cookies_as_secure
self.app = ActionDispatch::SSL.new(default_app, :host => "ssl.example.org") get headers: { 'Set-Cookie' => DEFAULT }
get "http://example.org/path?key=value" assert_cookies 'id=1; path=/; secure', 'token=abc; path=/; secure; HttpOnly'
assert_equal "https://ssl.example.org/path?key=value", end
response.headers['Location']
def test_flag_cookies_as_secure_at_end_of_line
get headers: { 'Set-Cookie' => 'problem=def; path=/; HttpOnly; secure' }
assert_cookies 'problem=def; path=/; HttpOnly; secure'
end
def test_flag_cookies_as_secure_with_more_spaces_before
get headers: { 'Set-Cookie' => 'problem=def; path=/; HttpOnly; secure' }
assert_cookies 'problem=def; path=/; HttpOnly; secure'
end end
def test_redirect_to_port def test_flag_cookies_as_secure_with_more_spaces_after
self.app = ActionDispatch::SSL.new(default_app, :port => 8443) get headers: { 'Set-Cookie' => 'problem=def; path=/; secure; HttpOnly' }
get "http://example.org/path?key=value" assert_cookies 'problem=def; path=/; secure; HttpOnly'
assert_equal "https://example.org:8443/path?key=value",
response.headers['Location']
end end
def test_redirect_to_host_and_port def test_flag_cookies_as_secure_with_has_not_spaces_before
self.app = ActionDispatch::SSL.new(default_app, :host => "ssl.example.org", :port => 8443) get headers: { 'Set-Cookie' => 'problem=def; path=/;secure; HttpOnly' }
get "http://example.org/path?key=value" assert_cookies 'problem=def; path=/;secure; HttpOnly'
assert_equal "https://ssl.example.org:8443/path?key=value",
response.headers['Location']
end end
def test_redirect_to_host_with_port def test_flag_cookies_as_secure_with_has_not_spaces_after
self.app = ActionDispatch::SSL.new(default_app, :host => "ssl.example.org:443") get headers: { 'Set-Cookie' => 'problem=def; path=/; secure;HttpOnly' }
get "http://example.org/path?key=value" assert_cookies 'problem=def; path=/; secure;HttpOnly'
assert_equal "https://ssl.example.org:443/path?key=value",
response.headers['Location']
end end
def test_redirect_to_secure_host_when_on_subdomain def test_flag_cookies_as_secure_with_ignore_case
self.app = ActionDispatch::SSL.new(default_app, :host => "ssl.example.org") get headers: { 'Set-Cookie' => 'problem=def; path=/; Secure; HttpOnly' }
get "http://ssl.example.org/path?key=value" assert_cookies 'problem=def; path=/; Secure; HttpOnly'
assert_equal "https://ssl.example.org/path?key=value",
response.headers['Location']
end end
def test_redirect_to_secure_subdomain_when_on_deep_subdomain def test_no_cookies
self.app = ActionDispatch::SSL.new(default_app, :host => "example.co.uk") get
get "http://double.rainbow.what.does.it.mean.example.co.uk/path?key=value" assert_nil response.headers['Set-Cookie']
assert_equal "https://example.co.uk/path?key=value",
response.headers['Location']
end end
def test_keeps_original_headers_behavior def test_keeps_original_headers_behavior
headers = Rack::Utils::HeaderHash.new( get headers: { 'Connection' => %w[close] }
"Content-Type" => "text/html", assert_equal 'close', response.headers['Connection']
"Connection" => ["close"]
)
self.app = ActionDispatch::SSL.new(lambda { |env| [200, headers, ["OK"]] })
get "https://example.org/"
assert_equal "close", response.headers["Connection"]
end end
end end
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册