ssrf_filter 1.1.2 → 1.5.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 +97 -57
- data/lib/ssrf_filter/version.rb +1 -1
- data/lib/ssrf_filter.rb +0 -1
- metadata +35 -21
- data/lib/ssrf_filter/patch/ssl_socket.rb +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d68f597eb7885a7c9941fc8f134f7a6b87eec03fc0c92b6b6684a00d1be00069
|
|
4
|
+
data.tar.gz: 2c934e958e0542c2ae1606b0657cb2096d54647969f291aa41579fd3f0363c67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 231e5f001e5540067710bc8894364592707fde79adafcbed2d4ea019646eb4b71fe60fae76caa562e8161090cffcb49390521045f57d153d8569eeeeaef85e79
|
|
7
|
+
data.tar.gz: 6854c8b312de12da5ea27bf842533eb48719ed98668d83838b4e07c1408ccacb302cfa7830b23c810e27d8f46e0f95cfcabd9919c29c86b351b8f285dac25934
|
|
@@ -6,11 +6,11 @@ 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
|
-
while (
|
|
13
|
+
while mask_addr.nobits?(0x1)
|
|
14
14
|
mask_addr >>= 1
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -22,7 +22,19 @@ class SsrfFilter
|
|
|
22
22
|
|
|
23
23
|
length
|
|
24
24
|
end
|
|
25
|
-
|
|
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
|
|
26
38
|
|
|
27
39
|
# https://en.wikipedia.org/wiki/Reserved_IP_addresses
|
|
28
40
|
IPV4_BLACKLIST = [
|
|
@@ -34,7 +46,6 @@ class SsrfFilter
|
|
|
34
46
|
::IPAddr.new('172.16.0.0/12'), # Private network
|
|
35
47
|
::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
|
|
36
48
|
::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples
|
|
37
|
-
::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16)
|
|
38
49
|
::IPAddr.new('192.168.0.0/16'), # Private network
|
|
39
50
|
::IPAddr.new('198.18.0.0/15'), # Network benchmark tests
|
|
40
51
|
::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples
|
|
@@ -44,9 +55,11 @@ class SsrfFilter
|
|
|
44
55
|
::IPAddr.new('255.255.255.255') # Broadcast
|
|
45
56
|
].freeze
|
|
46
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
|
+
|
|
47
61
|
IPV6_BLACKLIST = ([
|
|
48
62
|
::IPAddr.new('::1/128'), # Loopback
|
|
49
|
-
::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
|
|
50
63
|
::IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
|
|
51
64
|
::IPAddr.new('2001::/32'), # Teredo tunneling
|
|
52
65
|
::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID)
|
|
@@ -60,10 +73,14 @@ class SsrfFilter
|
|
|
60
73
|
prefixlen = prefixlen_from_ipaddr(ipaddr)
|
|
61
74
|
|
|
62
75
|
# Don't call ipaddr.ipv4_compat because it prints out a deprecation warning on ruby 2.5+
|
|
63
|
-
ipv4_compatible
|
|
64
|
-
ipv4_mapped
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
ipv4_compatible = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen)
|
|
77
|
+
ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
|
|
78
|
+
# IPv4-translated (RFC 2765): ::ffff:0:x.x.x.x/96+n
|
|
79
|
+
ipv4_translated = IPAddr.new("::ffff:0:#{ipaddr}").mask(96 + prefixlen)
|
|
80
|
+
# NAT64 well-known prefix (RFC 6052): 64:ff9b::x.x.x.x/96+n
|
|
81
|
+
nat64_well_known = IPAddr.new("64:ff9b::#{ipaddr}").mask(96 + prefixlen)
|
|
82
|
+
|
|
83
|
+
[ipv4_compatible, ipv4_mapped, ipv4_translated, nat64_well_known]
|
|
67
84
|
end).freeze
|
|
68
85
|
|
|
69
86
|
DEFAULT_SCHEME_WHITELIST = %w[http https].freeze
|
|
@@ -74,6 +91,8 @@ class SsrfFilter
|
|
|
74
91
|
|
|
75
92
|
DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS = false
|
|
76
93
|
DEFAULT_MAX_REDIRECTS = 10
|
|
94
|
+
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie].freeze
|
|
95
|
+
DEFAULT_ON_CROSS_ORIGIN_REDIRECT = :strip
|
|
77
96
|
|
|
78
97
|
VERB_MAP = {
|
|
79
98
|
get: ::Net::HTTP::Get,
|
|
@@ -84,8 +103,6 @@ class SsrfFilter
|
|
|
84
103
|
patch: ::Net::HTTP::Patch
|
|
85
104
|
}.freeze
|
|
86
105
|
|
|
87
|
-
FIBER_HOSTNAME_KEY = :__ssrf_filter_hostname
|
|
88
|
-
|
|
89
106
|
class Error < ::StandardError
|
|
90
107
|
end
|
|
91
108
|
|
|
@@ -104,16 +121,19 @@ class SsrfFilter
|
|
|
104
121
|
class CRLFInjection < Error
|
|
105
122
|
end
|
|
106
123
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
::SsrfFilter::Patch::SSLSocket.apply!
|
|
124
|
+
class CredentialLeakage < Error
|
|
125
|
+
end
|
|
110
126
|
|
|
127
|
+
VERB_MAP.each_key do |method|
|
|
128
|
+
define_singleton_method(method) do |url, options = {}, &block|
|
|
129
|
+
url = url.to_s
|
|
111
130
|
original_url = url
|
|
131
|
+
original_uri = URI(url)
|
|
112
132
|
scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST)
|
|
113
133
|
resolver = options.fetch(:resolver, DEFAULT_RESOLVER)
|
|
114
134
|
allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS)
|
|
115
135
|
max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
|
|
116
|
-
|
|
136
|
+
sensitive_headers = options.fetch(:sensitive_headers, DEFAULT_SENSITIVE_HEADERS)
|
|
117
137
|
|
|
118
138
|
response = nil
|
|
119
139
|
(max_redirects + 1).times do
|
|
@@ -130,7 +150,14 @@ class SsrfFilter
|
|
|
130
150
|
public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
|
|
131
151
|
raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
|
|
132
152
|
|
|
133
|
-
|
|
153
|
+
headers_to_strip = if !sensitive_headers.empty? && different_origin?(original_uri, uri)
|
|
154
|
+
sensitive_headers
|
|
155
|
+
else
|
|
156
|
+
[]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
response, url = fetch_once(uri, public_addresses.sample.to_s, method,
|
|
160
|
+
options.merge(headers_to_strip: headers_to_strip), &block)
|
|
134
161
|
return response if url.nil?
|
|
135
162
|
end
|
|
136
163
|
|
|
@@ -140,45 +167,54 @@ class SsrfFilter
|
|
|
140
167
|
end
|
|
141
168
|
end
|
|
142
169
|
|
|
143
|
-
def self.unsafe_ip_address?(ip_address)
|
|
170
|
+
private_class_method def self.unsafe_ip_address?(ip_address)
|
|
144
171
|
return true if ipaddr_has_mask?(ip_address)
|
|
145
172
|
|
|
146
173
|
return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4?
|
|
147
|
-
|
|
174
|
+
|
|
175
|
+
if ip_address.ipv6?
|
|
176
|
+
return true if IPV6_BLACKLIST.any? { |range| range.include?(ip_address) }
|
|
177
|
+
|
|
178
|
+
# RFC 6052 /48 encoding for NAT64 local-use prefix (RFC 8215): 64:ff9b:1::/48
|
|
179
|
+
# IPv4 is split around u-bits at positions 64-71, so must be decoded at runtime
|
|
180
|
+
if NAT64_LOCAL_PREFIX.dup.include?(ip_address)
|
|
181
|
+
ipv4 = ipv4_from_rfc6052(ip_address, 48)
|
|
182
|
+
return unsafe_ip_address?(ipv4)
|
|
183
|
+
end
|
|
184
|
+
return false
|
|
185
|
+
end
|
|
148
186
|
|
|
149
187
|
true
|
|
150
188
|
end
|
|
151
|
-
private_class_method :unsafe_ip_address?
|
|
152
189
|
|
|
153
|
-
def self.ipaddr_has_mask?(ipaddr)
|
|
190
|
+
private_class_method def self.ipaddr_has_mask?(ipaddr)
|
|
154
191
|
range = ipaddr.to_range
|
|
155
192
|
range.first != range.last
|
|
156
193
|
end
|
|
157
|
-
private_class_method :ipaddr_has_mask?
|
|
158
194
|
|
|
159
|
-
def self.
|
|
195
|
+
private_class_method def self.different_origin?(uri1, uri2)
|
|
196
|
+
uri1.scheme != uri2.scheme || uri1.hostname != uri2.hostname || uri1.port != uri2.port
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private_class_method def self.normalized_hostname(uri)
|
|
160
200
|
# Attach port for non-default as per RFC2616
|
|
161
201
|
if (uri.port == 80 && uri.scheme == 'http') ||
|
|
162
202
|
(uri.port == 443 && uri.scheme == 'https')
|
|
163
|
-
hostname
|
|
203
|
+
uri.hostname
|
|
164
204
|
else
|
|
165
|
-
"#{hostname}:#{uri.port}"
|
|
205
|
+
"#{uri.hostname}:#{uri.port}"
|
|
166
206
|
end
|
|
167
207
|
end
|
|
168
|
-
private_class_method :host_header
|
|
169
208
|
|
|
170
|
-
def self.fetch_once(uri, ip, verb, options, &block)
|
|
209
|
+
private_class_method def self.fetch_once(uri, ip, verb, options, &block)
|
|
171
210
|
if options[:params]
|
|
172
211
|
params = uri.query ? ::URI.decode_www_form(uri.query).to_h : {}
|
|
173
212
|
params.merge!(options[:params])
|
|
174
213
|
uri.query = ::URI.encode_www_form(params)
|
|
175
214
|
end
|
|
176
215
|
|
|
177
|
-
hostname = uri.hostname
|
|
178
|
-
uri.hostname = ip
|
|
179
|
-
|
|
180
216
|
request = VERB_MAP[verb].new(uri)
|
|
181
|
-
request['host'] =
|
|
217
|
+
request['host'] = normalized_hostname(uri)
|
|
182
218
|
|
|
183
219
|
Array(options[:headers]).each do |header, value|
|
|
184
220
|
request[header] = value
|
|
@@ -187,31 +223,44 @@ class SsrfFilter
|
|
|
187
223
|
request.body = options[:body] if options[:body]
|
|
188
224
|
|
|
189
225
|
options[:request_proc].call(request) if options[:request_proc].respond_to?(:call)
|
|
226
|
+
|
|
227
|
+
headers_to_strip = Array(options[:headers_to_strip])
|
|
228
|
+
unless headers_to_strip.empty?
|
|
229
|
+
if options[:on_cross_origin_redirect] == :raise
|
|
230
|
+
leaking = headers_to_strip.select { |h| request[h] }
|
|
231
|
+
unless leaking.empty?
|
|
232
|
+
raise CredentialLeakage,
|
|
233
|
+
"Cross-origin redirect would leak sensitive headers: #{leaking.join(', ')}"
|
|
234
|
+
end
|
|
235
|
+
else
|
|
236
|
+
headers_to_strip.each { |h| request.delete(h) }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
190
240
|
validate_request(request)
|
|
191
241
|
|
|
192
|
-
http_options = options[:http_options] || {}
|
|
193
|
-
|
|
242
|
+
http_options = (options[:http_options] || {}).merge(
|
|
243
|
+
use_ssl: uri.scheme == 'https',
|
|
244
|
+
ipaddr: ip
|
|
245
|
+
)
|
|
194
246
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
url = nil
|
|
207
|
-
end
|
|
208
|
-
return response, url
|
|
247
|
+
::Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http|
|
|
248
|
+
response = http.request(request) do |res|
|
|
249
|
+
block&.call(res)
|
|
250
|
+
end
|
|
251
|
+
case response
|
|
252
|
+
when ::Net::HTTPRedirection
|
|
253
|
+
url = response['location']
|
|
254
|
+
# Handle relative redirects
|
|
255
|
+
url = "#{uri.scheme}://#{normalized_hostname(uri)}#{url}" if url&.start_with?('/')
|
|
256
|
+
else
|
|
257
|
+
url = nil
|
|
209
258
|
end
|
|
259
|
+
return response, url
|
|
210
260
|
end
|
|
211
261
|
end
|
|
212
|
-
private_class_method :fetch_once
|
|
213
262
|
|
|
214
|
-
def self.validate_request(request)
|
|
263
|
+
private_class_method def self.validate_request(request)
|
|
215
264
|
# RFC822 allows multiline "folded" headers:
|
|
216
265
|
# https://tools.ietf.org/html/rfc822#section-3.1
|
|
217
266
|
# In practice if any user input is ever supplied as a header key/value, they'll get
|
|
@@ -222,13 +271,4 @@ class SsrfFilter
|
|
|
222
271
|
end
|
|
223
272
|
end
|
|
224
273
|
end
|
|
225
|
-
private_class_method :validate_request
|
|
226
|
-
|
|
227
|
-
def self.with_forced_hostname(hostname, &_block)
|
|
228
|
-
::Thread.current[FIBER_HOSTNAME_KEY] = hostname
|
|
229
|
-
yield
|
|
230
|
-
ensure
|
|
231
|
-
::Thread.current[FIBER_HOSTNAME_KEY] = nil
|
|
232
|
-
end
|
|
233
|
-
private_class_method :with_forced_hostname
|
|
234
274
|
end
|
data/lib/ssrf_filter/version.rb
CHANGED
data/lib/ssrf_filter.rb
CHANGED
metadata
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ssrf_filter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.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-04-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: base64
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
19
|
+
version: 0.2.0
|
|
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.
|
|
26
|
+
version: 0.2.0
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: bundler-audit
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version:
|
|
33
|
+
version: 0.9.2
|
|
34
34
|
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
|
-
- - "
|
|
38
|
+
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version:
|
|
40
|
+
version: 0.9.2
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: rspec
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: 3.
|
|
47
|
+
version: 3.13.0
|
|
48
48
|
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: 3.
|
|
54
|
+
version: 3.13.0
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: rubocop
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: 1.
|
|
61
|
+
version: 1.68.0
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: 1.
|
|
68
|
+
version: 1.68.0
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: rubocop-rspec
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
72
72
|
requirements:
|
|
73
73
|
- - "~>"
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: 2.
|
|
75
|
+
version: 3.2.0
|
|
76
76
|
type: :development
|
|
77
77
|
prerelease: false
|
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
79
|
requirements:
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: 2.
|
|
82
|
+
version: 3.2.0
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
84
|
name: simplecov
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -108,20 +108,34 @@ dependencies:
|
|
|
108
108
|
- - "~>"
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
110
|
version: 0.8.0
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: tsort
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - '='
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: 0.1.0
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - '='
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: 0.1.0
|
|
111
125
|
- !ruby/object:Gem::Dependency
|
|
112
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
140
|
name: webrick
|
|
127
141
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -144,13 +158,13 @@ extensions: []
|
|
|
144
158
|
extra_rdoc_files: []
|
|
145
159
|
files:
|
|
146
160
|
- lib/ssrf_filter.rb
|
|
147
|
-
- lib/ssrf_filter/patch/ssl_socket.rb
|
|
148
161
|
- lib/ssrf_filter/ssrf_filter.rb
|
|
149
162
|
- lib/ssrf_filter/version.rb
|
|
150
163
|
homepage: https://github.com/arkadiyt/ssrf_filter
|
|
151
164
|
licenses:
|
|
152
165
|
- MIT
|
|
153
166
|
metadata:
|
|
167
|
+
changelog_uri: https://github.com/arkadiyt/ssrf_filter/blob/main/CHANGELOG.md
|
|
154
168
|
rubygems_mfa_required: 'true'
|
|
155
169
|
post_install_message:
|
|
156
170
|
rdoc_options: []
|
|
@@ -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.5.16
|
|
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,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class SsrfFilter
|
|
4
|
-
module Patch
|
|
5
|
-
module SSLSocket
|
|
6
|
-
def self.apply!
|
|
7
|
-
return if instance_variable_defined?(:@patched_ssl_socket)
|
|
8
|
-
|
|
9
|
-
@patched_ssl_socket = true
|
|
10
|
-
|
|
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
|
-
|
|
36
|
-
original_post_connection_check = instance_method(:post_connection_check)
|
|
37
|
-
define_method(:post_connection_check) do |hostname|
|
|
38
|
-
original_post_connection_check.bind(self).call(::Thread.current[::SsrfFilter::FIBER_HOSTNAME_KEY] ||
|
|
39
|
-
hostname)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
if method_defined?(:hostname=)
|
|
43
|
-
original_hostname = instance_method(:hostname=)
|
|
44
|
-
define_method(:hostname=) do |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]
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|