ssrf_filter 1.0.7 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17984597a9ad2c3dd852793334951478090086e5c6b7f86d67cf827fa98bff4c
4
- data.tar.gz: 99c09e926f2b2c3a040dd0deed245ebb005f6cb242a185a7bca15592183f0b82
3
+ metadata.gz: 315ab29498751c0bb60964f06932464d8663ec66e50ed403b04c2e19c9fce5e6
4
+ data.tar.gz: 8b3475337b149bf8e04dd11e379e9c0698f74d05528524fea847ed996aff405c
5
5
  SHA512:
6
- metadata.gz: ad60b8c2efaca1de0b7d89de0102eef87d23c65d1c6b73e674092e7740e8c5fe1a006801c648529bf9150eb590df9b3c160af519b666176f41697ef69218918e
7
- data.tar.gz: '062843311bfdb75b9de842fb29e4864da4a4acc4c41d454fec593ee3c0a86f306cddd30d4cc9c46b6d6b169b2e3e3480c2e8499cb637eaf97d96afa534b41569'
6
+ metadata.gz: 299b91d225b906faa91a1ebd4a8ad32ce46889deaeffa89d5a7488b871310e68ed7f73332c9ee25fd0ff3d01a25dd949907a390e8ce44926910657e2fa054f3e
7
+ data.tar.gz: 89b6051cb2a8f232336e1e25e95a5898c71ba4439f582224452effbcc41b3a68af13d1e16d855f8ebb7634c013fefd3301c661d5d6bf72f3d98894feb867f47f
@@ -1,45 +1,62 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class SsrfFilter
2
4
  module Patch
3
5
  module SSLSocket
4
- # When fetching a url we'd like to have the following workflow:
5
- # 1) resolve the hostname www.example.com, and choose a public ip address to connect to
6
- # 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
7
- #
8
- # Ideally this would happen by the ruby http library giving us control over DNS resolution,
9
- # but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
10
- # and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
11
- # a 'Host: www.example.com' header.
12
- #
13
- # This works for the http case, http://www.example.com. For the https case, this causes certificate
14
- # validation failures, since the server certificate for https://www.example.com will not have a
15
- # Subject Alternate Name for 93.184.216.34.
16
- #
17
- # Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
18
- # and `hostname=(hostname)` methods:
19
- # If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
20
- # The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
21
- # which is used in ssrf_filter.rb.
22
- #
23
- # An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
24
- # `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
25
- # validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
26
- # that we connected to the desired hostname.
27
-
28
6
  def self.apply!
29
7
  return if instance_variable_defined?(:@patched_ssl_socket)
30
8
 
31
9
  @patched_ssl_socket = true
32
10
 
33
11
  ::OpenSSL::SSL::SSLSocket.class_eval do
12
+ # When fetching a url we'd like to have the following workflow:
13
+ # 1) resolve the hostname www.example.com, and choose a public ip address to connect to
14
+ # 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
15
+ #
16
+ # Ideally this would happen by the ruby http library giving us control over DNS resolution,
17
+ # but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
18
+ # and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
19
+ # a 'Host: www.example.com' header.
20
+ #
21
+ # This works for the http case, http://www.example.com. For the https case, this causes certificate
22
+ # validation failures, since the server certificate for https://www.example.com will not have a
23
+ # Subject Alternate Name for 93.184.216.34.
24
+ #
25
+ # Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
26
+ # and `hostname=(hostname)` methods:
27
+ # If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
28
+ # The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
29
+ # which is used in ssrf_filter.rb.
30
+ #
31
+ # An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
32
+ # `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
33
+ # validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
34
+ # that we connected to the desired hostname.
35
+
34
36
  original_post_connection_check = instance_method(:post_connection_check)
35
37
  define_method(:post_connection_check) do |hostname|
36
- original_post_connection_check.bind(self).call(::Thread.current[::SsrfFilter::FIBER_LOCAL_KEY] || hostname)
38
+ original_post_connection_check.bind(self).call(::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY] ||
39
+ hostname)
37
40
  end
38
41
 
39
42
  if method_defined?(:hostname=)
40
43
  original_hostname = instance_method(:hostname=)
41
44
  define_method(:hostname=) do |hostname|
