ssrf_filter 1.0.8 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ad08c7683abaf9c1cce31358e91eebd54e2276911da99c3d7bdbc1481ec6a8f
4
- data.tar.gz: 4993290726f69399264c18dfe8b4ef1c908b78c726f0cfb5dcbc5b9c09043ead
3
+ metadata.gz: '05509e34ae84e22f9e746604ec7717a095ea88a237c13dfabb2d840e24b38816'
4
+ data.tar.gz: 1c6694c52b643ec4992fd19c1822eb225c4e87d9cf75bd2b1b2654a5f06f9b10
5
5
  SHA512:
6
- metadata.gz: 522368253a813feb3ac735f419b8d9fe6ac3b006debf3e3c003f6909d809b5b51af06ccc9c42b7cd8d18e90d54a298d3a39fee0514304a1c3a2a1ffd1d54580d
7
- data.tar.gz: 21cd1fe841b77536db7e51e9cddc94b7ed96305bf766bb16df77e5b83029a6b6c44e042d1036ae52a135419e72ae70ec1155c471fdc6596eb9dab07809e54e36
6
+ metadata.gz: 352ca8aa91c7408f1b84d0791378e463da9e356feab9559209a05c43049879acf5f88445126fbb9532ce979a5ffed7960a057d9409399f5cba805cb062e19761
7
+ data.tar.gz: dbbdbb309c241adc37d81dc2f7a128834ce0bf04f0b87662121280aa37e81ddce6a39b2a206972fc63d72c492db81108ec67165bb7d3d5b8be45f1c46c579a55
@@ -6,11 +6,13 @@ require 'resolv'
6
6
  require 'uri'
7
7
 
8
8
  class SsrfFilter
9
- def self.prefixlen_from_ipaddr(ipaddr)
9
+ private_class_method def self.prefixlen_from_ipaddr(ipaddr)
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.nobits?(0x1)
14
+ mask_addr >>= 1
15
+ end
14
16
 
15
17
  length = 0
16
18
  while mask_addr & 0x1 == 0x1
@@ -20,7 +22,19 @@ class SsrfFilter
20
22
 
21
23
  length
22
24
  end
23
- private_class_method :prefixlen_from_ipaddr
25
+
26
+ private_class_method def self.ipv4_from_rfc6052(ipv6_addr, prefix_len)
27
+ n = ipv6_addr.to_i
28
+ ipv4_int = case prefix_len
29
+ when 32 then (n >> 64) & 0xFFFF_FFFF
30
+ when 40 then (((n >> 64) & 0xFFFFFF) << 8) | ((n >> 48) & 0xFF)
31
+ when 48 then (((n >> 64) & 0xFFFF) << 16) | ((n >> 40) & 0xFFFF)
32
+ when 56 then (((n >> 64) & 0xFF) << 24) | ((n >> 32) & 0xFFFFFF)
33
+ when 64 then (n >> 24) & 0xFFFF_FFFF
34
+ when 96 then n & 0xFFFF_FFFF
35
+ end
36
+ ::IPAddr.new(ipv4_int, Socket::AF_INET) if ipv4_int
37
+ end
24
38
 
25
39
  # https://en.wikipedia.org/wiki/Reserved_IP_addresses
26
40
  IPV4_BLACKLIST = [
@@ -32,7 +46,6 @@ class SsrfFilter
32
46
  ::IPAddr.new('172.16.0.0/12'), # Private network
33
47
  ::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
34
48
  ::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples
35
- ::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16)
36
49
  ::IPAddr.new('192.168.0.0/16'), # Private network
37
50
  ::IPAddr.new('198.18.0.0/15'), # Network benchmark tests
38
51
  ::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples
@@ -42,15 +55,22 @@ class SsrfFilter
42
55
  ::IPAddr.new('255.255.255.255') # Broadcast
43
56
  ].freeze
44
57
 
