From fd6a64fef1d0f7f40a8d4b046da882e83163299c Mon Sep 17 00:00:00 2001 From: Stef Schenkelaars Date: Wed, 7 Jul 2021 12:06:32 +0200 Subject: [PATCH] Fix invalid forwarded host vulnerability Prior to this commit, it was possible to pass an unvalidated host through the `X-Forwarded-Host` header. If the value of the header was prefixed with a invalid domain character (for example a `/`), it was always accepted as the actual host of that request. Since this host is used for all url helpers, an attacker could change generated links and redirects. If the header is set to `X-Forwarded-Host: //evil.hacker`, a redirect will be send to `https:////evil.hacker/`. Browsers will ignore these four slashes and redirect the user. [CVE-2021-44528] --- .../middleware/host_authorization.rb | 10 +-- .../test/dispatch/host_authorization_test.rb | 89 ++++++++++++++++++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/actionpack/lib/action_dispatch/middleware/host_authorization.rb b/actionpack/lib/action_dispatch/middleware/host_authorization.rb index 8e39aee87d..ca2b17cb8b 100644 --- a/actionpack/lib/action_dispatch/middleware/host_authorization.rb +++ b/actionpack/lib/action_dispatch/middleware/host_authorization.rb @@ -46,7 +46,7 @@ def sanitize_regexp(host) def sanitize_string(host) if host.start_with?(".") - /\A(.+\.)?#{Regexp.escape(host[1..-1])}\z/i + /\A([a-z0-9-]+\.)?#{Regexp.escape(host[1..-1])}\z/i else /\A#{Regexp.escape host}\z/i end @@ -86,13 +86,9 @@ def call(env) end private - HOSTNAME = /[a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9.:]+\]/i - VALID_ORIGIN_HOST = /\A(#{HOSTNAME})(?::\d+)?\z/ - VALID_FORWARDED_HOST = /(?:\A|,[ ]?)(#{HOSTNAME})(?::\d+)?\z/ - def authorized?(request) - origin_host = request.get_header("HTTP_HOST")&.slice(VALID_ORIGIN_HOST, 1) || "" - forwarded_host = request.x_forwarded_host&.slice(VALID_FORWARDED_HOST, 1) || "" + origin_host = request.get_header("HTTP_HOST") + forwarded_host = request.x_forwarded_host&.split(/,\s?/)&.last @permissions.allows?(origin_host) && (forwarded_host.blank? || @permissions.allows?(forwarded_host)) end diff --git a/actionpack/test/dispatch/host_authorization_test.rb b/actionpack/test/dispatch/host_authorization_test.rb index 63562c9046..8b29dabf0c 100644 --- a/actionpack/test/dispatch/host_authorization_test.rb +++ b/actionpack/test/dispatch/host_authorization_test.rb @@ -155,6 +155,44 @@ class HostAuthorizationTest < ActionDispatch::IntegrationTest assert_match "Blocked host: 127.0.0.1", response.body end + test "blocks requests with spoofed relative X-FORWARDED-HOST" do + @app = ActionDispatch::HostAuthorization.new(App, ["www.example.com"]) + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "//randomhost.com", + "HOST" => "www.example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked host: //randomhost.com", response.body + end + + test "forwarded secondary hosts are allowed when permitted" do + @app = ActionDispatch::HostAuthorization.new(App, ".domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "example.com, my-sub.domain.com", + "HOST" => "domain.com", + } + + assert_response :ok + assert_equal "Success", body + end + + test "forwarded secondary hosts are blocked when mismatch" do + @app = ActionDispatch::HostAuthorization.new(App, "domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "domain.com, evil.com", + "HOST" => "domain.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked host: evil.com", response.body + end + test "does not consider IP addresses in X-FORWARDED-HOST spoofed when disabled" do @app = ActionDispatch::HostAuthorization.new(App, nil) @@ -191,11 +229,23 @@ class HostAuthorizationTest < ActionDispatch::IntegrationTest assert_match "Blocked host: sub.domain.com", response.body end + test "sub-sub domains should not be permitted" do + @app = ActionDispatch::HostAuthorization.new(App, ".domain.com") + + get "/", env: { + "HOST" => "secondary.sub.domain.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked host: secondary.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", + "HTTP_X_FORWARDED_HOST" => "my-sub.domain.com", "HOST" => "domain.com", } @@ -224,4 +274,41 @@ class HostAuthorizationTest < ActionDispatch::IntegrationTest assert_response :forbidden assert_match "Blocked host: sub-example.com", response.body end + + test "lots of NG hosts" do + ng_hosts = [ + "hacker%E3%80%82com", + "hacker%00.com", + "www.theirsite.com@yoursite.com", + "hacker.com/test/", + "hacker%252ecom", + ".hacker.com", + "/\/\/hacker.com/", + "/hacker.com", + "../hacker.com", + ".hacker.com", + "@hacker.com", + "hacker.com", + "hacker.com%23@example.com", + "hacker.com/.jpg", + "hacker.com\texample.com/", + "hacker.com/example.com", + "hacker.com\@example.com", + "hacker.com/example.com", + "hacker.com/" + ] + + @app = ActionDispatch::HostAuthorization.new(App, "example.com") + + ng_hosts.each do |host| + get "/", env: { + "HTTP_X_FORWARDED_HOST" => host, + "HOST" => "example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked host: #{host}", response.body + end + end end -- 2.33.0