42
- original_hostname.bind(self).call(::Thread.current[::SsrfFilter::FIBER_LOCAL_KEY] || hostname)
45
+ original_hostname.bind(self).call(::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY] || hostname)
46
+ end
47
+ end
48
+
49
+ # This patch is the successor to https://github.com/arkadiyt/ssrf_filter/pull/54
50
+ # Due to some changes in Ruby's net/http library (namely https://github.com/ruby/net-http/pull/36),
51
+ # the SSLSocket in the request was no longer getting `s.hostname` set.
52
+ # The original fix tried to monkey-patch the Regexp class to cause the original code path to execute,
53
+ # but this caused other problems (like https://github.com/arkadiyt/ssrf_filter/issues/61)
54
+ # This fix attempts a different approach to set the hostname on the socket
55
+ original_initialize = instance_method(:initialize)
56
+ define_method(:initialize) do |*args|
57
+ original_initialize.bind(self).call(*args)
58
+ if ::Thread.current.key?(::SsrfFilter::FIBER_HOSTNAME_KEY)
59
+ self.hostname = ::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY]
43
60
  end
44
61
  end
45
62
  end
@@ -10,7 +10,9 @@ class SsrfFilter
10
10
  mask_addr = ipaddr.instance_variable_get('@mask_addr')
11
11
  raise ArgumentError, 'Invalid mask' if mask_addr.zero?
12
12
 
13
- mask_addr >>= 1 while (mask_addr & 0x1).zero?
13
+ while (mask_addr & 0x1).zero?
14
+ mask_addr >>= 1
15
+ end
14
16
 
15
17
  length = 0
16
18
  while mask_addr & 0x1 == 0x1
@@ -70,16 +72,19 @@ class SsrfFilter
70
72
  ::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) }
71
73
  end
72
74
 
75
+ DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS = false
73
76
  DEFAULT_MAX_REDIRECTS = 10
74
77
 
75
78
  VERB_MAP = {
76
79
  get: ::Net::HTTP::Get,
77
80
  put: ::Net::HTTP::Put,
78
81
  post: ::Net::HTTP::Post,
79
- delete: ::Net::HTTP::Delete
82
+ delete: ::Net::HTTP::Delete,
83
+ head: ::Net::HTTP::Head,
84
+ patch: ::Net::HTTP::Patch
80
85
  }.freeze
81
86
 
82
- FIBER_LOCAL_KEY = :__ssrf_filter_hostname
87
+ FIBER_HOSTNAME_KEY = :__ssrf_filter_hostname
83
88
 
84
89
  class Error < ::StandardError
85
90
  end
@@ -99,17 +104,18 @@ class SsrfFilter
99
104
  class CRLFInjection < Error
100
105
  end
101
106
 
102
- %i[get put post delete].each do |method|
107
+ %i[get put post delete head patch].each do |method|
103
108
  define_singleton_method(method) do |url, options = {}, &block|
104
109
  ::SsrfFilter::Patch::SSLSocket.apply!
105
- ::SsrfFilter::Patch::HTTPGenericRequest.apply!
106
110
 
107
111
  original_url = url
108
- scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST
109
- resolver = options[:resolver] || DEFAULT_RESOLVER
110
- max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS
112
+ scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST)
113
+ resolver = options.fetch(:resolver, DEFAULT_RESOLVER)
114
+ allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS)
115
+ max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
111
116
  url = url.to_s
112
117
 
118
+ response = nil
113
119
  (max_redirects + 1).times do
114
120
  uri = URI(url)
115
121
 
@@ -124,18 +130,12 @@ class SsrfFilter
124
130
  public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
125
131
  raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
126
132
 
127
- response = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
128
-
129
- case response
130
- when ::Net::HTTPRedirection then
131
- url = response['location']
132
- # Handle relative redirects
133
- url = "#{uri.scheme}://#{hostname}:#{uri.port}#{url}" if url.start_with?('/')
134
- else
135
- return response
136
- end
133
+ response, url = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
134
+ return response if url.nil?
137
135
  end
138
136
 
137
+ return response if allow_unfollowed_redirects
138
+
139
139
  raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}"
140
140
  end
141
141
  end
@@ -169,7 +169,7 @@ class SsrfFilter
169
169
 
170
170
  def self.fetch_once(uri, ip, verb, options, &block)
171
171
  if options[:params]
172
- params = uri.query ? ::Hash[::URI.decode_www_form(uri.query)] : {}
172
+ params = uri.query ? ::URI.decode_www_form(uri.query).to_h : {}
173
173
  params.merge!(options[:params])