58
+ # NAT64 local-use prefix (RFC 8215), uses RFC 6052 /48 encoding (checked at runtime).
59
+ NAT64_LOCAL_PREFIX = ::IPAddr.new('64:ff9b:1::/48').freeze
60
+
45
61
  IPV6_BLACKLIST = ([
62
+ ::IPAddr.new('::/128'), # Unspecified address
46
63
  ::IPAddr.new('::1/128'), # Loopback
47
- ::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
48
64
  ::IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
65
+ ::IPAddr.new('100:0:0:1::/64'), # Dummy IPv6 prefix (RFC 9780)
49
66
  ::IPAddr.new('2001::/32'), # Teredo tunneling
67
+ ::IPAddr.new('2001:2::/48'), # Benchmarking (RFC 5180)
50
68
  ::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID)
51
69
  ::IPAddr.new('2001:20::/28'), # ORCHIDv2
52
70
  ::IPAddr.new('2001:db8::/32'), # Addresses used in documentation and example source code
53
71
  ::IPAddr.new('2002::/16'), # 6to4
72
+ ::IPAddr.new('3fff::/20'), # Documentation (RFC 9637)
73
+ ::IPAddr.new('5f00::/16'), # Segment Routing (SRv6) SIDs (RFC 9602)
54
74
  ::IPAddr.new('fc00::/7'), # Unique local address
55
75
  ::IPAddr.new('fe80::/10'), # Link-local address
56
76
  ::IPAddr.new('ff00::/8') # Multicast
@@ -58,10 +78,14 @@ class SsrfFilter
58
78
  prefixlen = prefixlen_from_ipaddr(ipaddr)
59
79
 
60
80
  # Don't call ipaddr.ipv4_compat because it prints out a deprecation warning on ruby 2.5+
61
- ipv4_compatible = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen)
62
- ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
63
-
64
- [ipv4_compatible, ipv4_mapped]
81
+ ipv4_compatible = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen)
82
+ ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
83
+ # IPv4-translated (RFC 2765): ::ffff:0:x.x.x.x/96+n
84
+ ipv4_translated = IPAddr.new("::ffff:0:#{ipaddr}").mask(96 + prefixlen)
85
+ # NAT64 well-known prefix (RFC 6052): 64:ff9b::x.x.x.x/96+n
86
+ nat64_well_known = IPAddr.new("64:ff9b::#{ipaddr}").mask(96 + prefixlen)
87
+
88
+ [ipv4_compatible, ipv4_mapped, ipv4_translated, nat64_well_known]
65
89
  end).freeze
66
90
 
67
91
  DEFAULT_SCHEME_WHITELIST = %w[http https].freeze
@@ -70,7 +94,19 @@ class SsrfFilter
70
94
  ::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) }
71
95
  end
72
96
 
97
+ DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS = false
73
98
  DEFAULT_MAX_REDIRECTS = 10
99
+ DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie].freeze
100
+ DEFAULT_ON_CROSS_ORIGIN_REDIRECT = :strip
101
+
102
+ CONNECTION_ERRORS = [
103
+ ::Errno::EHOSTUNREACH,
104
+ ::Errno::ENETUNREACH,
105
+ ::Errno::EADDRNOTAVAIL,
106
+ ::Errno::ECONNREFUSED,
107
+ ::Errno::ETIMEDOUT,
108
+ ::Net::OpenTimeout
109
+ ].freeze
74
110
 
75
111
  VERB_MAP = {
76
112
  get: ::Net::HTTP::Get,
@@ -81,8 +117,6 @@ class SsrfFilter
81
117
  patch: ::Net::HTTP::Patch
82
118
  }.freeze
83
119
 
84
- FIBER_LOCAL_KEY = :__ssrf_filter_hostname
85
-
86
120
  class Error < ::StandardError
87
121
  end
88
122
 
@@ -101,17 +135,21 @@ class SsrfFilter
101
135
  class CRLFInjection < Error
102
136
  end
103
137
 
104
- %i[get put post delete head patch].each do |method|
105
- define_singleton_method(method) do |url, options = {}, &block|
106
- ::SsrfFilter::Patch::SSLSocket.apply!
107
- ::SsrfFilter::Patch::HTTPGenericRequest.apply!
138
+ class CredentialLeakage < Error
139
+ end
108
140
 
109
- original_url = url
110
- scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST
111
- resolver = options[:resolver] || DEFAULT_RESOLVER
112
- max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS
141
+ VERB_MAP.each_key do |method|
142
+ define_singleton_method(method) do |url, options = {}, &block|
113
143
  url = url.to_s
114
-
144
+ original_url = url
145
+ original_uri = URI(url)
146
+ scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST)
147
+ resolver = options.fetch(:resolver, DEFAULT_RESOLVER)
148
+ allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS)
149
+ max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
150
+ sensitive_headers = options.fetch(:sensitive_headers, DEFAULT_SENSITIVE_HEADERS)
151
+
152
+ response = nil
115
153
  (max_redirects + 1).times do
116
154
  uri = URI(url)
117
155
 
@@ -126,83 +164,133 @@ class SsrfFilter
126
164
  public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
127
165
  raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
128
166
 
