1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Introduce a guard against DNS rebinding attacks

The ActionDispatch::HostAuthorization is a new middleware that prevent
against DNS rebinding and other Host header attacks. By default it is
included only in the development environment with the following
configuration:

    Rails.application.config.hosts = [
      IPAddr.new("0.0.0.0/0"), # All IPv4 addresses.
      IPAddr.new("::/0"),      # All IPv6 addresses.
      "localhost"              # The localhost reserved domain.
    ]

In other environments, `Rails.application.config.hosts` is empty and no
Host header checks will be done. If you want to guard against header
attacks on production, you have to manually permit the allowed hosts
with:

    Rails.application.config.hosts << "product.com"

The host of a request is checked against the hosts entries with the case
operator (#===), which lets hosts support entries of type RegExp,
Proc and IPAddr to name a few. Here is an example with a regexp.

    # Allow requests from subdomains like `www.product.com` and
    # `beta1.product.com`.
    Rails.application.config.hosts << /.*\.product\.com/

A special case is supported that allows you to permit all sub-domains:

    # Allow requests from subdomains like `www.product.com` and
    # `beta1.product.com`.
    Rails.application.config.hosts << ".product.com"
This commit is contained in:
Genadi Samokovarov 2018-06-14 11:09:00 +03:00
parent ce48b5a366
commit 07ec8062e6
15 changed files with 393 additions and 50 deletions

View file

@ -1,3 +1,13 @@
* Introduce ActionDispatch::HostAuthorization
This is a new middleware that guards against DNS rebinding attacks by
white-listing the allowed hosts a request can be made to.
Each host is checked with the case operator (`#===`) to support `RegExp`,
`Proc`, `IPAddr` and custom objects as host allowances.
*Genadi Samokovarov*
* Raise an error on root route naming conflicts. * Raise an error on root route naming conflicts.
Raises an ArgumentError when multiple root routes are defined in the Raises an ArgumentError when multiple root routes are defined in the

View file

@ -49,11 +49,13 @@ module ActionDispatch
end end
autoload_under "middleware" do autoload_under "middleware" do
autoload :HostAuthorization
autoload :RequestId autoload :RequestId
autoload :Callbacks autoload :Callbacks
autoload :Cookies autoload :Cookies
autoload :DebugExceptions autoload :DebugExceptions
autoload :DebugLocks autoload :DebugLocks
autoload :DebugView
autoload :ExceptionWrapper autoload :ExceptionWrapper
autoload :Executor autoload :Executor
autoload :Flash autoload :Flash

View file

@ -3,53 +3,14 @@
require "action_dispatch/http/request" require "action_dispatch/http/request"
require "action_dispatch/middleware/exception_wrapper" require "action_dispatch/middleware/exception_wrapper"
require "action_dispatch/routing/inspector" require "action_dispatch/routing/inspector"
require "action_view" require "action_view"
require "action_view/base" require "action_view/base"
require "pp"
module ActionDispatch module ActionDispatch
# This middleware is responsible for logging exceptions and # This middleware is responsible for logging exceptions and
# showing a debugging page in case the request is local. # showing a debugging page in case the request is local.
class DebugExceptions class DebugExceptions
RESCUES_TEMPLATE_PATH = File.expand_path("templates", __dir__)
class DebugView < ActionView::Base
def debug_params(params)
clean_params = params.clone
clean_params.delete("action")
clean_params.delete("controller")
if clean_params.empty?
"None"
else
PP.pp(clean_params, +"", 200)
end
end
def debug_headers(headers)
if headers.present?
headers.inspect.gsub(",", ",\n")
else
"None"
end
end
def debug_hash(object)
object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n")
end
def render(*)
logger = ActionView::Base.logger
if logger && logger.respond_to?(:silence)
logger.silence { super }
else
super
end
end
end
cattr_reader :interceptors, instance_accessor: false, default: [] cattr_reader :interceptors, instance_accessor: false, default: []
def self.register_interceptor(object = nil, &block) def self.register_interceptor(object = nil, &block)
@ -152,7 +113,7 @@ module ActionDispatch
end end
def create_template(request, wrapper) def create_template(request, wrapper)
DebugView.new([RESCUES_TEMPLATE_PATH], DebugView.new(
request: request, request: request,
exception_wrapper: wrapper, exception_wrapper: wrapper,
exception: wrapper.exception, exception: wrapper.exception,

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
require "pp"
require "action_view"
require "action_view/base"
module ActionDispatch
class DebugView < ActionView::Base # :nodoc:
RESCUES_TEMPLATE_PATH = File.expand_path("templates", __dir__)
def initialize(assigns)
super([RESCUES_TEMPLATE_PATH], assigns)
end
def debug_params(params)
clean_params = params.clone
clean_params.delete("action")
clean_params.delete("controller")
if clean_params.empty?
"None"
else
PP.pp(clean_params, +"", 200)
end
end
def debug_headers(headers)
if headers.present?
headers.inspect.gsub(",", ",\n")
else
"None"
end
end
def debug_hash(object)
object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n")
end
def render(*)
logger = ActionView::Base.logger
if logger && logger.respond_to?(:silence)
logger.silence { super }
else
super
end
end
end
end

View file

@ -0,0 +1,105 @@
# frozen_string_literal: true
require "action_dispatch/http/request"
module ActionDispatch
# This middleware guards from DNS rebinding attacks by white-listing the
# hosts a request can be sent to.
#
# When a request comes to an unauthorized host, the +response_app+
# application will be executed and rendered. If no +response_app+ is given, a
# default one will run, which responds with +403 Forbidden+.
class HostAuthorization
class Permissions # :nodoc:
def initialize(hosts)
@hosts = sanitize_hosts(hosts)
end
def empty?
@hosts.empty?
end
def allows?(host)
@hosts.any? do |allowed|
begin
allowed === host
rescue
# IPAddr#=== raises an error if you give it a hostname instead of
# IP. Treat similar errors as blocked access.
false
end
end
end
private
def sanitize_hosts(hosts)
Array(hosts).map do |host|
case host
when Regexp then sanitize_regexp(host)
when String then sanitize_string(host)
else host
end
end
end
def sanitize_regexp(host)
/\A#{host}\z/
end
def sanitize_string(host)
if host.start_with?(".")
/\A(.+\.)?#{Regexp.escape(host[1..-1])}\z/
else
host
end
end
end
DEFAULT_RESPONSE_APP = -> env do
request = Request.new(env)
format = request.xhr? ? "text/plain" : "text/html"
template = DebugView.new(host: request.host)
body = template.render(template: "rescues/blocked_host", layout: "rescues/layout")
[403, {
"Content-Type" => "#{format}; charset=#{Response.default_charset}",
"Content-Length" => body.bytesize.to_s,
}, [body]]
end
def initialize(app, hosts, response_app = nil)
@app = app
@permissions = Permissions.new(hosts)
@response_app = response_app || DEFAULT_RESPONSE_APP
end
def call(env)
return @app.call(env) if @permissions.empty?
request = Request.new(env)
if authorized?(request)
mark_as_authorized(request)
@app.call(env)
else
@response_app.call(env)
end
end
private
def authorized?(request)
origin_host = request.get_header("HTTP_HOST").to_s.sub(/:\d+\z/, "")
forwarded_host = request.x_forwarded_host.to_s.split(/,\s?/).last.to_s.sub(/:\d+\z/, "")
@permissions.allows?(origin_host) &&
(forwarded_host.blank? || @permissions.allows?(forwarded_host))
end
def mark_as_authorized(request)
request.set_header("action_dispatch.authorized_host", request.host)
end
end
end

View file

@ -0,0 +1,7 @@
<header>
<h1>Blocked host: <%= @host %></h1>
</header>
<div id="container">
<h2>To allow requests to <%= @host %>, add the following configuration:</h2>
<pre>Rails.application.config.hosts &lt;&lt; "<%= @host %>"</pre>
</div>

View file

@ -0,0 +1,5 @@
Blocked host: <%= @host %>
To allow requests to <%= @host %>, add the following configuration:
Rails.application.config.hosts << "<%= @host %>"

View file

@ -0,0 +1,160 @@
# frozen_string_literal: true
require "abstract_unit"
class HostAuthorizationTest < ActionDispatch::IntegrationTest
App = -> env { [200, {}, %w(Success)] }
test "blocks requests to unallowed host" do
@app = ActionDispatch::HostAuthorization.new(App, %w(only.com))
get "/"
assert_response :forbidden
assert_match "Blocked host: www.example.com", response.body
end
test "passes all requests to if the whitelist is empty" do
@app = ActionDispatch::HostAuthorization.new(App, nil)
get "/"
assert_response :ok
assert_equal "Success", body
end
test "passes requests to allowed host" do
@app = ActionDispatch::HostAuthorization.new(App, %w(www.example.com))
get "/"
assert_response :ok
assert_equal "Success", body
end
test "the whitelist could be a single element" do
@app = ActionDispatch::HostAuthorization.new(App, "www.example.com")
get "/"
assert_response :ok
assert_equal "Success", body
end
test "passes requests to allowed hosts with domain name notation" do
@app = ActionDispatch::HostAuthorization.new(App, ".example.com")
get "/"
assert_response :ok
assert_equal "Success", body
end
test "does not allow domain name notation in the HOST header itself" do
@app = ActionDispatch::HostAuthorization.new(App, ".example.com")
get "/", env: {
"HOST" => ".example.com",
}
assert_response :forbidden
assert_match "Blocked host: .example.com", response.body
end
test "checks for requests with #=== to support wider range of host checks" do
@app = ActionDispatch::HostAuthorization.new(App, [-> input { input == "www.example.com" }])
get "/"
assert_response :ok
assert_equal "Success", body
end
test "mark the host when authorized" do
@app = ActionDispatch::HostAuthorization.new(App, ".example.com")
get "/"
assert_equal "www.example.com", request.get_header("action_dispatch.authorized_host")
end
test "sanitizes regular expressions to prevent accidental matches" do
@app = ActionDispatch::HostAuthorization.new(App, [/w.example.co/])
get "/"
assert_response :forbidden
assert_match "Blocked host: www.example.com", response.body
end
test "blocks requests to unallowed host supporting custom responses" do
@app = ActionDispatch::HostAuthorization.new(App, ["w.example.co"], -> env do
[401, {}, %w(Custom)]
end)
get "/"
assert_response :unauthorized
assert_equal "Custom", body
end
test "blocks requests with spoofed X-FORWARDED-HOST" do
@app = ActionDispatch::HostAuthorization.new(App, [IPAddr.new("127.0.0.1")])
get "/", env: {
"HTTP_X_FORWARDED_HOST" => "127.0.0.1",
"HOST" => "www.example.com",
}
assert_response :forbidden
assert_match "Blocked host: 127.0.0.1", response.body
end
test "does not consider IP addresses in X-FORWARDED-HOST spoofed when disabled" do
@app = ActionDispatch::HostAuthorization.new(App, nil)
get "/", env: {
"HTTP_X_FORWARDED_HOST" => "127.0.0.1",
"HOST" => "www.example.com",
}
assert_response :ok
assert_equal "Success", body
end
test "detects localhost domain spoofing" do
@app = ActionDispatch::HostAuthorization.new(App, "localhost")
get "/", env: {
"HTTP_X_FORWARDED_HOST" => "localhost",
"HOST" => "www.example.com",
}
assert_response :forbidden
assert_match "Blocked host: localhost", response.body
end
test "forwarded hosts should be permitted" do
@app = ActionDispatch::HostAuthorization.new(App, "domain.com")
get "/", env: {
"HTTP_X_FORWARDED_HOST" => "sub.domain.com",
"HOST" => "domain.com",
}
assert_response :forbidden
assert_match "Blocked host: sub.domain.com", response.body
end
test "forwarded hosts are allowed when permitted" do
@app = ActionDispatch::HostAuthorization.new(App, ".domain.com")
get "/", env: {
"HTTP_X_FORWARDED_HOST" => "sub.domain.com",
"HOST" => "domain.com",
}
assert_response :ok
assert_equal "Success", body
end
end

View file

@ -1,3 +1,38 @@
* Introduce guard against DNS rebinding attacks
The `ActionDispatch::HostAuthorization` is a new middleware that prevent
against DNS rebinding and other `Host` header attacks. It is included in
the development environment by default with the following configuration:
Rails.application.config.hosts = [
IPAddr.new("0.0.0.0/0"), # All IPv4 addresses.
IPAddr.new("::/0"), # All IPv6 addresses.
"localhost" # The localhost reserved domain.
]
In other environments `Rails.application.config.hosts` is empty and no
`Host` header checks will be done. If you want to guard against header
attacks on production, you have to manually whitelist the allowed hosts
with:
Rails.application.config.hosts << "product.com"
The host of a request is checked against the `hosts` entries with the case
operator (`#===`), which lets `hosts` support entries of type `RegExp`,
`Proc` and `IPAddr` to name a few. Here is an example with a regexp.
# Allow requests from subdomains like `www.product.com` and
# `beta1.product.com`.
Rails.application.config.hosts << /.*\.product\.com/
A special case is supported that allows you to whitelist all sub-domains:
# Allow requests from subdomains like `www.product.com` and
# `beta1.product.com`.
Rails.application.config.hosts << ".product.com"
*Genadi Samokovarov*
* Remove redundant suffixes on generated helpers. * Remove redundant suffixes on generated helpers.
*Gannon McGibbon* *Gannon McGibbon*

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require "ipaddr"
require "active_support/core_ext/kernel/reporting" require "active_support/core_ext/kernel/reporting"
require "active_support/file_update_checker" require "active_support/file_update_checker"
require "rails/engine/configuration" require "rails/engine/configuration"
@ -11,7 +12,7 @@ module Rails
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, :logger, :log_formatter, :log_tags, :force_ssl, :helpers_paths, :hosts, :logger, :log_formatter, :log_tags,
:railties_order, :relative_url_root, :secret_key_base, :secret_token, :railties_order, :relative_url_root, :secret_key_base, :secret_token,
: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,
@ -29,6 +30,7 @@ module Rails
@filter_parameters = [] @filter_parameters = []
@filter_redirect = [] @filter_redirect = []
@helpers_paths = [] @helpers_paths = []
@hosts = Array(([IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0"), "localhost"] if Rails.env.development?))
@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"

View file

@ -13,6 +13,8 @@ module Rails
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
if config.force_ssl if config.force_ssl
middleware.use ::ActionDispatch::SSL, config.ssl_options middleware.use ::ActionDispatch::SSL, config.ssl_options
end end

View file

@ -4,7 +4,7 @@ require "rails/application_controller"
require "action_dispatch/routing/inspector" require "action_dispatch/routing/inspector"
class Rails::InfoController < Rails::ApplicationController # :nodoc: class Rails::InfoController < Rails::ApplicationController # :nodoc:
prepend_view_path ActionDispatch::DebugExceptions::RESCUES_TEMPLATE_PATH prepend_view_path ActionDispatch::DebugView::RESCUES_TEMPLATE_PATH
layout -> { request.xhr? ? false : "application" } layout -> { request.xhr? ? false : "application" }
before_action :require_local! before_action :require_local!

View file

@ -3,7 +3,7 @@
require "rails/application_controller" require "rails/application_controller"
class Rails::MailersController < Rails::ApplicationController # :nodoc: class Rails::MailersController < Rails::ApplicationController # :nodoc:
prepend_view_path ActionDispatch::DebugExceptions::RESCUES_TEMPLATE_PATH prepend_view_path ActionDispatch::DebugView::RESCUES_TEMPLATE_PATH
before_action :require_local!, unless: :show_previews? before_action :require_local!, unless: :show_previews?
before_action :find_preview, :set_locale, only: :preview before_action :find_preview, :set_locale, only: :preview

View file

@ -26,6 +26,7 @@ module ApplicationTests
assert_equal [ assert_equal [
"Webpacker::DevServerProxy", "Webpacker::DevServerProxy",
"ActionDispatch::HostAuthorization",
"Rack::Sendfile", "Rack::Sendfile",
"ActionDispatch::Static", "ActionDispatch::Static",
"ActionDispatch::Executor", "ActionDispatch::Executor",
@ -58,6 +59,7 @@ module ApplicationTests
assert_equal [ assert_equal [
"Webpacker::DevServerProxy", "Webpacker::DevServerProxy",
"ActionDispatch::HostAuthorization",
"Rack::Sendfile", "Rack::Sendfile",
"ActionDispatch::Static", "ActionDispatch::Static",
"ActionDispatch::Executor", "ActionDispatch::Executor",
@ -140,7 +142,7 @@ module ApplicationTests
add_to_config "config.ssl_options = { redirect: { host: 'example.com' } }" add_to_config "config.ssl_options = { redirect: { host: 'example.com' } }"
boot! boot!
assert_equal [{ redirect: { host: "example.com" } }], Rails.application.middleware[1].args assert_equal [{ redirect: { host: "example.com" } }], Rails.application.middleware[2].args
end end
test "removing Active Record omits its middleware" do test "removing Active Record omits its middleware" do
@ -224,7 +226,7 @@ module ApplicationTests
test "insert middleware after" do test "insert middleware after" do
add_to_config "config.middleware.insert_after Rack::Sendfile, Rack::Config" add_to_config "config.middleware.insert_after Rack::Sendfile, Rack::Config"
boot! boot!
assert_equal "Rack::Config", middleware.third assert_equal "Rack::Config", middleware.fourth
end end
test "unshift middleware" do test "unshift middleware" do
@ -236,19 +238,19 @@ module ApplicationTests
test "Rails.cache does not respond to middleware" do test "Rails.cache does not respond to middleware" do
add_to_config "config.cache_store = :memory_store" add_to_config "config.cache_store = :memory_store"
boot! boot!
assert_equal "Rack::Runtime", middleware.fifth assert_equal "Rack::Runtime", middleware[5]
end end
test "Rails.cache does respond to middleware" do test "Rails.cache does respond to middleware" do
boot! boot!
assert_equal "ActiveSupport::Cache::Strategy::LocalCache", middleware.fifth assert_equal "ActiveSupport::Cache::Strategy::LocalCache", middleware[5]
assert_equal "Rack::Runtime", middleware[5] assert_equal "Rack::Runtime", middleware[6]
end end
test "insert middleware before" do test "insert middleware before" do
add_to_config "config.middleware.insert_before Rack::Sendfile, Rack::Config" add_to_config "config.middleware.insert_before Rack::Sendfile, Rack::Config"
boot! boot!
assert_equal "Rack::Config", middleware.second assert_equal "Rack::Config", middleware.third
end end
test "can't change middleware after it's built" do test "can't change middleware after it's built" do

View file

@ -197,6 +197,7 @@ module TestHelpers
end end
add_to_config <<-RUBY add_to_config <<-RUBY
config.hosts << proc { true }
config.eager_load = false config.eager_load = false
config.session_store :cookie_store, key: "_myapp_session" config.session_store :cookie_store, key: "_myapp_session"
config.active_support.deprecation = :log config.active_support.deprecation = :log
@ -220,6 +221,7 @@ module TestHelpers
@app = Class.new(Rails::Application) do @app = Class.new(Rails::Application) do
def self.name; "RailtiesTestApp"; end def self.name; "RailtiesTestApp"; end
end end
@app.config.hosts << proc { true }
@app.config.eager_load = false @app.config.eager_load = false
@app.config.session_store :cookie_store, key: "_myapp_session" @app.config.session_store :cookie_store, key: "_myapp_session"
@app.config.active_support.deprecation = :log @app.config.active_support.deprecation = :log