ssrf_filter 1.1.1 → 1.2.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 +31 -45
- data/lib/ssrf_filter/version.rb +1 -1
- data/lib/ssrf_filter.rb +0 -2
- metadata +30 -18
- data/lib/ssrf_filter/patch/resolv.rb +0 -44
- data/lib/ssrf_filter/patch/ssl_socket.rb +0 -52
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c7b6cb5db50edebd286cc411415c02c7e461815da7e21b75d5cc2d0780b149cb
|
4
|
+
data.tar.gz: e48564883db3e5db9b7e0d54a134680fe035f8f33f8f74a378dc75448545347a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cdc1bb1de979857312fe2d59d3b09c03ce3a58a63b768414bac40bba56e7ed87fc003a08249d0bc5df04328067aa914528cbffb41cf7bd1b648b531f36e44776
|
7
|
+
data.tar.gz: 8e733db18680051554b6a69148088837880a86a62a31c6427e2131966653bd9ebd4e5957e2ea42d8e58260eb474bdd5a00bde1776a25d679fadab65bbfbb6853
|
@@ -10,7 +10,7 @@ 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
|
-
while (
|
13
|
+
while mask_addr.nobits?(0x1)
|
14
14
|
mask_addr >>= 1
|
15
15
|
end
|
16
16
|
|
@@ -72,6 +72,7 @@ class SsrfFilter
|
|
72
72
|
::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) }
|
73
73
|
end
|
74
74
|
|
75
|
+
DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS = false
|
75
76
|
DEFAULT_MAX_REDIRECTS = 10
|
76
77
|
|
77
78
|
VERB_MAP = {
|
@@ -83,9 +84,6 @@ class SsrfFilter
|
|
83
84
|
patch: ::Net::HTTP::Patch
|
84
85
|
}.freeze
|
85
86
|
|
86
|
-
FIBER_HOSTNAME_KEY = :__ssrf_filter_hostname
|
87
|
-
FIBER_ADDRESS_KEY = :__ssrf_filter_address
|
88
|
-
|
89
87
|
class Error < ::StandardError
|
90
88
|
end
|
91
89
|
|
@@ -106,15 +104,14 @@ class SsrfFilter
|
|
106
104
|
|
107
105
|
%i[get put post delete head patch].each do |method|
|
108
106
|
define_singleton_method(method) do |url, options = {}, &block|
|
109
|
-
::SsrfFilter::Patch::SSLSocket.apply!
|
110
|
-
::SsrfFilter::Patch::Resolv.apply!
|
111
|
-
|
112
107
|
original_url = url
|
113
|
-
scheme_whitelist = options
|
114
|
-
resolver = options
|
115
|
-
|
108
|
+
scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST)
|
109
|
+
resolver = options.fetch(:resolver, DEFAULT_RESOLVER)
|
110
|
+
allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS)
|
111
|
+
max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
|
116
112
|
url = url.to_s
|
117
113
|
|
114
|
+
response = nil
|
118
115
|
(max_redirects + 1).times do
|
119
116
|
uri = URI(url)
|
120
117
|
|
@@ -133,6 +130,8 @@ class SsrfFilter
|
|
133
130
|
return response if url.nil?
|
134
131
|
end
|
135
132
|
|
133
|
+
return response if allow_unfollowed_redirects
|
134
|
+
|
136
135
|
raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}"
|
137
136
|
end
|
138
137
|
end
|
@@ -153,16 +152,16 @@ class SsrfFilter
|
|
153
152
|
end
|
154
153
|
private_class_method :ipaddr_has_mask?
|
155
154
|
|
156
|
-
def self.
|
155
|
+
def self.normalized_hostname(uri)
|
157
156
|
# Attach port for non-default as per RFC2616
|
158
157
|
if (uri.port == 80 && uri.scheme == 'http') ||
|
159
158
|
(uri.port == 443 && uri.scheme == 'https')
|
160
|
-
hostname
|
159
|
+
uri.hostname
|
161
160
|
else
|
162
|
-
"#{hostname}:#{uri.port}"
|
161
|
+
"#{uri.hostname}:#{uri.port}"
|
163
162
|
end
|
164
163
|
end
|
165
|
-
private_class_method :
|
164
|
+
private_class_method :normalized_hostname
|
166
165
|
|
167
166
|
def self.fetch_once(uri, ip, verb, options, &block)
|
168
167
|
if options[:params]
|
@@ -171,11 +170,8 @@ class SsrfFilter
|
|
171
170
|
uri.query = ::URI.encode_www_form(params)
|
172
171
|
end
|
173
172
|
|
174
|
-
hostname = uri.hostname
|
175
|
-
uri.hostname = ip
|
176
|
-
|
177
173
|
request = VERB_MAP[verb].new(uri)
|
178
|
-
request['host'] =
|
174
|
+
request['host'] = normalized_hostname(uri)
|
179
175
|
|
180
176
|
Array(options[:headers]).each do |header, value|
|
181
177
|
request[header] = value
|
@@ -186,24 +182,24 @@ class SsrfFilter
|
|
186
182
|
options[:request_proc].call(request) if options[:request_proc].respond_to?(:call)
|
187
183
|
validate_request(request)
|
188
184
|
|
189
|
-
http_options = options[:http_options] || {}
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
end
|
185
|
+
http_options = (options[:http_options] || {}).merge(
|
186
|
+
use_ssl: uri.scheme == 'https',
|
187
|
+
ipaddr: ip
|
188
|
+
)
|
189
|
+
|
190
|
+
::Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http|
|
191
|
+
response = http.request(request) do |res|
|
192
|
+
block&.call(res)
|
193
|
+
end
|
194
|
+
case response
|
195
|
+
when ::Net::HTTPRedirection
|
196
|
+
url = response['location']
|
197
|
+
# Handle relative redirects
|
198
|
+
url = "#{uri.scheme}://#{normalized_hostname(uri)}#{url}" if url.start_with?('/')
|
199
|
+
else
|
200
|
+
url = nil
|
206
201
|
end
|
202
|
+
return response, url
|
207
203
|
end
|
208
204
|
end
|
209
205
|
private_class_method :fetch_once
|
@@ -220,14 +216,4 @@ class SsrfFilter
|
|
220
216
|
end
|
221
217
|
end
|
222
218
|
private_class_method :validate_request
|
223
|
-
|
224
|
-
def self.with_forced_hostname(hostname, ip, &_block)
|
225
|
-
::Thread.current[FIBER_HOSTNAME_KEY] = hostname
|
226
|
-
::Thread.current[FIBER_ADDRESS_KEY] = ip
|
227
|
-
yield
|
228
|
-
ensure
|
229
|
-
::Thread.current[FIBER_HOSTNAME_KEY] = nil
|
230
|
-
::Thread.current[FIBER_ADDRESS_KEY] = nil
|
231
|
-
end
|
232
|
-
private_class_method :with_forced_hostname
|
233
219
|
end
|
data/lib/ssrf_filter/version.rb
CHANGED
data/lib/ssrf_filter.rb
CHANGED
metadata
CHANGED
@@ -1,29 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ssrf_filter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.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: 2024-11-08 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.9.
|
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.9.
|
40
|
+
version: 0.9.2
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: pry-byebug
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -44,56 +58,56 @@ dependencies:
|
|
44
58
|
requirements:
|
45
59
|
- - "~>"
|
46
60
|
- !ruby/object:Gem::Version
|
47
|
-
version: 3.
|
61
|
+
version: 3.13.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.
|
68
|
+
version: 3.13.0
|
55
69
|
- !ruby/object:Gem::Dependency
|
56
70
|
name: rubocop
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
58
72
|
requirements:
|
59
73
|
- - "~>"
|
60
74
|
- !ruby/object:Gem::Version
|
61
|
-
version: 1.
|
75
|
+
version: 1.68.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: 1.
|
82
|
+
version: 1.68.0
|
69
83
|
- !ruby/object:Gem::Dependency
|
70
84
|
name: rubocop-rspec
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
72
86
|
requirements:
|
73
87
|
- - "~>"
|
74
88
|
- !ruby/object:Gem::Version
|
75
|
-
version: 2.
|
89
|
+
version: 3.2.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: 2.
|
96
|
+
version: 3.2.0
|
83
97
|
- !ruby/object:Gem::Dependency
|
84
98
|
name: simplecov
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
86
100
|
requirements:
|
87
101
|
- - "~>"
|
88
102
|
- !ruby/object:Gem::Version
|
89
|
-
version: 0.
|
103
|
+
version: 0.22.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.22.0
|
97
111
|
- !ruby/object:Gem::Dependency
|
98
112
|
name: simplecov-lcov
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -114,14 +128,14 @@ dependencies:
|
|
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,8 +158,6 @@ extensions: []
|
|
144
158
|
extra_rdoc_files: []
|
145
159
|
files:
|
146
160
|
- lib/ssrf_filter.rb
|
147
|
-
- lib/ssrf_filter/patch/resolv.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
|
@@ -161,14 +173,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
161
173
|
requirements:
|
162
174
|
- - ">="
|
163
175
|
- !ruby/object:Gem::Version
|
164
|
-
version: 2.
|
176
|
+
version: 2.7.0
|
165
177
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
166
178
|
requirements:
|
167
179
|
- - ">="
|
168
180
|
- !ruby/object:Gem::Version
|
169
181
|
version: '0'
|
170
182
|
requirements: []
|
171
|
-
rubygems_version: 3.
|
183
|
+
rubygems_version: 3.3.7
|
172
184
|
signing_key:
|
173
185
|
specification_version: 4
|
174
186
|
summary: A gem that makes it easy to prevent server side request forgery (SSRF) attacks
|
@@ -1,44 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'resolv'
|
4
|
-
|
5
|
-
class SsrfFilter
|
6
|
-
module Patch
|
7
|
-
module Resolv
|
8
|
-
# As described in ssl_socket.rb, we want to patch ruby's http connection code to allow us to make outbound network
|
9
|
-
# requests while ensuring that both:
|
10
|
-
# 1) we're connecting to a public / non-private ip address
|
11
|
-
# 2) https connections continue to work
|
12
|
-
#
|
13
|
-
# This used to work fine prior to this change in ruby's net/http library:
|
14
|
-
# https://github.com/ruby/net-http/pull/36
|
15
|
-
# After this changed was introduced our patch no longer works - we need to set the hostname to the correct
|
16
|
-
# value on the SSLSocket (`s.hostname = ssl_host_address`), but that code path no longer executes due to the
|
17
|
-
# modification in the linked pull request.
|
18
|
-
#
|
19
|
-
# To work around this we introduce the patch below, which forces our ip address string to not match against the
|
20
|
-
# Resolv IPv4/IPv6 regular expressions. This is ugly and cumbersome but I didn't see any better path.
|
21
|
-
class PatchedRegexp < Regexp
|
22
|
-
def ===(other)
|
23
|
-
if ::Thread.current.key?(::SsrfFilter::FIBER_ADDRESS_KEY) &&
|
24
|
-
other.object_id.equal?(::Thread.current[::SsrfFilter::FIBER_ADDRESS_KEY].object_id)
|
25
|
-
false
|
26
|
-
else
|
27
|
-
super(other)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def self.apply!
|
33
|
-
return if instance_variable_defined?(:@patched_resolv)
|
34
|
-
|
35
|
-
@patched_resolv = true
|
36
|
-
|
37
|
-
old_ipv4 = ::Resolv::IPv4.send(:remove_const, :Regex)
|
38
|
-
old_ipv6 = ::Resolv::IPv6.send(:remove_const, :Regex)
|
39
|
-
::Resolv::IPv4.const_set(:Regex, PatchedRegexp.new(old_ipv4))
|
40
|
-
::Resolv::IPv6.const_set(:Regex, PatchedRegexp.new(old_ipv6))
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
@@ -1,52 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class SsrfFilter
|
4
|
-
module Patch
|
5
|
-
module SSLSocket
|
6
|
-
# When fetching a url we'd like to have the following workflow:
|
7
|
-
# 1) resolve the hostname www.example.com, and choose a public ip address to connect to
|
8
|
-
# 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
|
9
|
-
#
|
10
|
-
# Ideally this would happen by the ruby http library giving us control over DNS resolution,
|
11
|
-
# but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
|
12
|
-
# and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
|
13
|
-
# a 'Host: www.example.com' header.
|
14
|
-
#
|
15
|
-
# This works for the http case, http://www.example.com. For the https case, this causes certificate
|
16
|
-
# validation failures, since the server certificate for https://www.example.com will not have a
|
17
|
-
# Subject Alternate Name for 93.184.216.34.
|
18
|
-
#
|
19
|
-
# Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
|
20
|
-
# and `hostname=(hostname)` methods:
|
21
|
-
# If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
|
22
|
-
# The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
|
23
|
-
# which is used in ssrf_filter.rb.
|
24
|
-
#
|
25
|
-
# An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
|
26
|
-
# `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
|
27
|
-
# validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
|
28
|
-
# that we connected to the desired hostname.
|
29
|
-
|
30
|
-
def self.apply!
|
31
|
-
return if instance_variable_defined?(:@patched_ssl_socket)
|
32
|
-
|
33
|
-
@patched_ssl_socket = true
|
34
|
-
|
35
|
-
::OpenSSL::SSL::SSLSocket.class_eval do
|
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
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
end
|
52
|
-
end
|