129
- response = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
130
-
131
- case response
132
- when ::Net::HTTPRedirection then
133
- url = response['location']
134
- # Handle relative redirects
135
- url = "#{uri.scheme}://#{hostname}:#{uri.port}#{url}" if url.start_with?('/')
167
+ headers_to_strip = if !sensitive_headers.empty? && different_origin?(original_uri, uri)
168
+ sensitive_headers
136
169
  else
137
- return response
170
+ []
138
171
  end
172
+
173
+ response, url = fetch_with_fallback(uri, public_addresses, method,
174
+ options.merge(headers_to_strip: headers_to_strip), &block)
175
+ return response if url.nil?
139
176
  end
140
177
 
178
+ return response if allow_unfollowed_redirects
179
+
141
180
  raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}"
142
181
  end
143
182
  end
144
183
 
145
- def self.unsafe_ip_address?(ip_address)
184
+ private_class_method def self.unsafe_ip_address?(ip_address)
146
185
  return true if ipaddr_has_mask?(ip_address)
147
186
 
148
187
  return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4?
149
- return IPV6_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv6?
188
+
189
+ if ip_address.ipv6?
190
+ return true if IPV6_BLACKLIST.any? { |range| range.include?(ip_address) }
191
+
192
+ # RFC 6052 /48 encoding for NAT64 local-use prefix (RFC 8215): 64:ff9b:1::/48
193
+ # IPv4 is split around u-bits at positions 64-71, so must be decoded at runtime
194
+ if NAT64_LOCAL_PREFIX.dup.include?(ip_address)
195
+ ipv4 = ipv4_from_rfc6052(ip_address, 48)
196
+ return unsafe_ip_address?(ipv4)
197
+ end
198
+ return false
199
+ end
150
200
 
151
201
  true
152
202
  end
153
- private_class_method :unsafe_ip_address?
154
203
 
155
- def self.ipaddr_has_mask?(ipaddr)
204
+ private_class_method def self.ipaddr_has_mask?(ipaddr)
156
205
  range = ipaddr.to_range
157
206
  range.first != range.last
158
207
  end
159
- private_class_method :ipaddr_has_mask?
160
208
 
161
- def self.host_header(hostname, uri)
209
+ private_class_method def self.different_origin?(uri1, uri2)
210
+ uri1.scheme != uri2.scheme || uri1.hostname != uri2.hostname || uri1.port != uri2.port
211
+ end
212
+
213
+ private_class_method def self.normalized_hostname(uri)
162
214
  # Attach port for non-default as per RFC2616
163
215
  if (uri.port == 80 && uri.scheme == 'http') ||
164
216
  (uri.port == 443 && uri.scheme == 'https')
165
- hostname
217
+ uri.hostname
166
218
  else
167
- "#{hostname}:#{uri.port}"
219
+ "#{uri.hostname}:#{uri.port}"
220
+ end
221
+ end
222
+
223
+ private_class_method def self.fetch_with_fallback(uri, public_addresses, verb, options, &block)
224
+ last_error = nil
225
+
226
+ public_addresses.shuffle.each do |ip_address|
227
+ return fetch_once(uri.dup, ip_address.to_s, verb, options, &block)
228
+ rescue *CONNECTION_ERRORS => e
229
+ last_error = e
168
230
  end
231
+
232
+ raise last_error
169
233
  end
170
- private_class_method :host_header
171
234
 
172
- def self.fetch_once(uri, ip, verb, options, &block)
235
+ private_class_method def self.fetch_once(uri, ip, verb, options, &block)
173
236
  if options[:params]
174
- params = uri.query ? ::Hash[::URI.decode_www_form(uri.query)] : {}
237
+ params = uri.query ? ::URI.decode_www_form(uri.query).to_h : {}
175
238
  params.merge!(options[:params])
176
239
  uri.query = ::URI.encode_www_form(params)
177
240
  end
178
241
 
179
- hostname = uri.hostname
180
- uri.hostname = ip
181
-
182
242
  request = VERB_MAP[verb].new(uri)
183
- request['host'] = host_header(hostname, uri)
243
+ request['host'] = normalized_hostname(uri)
184
244
 
185
245
  Array(options[:headers]).each do |header, value|
246
+ # Newer rubies raise ArgumentError when assigning a header name with control characters, which
247
+ # would pre-empt the CRLFInjection check in validate_request, so reject it with our own error here
248
+ raise CRLFInjection, "CRLF injection in header #{header} with value #{value}" if header.to_s.count("\r\n") != 0
249
+
186
250
  request[header] = value
187
251
  end
188
252
 
189
253
  request.body = options[:body] if options[:body]
190
254
 
