Skip to content

Commit

Permalink
Add HostAuthorization rack-protection middleware (#2053)
Browse files Browse the repository at this point in the history
The Sinatra project received a security report with the following
details:

> Title: Reliance on Untrusted Inputs in a Security Decision
> CWE ID: CWE-807
> CVE ID: CVE-2024-21510
> Credit: t0rchwo0d
> Description: The sinatra package is vulnerable to Reliance on
Untrusted
> Inputs in a Security Decision via the `X-Forwarded-Host (XFH)` header.
> When making a request to a method with redirect applied, it is
possible
> to trigger an Open Redirect Attack by inserting an arbitrary address
> into this header. If used for caching purposes, such as with servers
> like Nginx, or as a reverse proxy, without handling the
> `X-Forwarded-Host` header, attackers can potentially exploit Cache
> Poisoning or Routing-based SSRF.

The vulnerable code was introduced in fae7c01. Sinatra can not know
whether the header value can be trusted or not without input from the
app creator. This change introduce the `host_authorization` settings for
that.

It is implemented as a Rack middleware, bundled with rack-protection,
but not exposed as a default nor opt-in protection. It is meant to be
used by itself, as sharing reaction with other protections is not ideal.
  • Loading branch information
dentarg authored Nov 18, 2024
1 parent 8c4cd0b commit cd3e00d
Show file tree
Hide file tree
Showing 11 changed files with 701 additions and 3 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,31 @@ set :protection, :session => true
<tt>"development"</tt> if not available.
</dd>

<dt>host_authorization</dt>
<dd>
You can pass a hash of options to <tt>host_authorization</tt>,
to be used by the <tt>Rack::Protection::HostAuthorization</tt> middleware.
<dd>
<dd>
The middleware can block requests with unrecognized hostnames, to prevent DNS rebinding
and other host header attacks. It checks the <tt>Host</tt>, <tt>X-Forwarded-Host</tt>
and <tt>Forwarded</tt> headers.
</dd>
<dd>
Useful options are:
<ul>
<li><tt>permitted_hosts</tt> – an array of hostnames (and <tt>IPAddr</tt> objects) your app recognizes
<ul>
<li>in the <tt>development</tt> environment, it is set to <tt>.localhost</tt>, <tt>.test</tt> and any IPv4/IPv6 address</li>
<li>if empty, any hostname is permitted (the default for any other environment)</li>
</ul>
</li>
<li><tt>status</tt> – the HTTP status code used in the response when a request is blocked (defaults to <tt>403</tt>)</li>
<li><tt>message</tt> – the body used in the response when a request is blocked (defaults to <tt>Host not permitted</tt>)</li>
<li><tt>allow_if</tt> – supply a <tt>Proc</tt> to use custom allow/deny logic, the proc is passed the request environment</li>
</ul>
</dd>

<dt>logging</dt>
<dd>Use the logger.</dd>

Expand Down
23 changes: 22 additions & 1 deletion lib/sinatra/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require 'mustermann/regular'

# stdlib dependencies
require 'ipaddr'
require 'time'
require 'uri'

Expand Down Expand Up @@ -63,7 +64,7 @@ def preferred_type(*types)
alias secure? ssl?

def forwarded?
@env.include? 'HTTP_X_FORWARDED_HOST'
!forwarded_authority.nil?
end

def safe?
Expand Down Expand Up @@ -1821,6 +1822,7 @@ def setup_default_middleware(builder)
setup_logging builder
setup_sessions builder
setup_protection builder
setup_host_authorization builder
end

def setup_middleware(builder)
Expand Down Expand Up @@ -1869,6 +1871,10 @@ def setup_protection(builder)
builder.use Rack::Protection, options
end

def setup_host_authorization(builder)
builder.use Rack::Protection::HostAuthorization, host_authorization
end

def setup_sessions(builder)
return unless sessions?

Expand Down Expand Up @@ -1967,6 +1973,21 @@ class << self
set :bind, proc { development? ? 'localhost' : '0.0.0.0' }
set :port, Integer(ENV['PORT'] && !ENV['PORT'].empty? ? ENV['PORT'] : 4567)
set :quiet, false
set :host_authorization, ->() do
if development?
{
permitted_hosts: [
"localhost",
".localhost",
".test",
IPAddr.new("0.0.0.0/0"),
IPAddr.new("::/0"),
]
}
else
{}
end
end

ruby_engine = defined?(RUBY_ENGINE) && RUBY_ENGINE

Expand Down
5 changes: 5 additions & 0 deletions rack-protection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ run MyApp

# Prevented Attacks

## DNS rebinding and other Host header attacks

* [`Rack::Protection::HostAuthorization`][host-authorization] (not included by `use Rack::Protection`)

## Cross Site Request Forgery

Prevented by:
Expand Down Expand Up @@ -109,6 +113,7 @@ The instrumenter is passed a namespace (String) and environment (Hash). The name
[escaped-params]: http://www.sinatrarb.com/protection/escaped_params
[form-token]: http://www.sinatrarb.com/protection/form_token
[frame-options]: http://www.sinatrarb.com/protection/frame_options
[host-authorization]: https://github.com/sinatra/sinatra/blob/main/rack-protection/lib/rack/protection/host_authorization.rb
[http-origin]: http://www.sinatrarb.com/protection/http_origin
[ip-spoofing]: http://www.sinatrarb.com/protection/ip_spoofing
[json-csrf]: http://www.sinatrarb.com/protection/json_csrf
Expand Down
1 change: 1 addition & 0 deletions rack-protection/lib/rack/protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module Protection
autoload :EscapedParams, 'rack/protection/escaped_params'
autoload :FormToken, 'rack/protection/form_token'
autoload :FrameOptions, 'rack/protection/frame_options'
autoload :HostAuthorization, 'rack/protection/host_authorization'
autoload :HttpOrigin, 'rack/protection/http_origin'
autoload :IPSpoofing, 'rack/protection/ip_spoofing'
autoload :JsonCsrf, 'rack/protection/json_csrf'
Expand Down
7 changes: 7 additions & 0 deletions rack-protection/lib/rack/protection/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ def react(env)
result if (Array === result) && (result.size == 3)
end

def debug(env, message)
return unless options[:logging]

l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors'])
l.debug(message)
end

def warn(env, message)
return unless options[:logging]

Expand Down
110 changes: 110 additions & 0 deletions rack-protection/lib/rack/protection/host_authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

require 'rack/protection'
require 'ipaddr'

module Rack
module Protection
##
# Prevented attack:: DNS rebinding and other Host header attacks
# Supported browsers:: all
# More infos:: https://en.wikipedia.org/wiki/DNS_rebinding
# https://portswigger.net/web-security/host-header
#
# Blocks HTTP requests with an unrecognized hostname in any of the following
# HTTP headers: Host, X-Forwarded-Host, Forwarded
#
# If you want to permit a specific hostname, you can pass in as the `:permitted_hosts` option:
#
# use Rack::Protection::HostAuthorization, permitted_hosts: ["www.example.org", "sinatrarb.com"]
#
# The `:allow_if` option can also be set to a proc to use custom allow/deny logic.
class HostAuthorization < Base
DOT = '.'
PORT_REGEXP = /:\d+\z/.freeze
SUBDOMAINS = /[a-z0-9\-.]+/.freeze
private_constant :DOT,
:PORT_REGEXP,
:SUBDOMAINS
default_reaction :deny
default_options allow_if: nil,
message: 'Host not permitted'

def initialize(*)
super
@permitted_hosts = []
@domain_hosts = []
@ip_hosts = []
@all_permitted_hosts = Array(options[:permitted_hosts])

@all_permitted_hosts.each do |host|
case host
when String
if host.start_with?(DOT)
domain = host[1..-1]
@permitted_hosts << domain.downcase
@domain_hosts << /\A#{SUBDOMAINS}#{Regexp.escape(domain)}\z/i
else
@permitted_hosts << host.downcase
end
when IPAddr then @ip_hosts << host
end
end
end

def accepts?(env)
return true if options[:allow_if]&.call(env)
return true if @all_permitted_hosts.empty?

request = Request.new(env)
origin_host = extract_host(request.host_authority)
forwarded_host = extract_host(request.forwarded_authority)

debug env, "#{self.class} " \
"@all_permitted_hosts=#{@all_permitted_hosts.inspect} " \
"@permitted_hosts=#{@permitted_hosts.inspect} " \
"@domain_hosts=#{@domain_hosts.inspect} " \
"@ip_hosts=#{@ip_hosts.inspect} " \
"origin_host=#{origin_host.inspect} " \
"forwarded_host=#{forwarded_host.inspect}"

if host_permitted?(origin_host)
if forwarded_host.nil?
true
else
host_permitted?(forwarded_host)
end
else
false
end
end

private

def extract_host(authority)
authority.to_s.split(PORT_REGEXP).first&.downcase
end

def host_permitted?(host)
exact_match?(host) || domain_match?(host) || ip_match?(host)
end

def exact_match?(host)
@permitted_hosts.include?(host)
end

def domain_match?(host)
return false if host.nil?
return false if host.start_with?(DOT)

@domain_hosts.any? { |domain_host| host.match?(domain_host) }
end

def ip_match?(host)
@ip_hosts.any? { |ip_host| ip_host.include?(host) }
rescue IPAddr::InvalidAddressError
false
end
end
end
end
Loading

0 comments on commit cd3e00d

Please sign in to comment.