mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
64f9802e90
When `config.force_ssl` is set to `true`, any POST/PUT/DELETE requests coming in to non-secure url are being redirected with a 301 status. However, when that happens, the request is converted to a GET request and ends up hitting a different action on the controller. Since we can not do non-GET redirects, we can instead redirect with a 307 status code instead to indicate to the caller that a fresh request should be tried preserving the original request method. `rack-ssl` gem which was used to achieve this before we had this middleware directly baked into Rails also used to do the same, ref: https://github.com/josh/rack-ssl/blob/master/lib/rack/ssl.rb#L54 This would be specially important for any apps switching from older version of Rails or apps which expose an API through Rails.
243 lines
7.1 KiB
Ruby
243 lines
7.1 KiB
Ruby
require "abstract_unit"
|
|
|
|
class SSLTest < ActionDispatch::IntegrationTest
|
|
HEADERS = Rack::Utils::HeaderHash.new "Content-Type" => "text/html"
|
|
|
|
attr_accessor :app
|
|
|
|
def build_app(headers: {}, ssl_options: {})
|
|
headers = HEADERS.merge(headers)
|
|
ActionDispatch::SSL.new lambda { |env| [200, headers, []] }, ssl_options.reverse_merge(hsts: { subdomains: true })
|
|
end
|
|
end
|
|
|
|
class RedirectSSLTest < SSLTest
|
|
def assert_not_redirected(url, headers: {}, redirect: {}, deprecated_host: nil,
|
|
deprecated_port: nil)
|
|
|
|
self.app = build_app ssl_options: { redirect: redirect,
|
|
host: deprecated_host, port: deprecated_port
|
|
}
|
|
|
|
get url, headers: headers
|
|
assert_response :ok
|
|
end
|
|
|
|
def assert_redirected(redirect: {}, deprecated_host: nil, deprecated_port: nil,
|
|
from: "http://a/b?c=d", to: from.sub("http", "https"))
|
|
|
|
redirect = { status: 301, body: [] }.merge(redirect)
|
|
|
|
self.app = build_app ssl_options: { redirect: redirect,
|
|
host: deprecated_host, port: deprecated_port
|
|
}
|
|
|
|
get from
|
|
assert_response redirect[:status] || 301
|
|
assert_redirected_to to
|
|
assert_equal redirect[:body].join, @response.body
|
|
end
|
|
|
|
def assert_post_redirected(redirect: {}, from: "http://a/b?c=d",
|
|
to: from.sub("http", "https"))
|
|
|
|
self.app = build_app ssl_options: { redirect: redirect }
|
|
|
|
post from
|
|
assert_response redirect[:status] || 307
|
|
assert_redirected_to to
|
|
end
|
|
|
|
test "exclude can avoid redirect" do
|
|
excluding = { exclude: -> request { request.path =~ /healthcheck/ } }
|
|
|
|
assert_not_redirected "http://example.org/healthcheck", redirect: excluding
|
|
assert_redirected from: "http://example.org/", redirect: excluding
|
|
end
|
|
|
|
test "https is not redirected" do
|
|
assert_not_redirected "https://example.org"
|
|
end
|
|
|
|
test "proxied https is not redirected" do
|
|
assert_not_redirected "http://example.org", headers: { "HTTP_X_FORWARDED_PROTO" => "https" }
|
|
end
|
|
|
|
test "http is redirected to https" do
|
|
assert_redirected
|
|
end
|
|
|
|
test "http POST is redirected to https with status 307" do
|
|
assert_post_redirected
|
|
end
|
|
|
|
test "redirect with non-301 status" do
|
|
assert_redirected redirect: { status: 307 }
|
|
end
|
|
|
|
test "redirect with custom body" do
|
|
assert_redirected redirect: { body: ["foo"] }
|
|
end
|
|
|
|
test "redirect to specific host" do
|
|
assert_redirected redirect: { host: "ssl" }, to: "https://ssl/b?c=d"
|
|
end
|
|
|
|
test "redirect to default port" do
|
|
assert_redirected redirect: { port: 443 }
|
|
end
|
|
|
|
test "redirect to non-default port" do
|
|
assert_redirected redirect: { port: 8443 }, to: "https://a:8443/b?c=d"
|
|
end
|
|
|
|
test "redirect to different host and non-default port" do
|
|
assert_redirected redirect: { host: "ssl", port: 8443 }, to: "https://ssl:8443/b?c=d"
|
|
end
|
|
|
|
test "redirect to different host including port" do
|
|
assert_redirected redirect: { host: "ssl:443" }, to: "https://ssl:443/b?c=d"
|
|
end
|
|
|
|
test ":host is deprecated, moved within redirect: { host: … }" do
|
|
assert_deprecated do
|
|
assert_redirected deprecated_host: "foo", to: "https://foo/b?c=d"
|
|
end
|
|
end
|
|
|
|
test ":port is deprecated, moved within redirect: { port: … }" do
|
|
assert_deprecated do
|
|
assert_redirected deprecated_port: 1, to: "https://a:1/b?c=d"
|
|
end
|
|
end
|
|
|
|
test "no redirect with redirect set to false" do
|
|
assert_not_redirected "http://example.org", redirect: false
|
|
end
|
|
end
|
|
|
|
class StrictTransportSecurityTest < SSLTest
|
|
EXPECTED = "max-age=15552000"
|
|
EXPECTED_WITH_SUBDOMAINS = "max-age=15552000; includeSubDomains"
|
|
|
|
def assert_hsts(expected, url: "https://example.org", hsts: { subdomains: true }, headers: {})
|
|
self.app = build_app ssl_options: { hsts: hsts }, headers: headers
|
|
get url
|
|
assert_equal expected, response.headers["Strict-Transport-Security"]
|
|
end
|
|
|
|
test "enabled by default" do
|
|
assert_hsts EXPECTED_WITH_SUBDOMAINS
|
|
end
|
|
|
|
test "not sent with http:// responses" do
|
|
assert_hsts nil, url: "http://example.org"
|
|
end
|
|
|
|
test "defers to app-provided header" do
|
|
assert_hsts "app-provided", headers: { "Strict-Transport-Security" => "app-provided" }
|
|
end
|
|
|
|
test "hsts: true enables default settings" do
|
|
assert_hsts EXPECTED, hsts: true
|
|
end
|
|
|
|
test "hsts: false sets max-age to zero, clearing browser HSTS settings" do
|
|
assert_hsts "max-age=0", hsts: false
|
|
end
|
|
|
|
test ":expires sets max-age" do
|
|
assert_deprecated do
|
|
assert_hsts "max-age=500", hsts: { expires: 500 }
|
|
end
|
|
end
|
|
|
|
test ":expires supports AS::Duration arguments" do
|
|
assert_deprecated do
|
|
assert_hsts "max-age=31557600", hsts: { expires: 1.year }
|
|
end
|
|
end
|
|
|
|
test "include subdomains" do
|
|
assert_hsts "#{EXPECTED}; includeSubDomains", hsts: { subdomains: true }
|
|
end
|
|
|
|
test "exclude subdomains" do
|
|
assert_hsts EXPECTED, hsts: { subdomains: false }
|
|
end
|
|
|
|
test "opt in to browser preload lists" do
|
|
assert_deprecated do
|
|
assert_hsts "#{EXPECTED}; preload", hsts: { preload: true }
|
|
end
|
|
end
|
|
|
|
test "opt out of browser preload lists" do
|
|
assert_deprecated do
|
|
assert_hsts EXPECTED, hsts: { preload: false }
|
|
end
|
|
end
|
|
end
|
|
|
|
class SecureCookiesTest < SSLTest
|
|
DEFAULT = %(id=1; path=/\ntoken=abc; path=/; secure; HttpOnly)
|
|
|
|
def get(**options)
|
|
self.app = build_app(**options)
|
|
super "https://example.org"
|
|
end
|
|
|
|
def assert_cookies(*expected)
|
|
assert_equal expected, response.headers["Set-Cookie"].split("\n")
|
|
end
|
|
|
|
def test_flag_cookies_as_secure
|
|
get headers: { "Set-Cookie" => DEFAULT }
|
|
assert_cookies "id=1; path=/; secure", "token=abc; path=/; secure; HttpOnly"
|
|
end
|
|
|
|
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
|
|
|
|
def test_flag_cookies_as_secure_with_more_spaces_after
|
|
get headers: { "Set-Cookie" => "problem=def; path=/; secure; HttpOnly" }
|
|
assert_cookies "problem=def; path=/; secure; HttpOnly"
|
|
end
|
|
|
|
def test_flag_cookies_as_secure_with_has_not_spaces_before
|
|
get headers: { "Set-Cookie" => "problem=def; path=/;secure; HttpOnly" }
|
|
assert_cookies "problem=def; path=/;secure; HttpOnly"
|
|
end
|
|
|
|
def test_flag_cookies_as_secure_with_has_not_spaces_after
|
|
get headers: { "Set-Cookie" => "problem=def; path=/; secure;HttpOnly" }
|
|
assert_cookies "problem=def; path=/; secure;HttpOnly"
|
|
end
|
|
|
|
def test_flag_cookies_as_secure_with_ignore_case
|
|
get headers: { "Set-Cookie" => "problem=def; path=/; Secure; HttpOnly" }
|
|
assert_cookies "problem=def; path=/; Secure; HttpOnly"
|
|
end
|
|
|
|
def test_cookies_as_not_secure_with_secure_cookies_disabled
|
|
get headers: { "Set-Cookie" => DEFAULT }, ssl_options: { secure_cookies: false }
|
|
assert_cookies(*DEFAULT.split("\n"))
|
|
end
|
|
|
|
def test_no_cookies
|
|
get
|
|
assert_nil response.headers["Set-Cookie"]
|
|
end
|
|
|
|
def test_keeps_original_headers_behavior
|
|
get headers: { "Connection" => %w[close] }
|
|
assert_equal "close", response.headers["Connection"]
|
|
end
|
|
end
|