Skip to content

Commit cd3e00d

Browse files
authored
Add HostAuthorization rack-protection middleware (#2053)
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.
1 parent 8c4cd0b commit cd3e00d

File tree

11 files changed

+701
-3
lines changed

11 files changed

+701
-3
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,6 +1992,31 @@ set :protection, :session => true
19921992
<tt>"development"</tt> if not available.
19931993
</dd>
19941994

1995+
<dt>host_authorization</dt>
1996+
<dd>
1997+
You can pass a hash of options to <tt>host_authorization</tt>,
1998+
to be used by the <tt>Rack::Protection::HostAuthorization</tt> middleware.
1999+
<dd>
2000+
<dd>
2001+
The middleware can block requests with unrecognized hostnames, to prevent DNS rebinding
2002+
and other host header attacks. It checks the <tt>Host</tt>, <tt>X-Forwarded-Host</tt>
2003+
and <tt>Forwarded</tt> headers.
2004+
</dd>
2005+
<dd>
2006+
Useful options are:
2007+
<ul>
2008+
<li><tt>permitted_hosts</tt> – an array of hostnames (and <tt>IPAddr</tt> objects) your app recognizes
2009+
<ul>
2010+
<li>in the <tt>development</tt> environment, it is set to <tt>.localhost</tt>, <tt>.test</tt> and any IPv4/IPv6 address</li>
2011+
<li>if empty, any hostname is permitted (the default for any other environment)</li>
2012+
</ul>
2013+
</li>
2014+
<li><tt>status</tt> – the HTTP status code used in the response when a request is blocked (defaults to <tt>403</tt>)</li>
2015+
<li><tt>message</tt> – the body used in the response when a request is blocked (defaults to <tt>Host not permitted</tt>)</li>
2016+
<li><tt>allow_if</tt> – supply a <tt>Proc</tt> to use custom allow/deny logic, the proc is passed the request environment</li>
2017+
</ul>
2018+
</dd>
2019+
19952020
<dt>logging</dt>
19962021
<dd>Use the logger.</dd>
19972022

lib/sinatra/base.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require 'mustermann/regular'
1515

1616
# stdlib dependencies
17+
require 'ipaddr'
1718
require 'time'
1819
require 'uri'
1920

@@ -63,7 +64,7 @@ def preferred_type(*types)
6364
alias secure? ssl?
6465

6566
def forwarded?
66-
@env.include? 'HTTP_X_FORWARDED_HOST'
67+
!forwarded_authority.nil?
6768
end
6869

6970
def safe?
@@ -1821,6 +1822,7 @@ def setup_default_middleware(builder)
18211822
setup_logging builder
18221823
setup_sessions builder
18231824
setup_protection builder
1825+
setup_host_authorization builder
18241826
end
18251827

18261828
def setup_middleware(builder)
@@ -1869,6 +1871,10 @@ def setup_protection(builder)
18691871
builder.use Rack::Protection, options
18701872
end
18711873

1874+
def setup_host_authorization(builder)
1875+
builder.use Rack::Protection::HostAuthorization, host_authorization
1876+
end
1877+
18721878
def setup_sessions(builder)
18731879
return unless sessions?
18741880

@@ -1967,6 +1973,21 @@ class << self
19671973
set :bind, proc { development? ? 'localhost' : '0.0.0.0' }
19681974
set :port, Integer(ENV['PORT'] && !ENV['PORT'].empty? ? ENV['PORT'] : 4567)
19691975
set :quiet, false
1976+
set :host_authorization, ->() do
1977+
if development?
1978+
{
1979+
permitted_hosts: [
1980+
"localhost",
1981+
".localhost",
1982+
".test",
1983+
IPAddr.new("0.0.0.0/0"),
1984+
IPAddr.new("::/0"),
1985+
]
1986+
}
1987+
else
1988+
{}
1989+
end
1990+
end
19701991

19711992
ruby_engine = defined?(RUBY_ENGINE) && RUBY_ENGINE
19721993

rack-protection/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ run MyApp
3434

3535
# Prevented Attacks
3636

37+
## DNS rebinding and other Host header attacks
38+
39+
* [`Rack::Protection::HostAuthorization`][host-authorization] (not included by `use Rack::Protection`)
40+
3741
## Cross Site Request Forgery
3842

3943
Prevented by:
@@ -109,6 +113,7 @@ The instrumenter is passed a namespace (String) and environment (Hash). The name
109113
[escaped-params]: http://www.sinatrarb.com/protection/escaped_params
110114
[form-token]: http://www.sinatrarb.com/protection/form_token
111115
[frame-options]: http://www.sinatrarb.com/protection/frame_options
116+
[host-authorization]: https://github.com/sinatra/sinatra/blob/main/rack-protection/lib/rack/protection/host_authorization.rb
112117
[http-origin]: http://www.sinatrarb.com/protection/http_origin
113118
[ip-spoofing]: http://www.sinatrarb.com/protection/ip_spoofing
114119
[json-csrf]: http://www.sinatrarb.com/protection/json_csrf

rack-protection/lib/rack/protection.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module Protection
1212
autoload :EscapedParams, 'rack/protection/escaped_params'
1313
autoload :FormToken, 'rack/protection/form_token'
1414
autoload :FrameOptions, 'rack/protection/frame_options'
15+
autoload :HostAuthorization, 'rack/protection/host_authorization'
1516
autoload :HttpOrigin, 'rack/protection/http_origin'
1617
autoload :IPSpoofing, 'rack/protection/ip_spoofing'
1718
autoload :JsonCsrf, 'rack/protection/json_csrf'

rack-protection/lib/rack/protection/base.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ def react(env)
5858
result if (Array === result) && (result.size == 3)
5959
end
6060

61+
def debug(env, message)
62+
return unless options[:logging]
63+
64+
l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors'])
65+
l.debug(message)
66+
end
67+
6168
def warn(env, message)
6269
return unless options[:logging]
6370

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# frozen_string_literal: true
2+
3+
require 'rack/protection'
4+
require 'ipaddr'
5+
6+
module Rack
7+
module Protection
8+
##
9+
# Prevented attack:: DNS rebinding and other Host header attacks
10+
# Supported browsers:: all
11+
# More infos:: https://en.wikipedia.org/wiki/DNS_rebinding
12+
# https://portswigger.net/web-security/host-header
13+
#
14+
# Blocks HTTP requests with an unrecognized hostname in any of the following
15+
# HTTP headers: Host, X-Forwarded-Host, Forwarded
16+
#
17+
# If you want to permit a specific hostname, you can pass in as the `:permitted_hosts` option:
18+
#
19+
# use Rack::Protection::HostAuthorization, permitted_hosts: ["www.example.org", "sinatrarb.com"]
20+
#
21+
# The `:allow_if` option can also be set to a proc to use custom allow/deny logic.
22+
class HostAuthorization < Base
23+
DOT = '.'
24+
PORT_REGEXP = /:\d+\z/.freeze
25+
SUBDOMAINS = /[a-z0-9\-.]+/.freeze
26+
private_constant :DOT,
27+
:PORT_REGEXP,
28+
:SUBDOMAINS
29+
default_reaction :deny
30+
default_options allow_if: nil,
31+
message: 'Host not permitted'
32+
33+
def initialize(*)
34+
super
35+
@permitted_hosts = []
36+
@domain_hosts = []
37+
@ip_hosts = []
38+
@all_permitted_hosts = Array(options[:permitted_hosts])
39+
40+
@all_permitted_hosts.each do |host|
41+
case host
42+
when String
43+
if host.start_with?(DOT)
44+
domain = host[1..-1]
45+
@permitted_hosts << domain.downcase
46+
@domain_hosts << /\A#{SUBDOMAINS}#{Regexp.escape(domain)}\z/i
47+
else
48+
@permitted_hosts << host.downcase
49+
end
50+
when IPAddr then @ip_hosts << host
51+
end
52+
end
53+
end
54+
55+
def accepts?(env)
56+
return true if options[:allow_if]&.call(env)
57+
return true if @all_permitted_hosts.empty?
58+
59+
request = Request.new(env)
60+
origin_host = extract_host(request.host_authority)
61+
forwarded_host = extract_host(request.forwarded_authority)
62+
63+
debug env, "#{self.class} " \
64+
"@all_permitted_hosts=#{@all_permitted_hosts.inspect} " \
65+
"@permitted_hosts=#{@permitted_hosts.inspect} " \
66+
"@domain_hosts=#{@domain_hosts.inspect} " \
67+
"@ip_hosts=#{@ip_hosts.inspect} " \
68+
"origin_host=#{origin_host.inspect} " \
69+
"forwarded_host=#{forwarded_host.inspect}"
70+
71+
if host_permitted?(origin_host)
72+
if forwarded_host.nil?
73+
true
74+
else
75+
host_permitted?(forwarded_host)
76+
end
77+
else
78+
false
79+
end
80+
end
81+
82+
private
83+
84+
def extract_host(authority)
85+
authority.to_s.split(PORT_REGEXP).first&.downcase
86+
end
87+
88+
def host_permitted?(host)
89+
exact_match?(host) || domain_match?(host) || ip_match?(host)
90+
end
91+
92+
def exact_match?(host)
93+
@permitted_hosts.include?(host)
94+
end
95+
96+
def domain_match?(host)
97+
return false if host.nil?
98+
return false if host.start_with?(DOT)
99+
100+
@domain_hosts.any? { |domain_host| host.match?(domain_host) }
101+
end
102+
103+
def ip_match?(host)
104+
@ip_hosts.any? { |ip_host| ip_host.include?(host) }
105+
rescue IPAddr::InvalidAddressError
106+
false
107+
end
108+
end
109+
end
110+
end

0 commit comments

Comments
 (0)