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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82909e9f3a32689d3a678e5474d09fbcfb8eae84308dd1ae13d622c8d1e6f6cd
4
- data.tar.gz: 6c349dc635f346cb0204d1a238ab02e4f6adcc6e9d9cd4268404195dfa9a66b2
3
+ metadata.gz: d68f597eb7885a7c9941fc8f134f7a6b87eec03fc0c92b6b6684a00d1be00069
4
+ data.tar.gz: 2c934e958e0542c2ae1606b0657cb2096d54647969f291aa41579fd3f0363c67
5
5
  SHA512:
6
- metadata.gz: 602e11a98e4da6eaa0f869dc5f64aea715038b3a705b9354e25b7378dd96a893070bf4eb31d5c26c642b474d7891320c2749f7d9fc600e015c0e200cb04c3575
7
- data.tar.gz: 4cd5d4382850e12b23784e807dc4728c8e67a7c214fa1aa8637fb10d49a570a1ca94ecacf9a2ef41d3c7ed983e2569545eacacdc80030dabbb99a6ef033e0e8c
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
- private_class_method :prefixlen_from_ipaddr
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 = IPAddr.new(ipaddr.to_i, Socket::AF_INET6).mask(96 + prefixlen)
64
- ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
65
-
66
- [ipv4_compatible, ipv4_mapped]
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
- %i[get put post delete head patch].each do |method|
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
- url = url.to_s
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
- response, url = fetch_once(uri, public_addresses.sample.to_s, method, options, &block)
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
- return IPV6_BLACKLIST.any? { |range| range.include?(ip_address) } if ip_address.ipv6?
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.normalized_hostname(uri)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SsrfFilter
4
- VERSION = '1.3.0'
4
+ VERSION = '1.5.0'
5
5
  end
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.3.0
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: 2025-05-10 00:00:00.000000000 Z
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.3.7
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