Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HostAuthorization rack-protection middleware #2053

Merged
merged 34 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fb2d76d
Add `permitted_hosts` setting
dentarg Nov 5, 2024
b5df6e1
Split bad/good cases in `HostAuthorization` specs
dentarg Nov 8, 2024
492589f
Less brittle `HostAuthorization` specs
dentarg Nov 8, 2024
841ee31
Simplify `HostAuthorization` tests for Sinatra
dentarg Nov 8, 2024
d2293d9
Document `HostAuthorization` in rack-protection README
dentarg Nov 8, 2024
c9c2625
Expose all the options for `HostAuthorization`
dentarg Nov 8, 2024
6e3711e
Test with multiple hostnames in headers
dentarg Nov 8, 2024
1701645
Improve test descriptions
dentarg Nov 8, 2024
071358e
Port should not matter for `HostAuthorization`
dentarg Nov 8, 2024
856d8e6
No need for `Rack::Protection::HostAuthorization.forwarded?` now
dentarg Nov 8, 2024
ffaf412
Support for `IPAddr` hosts
dentarg Nov 8, 2024
44dd856
Always look at the `Host` header
dentarg Nov 12, 2024
76e614a
Better grouping of `HostAuthorization` specs
dentarg Nov 12, 2024
e6264be
Test with more exotic header values
dentarg Nov 12, 2024
d179c0c
Support for subdomains
dentarg Nov 12, 2024
4652ad2
Support `Forwarded` header in `#forwarded?`
dentarg Nov 12, 2024
a167d8b
Use the same `require` order as other protections
dentarg Nov 13, 2024
3d5383d
Better `allow_if` example in tests
dentarg Nov 13, 2024
dd73d0e
`HostAuthorization` development settings
dentarg Nov 13, 2024
8466a9e
Test with `nil` `HTTP_HOST`
dentarg Nov 13, 2024
1081bf8
Fix typo in README
dentarg Nov 14, 2024
85d2079
Mention `IPAddr` objects
dentarg Nov 14, 2024
801e821
Add (optional) debug logging in `HostAuthorization`
dentarg Nov 14, 2024
579a2e6
Fix incorrect test description
dentarg Nov 15, 2024
affc961
Use keyword argument for `mock_app` helper
dentarg Nov 15, 2024
543bac2
Better test description
dentarg Nov 15, 2024
b1bf256
Fix typo in test description
dentarg Nov 15, 2024
9f0d130
Reject invalid hostnames
dentarg Nov 17, 2024
5947a6d
Document `host_authorization` defaults
dentarg Nov 17, 2024
b29d698
Lint `HostAuthorization` middleware
dentarg Nov 18, 2024
5ba1250
Lint `HostAuthorization` specs
dentarg Nov 18, 2024
3059727
Avoid `warning: character class has '-' without escape: ...`
dentarg Nov 18, 2024
4dfd160
Silence `Style/SafeNavigationChainLength` cop
dentarg Nov 18, 2024
7697337
No need to silence `SafeNavigationChainLength` cop
dentarg Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,25 @@ set :protection, :session => true
<tt>"development"</tt> if not available.
</dd>

<dt>host_authorization</dt>
dentarg marked this conversation as resolved.
Show resolved Hide resolved
<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 your app recognizes, if empty all hostnames are permitted</li>
dentarg marked this conversation as resolved.
Show resolved Hide resolved
<li><tt>status</tt> – the HTTP status code used in the response when a request is blcoked</li>
dentarg marked this conversation as resolved.
Show resolved Hide resolved
<li><tt>message</tt> – the body used in the response when a request is blocked</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>
</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
84 changes: 84 additions & 0 deletions rack-protection/lib/rack/protection/host_authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 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
PORT_REGEXP = /:\d+\z/.freeze
default_reaction :deny
default_options allow_if: nil,
message: "Host not permitted"

def initialize(*)
super
@all_permitted_hosts = Array(options[:permitted_hosts])
@permitted_hosts = @all_permitted_hosts
.select { |host| host.is_a?(String) }
.map(&:downcase)
@domain_hosts = @permitted_hosts.select { |host| host[0] == "." }
@ip_hosts = @all_permitted_hosts.select { |host| host.is_a?(IPAddr) }
end

def accepts?(env)
return true if options[:allow_if]&.call(env)
return true if @all_permitted_hosts.empty?
dentarg marked this conversation as resolved.
Show resolved Hide resolved

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

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&.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?

@domain_hosts.any? { |domain_host| host.end_with?(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
Loading