191
- block.call(request) if block_given?
255
+ options[:request_proc].call(request) if options[:request_proc].respond_to?(:call)
256
+
257
+ headers_to_strip = Array(options[:headers_to_strip])
258
+ unless headers_to_strip.empty?
259
+ if options[:on_cross_origin_redirect] == :raise
260
+ leaking = headers_to_strip.select { |h| request[h] }
261
+ unless leaking.empty?
262
+ raise CredentialLeakage,
263
+ "Cross-origin redirect would leak sensitive headers: #{leaking.join(', ')}"
264
+ end
265
+ else
266
+ headers_to_strip.each { |h| request.delete(h) }
267
+ end
268
+ end
269
+
192
270
  validate_request(request)
193
271
 
194
- http_options = options[:http_options] || {}
195
- http_options[:use_ssl] = (uri.scheme == 'https')
272
+ http_options = (options[:http_options] || {}).merge(
273
+ use_ssl: uri.scheme == 'https',
274
+ ipaddr: ip
275
+ )
196
276
 
197
- with_forced_hostname(hostname) do
198
- ::Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
199
- http.request(request)
277
+ ::Net::HTTP.start(uri.hostname, uri.port, nil, **http_options) do |http|
278
+ response = http.request(request) do |res|
279
+ block&.call(res)
280
+ end
281
+ case response
282
+ when ::Net::HTTPRedirection
283
+ url = response['location']
284
+ # Handle relative redirects
285
+ url = "#{uri.scheme}://#{normalized_hostname(uri)}#{url}" if url&.start_with?('/')
286
+ else
287
+ url = nil
200
288
  end
289
+ return response, url
201
290
  end
202
291
  end
203
- private_class_method :fetch_once
204
292
 
205
- def self.validate_request(request)
293
+ private_class_method def self.validate_request(request)
206
294
  # RFC822 allows multiline "folded" headers:
207
295
  # https://tools.ietf.org/html/rfc822#section-3.1
208
296
  # In practice if any user input is ever supplied as a header key/value, they'll get
@@ -213,13 +301,4 @@ class SsrfFilter
213
301
  end
214
302
  end
215
303
  end
216
- private_class_method :validate_request
217
-
218
- def self.with_forced_hostname(hostname, &_block)
219
- ::Thread.current[FIBER_LOCAL_KEY] = hostname
220
- yield
221
- ensure
222
- ::Thread.current[FIBER_LOCAL_KEY] = nil
223
- end
224
- private_class_method :with_forced_hostname
225
304
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SsrfFilter
4
- VERSION = '1.0.8'.freeze
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/ssrf_filter.rb CHANGED
@@ -1,6 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ssrf_filter/patch/http_generic_request'
4
- require 'ssrf_filter/patch/ssl_socket'
5
3
  require 'ssrf_filter/ssrf_filter'
6
4
  require 'ssrf_filter/version'
metadata CHANGED
@@ -1,141 +1,155 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ssrf_filter
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arkadiy Tetelman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-04 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler-audit
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - "~>"
18
32
  - !ruby/object:Gem::Version
19
- version: 0.6.1
33
+ version: 0.9.2
20
34
  type: :development
21
35
  prerelease: false
22
36
  version_requirements: !ruby/object:Gem::Requirement
23
37
  requirements:
24
38
  - - "~>"
25
39
  - !ruby/object:Gem::Version
26
- version: 0.6.1
40
+ version: 0.9.2
27
41
  - !ruby/object:Gem::Dependency
28
- name: coveralls
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.13.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.13.0
41
55
  - !ruby/object:Gem::Dependency
42
- name: psych
56
+ name: rubocop
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
- - - "<"
59
+ - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: '4'
61
+ version: 1.68.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: '4'
68
+ version: 1.68.0
55
69
  - !ruby/object:Gem::Dependency
56
- name: rspec
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.8.0
75
+ version: 3.2.0
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.8.0
82
+ version: 3.2.0
69
83
  - !ruby/object:Gem::Dependency
70
- name: webmock
84
+ name: simplecov
71
85
  requirement: !ruby/object:Gem::Requirement
72
86
  requirements:
73
- - - ">="
87
+ - - "~>"
74
88
  - !ruby/object:Gem::Version
75
- version: 3.5.1
89
+ version: 0.22.0
76
90
  type: :development
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
- - - ">="
94
+ - - "~>"
81
95
  - !ruby/object:Gem::Version
82
- version: 3.5.1
96
+ version: 0.22.0
83
97
  - !ruby/object:Gem::Dependency
84
- name: webrick
98
+ name: simplecov-lcov
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
- - - ">="
101
+ - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: '0'
103
+ version: 0.8.0
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
- - - ">="
108
+ - - "~>"
95
109
  - !ruby/object:Gem::Version
