ssrf_filter 1.0.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 +7 -0
- data/lib/ssrf_filter.rb +2 -0
- data/lib/ssrf_filter/ssrf_filter.rb +214 -0
- data/lib/ssrf_filter/version.rb +3 -0
- metadata +117 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 68db49bb250c05d11655c0afe1913b34247d783c
|
4
|
+
data.tar.gz: f1d11ee2913e121b3c996a1af745926a3da953e2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 201bf113012c32ddb97e79df1fe9428d9dff43be894d340789a6b937f819e4538e680f8ec66e0d458a396ed7e082d1d20374d4b8c0fc0833970fb6b1d9a81bad
|
7
|
+
data.tar.gz: 769532a2d3f68fe97a20a001d73fc930855dbdd7c1dbe4ac115c29e2f7661f509c3d89cbe21480a7630e503af7bb3bea7b4fedbb625fb2e51e5a08aa4a1a1dc4
|
data/lib/ssrf_filter.rb
ADDED
@@ -0,0 +1,214 @@
|
|
1
|
+
require 'ipaddr'
|
2
|
+
require 'net/http'
|
3
|
+
require 'resolv'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
class SsrfFilter
|
7
|
+
# https://en.wikipedia.org/wiki/Reserved_IP_addresses
|
8
|
+
IPV4_BLACKLIST = [
|
9
|
+
::IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address)
|
10
|
+
::IPAddr.new('10.0.0.0/8'), # Private network
|
11
|
+
::IPAddr.new('100.64.0.0/10'), # Shared Address Space
|
12
|
+
::IPAddr.new('127.0.0.0/8'), # Loopback
|
13
|
+
::IPAddr.new('169.254.0.0/16'), # Link-local
|
14
|
+
::IPAddr.new('172.16.0.0/12'), # Private network
|
15
|
+
::IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
|
16
|
+
::IPAddr.new('192.0.2.0/24'), # TEST-NET-1, documentation and examples
|
17
|
+
::IPAddr.new('192.88.99.0/24'), # IPv6 to IPv4 relay (includes 2002::/16)
|
18
|
+
::IPAddr.new('192.168.0.0/16'), # Private network
|
19
|
+
::IPAddr.new('198.18.0.0/15'), # Network benchmark tests
|
20
|
+
::IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples
|
21
|
+
::IPAddr.new('203.0.113.0/24'), # TEST-NET-3, documentation and examples
|
22
|
+
::IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network)
|
23
|
+
::IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network)
|
24
|
+
::IPAddr.new('255.255.255.255') # Broadcast
|
25
|
+
].freeze
|
26
|
+
|
27
|
+
IPV6_BLACKLIST = [
|
28
|
+
::IPAddr.new('::1/128'), # Loopback
|
29
|
+
::IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
|
30
|
+
::IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
|
31
|
+
::IPAddr.new('2001::/32'), # Teredo tunneling
|
32
|
+
::IPAddr.new('2001:10::/28'), # Deprecated (previously ORCHID)
|
33
|
+
::IPAddr.new('2001:20::/28'), # ORCHIDv2
|
34
|
+
::IPAddr.new('2001:db8::/32'), # Addresses used in documentation and example source code
|
35
|
+
::IPAddr.new('2002::/16'), # 6to4
|
36
|
+
::IPAddr.new('fc00::/7'), # Unique local address
|
37
|
+
::IPAddr.new('fe80::/10'), # Link-local address
|
38
|
+
::IPAddr.new('ff00::/8'), # Multicast
|
39
|
+
].freeze
|
40
|
+
|
41
|
+
DEFAULT_SCHEME_WHITELIST = %w[http https].freeze
|
42
|
+
|
43
|
+
DEFAULT_RESOLVER = proc do |hostname|
|
44
|
+
::Resolv.getaddresses(hostname).map { |ip| ::IPAddr.new(ip) }
|
45
|
+
end
|
46
|
+
|
47
|
+
DEFAULT_MAX_REDIRECTS = 10
|
48
|
+
|
49
|
+
VERB_MAP = {
|
50
|
+
get: ::Net::HTTP::Get,
|
51
|
+
put: ::Net::HTTP::Put,
|
52
|
+
post: ::Net::HTTP::Post,
|
53
|
+
delete: ::Net::HTTP::Delete
|
54
|
+
}.freeze
|
55
|
+
|
56
|
+
FIBER_LOCAL_KEY = :__ssrf_filter_hostname
|
57
|
+
|
58
|
+
class Error < ::StandardError
|
59
|
+
end
|
60
|
+
|
61
|
+
class InvalidUriScheme < Error
|
62
|
+
end
|
63
|
+
|
64
|
+
class PrivateIPAddress < Error
|
65
|
+
end
|
66
|
+
|
67
|
+
class UnresolvedHostname < Error
|
68
|
+
end
|
69
|
+
|
70
|
+
class TooManyRedirects < Error
|
71
|
+
end
|
72
|
+
|
73
|
+
%i[get put post delete].each do |method|
|
74
|
+
define_singleton_method(method) do |url, options = {}, &block|
|
75
|
+
original_url = url
|
76
|
+
scheme_whitelist = options[:scheme_whitelist] || DEFAULT_SCHEME_WHITELIST
|
77
|
+
resolver = options[:resolver] || DEFAULT_RESOLVER
|
78
|
+
max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS
|
79
|
+
url = url.to_s
|
80
|
+
|
81
|
+
(max_redirects + 1).times do
|
82
|
+
uri = URI(url)
|
83
|
+
|
84
|
+
unless scheme_whitelist.include?(uri.scheme)
|
85
|
+
raise InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{scheme_whitelist}"
|
86
|
+
end
|
87
|
+
|
88
|
+
hostname = uri.hostname
|
89
|
+
ip_addresses = resolver.call(hostname)
|
90
|
+
raise UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty?
|
91
|
+
|
92
|
+
public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
|
93
|
+
raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
|
94
|
+
|
95
|
+
response = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
|
96
|
+
|
97
|
+
case response
|
98
|
+
when ::Net::HTTPRedirection then
|
99
|
+
url = response['location']
|
100
|
+
else
|
101
|
+
return response
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
raise TooManyRedirects, "Got #{max_redirects} redirects fetching #{original_url}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.unsafe_ip_address?(ip_address)
|
110
|
+
return true if ipaddr_has_mask?(ip_address)
|
111
|
+
|
112
|
+
return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4?
|
113
|
+
|
114
|
+
if ip_address.ipv6?
|
115
|
+
result = IPV6_BLACKLIST.any? { |range| range.include?(ip_address) }
|
116
|
+
# TODO: convert these to be members of IPV6_BLACKLIST
|
117
|
+
result ||= ip_address.ipv4_compat? && IPV4_BLACKLIST.any? { |range| range.include?(ip_address.ipv4_compat) }
|
118
|
+
result ||= ip_address.ipv4_mapped? && IPV4_BLACKLIST.any? { |range| range.include?(ip_address.ipv4_mapped) }
|
119
|
+
return result
|
120
|
+
end
|
121
|
+
|
122
|
+
true
|
123
|
+
end
|
124
|
+
private_class_method :unsafe_ip_address?
|
125
|
+
|
126
|
+
def self.ipaddr_has_mask?(ipaddr)
|
127
|
+
range = ipaddr.to_range
|
128
|
+
range.first != range.last
|
129
|
+
end
|
130
|
+
private_class_method :ipaddr_has_mask?
|
131
|
+
|
132
|
+
def self.fetch_once(uri, ip, verb, options, &block)
|
133
|
+
if options[:params]
|
134
|
+
params = uri.query ? ::Hash[::URI.decode_www_form(uri.query)] : {}
|
135
|
+
params.merge!(options[:params])
|
136
|
+
uri.query = ::URI.encode_www_form(params)
|
137
|
+
end
|
138
|
+
|
139
|
+
hostname = uri.hostname
|
140
|
+
uri.hostname = ip
|
141
|
+
|
142
|
+
request = VERB_MAP[verb].new(uri)
|
143
|
+
request['host'] = hostname
|
144
|
+
|
145
|
+
Array(options[:headers]).each do |header, value|
|
146
|
+
request[header] = value
|
147
|
+
end
|
148
|
+
|
149
|
+
request.body = options[:body] if options[:body]
|
150
|
+
|
151
|
+
block.call(request) if block_given?
|
152
|
+
|
153
|
+
use_ssl = uri.scheme == 'https'
|
154
|
+
with_forced_hostname(hostname) do
|
155
|
+
::Net::HTTP.start(uri.hostname, uri.port, use_ssl: use_ssl) do |http|
|
156
|
+
http.request(request)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
private_class_method :fetch_once
|
161
|
+
|
162
|
+
def self.patch_ssl_socket!
|
163
|
+
return if instance_variable_defined?(:@patched_ssl_socket)
|
164
|
+
|
165
|
+
# What we'd like to do is have the following workflow:
|
166
|
+
# 1) resolve the hostname www.example.com, and choose a public ip address to connect to
|
167
|
+
# 2) connect to that specific ip address, to prevent things like DNS TOCTTOU bugs or other trickery
|
168
|
+
#
|
169
|
+
# Ideally this would happen by the ruby http library giving us control over DNS resolution,
|
170
|
+
# but it doesn't. Instead, when making the request we set the uri.hostname to the chosen ip address,
|
171
|
+
# and send a 'Host' header of the original hostname, i.e. connect to 'http://93.184.216.34/' and send
|
172
|
+
# a 'Host: www.example.com' header.
|
173
|
+
#
|
174
|
+
# This works for the http case, http://www.example.com. For the https case, this causes certificate
|
175
|
+
# validation failures, since the server certificate does not have a Subject Alternate Name for 93.184.216.34.
|
176
|
+
#
|
177
|
+
# Thus we perform the monkey-patch below, modifying SSLSocket's `post_connection_check(hostname)`
|
178
|
+
# and `hostname=(hostname)` methods:
|
179
|
+
# If our fiber local variable is set, use that for the hostname instead, otherwise behave as usual.
|
180
|
+
# The only time the variable will be set is if you are executing inside a `with_forced_hostname` block,
|
181
|
+
# which is used above.
|
182
|
+
#
|
183
|
+
# An alternative approach could be to pass in our own OpenSSL::X509::Store with a custom
|
184
|
+
# `verify_callback` to the ::Net::HTTP.start call, but this would require reimplementing certification
|
185
|
+
# validation, which is dangerous. This way we can piggyback on the existing validation and simply pretend
|
186
|
+
# that we connected to the desired hostname.
|
187
|
+
|
188
|
+
::OpenSSL::SSL::SSLSocket.class_eval do
|
189
|
+
original_post_connection_check = instance_method(:post_connection_check)
|
190
|
+
define_method(:post_connection_check) do |hostname|
|
191
|
+
original_post_connection_check.bind(self).call(::Thread.current[FIBER_LOCAL_KEY] || hostname)
|
192
|
+
end
|
193
|
+
|
194
|
+
if method_defined?(:hostname=)
|
195
|
+
original_hostname = instance_method(:hostname=)
|
196
|
+
define_method(:hostname=) do |hostname|
|
197
|
+
original_hostname.bind(self).call(::Thread.current[FIBER_LOCAL_KEY] || hostname)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
@patched_ssl_socket = true
|
203
|
+
end
|
204
|
+
private_class_method :patch_ssl_socket!
|
205
|
+
|
206
|
+
def self.with_forced_hostname(hostname, &_block)
|
207
|
+
patch_ssl_socket!
|
208
|
+
::Thread.current[FIBER_LOCAL_KEY] = hostname
|
209
|
+
yield
|
210
|
+
ensure
|
211
|
+
::Thread.current[FIBER_LOCAL_KEY] = nil
|
212
|
+
end
|
213
|
+
private_class_method :with_forced_hostname
|
214
|
+
end
|
metadata
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ssrf_filter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Arkadiy Tetelman
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-07-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler-audit
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.6'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: coveralls
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.8'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.8'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.6'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.6'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.49'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.49'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: webmock
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.0'
|
83
|
+
description: A gem that makes it easy to prevent server side request forgery (SSRF)
|
84
|
+
attacks
|
85
|
+
email:
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- lib/ssrf_filter.rb
|
91
|
+
- lib/ssrf_filter/ssrf_filter.rb
|
92
|
+
- lib/ssrf_filter/version.rb
|
93
|
+
homepage: https://github.com/arkadiyt/ssrf_filter
|
94
|
+
licenses:
|
95
|
+
- MIT
|
96
|
+
metadata: {}
|
97
|
+
post_install_message:
|
98
|
+
rdoc_options: []
|
99
|
+
require_paths:
|
100
|
+
- lib
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: 2.0.0
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
requirements: []
|
112
|
+
rubyforge_project:
|
113
|
+
rubygems_version: 2.4.6
|
114
|
+
signing_key:
|
115
|
+
specification_version: 4
|
116
|
+
summary: A gem that makes it easy to prevent server side request forgery (SSRF) attacks
|
117
|
+
test_files: []
|