174
174
  uri.query = ::URI.encode_www_form(params)
175
175
  end
@@ -186,15 +186,26 @@ class SsrfFilter
186
186
 
187
187
  request.body = options[:body] if options[:body]
188
188
 
189
- block.call(request) if block_given?
189
+ options[:request_proc].call(request) if options[:request_proc].respond_to?(:call)
190
190
  validate_request(request)
191
191
 
192
192
  http_options = options[:http_options] || {}
193
193
  http_options[:use_ssl] = (uri.scheme == 'https')
194
194
 
195
195
  with_forced_hostname(hostname) do
196
- ::Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
197
- http.request(request)
196
+ ::Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http|
197
+ response = http.request(request) do |res|
198
+ block&.call(res)
199
+ end
200
+ case response
201
+ when ::Net::HTTPRedirection
202
+ url = response['location']
203
+ # Handle relative redirects
204
+ url = "#{uri.scheme}://#{hostname}:#{uri.port}#{url}" if url.start_with?('/')
205
+ else
206
+ url = nil
207
+ end
208
+ return response, url
198
209
  end
199
210
  end
200
211
  end
@@ -214,10 +225,10 @@ class SsrfFilter
214
225
  private_class_method :validate_request
215
226
 
216
227
  def self.with_forced_hostname(hostname, &_block)
217
- ::Thread.current[FIBER_LOCAL_KEY] = hostname
228
+ ::Thread.current[FIBER_HOSTNAME_KEY] = hostname
218
229
  yield
219
230
  ensure
220
- ::Thread.current[FIBER_LOCAL_KEY] = nil
231
+ ::Thread.current[FIBER_HOSTNAME_KEY] = nil
221
232
  end
222
233
  private_class_method :with_forced_hostname
223
234
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SsrfFilter
4
- VERSION = '1.0.7'.freeze
4
+ VERSION = '1.1.2'
5
5
  end
data/lib/ssrf_filter.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ssrf_filter/patch/http_generic_request'
4
3
  require 'ssrf_filter/patch/ssl_socket'
5
4
  require 'ssrf_filter/ssrf_filter'
6
5
  require 'ssrf_filter/version'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ssrf_filter
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.7
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arkadiy Tetelman
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-10-21 00:00:00.000000000 Z
11
+ date: 2023-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler-audit
@@ -16,87 +16,143 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.6.1
19
+ version: 0.9.1
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.6.1
26
+ version: 0.9.1
27
27
  - !ruby/object:Gem::Dependency
28
- name: coveralls
28
+ name: pry-byebug
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: 0.8.22
47
+ version: 3.12.0
34
48
  type: :development
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
- version: 0.8.22
54
+ version: 3.12.0
41
55
  - !ruby/object:Gem::Dependency
42
- name: rspec
56
+ name: rubocop
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
59
  - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: 3.8.0
61
+ version: 1.35.0
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
- version: 3.8.0
68
+ version: 1.35.0
55
69
  - !ruby/object:Gem::Dependency
56
- name: webmock
70
+ name: rubocop-rspec
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: 3.5.1
75
+ version: 2.12.1
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: 3.5.1
82
+ version: 2.12.1
69
83
  - !ruby/object:Gem::Dependency
70
- name: rubocop
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.22.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.22.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: simplecov-lcov
71
99
  requirement: !ruby/object:Gem::Requirement
72
100
  requirements:
73
101
  - - "~>"
74
102
  - !ruby/object:Gem::Version
75
- version: 0.65.0
103
+ version: 0.8.0
76
104
  type: :development
77
105
  prerelease: false
78
106
  version_requirements: !ruby/object:Gem::Requirement
79
107
  requirements:
80
108
  - - "~>"
81
109
  - !ruby/object:Gem::Version
82
- version: 0.65.0
110
+ version: 0.8.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 3.18.0
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 3.18.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: webrick
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
83
139
  description: A gem that makes it easy to prevent server side request forgery (SSRF)
84
140
  attacks
85
- email:
141
+ email:
86
142
  executables: []
87
143
  extensions: []
88
144
  extra_rdoc_files: []
89
145
  files:
90
146
  - lib/ssrf_filter.rb
91
- - lib/ssrf_filter/patch/http_generic_request.rb
92
147
  - lib/ssrf_filter/patch/ssl_socket.rb