96
- version: '0'
110
+ version: 0.8.0
97
111
  - !ruby/object:Gem::Dependency
98
- name: public_suffix
112
+ name: tsort
99
113
  requirement: !ruby/object:Gem::Requirement
100
114
  requirements:
101
115
  - - '='
102
116
  - !ruby/object:Gem::Version
103
- version: 2.0.5
117
+ version: 0.1.0
104
118
  type: :development
105
119
  prerelease: false
106
120
  version_requirements: !ruby/object:Gem::Requirement
107
121
  requirements:
108
122
  - - '='
109
123
  - !ruby/object:Gem::Version
110
- version: 2.0.5
124
+ version: 0.1.0
111
125
  - !ruby/object:Gem::Dependency
112
- name: rexml
126
+ name: webmock
113
127
  requirement: !ruby/object:Gem::Requirement
114
128
  requirements:
115
- - - '='
129
+ - - ">="
116
130
  - !ruby/object:Gem::Version
117
- version: 3.2.4
131
+ version: 3.24.0
118
132
  type: :development
119
133
  prerelease: false
120
134
  version_requirements: !ruby/object:Gem::Requirement
121
135
  requirements:
122
- - - '='
136
+ - - ">="
123
137
  - !ruby/object:Gem::Version
124
- version: 3.2.4
138
+ version: 3.24.0
125
139
  - !ruby/object:Gem::Dependency
126
- name: rubocop
140
+ name: webrick
127
141
  requirement: !ruby/object:Gem::Requirement
128
142
  requirements:
129
- - - "~>"
143
+ - - ">="
130
144
  - !ruby/object:Gem::Version
131
- version: 0.50.0
145
+ version: '0'
132
146
  type: :development
133
147
  prerelease: false
134
148
  version_requirements: !ruby/object:Gem::Requirement
135
149
  requirements:
136
- - - "~>"
150
+ - - ">="
137
151
  - !ruby/object:Gem::Version
138
- version: 0.50.0
152
+ version: '0'
139
153
  description: A gem that makes it easy to prevent server side request forgery (SSRF)
140
154
  attacks
141
155
  email:
@@ -144,14 +158,14 @@ extensions: []
144
158
  extra_rdoc_files: []
145
159
  files:
146
160
  - lib/ssrf_filter.rb
147
- - lib/ssrf_filter/patch/http_generic_request.rb
148
- - lib/ssrf_filter/patch/ssl_socket.rb
149
161
  - lib/ssrf_filter/ssrf_filter.rb
150
162
  - lib/ssrf_filter/version.rb
151
163
  homepage: https://github.com/arkadiyt/ssrf_filter
152
164
  licenses:
153
165
  - MIT
154
- metadata: {}
166
+ metadata:
167
+ changelog_uri: https://github.com/arkadiyt/ssrf_filter/blob/main/CHANGELOG.md
168
+ rubygems_mfa_required: 'true'
155
169
  post_install_message:
156
170
  rdoc_options: []
157
171
  require_paths:
@@ -160,14 +174,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
160
174
  requirements:
161
175
  - - ">="
162
176
  - !ruby/object:Gem::Version
163
- version: 2.0.0
177
+ version: 2.7.0
164
178
  required_rubygems_version: !ruby/object:Gem::Requirement
165
179
  requirements:
166
180
  - - ">="
167
181
  - !ruby/object:Gem::Version
168
182
  version: '0'
169
183
  requirements: []
170
- rubygems_version: 3.2.15
184
+ rubygems_version: 3.3.7
171
185
  signing_key:
172
186
  specification_version: 4
173
187
  summary: A gem that makes it easy to prevent server side request forgery (SSRF) attacks
@@ -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
@@ -1,49 +0,0 @@
1
- class SsrfFilter
2
- module Patch
3
- 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
- def self.apply!
29
- return if instance_variable_defined?(:@patched_ssl_socket)
30
-
31
- @patched_ssl_socket = true
32
-
33
- ::OpenSSL::SSL::SSLSocket.class_eval do
34
- original_post_connection_check = instance_method(:post_connection_check)
35
- define_method(:post_connection_check) do |hostname|
36
- original_post_connection_check.bind(self).call(::Thread.current[::SsrfFilter::FIBER_LOCAL_KEY] || hostname)
37
- end
38
-
39
- if method_defined?(:hostname=)
40
- original_hostname = instance_method(:hostname=)
41
- define_method(:hostname=) do |hostname|
42
- original_hostname.bind(self).call(::Thread.current[::SsrfFilter::FIBER_LOCAL_KEY] || hostname)
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
49
- end