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 +4 -4
- data/lib/ssrf_filter/ssrf_filter.rb +139 -60
- data/lib/ssrf_filter/version.rb +1 -1
- data/lib/ssrf_filter.rb +0 -2
- metadata +57 -43
- data/lib/ssrf_filter/patch/http_generic_request.rb +0 -83
- data/lib/ssrf_filter/patch/ssl_socket.rb +0 -49
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '05509e34ae84e22f9e746604ec7717a095ea88a237c13dfabb2d840e24b38816'
|
|
4
|
+
data.tar.gz: 1c6694c52b643ec4992fd19c1822eb225c4e87d9cf75bd2b1b2654a5f06f9b10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
ipv4_mapped
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
::SsrfFilter::Patch::SSLSocket.apply!
|
|
107
|
-
::SsrfFilter::Patch::HTTPGenericRequest.apply!
|
|
138
|
+
class CredentialLeakage < Error
|
|
139
|
+
end
|
|
108
140
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ? ::
|
|
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'] =
|
|
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
|
-
|
|
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
|
-
|
|
272
|
+
http_options = (options[:http_options] || {}).merge(
|
|
273
|
+
use_ssl: uri.scheme == 'https',
|
|
274
|
+
ipaddr: ip
|
|
275
|
+
)
|
|
196
276
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
data/lib/ssrf_filter/version.rb
CHANGED
data/lib/ssrf_filter.rb
CHANGED
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
|
|
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:
|
|
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.
|
|
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.
|
|
40
|
+
version: 0.9.2
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
42
|
+
name: rspec
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
|
30
44
|
requirements:
|
|
31
45
|
- - "~>"
|
|
32
46
|
- !ruby/object:Gem::Version
|
|
33
|
-
version:
|
|
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:
|
|
54
|
+
version: 3.13.0
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
56
|
+
name: rubocop
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
44
58
|
requirements:
|
|
45
|
-
- - "
|
|
59
|
+
- - "~>"
|
|
46
60
|
- !ruby/object:Gem::Version
|
|
47
|
-
version:
|
|
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:
|
|
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.
|
|
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.
|
|
82
|
+
version: 3.2.0
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
84
|
+
name: simplecov
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
72
86
|
requirements:
|
|
73
|
-
- - "
|
|
87
|
+
- - "~>"
|
|
74
88
|
- !ruby/object:Gem::Version
|
|
75
|
-
version:
|
|
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:
|
|
96
|
+
version: 0.22.0
|
|
83
97
|
- !ruby/object:Gem::Dependency
|
|
84
|
-
name:
|
|
98
|
+
name: simplecov-lcov
|
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
|
86
100
|
requirements:
|
|
87
|
-
- - "
|
|
101
|
+
- - "~>"
|
|
88
102
|
- !ruby/object:Gem::Version
|
|
89
|
-
version:
|
|
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:
|
|
110
|
+
version: 0.8.0
|
|
97
111
|
- !ruby/object:Gem::Dependency
|
|
98
|
-
name:
|
|
112
|
+
name: tsort
|
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|
|
100
114
|
requirements:
|
|
101
115
|
- - '='
|
|
102
116
|
- !ruby/object:Gem::Version
|
|
103
|
-
version:
|
|
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:
|
|
124
|
+
version: 0.1.0
|
|
111
125
|
- !ruby/object:Gem::Dependency
|
|
112
|
-
name:
|
|
126
|
+
name: webmock
|
|
113
127
|
requirement: !ruby/object:Gem::Requirement
|
|
114
128
|
requirements:
|
|
115
|
-
- -
|
|
129
|
+
- - ">="
|
|
116
130
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: 3.
|
|
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.
|
|
138
|
+
version: 3.24.0
|
|
125
139
|
- !ruby/object:Gem::Dependency
|
|
126
|
-
name:
|
|
140
|
+
name: webrick
|
|
127
141
|
requirement: !ruby/object:Gem::Requirement
|
|
128
142
|
requirements:
|
|
129
|
-
- - "
|
|
143
|
+
- - ">="
|
|
130
144
|
- !ruby/object:Gem::Version
|
|
131
|
-
version: 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
|
|
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.
|
|
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.
|
|
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
|