ssrf_filter 1.3.0 → 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 +77 -22
- data/lib/ssrf_filter/version.rb +1 -1
- metadata +17 -3
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,7 +6,7 @@ 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
|
|
|
@@ -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,
|
|
@@ -102,14 +121,19 @@ class SsrfFilter
|
|
|
102
121
|
class CRLFInjection < Error
|
|
103
122
|
end
|
|
104
123
|
|
|
105
|
-
|
|
124
|
+
class CredentialLeakage < Error
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
VERB_MAP.each_key do |method|
|
|
106
128
|
define_singleton_method(method) do |url, options = {}, &block|
|
|
129
|
+
url = url.to_s
|
|
107
130
|
original_url = url
|
|
131
|
+
original_uri = URI(url)
|
|
108
132
|
scheme_whitelist = options.fetch(:scheme_whitelist, DEFAULT_SCHEME_WHITELIST)
|
|
109
133
|
resolver = options.fetch(:resolver, DEFAULT_RESOLVER)
|
|
110
134
|
allow_unfollowed_redirects = options.fetch(:allow_unfollowed_redirects, DEFAULT_ALLOW_UNFOLLOWED_REDIRECTS)
|
|
111
135
|
max_redirects = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
|
|
112
|
-
|
|
136
|
+
sensitive_headers = options.fetch(:sensitive_headers, DEFAULT_SENSITIVE_HEADERS)
|
|
113
137
|
|
|
114
138
|
response = nil
|
|
115
139
|
(max_redirects + 1).times do
|
|
@@ -126,7 +150,14 @@ class SsrfFilter
|
|
|
126
150
|
public_addresses = ip_addresses.reject(&method(:unsafe_ip_address?))
|
|
127
151
|
raise PrivateIPAddress, "Hostname '#{hostname}' has no public ip addresses" if public_addresses.empty?
|
|
128
152
|
|
|
129
|
-
|
|
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)
|
|
130
161
|
return response if url.nil?
|
|
131
162
|
end
|
|
132
163
|
|
|
@@ -136,23 +167,36 @@ class SsrfFilter
|
|
|
136
167
|
end
|
|
137
168
|
end
|
|
138
169
|
|
|
139
|
-
def self.unsafe_ip_address?(ip_address)
|
|
170
|
+
private_class_method def self.unsafe_ip_address?(ip_address)
|
|
140
171
|
return true if ipaddr_has_mask?(ip_address)
|
|
141
172
|
|
|
142
173
|
return IPV4_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv4?
|
|
143
|
-
|
|
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
|
|
144
186
|
|
|
145
187
|
true
|
|
146
188
|
end
|
|
147
|
-
private_class_method :unsafe_ip_address?
|
|
148
189
|
|
|
149
|
-
def self.ipaddr_has_mask?(ipaddr)
|
|
190
|
+
private_class_method def self.ipaddr_has_mask?(ipaddr)
|
|
150
191
|
range = ipaddr.to_range
|
|
151
192
|
range.first != range.last
|
|
152
193
|
end
|
|
153
|
-
private_class_method :ipaddr_has_mask?
|
|
154
194
|
|
|
155
|
-
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)
|
|
156
200
|
# Attach port for non-default as per RFC2616
|
|
157
201
|
if (uri.port == 80 && uri.scheme == 'http') ||
|
|
158
202
|
(uri.port == 443 && uri.scheme == 'https')
|
|
@@ -161,9 +205,8 @@ class SsrfFilter
|
|
|
161
205
|
"#{uri.hostname}:#{uri.port}"
|
|
162
206
|
end
|
|
163
207
|
end
|
|
164
|
-
private_class_method :normalized_hostname
|
|
165
208
|
|
|
166
|
-
def self.fetch_once(uri, ip, verb, options, &block)
|
|
209
|
+
private_class_method def self.fetch_once(uri, ip, verb, options, &block)
|
|
167
210
|
if options[:params]
|
|
168
211
|
params = uri.query ? ::URI.decode_www_form(uri.query).to_h : {}
|
|
169
212
|
params.merge!(options[:params])
|
|
@@ -180,6 +223,20 @@ class SsrfFilter
|
|
|
180
223
|
request.body = options[:body] if options[:body]
|
|
181
224
|
|
|
182
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
|
+
|
|
183
240
|
validate_request(request)
|
|
184
241
|
|
|
185
242
|
http_options = (options[:http_options] || {}).merge(
|
|
@@ -202,9 +259,8 @@ class SsrfFilter
|
|
|
202
259
|
return response, url
|
|
203
260
|
end
|
|
204
261
|
end
|
|
205
|
-
private_class_method :fetch_once
|
|
206
262
|
|
|
207
|
-
def self.validate_request(request)
|
|
263
|
+
private_class_method def self.validate_request(request)
|
|
208
264
|
# RFC822 allows multiline "folded" headers:
|
|
209
265
|
# https://tools.ietf.org/html/rfc822#section-3.1
|
|
210
266
|
# In practice if any user input is ever supplied as a header key/value, they'll get
|
|
@@ -215,5 +271,4 @@ class SsrfFilter
|
|
|
215
271
|
end
|
|
216
272
|
end
|
|
217
273
|
end
|
|
218
|
-
private_class_method :validate_request
|
|
219
274
|
end
|
data/lib/ssrf_filter/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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
14
|
name: base64
|
|
@@ -108,6 +108,20 @@ 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
|
|
@@ -167,7 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
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
|