93
148
  - lib/ssrf_filter/ssrf_filter.rb
94
149
  - lib/ssrf_filter/version.rb
95
150
  homepage: https://github.com/arkadiyt/ssrf_filter
96
151
  licenses:
97
152
  - MIT
98
- metadata: {}
99
- post_install_message:
153
+ metadata:
154
+ rubygems_mfa_required: 'true'
155
+ post_install_message:
100
156
  rdoc_options: []
101
157
  require_paths:
102
158
  - lib
@@ -104,15 +160,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
104
160
  requirements:
105
161
  - - ">="
106
162
  - !ruby/object:Gem::Version
107
- version: 2.0.0
163
+ version: 2.6.0
108
164
  required_rubygems_version: !ruby/object:Gem::Requirement
109
165
  requirements:
110
166
  - - ">="
111
167
  - !ruby/object:Gem::Version
112
168
  version: '0'
113
169
  requirements: []
114
- rubygems_version: 3.0.3
115
- signing_key:
170
+ rubygems_version: 3.3.7
171
+ signing_key:
116
172
  specification_version: 4
117
173
  summary: A gem that makes it easy to prevent server side request forgery (SSRF) attacks
118
174
  test_files: []
@@ -1,83 +0,0 @@
1
- require 'stringio'
2
-
3
- class SsrfFilter
4
- module Patch
5
- module HTTPGenericRequest
6
- # Ruby had a bug in its Http library where if you set a custom `Host` header on a request it would get
7
- # overwritten. This was tracked in:
8
- # https://bugs.ruby-lang.org/issues/10054
9
- # and resolved with the commit:
10
- # https://github.com/ruby/ruby/commit/70a2eb63999265ff7e8d46d1f5b410c8ee3d30d7#diff-5c08b4ae27d2294a8294a27ff9af4a85
11
- # Versions of Ruby that don't have this fix applied will fail to connect to certain hosts via SsrfFilter. The
12
- # patch below backports the fix from the linked commit.
13
-
14
- def self.should_apply?
15
- # Check if the patch needs to be applied:
16
- # The Ruby bug was that HTTPGenericRequest#exec overwrote the Host header, so this snippet checks
17
- # if we can reproduce that behavior. It does not actually open any network connections.
18
-
19
- uri = URI('https://www.example.com')
20
- request = ::Net::HTTPGenericRequest.new('HEAD', false, false, uri)
21
- request['host'] = '127.0.0.1'
22
- request.exec(StringIO.new, '1.1', '/')
23
- request['host'] == uri.hostname
24
- end
25
-
26
- # Apply the patch from the linked commit onto ::Net::HTTPGenericRequest. Since this is 3rd party code,
27
- # disable code coverage and rubocop linting for this section.
28
- # :nocov:
29
- # rubocop:disable all
30
- def self.apply!
31
- return if instance_variable_defined?(:@checked_http_generic_request)
32
- @checked_http_generic_request = true
33
- return unless should_apply?
34
-
35
- ::Net::HTTPGenericRequest.class_eval do
36
- def exec(sock, ver, path)
37
- if @body
38
- send_request_with_body sock, ver, path, @body
39
- elsif @body_stream
40
- send_request_with_body_stream sock, ver, path, @body_stream
41
- elsif @body_data
42
- send_request_with_body_data sock, ver, path, @body_data
43
- else
44
- write_header sock, ver, path
45
- end
46
- end
47
-
48
- def update_uri(addr, port, ssl)
49
- # reflect the connection and @path to @uri
50
- return unless @uri
51
-
52
- if ssl
53
- scheme = 'https'.freeze
54
- klass = URI::HTTPS
55
- else
56
- scheme = 'http'.freeze
57
- klass = URI::HTTP
58
- end
59
-
60
- if host = self['host']
61
- host.sub!(/:.*/s, ''.freeze)
62
- elsif host = @uri.host
63
- else
64
- host = addr
65
- end
66
- # convert the class of the URI
67
- if @uri.is_a?(klass)
68
- @uri.host = host
69
- @uri.port = port
70
- else
71
- @uri = klass.new(
72
- scheme, @uri.userinfo,
73
- host, port, nil,
74
- @uri.path, nil, @uri.query, nil)
75
- end
76
- end
77
- end
78
- end
79
- # rubocop:enable all
80
- # :nocov:
81
- end
82
- end
83
- end