ssrf_filter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 68db49bb250c05d11655c0afe1913b34247d783c
4
+ data.tar.gz: f1d11ee2913e121b3c996a1af745926a3da953e2
5
+ SHA512:
6
+ metadata.gz: 201bf113012c32ddb97e79df1fe9428d9dff43be894d340789a6b937f819e4538e680f8ec66e0d458a396ed7e082d1d20374d4b8c0fc0833970fb6b1d9a81bad
7
+ data.tar.gz: 769532a2d3f68fe97a20a001d73fc930855dbdd7c1dbe4ac115c29e2f7661f509c3d89cbe21480a7630e503af7bb3bea7b4fedbb625fb2e51e5a08aa4a1a1dc4
@@ -0,0 +1,2 @@
1
+ require 'ssrf_filter/ssrf_filter'
2
+ require 'ssrf_filter/version'
@@ -0,0 +1,214 @@
1
+ require 'ipaddr'
2
+ require 'net/http'
3
+ require 'resolv'
4
+ require 'uri'
5
+
6
+ class SsrfFilter
7
+ # https://en.wikipedia.org/wiki/Reserved_IP_addresses
8
+ IPV4_BLACKLIST = [
9
+ ::IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address)
10
+ ::IPAddr.new('10.0.0.0/8'), # Private network
11
+ ::IPAddr.new('100.64.0.0/10'), # Shared Address Space
12
+ ::IPAddr.new('127.0.0.0/8'), # Loopback
13
+ ::IPAddr.new('169.254.0.0/16'), # Link-local
14
+ ::IPAddr.new('172.16.0.0/12'), # Private network
15
+ ::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
16
+ ::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples
17
+ ::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16)
18
+ ::IPAddr.new('192.168.0.0/16'), # Private network
19
+ ::IPAddr.new('198.18.0.0/15'), # Network benchmark tests
20
+ ::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples
21
+ ::IPAddr.new('203.0.113.0/24'), # TEST-NET-3, documentation and examples
22
+ ::IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network)
23
+ ::IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network)
24
+ ::IPAddr.new('255.255.255.255') # Broadcast
25
+ ].freeze
26
+
27
+ IPV6_BLACKLIST = [
28
+ ::IPAddr.new('::1/128'), # Loopback
29
+ ::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
30
+ ::IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
31
+ ::IPAddr.new('2001::/32'), # Teredo tunneling
32
+ ::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID)
33
+ ::IPAddr.new('2001:20::/28'), # ORCHIDv2
34
+ ::IPAddr.new('2001:db8::/32'), # Addresses used in documentation and example source code
35
+ ::IPAddr.new('2002::/16'), # 6to4
36
+ ::IPAddr.new('fc00::/7'), # Unique local address
37
+ ::IPAddr.new('fe80::/10'), # Link-local address
38
+ ::IPAddr.new('ff00::/8'), # Multicast
39
+ ].freeze
40
+
41
+ DEFAULT_SCHEME_WHITELIST = %w[http https].freeze
42
+
43
+ DEFAULT_RESOLVER = proc do |hostname|
44
+ ::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) }
45
+ end
46
+
47
+ DEFAULT_MAX_REDIRECTS = 10
48
+
49
+ VERB_MAP = {
50
+ get: ::Net::HTTP::Get,
51
+ put: ::Net::HTTP::Put,
52
+ post: ::Net::HTTP::Post,
53
+ delete: ::Net::HTTP::Delete
54
+ }.freeze
55
+
56
+ FIBER_LOCAL_KEY = :__ssrf_filter_hostname
57
+
58
+ class Error < ::StandardError
59
+ end
60
+
61
+ class InvalidUriScheme < Error
62
+ end
63
+
64
+ class PrivateIPAddress < Error
65
+ end
66
+
67
+ class UnresolvedHostname < Error
68
+ end
69
+
70
+ class TooManyRedirects < Error
71
+ end
72
+
73
+ %i[get put post delete].each do |method|
74
+ define_singleton_method(method) do |url, options = {}, &block|
75
+ original_url = url
76
+ scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST
77
+ resolver = options[:resolver] || DEFAULT_RESOLVER
78
+ max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS
79
+ url = url.to_s
80
+
81
+ (max_redirects + 1).times do
82
+ uri = URI(url)
83
+
84
+ unless scheme_whitelist.include?(uri.scheme)
85
+ raise InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{scheme_whitelist}"
86
+ end
87
+
88
+ hostname = uri.hostname
89
+ ip_addresses = resolver.call(hostname)
90
+ raise UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty?
91
+
92
+ public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
93
+ raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
94
+
95
+ response = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
96
+
97
+ case response
98
+ when ::Net::HTTPRedirection then
99
+ url = response['location']
100
+ else
101
+ return response
102
+ end
103
+ end
104
+
105
+ raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}"
106
+ end
107
+ end
108
+
109
+ def self.unsafe_ip_address?(ip_address)
110
+ return true if ipaddr_has_mask?(ip_address)
111
+
112
+ return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4?
113
+
114
+ if ip_address.ipv6?
115
+ result = IPV6_BLACKLIST.any? { |range| range.include?(ip_address) }
116
+ # TODO: convert these to be members of IPV6_BLACKLIST
117
+ result ||= ip_address.ipv4_compat? && IPV4_BLACKLIST.any? { |range| range.include?(ip_address.ipv4_compat) }
118
+ result ||= ip_address.ipv4_mapped? && IPV4_BLACKLIST.any? { |range| range.include?(ip_address.ipv4_mapped) }
119
+ return result
120
+ end
121
+
122
+ true
123
+ end
124
+ private_class_method :unsafe_ip_address?
125
+
126
+ def self.ipaddr_has_mask?(ipaddr)
127
+ range = ipaddr.to_range
128
+ range.first != range.last
129
+ end
130
+ private_class_method :ipaddr_has_mask?
131
+
132
+ def self.fetch_once(uri, ip, verb, options, &block)
133
+ if options[:params]
134
+ params = uri.query ? ::Hash[::URI.decode_www_form(uri.query)] : {}
135
+ params.merge!(options[:params])
136
+ uri.query = ::URI.encode_www_form(params)
137
+ end
138
+
139
+ hostname = uri.hostname
140
+ uri.hostname = ip
141
+
142
+ request = VERB_MAP[verb].new(uri)
143
+ request['host'] = hostname
144
+
145
+ Array(options[:headers]).each do |header, value|
146
+ request[header] = value
147
+ end
148
+
149
+ request.body = options[:body] if options[:body]
150
+
151
+ block.call(request) if block_given?
152
+
153
+ use_ssl = uri.scheme == 'https'
154
+ with_forced_hostname(hostname) do
155
+ ::Net::HTTP.start(uri.hostname, uri.port, use_ssl: use_ssl) do |http|
156
+ http.request(request)
157
+ end
158
+ end
159
+ end
160
+ private_class_method :fetch_once
161
+
162
+ def self.patch_ssl_socket!
163
+ return if instance_variable_defined?(:@patched_ssl_socket)
164
+
165
+ # What we'd like to do is have the following workflow:
166
+ # 1) resolve the hostname www.example.com, and choose a public ip address to connect to
167
+ # 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
168
+ #
169
+ # Ideally this would happen by the ruby http library giving us control over DNS resolution,
170
+ # but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
171
+ # and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
172
+ # a 'Host: www.example.com' header.
173
+ #
174
+ # This works for the http case, http://www.example.com. For the https case, this causes certificate
175
+ # validation failures, since the server certificate does not have a Subject Alternate Name for 93.184.216.34.
176
+ #
177
+ # Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
178
+ # and `hostname=(hostname)` methods:
179
+ # If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
180
+ # The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
181
+ # which is used above.
182
+ #
183
+ # An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
184
+ # `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
185
+ # validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
186
+ # that we connected to the desired hostname.
187
+
188
+ ::OpenSSL::SSL::SSLSocket.class_eval do
189
+ original_post_connection_check = instance_method(:post_connection_check)
190
+ define_method(:post_connection_check) do |hostname|
191
+ original_post_connection_check.bind(self).call(::Thread.current[FIBER_LOCAL_KEY] || hostname)
192
+ end
193
+
194
+ if method_defined?(:hostname=)
195
+ original_hostname = instance_method(:hostname=)
196
+ define_method(:hostname=) do |hostname|
197
+ original_hostname.bind(self).call(::Thread.current[FIBER_LOCAL_KEY] || hostname)
198
+ end
199
+ end
200
+ end
201
+
202
+ @patched_ssl_socket = true
203
+ end
204
+ private_class_method :patch_ssl_socket!
205
+
206
+ def self.with_forced_hostname(hostname, &_block)
207
+ patch_ssl_socket!
208
+ ::Thread.current[FIBER_LOCAL_KEY] = hostname
209
+ yield
210
+ ensure
211
+ ::Thread.current[FIBER_LOCAL_KEY] = nil
212
+ end
213
+ private_class_method :with_forced_hostname
214
+ end
@@ -0,0 +1,3 @@
1
+ class SsrfFilter
2
+ VERSION = '1.0.0'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ssrf_filter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Arkadiy Tetelman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-07-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler-audit
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: coveralls
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.6'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.49'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.49'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: A gem that makes it easy to prevent server side request forgery (SSRF)
84
+ attacks
85
+ email:
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - lib/ssrf_filter.rb
91
+ - lib/ssrf_filter/ssrf_filter.rb
92
+ - lib/ssrf_filter/version.rb
93
+ homepage: https://github.com/arkadiyt/ssrf_filter
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 2.0.0
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubyforge_project:
113
+ rubygems_version: 2.4.6
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: A gem that makes it easy to prevent server side request forgery (SSRF) attacks
117
+ test_files: []