Add request exclusion to Host Authorization

In the same way that requests may need to be excluded from forced SSL,
requests may also need to be excluded from the Host Authorization
checks. By providing this additional flexibility more applications
will be able to enable Host Authorization while excluding requests
that may not conform. For example, AWS Classic Load Balancers don't
provide a Host header and cannot be configured to send one. This means
that Host Authorization must be disabled to use the health check
provided by the load balancer. This change will allow an application
to exclude the health check requests from the Host Authorization
requirements.

I've modified the `ActionDispatch::HostAuthorization` middleware to
accept arguments in a similar way to `ActionDispatch::SSL`. The hosts
configuration setting still exists separately as does the
hosts_response_app but I've tried to group the Host Authorization
settings like the ssl_options. It may make sense to deprecate the
global hosts_response_app if it's only used as part of the Host
Authorization failure response. I've also updated the existing tests
as the method signature changed and added new tests to verify the
exclusion functionality.
This commit is contained in:
Chris Bisnett 2020-03-26 20:47:52 -04:00 committed by Eugene Kenny
parent 872e757145
commit 1f767407cb
5 changed files with 58 additions and 7 deletions

@ -1,3 +1,9 @@
* Allow `ActionDispatch::HostAuthorization` to exclude specific requests.
Host Authorization checks can be skipped for specific requests. This allows for health check requests to be permitted for requests with missing or non-matching host headers.
*Chris Bisnett*
* Add `config.action_dispatch.request_id_header` to allow changing the name of * Add `config.action_dispatch.request_id_header` to allow changing the name of
the unique X-Request-Id header the unique X-Request-Id header

@ -4,7 +4,12 @@
module ActionDispatch module ActionDispatch
# This middleware guards from DNS rebinding attacks by explicitly permitting # This middleware guards from DNS rebinding attacks by explicitly permitting
# the hosts a request can be sent to. # the hosts a request can be sent to, and is passed the options set in
# +config.host_authorization+.
#
# Requests can opt-out of Host Authorization with +exclude+:
#
# config.host_authorization = { exclude: ->(request) { request.path =~ /healthcheck/ } }
# #
# When a request comes to an unauthorized host, the +response_app+ # When a request comes to an unauthorized host, the +response_app+
# application will be executed and rendered. If no +response_app+ is given, a # application will be executed and rendered. If no +response_app+ is given, a
@ -66,9 +71,20 @@ def sanitize_string(host)
}, [body]] }, [body]]
end end
def initialize(app, hosts, response_app = nil) def initialize(app, hosts, deprecated_response_app = nil, exclude: nil, response_app: nil)
@app = app @app = app
@permissions = Permissions.new(hosts) @permissions = Permissions.new(hosts)
@exclude = exclude
unless deprecated_response_app.nil?
ActiveSupport::Deprecation.warn(<<-MSG.squish)
`action_dispatch.hosts_response_app` is deprecated and will be ignored in Rails 6.2.
Use the Host Authorization `response_app` setting instead.
MSG
response_app ||= deprecated_response_app
end
@response_app = response_app || DEFAULT_RESPONSE_APP @response_app = response_app || DEFAULT_RESPONSE_APP
end end
@ -77,7 +93,7 @@ def call(env)
request = Request.new(env) request = Request.new(env)
if authorized?(request) if authorized?(request) || excluded?(request)
mark_as_authorized(request) mark_as_authorized(request)
@app.call(env) @app.call(env)
else else
@ -94,6 +110,10 @@ def authorized?(request)
(forwarded_host.blank? || @permissions.allows?(forwarded_host)) (forwarded_host.blank? || @permissions.allows?(forwarded_host))
end end
def excluded?(request)
@exclude && @exclude.call(request)
end
def mark_as_authorized(request) def mark_as_authorized(request)
request.set_header("action_dispatch.authorized_host", request.host) request.set_header("action_dispatch.authorized_host", request.host)
end end

@ -89,7 +89,7 @@ class HostAuthorizationTest < ActionDispatch::IntegrationTest
end end
test "blocks requests to unallowed host supporting custom responses" do test "blocks requests to unallowed host supporting custom responses" do
@app = ActionDispatch::HostAuthorization.new(App, ["w.example.co"], -> env do @app = ActionDispatch::HostAuthorization.new(App, ["w.example.co"], response_app: -> env do
[401, {}, %w(Custom)] [401, {}, %w(Custom)]
end) end)
@ -158,4 +158,28 @@ class HostAuthorizationTest < ActionDispatch::IntegrationTest
assert_response :ok assert_response :ok
assert_equal "Success", body assert_equal "Success", body
end end
test "exclude matches allow any host" do
@app = ActionDispatch::HostAuthorization.new(App, "only.com", exclude: ->(req) { req.path == "/foo" })
get "/foo"
assert_response :ok
assert_equal "Success", body
end
test "exclude misses block unallowed hosts" do
@app = ActionDispatch::HostAuthorization.new(App, "only.com", exclude: ->(req) { req.path == "/bar" })
get "/foo"
assert_response :forbidden
assert_match "Blocked host: www.example.com", response.body
end
test "config setting action_dispatch.hosts_response_app is deprecated" do
assert_deprecated do
ActionDispatch::HostAuthorization.new(App, "example.com", ->(env) { true })
end
end
end end

@ -14,8 +14,8 @@ class Configuration < ::Rails::Engine::Configuration
attr_accessor :allow_concurrency, :asset_host, :autoflush_log, attr_accessor :allow_concurrency, :asset_host, :autoflush_log,
:cache_classes, :cache_store, :consider_all_requests_local, :console, :cache_classes, :cache_store, :consider_all_requests_local, :console,
:eager_load, :exceptions_app, :file_watcher, :filter_parameters, :eager_load, :exceptions_app, :file_watcher, :filter_parameters,
:force_ssl, :helpers_paths, :hosts, :logger, :log_formatter, :log_tags, :force_ssl, :helpers_paths, :hosts, :host_authorization, :logger, :log_formatter,
:railties_order, :relative_url_root, :secret_key_base, :log_tags, :railties_order, :relative_url_root, :secret_key_base,
:ssl_options, :public_file_server, :ssl_options, :public_file_server,
:session_options, :time_zone, :reload_classes_only_on_change, :session_options, :time_zone, :reload_classes_only_on_change,
:beginning_of_week, :filter_redirect, :x, :enable_dependency_loading, :beginning_of_week, :filter_redirect, :x, :enable_dependency_loading,
@ -35,6 +35,7 @@ def initialize(*)
@filter_redirect = [] @filter_redirect = []
@helpers_paths = [] @helpers_paths = []
@hosts = Array(([".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")] if Rails.env.development?)) @hosts = Array(([".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")] if Rails.env.development?))
@host_authorization = {}
@public_file_server = ActiveSupport::OrderedOptions.new @public_file_server = ActiveSupport::OrderedOptions.new
@public_file_server.enabled = true @public_file_server.enabled = true
@public_file_server.index_name = "index" @public_file_server.index_name = "index"

@ -13,7 +13,7 @@ def initialize(app, config, paths)
def build_stack def build_stack
ActionDispatch::MiddlewareStack.new do |middleware| ActionDispatch::MiddlewareStack.new do |middleware|
middleware.use ::ActionDispatch::HostAuthorization, config.hosts, config.action_dispatch.hosts_response_app middleware.use ::ActionDispatch::HostAuthorization, config.hosts, config.action_dispatch.hosts_response_app, **config.host_authorization
if config.force_ssl if config.force_ssl
middleware.use ::ActionDispatch::SSL, **config.ssl_options, middleware.use ::ActionDispatch::SSL, **config.ssl_options,