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.
Raises an ArgumentError when multiple root routes are defined in the

View file

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

View file

@ -3,53 +3,14 @@
require "action_dispatch/http/request"
require "action_dispatch/middleware/exception_wrapper"
require "action_dispatch/routing/inspector"
require "action_view"
require "action_view/base"
require "pp"
module ActionDispatch
# This middleware is responsible for logging exceptions and
# showing a debugging page in case the request is local.
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: []
def self.register_interceptor(object = nil, &block)
@ -152,7 +113,7 @@ module ActionDispatch
end
def create_template(request, wrapper)
DebugView.new([RESCUES_TEMPLATE_PATH],
DebugView.new(
request: request,
exception_wrapper: wrapper,
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.
*Gannon McGibbon*

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true
require "ipaddr"
require "active_support/core_ext/kernel/reporting"
require "active_support/file_update_checker"
require "rails/engine/configuration"
@ -11,7 +12,7 @@ module Rails
attr_accessor :allow_concurrency, :asset_host, :autoflush_log,
:cache_classes, :cache_store, :consider_all_requests_local, :console,
: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,
:ssl_options, :public_file_server,
:session_options, :time_zone, :reload_classes_only_on_change,
@ -29,6 +30,7 @@ module Rails
@filter_parameters = []
@filter_redirect = []
@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.enabled = true
@public_file_server.index_name = "index"

View file

@ -13,6 +13,8 @@ module Rails
def build_stack
ActionDispatch::MiddlewareStack.new do |middleware|
middleware.use ::ActionDispatch::HostAuthorization, config.hosts, config.action_dispatch.hosts_response_app
if config.force_ssl
middleware.use ::ActionDispatch::SSL, config.ssl_options
end

View file

@ -4,7 +4,7 @@ require "rails/application_controller"
require "action_dispatch/routing/inspector"
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" }
before_action :require_local!

View file

@ -3,7 +3,7 @@
require "rails/application_controller"
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 :find_preview, :set_locale, only: :preview

View file

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

View file

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