ssrf_proxy 0.0.3 → 0.0.4

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.
@@ -1,6 +1,5 @@
1
- # coding: utf-8
2
1
  #
3
- # Copyright (c) 2015-2016 Brendan Coles <bcoles@gmail.com>
2
+ # Copyright (c) 2015-2017 Brendan Coles <bcoles@gmail.com>
4
3
  # SSRF Proxy - https://github.com/bcoles/ssrf_proxy
5
4
  # See the file 'LICENSE.md' for copying permission
6
5
  #
@@ -30,7 +29,9 @@ require 'cgi'
30
29
  require 'webrick'
31
30
  require 'stringio'
32
31
  require 'base64'
32
+ require 'stringio'
33
33
  require 'htmlentities'
34
+ require 'mimemagic'
34
35
 
35
36
  # client request url rules
36
37
  require 'digest'
@@ -41,5 +42,6 @@ require 'ipaddress'
41
42
 
42
43
  # SSRF Proxy gem libs
43
44
  require 'ssrf_proxy/version'
45
+ require 'ssrf_proxy/banner'
44
46
  require 'ssrf_proxy/http'
45
47
  require 'ssrf_proxy/server'
@@ -0,0 +1,15 @@
1
+ #
2
+ # Copyright (c) 2015-2017 Brendan Coles <bcoles@gmail.com>
3
+ # SSRF Proxy - https://github.com/bcoles/ssrf_proxy
4
+ # See the file 'LICENSE.md' for copying permission
5
+ #
6
+
7
+ module SSRFProxy
8
+ # Elite font ASCII art from: http://patorjk.com/software/taag/
9
+ BANNER = " \n" \
10
+ " .▄▄ · .▄▄ · ▄▄▄ ·▄▄▄ ▄▄▄·▄▄▄ ▐▄• ▄ ▄· ▄▌ \n" \
11
+ " ▐█ ▀. ▐█ ▀. ▀▄ █·▐▄▄· ▐█ ▄█▀▄ █·▪ █▌█▌▪▐█▪██▌ \n" \
12
+ " ▄▀▀▀█▄▄▀▀▀█▄▐▀▀▄ ██▪ ██▀·▐▀▀▄ ▄█▀▄ ·██· ▐█▌▐█▪ \n" \
13
+ " ▐█▄▪▐█▐█▄▪▐█▐█•█▌██▌. ▐█▪·•▐█•█▌▐█▌.▐▌▪▐█·█▌ ▐█▀·. \n" \
14
+ " ▀▀▀▀ ▀▀▀▀ .▀ ▀▀▀▀ .▀ .▀ ▀ ▀█▄▀▪•▀▀ ▀▀ ▀ • \n".freeze
15
+ end
@@ -1,6 +1,5 @@
1
- # coding: utf-8
2
1
  #
3
- # Copyright (c) 2015-2016 Brendan Coles <bcoles@gmail.com>
2
+ # Copyright (c) 2015-2017 Brendan Coles <bcoles@gmail.com>
4
3
  # SSRF Proxy - https://github.com/bcoles/ssrf_proxy
5
4
  # See the file 'LICENSE.md' for copying permission
6
5
  #
@@ -8,11 +7,12 @@
8
7
  module SSRFProxy
9
8
  #
10
9
  # SSRFProxy::HTTP object takes information required to connect
11
- # to a HTTP server vulnerable to SSRF and issue arbitrary HTTP
12
- # requests via the SSRF.
10
+ # to a HTTP(S) server vulnerable to Server-Side Request Forgery
11
+ # (SSRF) and issue arbitrary HTTP requests via the vulnerable
12
+ # server.
13
13
  #
14
- # Once configured, the #send_uri method can be used to tunnel
15
- # HTTP requests through the server.
14
+ # Once configured, the #send_uri and #send_request methods can
15
+ # be used to tunnel HTTP requests through the vulnerable server.
16
16
  #
17
17
  # Several request modification options can be used to format
18
18
  # the HTTP request appropriately for the SSRF vector and
@@ -25,444 +25,726 @@ module SSRFProxy
25
25
  # HTTP request.
26
26
  #
27
27
  # Refer to the wiki for more information about configuring the
28
- # SSRF, requestion modification, and response modification:
28
+ # SSRF, requestion modification, response modification, and
29
+ # example configurations:
29
30
  # https://github.com/bcoles/ssrf_proxy/wiki/Configuration
30
31
  #
31
32
  class HTTP
33
+ # @return [Logger] logger
34
+ attr_reader :logger
35
+ # @return [URI] SSRF URL
36
+ attr_reader :url
37
+ # @return [URI] upstream proxy
38
+ attr_reader :proxy
39
+ # @return [String] SSRF request HTTP method
40
+ attr_reader :method
41
+ # @return [Hash] SSRF request HTTP headers
42
+ attr_reader :headers
43
+ # @return [String] SSRF request HTTP body
44
+ attr_reader :post_data
45
+
32
46
  #
33
47
  # SSRFProxy::HTTP errors
34
48
  #
35
49
  module Error
36
- # SSRFProxy::HTTP custom errors
50
+ # SSRFProxy::HTTP errors
37
51
  class Error < StandardError; end
38
- exceptions = %w(
39
- NoUrlPlaceholder
40
- InvalidSsrfRequest
41
- InvalidSsrfRequestMethod
42
- InvalidUpstreamProxy
43
- InvalidIpEncoding
44
- InvalidClientRequest
45
- ConnectionTimeout )
52
+ exceptions = %w[NoUrlPlaceholder
53
+ InvalidSsrfRequest
54
+ InvalidSsrfRequestMethod
55
+ InvalidUpstreamProxy
56
+ InvalidIpEncoding
57
+ InvalidClientRequest
58
+ InvalidResponse
59
+ ConnectionFailed
60
+ ConnectionTimeout]
46
61
  exceptions.each { |e| const_set(e, Class.new(Error)) }
47
62
  end
48
63
 
49
64
  #
50
65
  # SSRFProxy::HTTP accepts SSRF connection information,
51
- # and configuration options for request modificaiton
66
+ # and configuration options for request modification
52
67
  # and response modification.
53
68
  #
54
- # @param [String] url SSRF URL with 'xxURLxx' placeholder
55
- # @param [Hash] opts SSRF and HTTP connection options:
56
- # @option opts [String] proxy
57
- # @option opts [String] method
58
- # @option opts [String] post_data
59
- # @option opts [String] rules
60
- # @option opts [String] ip_encoding
61
- # @option opts [Regex] match
62
- # @option opts [String] strip
63
- # @option opts [Boolean] decode_html
64
- # @option opts [Boolean] guess_status
65
- # @option opts [Boolean] guess_mime
66
- # @option opts [Boolean] ask_password
67
- # @option opts [Boolean] forward_cookies
68
- # @option opts [Boolean] body_to_uri
69
- # @option opts [Boolean] auth_to_uri
70
- # @option opts [Boolean] cookies_to_uri
71
- # @option opts [String] cookie
72
- # @option opts [Integer] timeout
73
- # @option opts [String] user_agent
74
- # @option opts [Boolean] insecure
75
- #
76
- # @example SSRF with default options
77
- # SSRFProxy::HTTP.new('http://example.local/index.php?url=xxURLxx')
69
+ # @param url [String] Target URL vulnerable to SSRF
78
70
  #
79
- # @raise [SSRFProxy::HTTP::Error::InvalidSsrfRequest]
80
- # Invalid SSRF request specified.
71
+ # @param file [String] Load HTTP request from a file
81
72
  #
82
- def initialize(url = '', opts = {})
83
- @detect_waf = true
84
- @logger = ::Logger.new(STDOUT).tap do |log|
85
- log.progname = 'ssrf-proxy'
86
- log.level = ::Logger::WARN
87
- log.datetime_format = '%Y-%m-%d %H:%M:%S '
88
- end
89
- begin
90
- @ssrf_url = URI.parse(url.to_s)
91
- rescue URI::InvalidURIError
92
- raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
93
- 'Invalid SSRF request specified.'
94
- end
95
- if @ssrf_url.scheme.nil? || @ssrf_url.host.nil? || @ssrf_url.port.nil?
96
- raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
97
- 'Invalid SSRF request specified.'
98
- end
99
- if @ssrf_url.scheme !~ /\Ahttps?\z/
100
- raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
101
- 'Invalid SSRF request specified. Scheme must be http(s).'
102
- end
103
- parse_options(opts)
104
- end
105
-
73
+ # @param proxy [String] Use a proxy to connect to the server.
74
+ # (Supported proxies: http, https, socks)
75
+ #
76
+ # @param ssl [Boolean] Connect using SSL/TLS
77
+ #
78
+ # @param method [String] HTTP method (GET/HEAD/DELETE/POST/PUT/OPTIONS)
79
+ # (Default: GET)
80
+ #
81
+ # @param post_data [String] HTTP post data
82
+ #
83
+ # @param user [String] HTTP basic authentication credentials
84
+ #
85
+ # @param rules [String] Rules for parsing client request
86
+ # (separated by ',') (Default: none)
87
+ #
88
+ # @param no_urlencode [Boolean] Do not URL encode client request
89
+ #
90
+ # @param ip_encoding [String] Encode client request host IP address.
91
+ # (Modes: int, ipv6, oct, hex, dotted_hex)
92
+ #
93
+ # @param match [String] Regex to match response body content.
94
+ # (Default: \A(.*)\z)
95
+ #
96
+ # @param strip [String] Headers to remove from the response.
97
+ # (separated by ',') (Default: none)
98
+ #
99
+ # @param decode_html [Boolean] Decode HTML entities in response body
100
+ #
101
+ # @param unescape [Boolean] Unescape special characters in response body
102
+ #
103
+ # @param guess_status [Boolean] Replaces response status code and message
104
+ # headers (determined by common strings in the
105
+ # response body, such as 404 Not Found.)
106
+ #
107
+ # @param guess_mime [Boolean] Replaces response content-type header with the
108
+ # appropriate mime type (determined by the file
109
+ # extension of the requested resource.)
110
+ #
111
+ # @param sniff_mime [Boolean] Replaces response content-type header with the
112
+ # appropriate mime type (determined by magic bytes
113
+ # in the response body.)
114
+ #
115
+ # @param timeout_ok [Boolean] Replaces timeout HTTP status code 504 with 200.
116
+ #
117
+ # @param detect_headers [Boolean] Replaces response headers if response headers
118
+ # are identified in the response body.
119
+ #
120
+ # @param fail_no_content [Boolean] Return HTTP status 502 if response body
121
+ # is empty.
122
+ #
123
+ # @param forward_method [Boolean] Forward client request method
124
+ #
125
+ # @param forward_headers [Boolean] Forward all client request headers
126
+ #
127
+ # @param forward_body [Boolean] Forward client request body
128
+ #
129
+ # @param forward_cookies [Boolean] Forward client request cookies
130
+ #
131
+ # @param body_to_uri [Boolean] Add client request body to URI query string
132
+ #
133
+ # @param auth_to_uri [Boolean] Use client request basic authentication
134
+ # credentials in request URI.
135
+ #
136
+ # @param cookies_to_uri [Boolean] Add client request cookies to URI query string
137
+ #
138
+ # @param cache_buster [Boolean] Append a random value to the client request
139
+ # query string
140
+ #
141
+ # @param timeout [Integer] Connection timeout in seconds (Default: 10)
106
142
  #
107
- # Parse initialization configuration options
143
+ # @param user_agent [String] HTTP user-agent (Default: none)
108
144
  #
109
- # @param [Hash] opts Options for SSRF and HTTP connection options
145
+ # @param insecure [Boolean] Skip server SSL certificate validation
146
+ #
147
+ #
148
+ #
149
+ # @example Configure SSRF with URL, GET method
150
+ # SSRFProxy::HTTP.new(url: 'http://example.local/?url=xxURLxx')
151
+ #
152
+ # @example Configure SSRF with URL, POST method
153
+ # SSRFProxy::HTTP.new(url: 'http://example.local/',
154
+ # method: 'POST',
155
+ # post_data: 'url=xxURLxx')
156
+ #
157
+ # @example Configure SSRF with raw HTTP request file
158
+ # SSRFProxy::HTTP.new(file: 'ssrf.txt')
159
+ #
160
+ # @example Configure SSRF with raw HTTP request file and force SSL/TLS
161
+ # SSRFProxy::HTTP.new(file: 'ssrf.txt', ssl: true)
162
+ #
163
+ # @example Configure SSRF with raw HTTP request StringIO
164
+ # SSRFProxy::HTTP.new(file: StringIO.new("GET http://example.local/?url=xxURLxx HTTP/1.1\n\n"))
165
+ #
166
+ # @raise [SSRFProxy::HTTP::Error::InvalidSsrfRequest]
167
+ # Invalid SSRF request specified.
110
168
  #
111
169
  # @raise [SSRFProxy::HTTP::Error::InvalidUpstreamProxy]
112
170
  # Invalid upstream proxy specified.
171
+ #
113
172
  # @raise [SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod]
114
173
  # Invalid SSRF request method specified.
115
- # Method must be GET/HEAD/DELETE/POST/PUT.
174
+ # Supported methods: GET, HEAD, DELETE, POST, PUT, OPTIONS.
175
+ #
116
176
  # @raise [SSRFProxy::HTTP::Error::NoUrlPlaceholder]
117
177
  # 'xxURLxx' URL placeholder must be specified in the
118
178
  # SSRF request URL or body.
179
+ #
119
180
  # @raise [SSRFProxy::HTTP::Error::InvalidIpEncoding]
120
181
  # Invalid IP encoding method specified.
121
182
  #
122
- def parse_options(opts = {})
183
+ def initialize(url: nil,
184
+ file: nil,
185
+ proxy: nil,
186
+ ssl: false,
187
+ method: 'GET',
188
+ placeholder: 'xxURLxx',
189
+ post_data: nil,
190
+ rules: nil,
191
+ no_urlencode: false,
192
+ ip_encoding: nil,
193
+ match: '\A(.*)\z',
194
+ strip: nil,
195
+ decode_html: false,
196
+ unescape: false,
197
+ guess_mime: false,
198
+ sniff_mime: false,
199
+ guess_status: false,
200
+ cors: false,
201
+ timeout_ok: false,
202
+ detect_headers: false,
203
+ fail_no_content: false,
204
+ forward_method: false,
205
+ forward_headers: false,
206
+ forward_body: false,
207
+ forward_cookies: false,
208
+ body_to_uri: false,
209
+ auth_to_uri: false,
210
+ cookies_to_uri: false,
211
+ cache_buster: false,
212
+ cookie: nil,
213
+ user: nil,
214
+ timeout: 10,
215
+ user_agent: nil,
216
+ insecure: false)
217
+
218
+ @SUPPORTED_METHODS = %w[GET HEAD DELETE POST PUT OPTIONS].freeze
219
+ @SUPPORTED_IP_ENCODINGS = %w[int ipv6 oct hex dotted_hex].freeze
220
+
221
+ @logger = ::Logger.new(STDOUT).tap do |log|
222
+ log.progname = 'ssrf-proxy'
223
+ log.level = ::Logger::WARN
224
+ log.datetime_format = '%Y-%m-%d %H:%M:%S '
225
+ end
226
+
123
227
  # SSRF configuration options
124
- @upstream_proxy = nil
228
+ @proxy = nil
229
+ @placeholder = placeholder.to_s || 'xxURLxx'
125
230
  @method = 'GET'
126
- @post_data = ''
127
- @rules = []
128
- opts.each do |option, value|
129
- next if value.eql?('')
130
- case option
131
- when 'proxy'
132
- begin
133
- @upstream_proxy = URI.parse(value)
134
- rescue URI::InvalidURIError
135
- raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
136
- 'Invalid upstream proxy specified.'
137
- end
138
- if @upstream_proxy.host.nil? || @upstream_proxy.port.nil?
139
- raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
140
- 'Invalid upstream proxy specified.'
141
- end
142
- if @upstream_proxy.scheme !~ /\A(socks|https?)\z/
143
- raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
144
- 'Unsupported upstream proxy specified. Scheme must be http(s) or socks.'
145
- end
146
- when 'method'
147
- case value.to_s.downcase
148
- when 'get'
149
- @method = 'GET'
150
- when 'head'
151
- @method = 'HEAD'
152
- when 'delete'
153
- @method = 'DELETE'
154
- when 'post'
155
- @method = 'POST'
156
- when 'put'
157
- @method = 'PUT'
231
+ @headers ||= {}
232
+ @post_data = post_data.to_s || ''
233
+ @rules = rules.to_s.split(/,/) || []
234
+ @no_urlencode = no_urlencode || false
235
+
236
+ # client request modification
237
+ @ip_encoding = nil
238
+ @forward_method = forward_method || false
239
+ @forward_headers = forward_headers || false
240
+ @forward_body = forward_body || false
241
+ @forward_cookies = forward_cookies || false
242
+ @body_to_uri = body_to_uri || false
243
+ @auth_to_uri = auth_to_uri || false
244
+ @cookies_to_uri = cookies_to_uri || false
245
+ @cache_buster = cache_buster || false
246
+
247
+ # SSRF connection options
248
+ @user = ''
249
+ @pass = ''
250
+ @timeout = timeout.to_i || 10
251
+ @insecure = insecure || false
252
+
253
+ # HTTP response modification options
254
+ @match_regex = match.to_s || '\A(.*)\z'
255
+ @strip = strip.to_s.downcase.split(/,/) || []
256
+ @decode_html = decode_html || false
257
+ @unescape = unescape || false
258
+ @guess_status = guess_status || false
259
+ @guess_mime = guess_mime || false
260
+ @sniff_mime = sniff_mime || false
261
+ @detect_headers = detect_headers || false
262
+ @fail_no_content = fail_no_content || false
263
+ @timeout_ok = timeout_ok || false
264
+ @cors = cors || false
265
+
266
+ # ensure either a URL or file path was provided
267
+ if url.to_s.eql?('') && file.to_s.eql?('')
268
+ raise ArgumentError,
269
+ "Option 'url' or 'file' must be provided."
270
+ end
271
+
272
+ # parse HTTP request file
273
+ unless file.to_s.eql?('')
274
+ unless url.to_s.eql?('')
275
+ raise ArgumentError,
276
+ "Options 'url' and 'file' are mutually exclusive."
277
+ end
278
+
279
+ if file.is_a?(String)
280
+ if File.exist?(file) && File.readable?(file)
281
+ http = File.read(file).to_s
158
282
  else
159
- raise SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod.new,
160
- 'Invalid SSRF request method specified. Method must be GET/HEAD/DELETE/POST/PUT.'
283
+ raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
284
+ "Invalid SSRF request specified : Could not read file #{file.inspect}"
161
285
  end
162
- when 'post_data'
163
- @post_data = value.to_s
164
- when 'rules'
165
- @rules = value.to_s.split(/,/)
286
+ elsif file.is_a?(StringIO)
287
+ http = file.read
288
+ end
289
+
290
+ req = parse_http_request(http)
291
+ url = req['uri']
292
+ @method = req['method']
293
+ @headers = {}
294
+ req['headers'].each do |k, v|
295
+ @headers[k.downcase] = v.flatten.first
166
296
  end
297
+ @headers.delete('host')
298
+ @post_data = req['body']
167
299
  end
168
- if @ssrf_url.request_uri !~ /xxURLxx/ && @post_data.to_s !~ /xxURLxx/
169
- raise SSRFProxy::HTTP::Error::NoUrlPlaceholder.new,
170
- "You must specify a URL placeholder with 'xxURLxx' in the SSRF request"
300
+
301
+ # parse target URL
302
+ begin
303
+ @url = URI.parse(url.to_s)
304
+ rescue URI::InvalidURIError
305
+ raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
306
+ 'Invalid SSRF request specified : Could not parse URL.'
171
307
  end
172
308
 
173
- # client request modification
174
- @ip_encoding = nil
175
- @forward_cookies = false
176
- @body_to_uri = false
177
- @auth_to_uri = false
178
- @cookies_to_uri = false
179
- opts.each do |option, value|
180
- next if value.eql?('')
181
- case option
182
- when 'ip_encoding'
183
- if value.to_s !~ /\A[a-z0-9_]+\z/i
184
- raise SSRFProxy::HTTP::Error::InvalidIpEncoding.new,
185
- 'Invalid IP encoding method specified.'
186
- end
187
- @ip_encoding = value.to_s
188
- when 'forward_cookies'
189
- @forward_cookies = true if value
190
- when 'body_to_uri'
191
- @body_to_uri = true if value
192
- when 'auth_to_uri'
193
- @auth_to_uri = true if value
194
- when 'cookies_to_uri'
195
- @cookies_to_uri = true if value
309
+ if @url.scheme.nil? || @url.host.nil? || @url.port.nil?
310
+ raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
311
+ 'Invalid SSRF request specified : Invalid URL.'
312
+ end
313
+
314
+ unless @url.scheme.eql?('http') || @url.scheme.eql?('https')
315
+ raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new,
316
+ 'Invalid SSRF request specified : URL scheme must be http(s).'
317
+ end
318
+
319
+ if proxy
320
+ begin
321
+ @proxy = URI.parse(proxy.to_s)
322
+ rescue URI::InvalidURIError
323
+ raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
324
+ 'Invalid upstream proxy specified.'
325
+ end
326
+ if @proxy.host.nil? || @proxy.port.nil?
327
+ raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
328
+ 'Invalid upstream proxy specified.'
329
+ end
330
+ if @proxy.scheme !~ /\A(socks|https?)\z/
331
+ raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
332
+ 'Unsupported upstream proxy specified. ' \
333
+ 'Scheme must be http(s) or socks.'
196
334
  end
197
335
  end
198
336
 
199
- # SSRF connection options
200
- @cookie = nil
201
- @timeout = 10
202
- @user_agent = 'Mozilla/5.0'
203
- @insecure = false
204
- opts.each do |option, value|
205
- next if value.eql?('')
206
- case option
207
- when 'cookie'
208
- @cookie = value.to_s
209
- when 'timeout'
210
- @timeout = value.to_i
211
- when 'user_agent'
212
- @user_agent = value.to_s
213
- when 'insecure'
214
- @insecure = true if value
337
+ if ssl
338
+ @url.scheme = 'https'
339
+ end
340
+
341
+ if method
342
+ case method.to_s.downcase
343
+ when 'get'
344
+ @method = 'GET'
345
+ when 'head'
346
+ @method = 'HEAD'
347
+ when 'delete'
348
+ @method = 'DELETE'
349
+ when 'post'
350
+ @method = 'POST'
351
+ when 'put'
352
+ @method = 'PUT'
353
+ when 'options'
354
+ @method = 'OPTIONS'
355
+ else
356
+ raise SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod.new,
357
+ 'Invalid SSRF request method specified. ' \
358
+ "Supported methods: #{@SUPPORTED_METHODS.join(', ')}."
215
359
  end
216
360
  end
217
361
 
218
- # HTTP response modification options
219
- @match_regex = '\\A(.*)\\z'
220
- @strip = []
221
- @decode_html = false
222
- @guess_status = false
223
- @guess_mime = false
224
- @ask_password = false
225
- opts.each do |option, value|
226
- next if value.eql?('')
227
- case option
228
- when 'match'
229
- @match_regex = value.to_s
230
- when 'strip'
231
- @strip = value.to_s.split(/,/)
232
- when 'decode_html'
233
- @decode_html = true if value
234
- when 'guess_status'
235
- @guess_status = true if value
236
- when 'guess_mime'
237
- @guess_mime = true if value
238
- when 'ask_password'
239
- @ask_password = true if value
362
+ if ip_encoding
363
+ unless @SUPPORTED_IP_ENCODINGS.include?(ip_encoding)
364
+ raise SSRFProxy::HTTP::Error::InvalidIpEncoding.new,
365
+ 'Invalid IP encoding method specified.'
240
366
  end
367
+ @ip_encoding = ip_encoding.to_s
241
368
  end
242
- end
243
369
 
244
- #
245
- # Logger accessor
246
- #
247
- # @return [Logger] class logger object
248
- #
249
- def logger
250
- @logger
251
- end
370
+ if cookie
371
+ @headers['cookie'] = cookie.to_s
372
+ end
252
373
 
253
- #
254
- # URL accessor
255
- #
256
- # @return [String] SSRF URL
257
- #
258
- def url
259
- @ssrf_url
374
+ if user
375
+ if user.to_s =~ /^(.*?):(.*)/
376
+ @user = $1
377
+ @pass = $2
378
+ else
379
+ @user = user.to_s
380
+ end
381
+ end
382
+
383
+ if user_agent
384
+ @headers['user-agent'] = user_agent
385
+ end
386
+
387
+ # Ensure a URL placeholder was provided
388
+ unless @url.request_uri.to_s.include?(@placeholder) ||
389
+ @post_data.to_s.include?(@placeholder) ||
390
+ @headers.to_s.include?(@placeholder)
391
+ raise SSRFProxy::HTTP::Error::NoUrlPlaceholder.new,
392
+ 'You must specify a URL placeholder with ' \
393
+ "'#{@placeholder}' in the SSRF request"
394
+ end
260
395
  end
261
396
 
262
397
  #
263
- # Host accessor
264
- #
265
- # @return [String] SSRF host
398
+ # Parse a raw HTTP request as a string
266
399
  #
267
- def host
268
- @ssrf_url.host
269
- end
270
-
400
+ # @param [String] request raw HTTP request
271
401
  #
272
- # Port accessor
402
+ # @raise [SSRFProxy::HTTP::Error::InvalidClientRequest]
403
+ # An invalid client HTTP request was supplied.
273
404
  #
274
- # @return [String] SSRF host port
405
+ # @return [Hash] HTTP request hash (url, method, headers, body)
275
406
  #
276
- def port
277
- @ssrf_url.port
407
+ def parse_http_request(request)
408
+ # parse method
409
+ if request.to_s !~ /\A(GET|HEAD|DELETE|POST|PUT|OPTIONS) /
410
+ logger.warn('HTTP request method is not supported')
411
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
412
+ 'HTTP request method is not supported.'
413
+ end
414
+
415
+ # parse client request
416
+ begin
417
+ req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
418
+ req.parse(StringIO.new(request))
419
+ rescue
420
+ logger.warn('HTTP request is malformed.')
421
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
422
+ 'HTTP request is malformed.'
423
+ end
424
+
425
+ # validate host
426
+ if request.to_s !~ %r{\A(GET|HEAD|DELETE|POST|PUT|OPTIONS) https?://}
427
+ if request.to_s =~ /^Host: ([^\s]+)\r?\n/
428
+ logger.info("Using host header: #{$1}")
429
+ else
430
+ logger.warn('HTTP request is malformed : No host specified.')
431
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
432
+ 'HTTP request is malformed : No host specified.'
433
+ end
434
+ end
435
+
436
+ # return request hash
437
+ uri = req.request_uri
438
+ method = req.request_method
439
+ headers = req.header
440
+ begin
441
+ body = req.body.to_s
442
+ rescue WEBrick::HTTPStatus::BadRequest => e
443
+ logger.warn("HTTP request is malformed : #{e.message}")
444
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
445
+ "HTTP request is malformed : #{e.message}"
446
+ rescue WEBrick::HTTPStatus::LengthRequired
447
+ logger.warn("HTTP request is malformed : Request body without 'Content-Length' header.")
448
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
449
+ "HTTP request is malformed : Request body without 'Content-Length' header."
450
+ end
451
+
452
+ { 'uri' => uri,
453
+ 'method' => method,
454
+ 'headers' => headers,
455
+ 'body' => body }
278
456
  end
279
457
 
280
458
  #
281
- # Upstream proxy accessor
459
+ # Parse a raw HTTP request as a string,
460
+ # then send the requested URL and HTTP headers to #send_uri
282
461
  #
283
- # @return [URI] upstream HTTP proxy
462
+ # @param request [String] Raw HTTP request
463
+ # @param use_ssl [Boolean] Connect using SSL/TLS
284
464
  #
285
- def proxy
286
- @upstream_proxy
465
+ # @return [Hash] HTTP response hash (version, code, message, headers, body)
466
+ #
467
+ def send_request(request, use_ssl: false)
468
+ req = parse_http_request(request)
469
+ req['uri'].scheme = 'https' if use_ssl
470
+ send_uri(req['uri'],
471
+ method: req['method'],
472
+ headers: req['headers'],
473
+ body: req['body'])
287
474
  end
288
475
 
289
476
  #
290
- # Parse a HTTP request as a string, then send the requested URL
291
- # and HTTP headers to send_uri
477
+ # Fetch a URI via SSRF
292
478
  #
293
- # @param [String] request raw HTTP request
479
+ # @param [String] uri URI to fetch
480
+ # @param [String] method HTTP request method
481
+ # @param [Hash] headers HTTP request headers
482
+ # @param [String] body HTTP request body
294
483
  #
295
484
  # @raise [SSRFProxy::HTTP::Error::InvalidClientRequest]
296
485
  # An invalid client HTTP request was supplied.
297
486
  #
298
487
  # @return [Hash] HTTP response hash (version, code, message, headers, body, etc)
299
488
  #
300
- def send_request(request)
301
- if request.to_s !~ /\A(GET|HEAD|DELETE|POST|PUT) /
302
- logger.warn("Client request method is not supported")
489
+ def send_uri(uri, method: 'GET', headers: {}, body: '')
490
+ uri = uri.to_s
491
+ body = body.to_s
492
+ headers = {} unless headers.is_a?(Hash)
493
+
494
+ # validate url
495
+ unless uri.start_with?('http://', 'https://')
303
496
  raise SSRFProxy::HTTP::Error::InvalidClientRequest,
304
- 'Client request method is not supported'
497
+ 'Invalid request URI'
305
498
  end
306
- if request.to_s !~ %r{\A(GET|HEAD|DELETE|POST|PUT) https?://}
307
- if request.to_s =~ /^Host: ([^\s]+)\r?\n/
308
- logger.info("Using host header: #{$1}")
499
+
500
+ # set request method
501
+ if @forward_method
502
+ if @SUPPORTED_METHODS.include?(method)
503
+ request_method = method
309
504
  else
310
- logger.warn('No host specified')
311
505
  raise SSRFProxy::HTTP::Error::InvalidClientRequest,
312
- 'No host specified'
506
+ "Request method '#{method}' is not supported"
313
507
  end
508
+ else
509
+ request_method = @method
314
510
  end
315
- # parse client request
316
- begin
317
- req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
318
- req.parse(StringIO.new(request))
319
- rescue
320
- logger.info('Received malformed client HTTP request.')
321
- raise SSRFProxy::HTTP::Error::InvalidClientRequest,
322
- 'Received malformed client HTTP request.'
511
+
512
+ # parse request headers
513
+ client_headers = {}
514
+ headers.each do |k, v|
515
+ if v.is_a?(Array)
516
+ client_headers[k.downcase] = v.flatten.first
517
+ elsif v.is_a?(String)
518
+ client_headers[k.downcase] = v.to_s
519
+ else
520
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
521
+ "Request header #{k.inspect} value is malformed: #{v}"
522
+ end
323
523
  end
324
- if req.to_s =~ /^Upgrade: WebSocket/
325
- logger.warn("WebSocket tunneling is not supported: #{req.host}:#{req.port}")
524
+
525
+ # reject websocket requests
526
+ if client_headers['upgrade'].to_s.start_with?('WebSocket')
527
+ logger.warn('WebSocket tunneling is not supported')
326
528
  raise SSRFProxy::HTTP::Error::InvalidClientRequest,
327
- "WebSocket tunneling is not supported: #{req.host}:#{req.port}"
529
+ 'WebSocket tunneling is not supported'
328
530
  end
329
- uri = req.request_uri
330
- if uri.nil?
331
- raise SSRFProxy::HTTP::Error::InvalidClientRequest,
332
- 'URI is nil'
531
+
532
+ # copy request body to URL
533
+ if @body_to_uri && !body.eql?('')
534
+ logger.debug("Parsing request body: #{body}")
535
+ separator = uri.include?('?') ? '&' : '?'
536
+ uri = "#{uri}#{separator}#{body}"
537
+ logger.info("Added request body to URI: #{body.inspect}")
333
538
  end
334
539
 
335
- # parse request body and move to uri
336
- if @body_to_uri && !req.body.nil?
337
- logger.debug("Parsing request body: #{req.body}")
540
+ # copy basic authentication credentials to uri
541
+ if @auth_to_uri && client_headers['authorization'].to_s.downcase.start_with?('basic ')
542
+ logger.debug("Parsing basic authentication header: #{client_headers['authorization']}")
338
543
  begin
339
- if req.query_string.nil?
340
- uri = "#{uri}?#{req.body}"
341
- else
342
- uri = "#{uri}&#{req.body}"
343
- end
344
- logger.info("Added request body to URI: #{req.body}")
544
+ creds = client_headers['authorization'].split(' ')[1]
545
+ user = Base64.decode64(creds).chomp
546
+ uri = uri.gsub!(%r{://}, "://#{CGI.escape(user).gsub(/\+/, '%20').gsub('%3A', ':')}@")
547
+ logger.info("Using basic authentication credentials: #{user}")
345
548
  rescue
346
- logger.warn('Could not parse client request body')
347
- end
348
- end
349
-
350
- # move basic authentication credentials to uri
351
- if @auth_to_uri && !req.header.nil?
352
- req.header['authorization'].each do |header|
353
- logger.debug("Parsing basic authentication header: #{header}")
354
- next unless header.split(' ').first =~ /^basic$/i
355
- begin
356
- creds = header.split(' ')[1]
357
- user = Base64.decode64(creds).chomp
358
- uri = uri.to_s.gsub!(%r{:(//)}, "://#{user}@")
359
- logger.info("Using basic authentication credentials: #{user}")
360
- rescue
361
- logger.warn "Could not parse request authorization header: #{header}"
362
- end
363
- break
549
+ logger.warn('Could not parse request authorization header: ' \
550
+ "#{client_headers['authorization']}")
364
551
  end
365
552
  end
366
553
 
367
554
  # copy cookies to uri
368
555
  cookies = []
369
- if @cookies_to_uri && !req.cookies.nil? && !req.cookies.empty?
370
- logger.debug("Parsing request cookies: #{req.cookies.join('; ')}")
371
- cookies = []
372
- begin
373
- req.cookies.each do |c|
374
- cookies << c.to_s.gsub(/;\z/, '').to_s unless c.nil?
375
- end
376
- query_string = uri.to_s.split('?')[1..-1]
377
- if query_string.empty?
378
- s = '?'
379
- else
380
- s = '&'
381
- end
382
- uri = "#{uri}#{s}#{cookies.join('&')}"
383
- logger.info("Added cookies to URI: #{cookies.join('&')}")
384
- rescue => e
385
- logger.warn "Could not parse request coookies: #{e}"
556
+ if @cookies_to_uri && !client_headers['cookie'].nil?
557
+ logger.debug("Parsing request cookies: #{client_headers['cookie']}")
558
+ client_headers['cookie'].split(/;\s*/).each do |c|
559
+ cookies << c.to_s unless c.nil?
386
560
  end
561
+ separator = uri.include?('?') ? '&' : '?'
562
+ uri = "#{uri}#{separator}#{cookies.join('&')}"
563
+ logger.info("Added cookies to URI: #{cookies.join('&')}")
387
564
  end
388
565
 
389
- # HTTP request headers
390
- headers = {}
566
+ # add cache buster
567
+ if @cache_buster
568
+ separator = uri.include?('?') ? '&' : '?'
569
+ junk = "#{rand(36**6).to_s(36)}=#{rand(36**6).to_s(36)}"
570
+ uri = "#{uri}#{separator}#{junk}"
571
+ end
572
+
573
+ # set request headers
574
+ request_headers = @headers.dup
391
575
 
392
- # forward client cookies
576
+ # forward request cookies
393
577
  new_cookie = []
394
- new_cookie << @cookie unless @cookie.nil?
395
- if @forward_cookies
396
- req.cookies.each do |c|
397
- new_cookie << c.to_s
578
+ new_cookie << @headers['cookie'] unless @headers['cookie'].to_s.eql?('')
579
+ if @forward_cookies && !client_headers['cookie'].nil?
580
+ client_headers['cookie'].split(/;\s*/).each do |c|
581
+ new_cookie << c.to_s unless c.nil?
398
582
  end
399
583
  end
400
584
  unless new_cookie.empty?
401
- headers['cookie'] = new_cookie.uniq.join('; ').to_s
402
- logger.info("Using cookie: #{headers['cookie']}")
585
+ request_headers['cookie'] = new_cookie.uniq.join('; ')
586
+ logger.info("Using cookie: #{new_cookie.join('; ')}")
403
587
  end
404
- send_uri(uri, headers)
405
- end
406
588
 
407
- #
408
- # Fetch a URI via SSRF
409
- #
410
- # @param [String] uri URI to fetch
411
- # @param [Hash] HTTP request headers
412
- #
413
- # @raise [SSRFProxy::HTTP::Error::InvalidClientRequest]
414
- # An invalid client HTTP request was supplied.
415
- #
416
- # @return [Hash] HTTP response hash (version, code, message, headers, body, etc)
417
- #
418
- def send_uri(uri, headers = {})
419
- if uri.nil?
420
- raise SSRFProxy::HTTP::Error::InvalidClientRequest,
421
- 'Request URI is nil'
589
+ # forward request headers and strip proxy headers
590
+ if @forward_headers && !client_headers.empty?
591
+ client_headers.each do |k, v|
592
+ next if k.eql?('proxy-connection')
593
+ next if k.eql?('proxy-authorization')
594
+ if v.is_a?(Array)
595
+ request_headers[k.downcase] = v.flatten.first
596
+ elsif v.is_a?(String)
597
+ request_headers[k.downcase] = v.to_s
598
+ end
599
+ end
422
600
  end
423
601
 
424
602
  # encode target host ip
425
- if @ip_encoding
426
- encoded_uri = encode_ip(uri, @ip_encoding)
603
+ ip_encoded_uri = @ip_encoding ? encode_ip(uri, @ip_encoding) : uri
604
+
605
+ # run request URI through rules
606
+ target_uri = run_rules(ip_encoded_uri, @rules).to_s
607
+
608
+ # URL encode target URI
609
+ unless @no_urlencode
610
+ target_uri = CGI.escape(target_uri).gsub(/\+/, '%20').to_s
611
+ end
612
+
613
+ # set path and query string
614
+ if @url.query.to_s.eql?('')
615
+ ssrf_url = @url.path.to_s
427
616
  else
428
- encoded_uri = uri
617
+ ssrf_url = "#{@url.path}?#{@url.query}"
429
618
  end
430
619
 
431
- # run target url through rules
432
- target_uri = run_rules(encoded_uri, @rules)
620
+ # replace xxURLxx placeholder in request URL
621
+ ssrf_url.gsub!(/#{@placeholder}/, target_uri)
433
622
 
434
- # replace xxURLxx placeholder in SSRF request URL
435
- ssrf_url = "#{@ssrf_url.path}?#{@ssrf_url.query}".gsub(/xxURLxx/, target_uri.to_s)
623
+ # replace xxURLxx placeholder in request body
624
+ post_data = @post_data.gsub(/#{@placeholder}/, target_uri)
436
625
 
437
- # replace xxURLxx placeholder in SSRF request body
438
- if @post_data.nil?
439
- body = ''
626
+ # set request body
627
+ if @forward_body && !body.eql?('')
628
+ request_body = post_data.eql?('') ? body : "#{post_data}&#{body}"
440
629
  else
441
- body = @post_data.gsub(/xxURLxx/, target_uri.to_s)
630
+ request_body = post_data
442
631
  end
443
632
 
444
- # set user agent
445
- headers['User-Agent'] = @user_agent if headers['User-Agent'].nil?
633
+ # replace xxURLxx in request header values
634
+ request_headers.each do |k, v|
635
+ request_headers[k] = v.gsub(/#{@placeholder}/, target_uri)
636
+ end
446
637
 
447
638
  # set content type
448
- if headers['Content-Type'].nil? && @method.eql?('POST')
449
- headers['Content-Type'] = 'application/x-www-form-urlencoded'
639
+ if request_headers['content-type'].nil? && !request_body.eql?('')
640
+ request_headers['content-type'] = 'application/x-www-form-urlencoded'
450
641
  end
451
642
 
643
+ # set content length
644
+ request_headers['content-length'] = request_body.length.to_s
645
+
452
646
  # send request
647
+ response = nil
453
648
  start_time = Time.now
454
- response = send_http_request(ssrf_url, @method, headers, body)
649
+ begin
650
+ response = send_http_request(ssrf_url,
651
+ request_method,
652
+ request_headers,
653
+ request_body)
654
+ if response['content-encoding'].to_s.downcase.eql?('gzip') && response.body
655
+ begin
656
+ sio = StringIO.new(response.body)
657
+ gz = Zlib::GzipReader.new(sio)
658
+ response.body = gz.read
659
+ rescue
660
+ logger.warn('Could not decompress response body')
661
+ end
662
+ end
663
+
664
+ result = { 'url' => uri,
665
+ 'http_version' => response.http_version,
666
+ 'code' => response.code,
667
+ 'message' => response.message,
668
+ 'headers' => '',
669
+ 'body' => response.body.to_s || '' }
670
+ rescue SSRFProxy::HTTP::Error::ConnectionTimeout => e
671
+ unless @timeout_ok
672
+ raise SSRFProxy::HTTP::Error::ConnectionTimeout, e.message
673
+ end
674
+ result = { 'url' => uri,
675
+ 'http_version' => '1.0',
676
+ 'code' => 200,
677
+ 'message' => 'Timeout',
678
+ 'headers' => '',
679
+ 'body' => '' }
680
+ logger.info('Changed HTTP status code 504 to 200')
681
+ end
682
+
683
+ # set duration
455
684
  end_time = Time.now
456
685
  duration = ((end_time - start_time) * 1000).round(3)
457
- result = {
458
- 'url' => uri,
459
- 'duration' => duration,
460
- 'http_version' => response.http_version,
461
- 'code' => response.code,
462
- 'message' => response.message,
463
- 'headers' => '',
464
- 'body' => response.body.to_s || '' }
465
- logger.info("Received #{result['body'].length} bytes in #{duration} ms")
686
+ result['duration'] = duration
687
+
688
+ # body content encoding
689
+ result['body'].force_encoding('BINARY')
690
+ unless result['body'].valid_encoding?
691
+ begin
692
+ result['body'] = result['body'].encode(
693
+ 'UTF-8',
694
+ 'binary',
695
+ :invalid => :replace,
696
+ :undef => :replace,
697
+ :replace => ''
698
+ )
699
+ rescue
700
+ end
701
+ end
702
+
703
+ logger.info("Received #{result['body'].bytes.length} bytes in #{duration} ms")
704
+
705
+ # match response content
706
+ unless @match_regex.nil?
707
+ matches = result['body'].scan(/#{@match_regex}/m)
708
+ if !matches.empty?
709
+ result['body'] = matches.flatten.first.to_s
710
+ logger.info("Response body matches pattern '#{@match_regex}'")
711
+ else
712
+ result['body'] = ''
713
+ logger.warn("Response body does not match pattern '#{@match_regex}'")
714
+ end
715
+ end
716
+
717
+ # return 502 if matched response body is empty
718
+ if @fail_no_content
719
+ if result['body'].to_s.eql?('')
720
+ result['code'] = 502
721
+ result['message'] = 'Bad Gateway'
722
+ result['status_line'] = "HTTP/#{result['http_version']} #{result['code']} #{result['message']}"
723
+ return result
724
+ end
725
+ end
726
+
727
+ # unescape response body
728
+ if @unescape
729
+ # unescape slashes
730
+ result['body'] = result['body'].tr('\\', '\\')
731
+ result['body'] = result['body'].gsub('\\/', '/')
732
+ # unescape whitespace
733
+ result['body'] = result['body'].gsub('\r', "\r")
734
+ result['body'] = result['body'].gsub('\n', "\n")
735
+ result['body'] = result['body'].gsub('\t', "\t")
736
+ # unescape quotes
737
+ result['body'] = result['body'].gsub('\"', '"')
738
+ result['body'] = result['body'].gsub("\\'", "'")
739
+ end
740
+
741
+ # decode HTML entities
742
+ if @decode_html
743
+ result['body'] = HTMLEntities.new.decode(result['body'])
744
+ end
745
+
746
+ # set title
747
+ result['title'] = result['body'][0..8192] =~ %r{<title>([^<]*)</title>}im ? $1.to_s : ''
466
748
 
467
749
  # guess HTTP response code and message
468
750
  if @guess_status
@@ -474,24 +756,72 @@ module SSRFProxy
474
756
  logger.info("Using HTTP response status: #{result['code']} #{result['message']}")
475
757
  end
476
758
  end
759
+
760
+ # replace timeout response with 200 OK
761
+ if @timeout_ok
762
+ if result['code'].eql?('504')
763
+ logger.info('Changed HTTP status code 504 to 200')
764
+ result['code'] = 200
765
+ end
766
+ end
767
+
768
+ # detect headers in response body
769
+ if @detect_headers
770
+ headers = ''
771
+ head = result['body'][0..8192] # use first 8192 byes
772
+ detected_headers = head.scan(%r{HTTP/(1\.\d) (\d+) (.*?)\r?\n(.*?)\r?\n\r?\n}m)
773
+
774
+ if detected_headers.empty?
775
+ logger.info('Found no HTTP response headers in response body.')
776
+ else
777
+ # HTTP redirects may contain more than one set of HTTP response headers
778
+ # Use the last
779
+ logger.info("Found #{detected_headers.count} sets of HTTP response headers in reponse. Using last.")
780
+ version = detected_headers.last[0]
781
+ code = detected_headers.last[1]
782
+ message = detected_headers.last[2]
783
+ detected_headers.last[3].split(/\r?\n/).each do |line|
784
+ if line =~ /^[A-Za-z0-9\-_\.]+: /
785
+ k = line.split(': ').first
786
+ v = line.split(': ')[1..-1].flatten.first
787
+ headers << "#{k}: #{v}\n"
788
+ else
789
+ logger.warn('Could not use response headers in response body : Headers are malformed.')
790
+ headers = ''
791
+ break
792
+ end
793
+ end
794
+ end
795
+ unless headers.eql?('')
796
+ result['http_version'] = version
797
+ result['code'] = code.to_i
798
+ result['message'] = message
799
+ result['headers'] = headers
800
+ result['body'] = result['body'].split(/\r?\n\r?\n/)[detected_headers.count..-1].flatten.join("\n\n")
801
+ end
802
+ end
803
+
804
+ # set status line
477
805
  result['status_line'] = "HTTP/#{result['http_version']} #{result['code']} #{result['message']}"
478
806
 
479
807
  # strip unwanted HTTP response headers
480
- response.each_header do |header_name, header_value|
481
- if @strip.include?(header_name.downcase)
482
- logger.info("Removed response header: #{header_name}")
483
- next
808
+ unless response.nil?
809
+ response.each_header do |header_name, header_value|
810
+ if header_name.downcase.eql?('content-encoding')
811
+ next if header_value.downcase.eql?('gzip')
812
+ end
813
+
814
+ if @strip.include?(header_name.downcase)
815
+ logger.info("Removed response header: #{header_name}")
816
+ next
817
+ end
818
+ result['headers'] << "#{header_name}: #{header_value}\n"
484
819
  end
485
- result['headers'] << "#{header_name}: #{header_value}\n"
486
820
  end
487
821
 
488
- # detect WAF and SSRF protection libraries
489
- if @detect_waf
490
- head = result['body'][0..8192]
491
- # SafeCurl (safe_curl) InvalidURLException
492
- if head =~ /fin1te\\SafeCurl\\Exception\\InvalidURLException/
493
- logger.info('SafeCurl protection mechanism appears to be in use')
494
- end
822
+ # add wildcard CORS header
823
+ if @cors
824
+ result['headers'] << "Access-Control-Allow-Origin: *\n"
495
825
  end
496
826
 
497
827
  # advise client to close HTTP connection
@@ -502,43 +832,30 @@ module SSRFProxy
502
832
  end
503
833
 
504
834
  # guess mime type and add content-type header
505
- if @guess_mime
506
- content_type = guess_mime(File.extname(uri.to_s.split('?').first))
507
- unless content_type.nil?
508
- logger.info("Using content-type: #{content_type}")
509
- if result['headers'] =~ /^content\-type:.*$/i
510
- result['headers'].gsub!(/^content\-type:.*$/i, "Content-Type: #{content_type}")
511
- else
512
- result['headers'] << "Content-Type: #{content_type}\n"
513
- end
835
+ content_type = nil
836
+ if @sniff_mime
837
+ head = result['body'][0..8192] # use first 8192 byes
838
+ content_type = sniff_mime(head)
839
+ if content_type.nil?
840
+ content_type = guess_mime(File.extname(uri.to_s.split('?').first))
514
841
  end
842
+ elsif @guess_mime
843
+ content_type = guess_mime(File.extname(uri.to_s.split('?').first))
515
844
  end
516
845
 
517
- # match response content
518
- unless @match_regex.nil?
519
- matches = result['body'].scan(/#{@match_regex}/m)
520
- if matches.length > 0
521
- result['body'] = matches.flatten.first.to_s
522
- logger.info("Response body matches pattern '#{@match_regex}'")
846
+ unless content_type.nil?
847
+ logger.info("Using content-type: #{content_type}")
848
+ if result['headers'] =~ /^content\-type:.*$/i
849
+ result['headers'].gsub!(/^content\-type:.*$/i,
850
+ "Content-Type: #{content_type}")
523
851
  else
524
- result['body'] = ''
525
- logger.warn("Response body does not match pattern '#{@match_regex}'")
852
+ result['headers'] << "Content-Type: #{content_type}\n"
526
853
  end
527
854
  end
528
855
 
529
- # decode HTML entities
530
- if @decode_html
531
- result['body'] = HTMLEntities.new.decode(
532
- result['body'].encode(
533
- 'UTF-8',
534
- :invalid => :replace,
535
- :undef => :replace,
536
- :replace => '?'))
537
- end
538
-
539
- # prompt for password
540
- if @ask_password
541
- if result['code'].to_i == 401
856
+ # prompt for password if unauthorised
857
+ if result['code'] == 401
858
+ if result['headers'] !~ /^WWW-Authenticate:.*$/i
542
859
  auth_uri = URI.parse(uri.to_s.split('?').first)
543
860
  realm = "#{auth_uri.host}:#{auth_uri.port}"
544
861
  result['headers'] << "WWW-Authenticate: Basic realm=\"#{realm}\"\n"
@@ -546,25 +863,39 @@ module SSRFProxy
546
863
  end
547
864
  end
548
865
 
866
+ # set location header if redirected
867
+ if result['code'] == 301 || result['code'] == 302
868
+ if result['headers'] !~ /^location:.*$/i
869
+ location = nil
870
+ if result['body'] =~ /This document may be found <a href="(.+)">/i
871
+ location = $1
872
+ elsif result['body'] =~ /The document has moved <a href="(.+)">/i
873
+ location = $1
874
+ end
875
+ unless location.nil?
876
+ result['headers'] << "Location: #{location}\n"
877
+ logger.info("Added Location header: #{location}")
878
+ end
879
+ end
880
+ end
881
+
549
882
  # set content length
550
883
  content_length = result['body'].length
551
884
  if result['headers'] =~ /^transfer\-encoding:.*$/i
552
- result['headers'].gsub!(/^transfer\-encoding:.*$/i, "Content-Length: #{content_length}")
885
+ result['headers'].gsub!(/^transfer\-encoding:.*$/i,
886
+ "Content-Length: #{content_length}")
553
887
  elsif result['headers'] =~ /^content\-length:.*$/i
554
- result['headers'].gsub!(/^content\-length:.*$/i, "Content-Length: #{content_length}")
888
+ result['headers'].gsub!(/^content\-length:.*$/i,
889
+ "Content-Length: #{content_length}")
555
890
  else
556
891
  result['headers'] << "Content-Length: #{content_length}\n"
557
892
  end
558
893
 
559
- # set title
560
- if result['body'][0..1024] =~ %r{<title>([^<]*)<\/title>}im
561
- result['title'] = $1.to_s
562
- else
563
- result['title'] = ''
564
- end
565
-
566
894
  # return HTTP response
567
- logger.debug("Response:\n#{result['status_line']}\n#{result['headers']}\n#{result['body']}")
895
+ logger.debug("Response:\n" \
896
+ "#{result['status_line']}\n" \
897
+ "#{result['headers']}\n" \
898
+ "#{result['body']}")
568
899
  result
569
900
  end
570
901
 
@@ -584,7 +915,7 @@ module SSRFProxy
584
915
  ip = IPAddress::IPv4.new(host)
585
916
  rescue
586
917
  logger.warn("Could not parse requested host as IPv4 address: #{host}")
587
- return
918
+ return url
588
919
  end
589
920
  case mode
590
921
  when 'int'
@@ -644,9 +975,14 @@ module SSRFProxy
644
975
  when 'rot13'
645
976
  str = str.tr('A-Za-z', 'N-ZA-Mn-za-m')
646
977
  when 'urlencode'
647
- str = CGI.escape(str)
978
+ str = CGI.escape(str).gsub(/\+/, '%20')
648
979
  when 'urldecode'
649
980
  str = CGI.unescape(str)
981
+ when 'append-hash'
982
+ str = "#{str}##{rand(36**6).to_s(36)}"
983
+ when 'append-method-get'
984
+ separator = str.include?('?') ? '&' : '?'
985
+ str = "#{str}#{separator}method=get&_method=get"
650
986
  else
651
987
  logger.warn("Unknown rule: #{rule}")
652
988
  end
@@ -664,7 +1000,7 @@ module SSRFProxy
664
1000
  #
665
1001
  # @raise [SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod]
666
1002
  # Invalid SSRF request method specified.
667
- # Method must be GET/HEAD/DELETE/POST/PUT.
1003
+ # Method must be GET/HEAD/DELETE/POST/PUT/OPTIONS.
668
1004
  # @raise [SSRFProxy::HTTP::Error::ConnectionTimeout]
669
1005
  # The request to the remote host timed out.
670
1006
  # @raise [SSRFProxy::HTTP::Error::InvalidUpstreamProxy]
@@ -674,50 +1010,83 @@ module SSRFProxy
674
1010
  #
675
1011
  def send_http_request(url, method, headers, body)
676
1012
  # use upstream proxy
677
- if @upstream_proxy.nil?
678
- http = Net::HTTP.new(@ssrf_url.host, @ssrf_url.port)
679
- elsif @upstream_proxy.scheme =~ /\Ahttps?\z/
680
- http = Net::HTTP::Proxy(@upstream_proxy.host, @upstream_proxy.port).new(@ssrf_url.host, @ssrf_url.port)
681
- elsif @upstream_proxy.scheme =~ /\Asocks\z/
682
- http = Net::HTTP.SOCKSProxy(@upstream_proxy.host, @upstream_proxy.port).new(@ssrf_url.host, @ssrf_url.port)
1013
+ if @proxy.nil?
1014
+ http = Net::HTTP::Proxy(nil).new(
1015
+ @url.host,
1016
+ @url.port
1017
+ )
1018
+ elsif @proxy.scheme.eql?('http') || @proxy.scheme.eql?('https')
1019
+ http = Net::HTTP::Proxy(
1020
+ @proxy.host,
1021
+ @proxy.port
1022
+ ).new(
1023
+ @url.host,
1024
+ @url.port
1025
+ )
1026
+ elsif @proxy.scheme.eql?('socks')
1027
+ http = Net::HTTP.SOCKSProxy(
1028
+ @proxy.host,
1029
+ @proxy.port
1030
+ ).new(
1031
+ @url.host,
1032
+ @url.port
1033
+ )
683
1034
  else
684
1035
  raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new,
685
1036
  'Unsupported upstream proxy specified. Scheme must be http(s) or socks.'
686
1037
  end
687
- if @ssrf_url.scheme == 'https'
1038
+
1039
+ # set SSL
1040
+ if @url.scheme.eql?('https')
688
1041
  http.use_ssl = true
689
- if @insecure
690
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
691
- else
692
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
693
- end
1042
+ http.verify_mode = @insecure ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
694
1043
  end
1044
+
695
1045
  # set socket options
696
1046
  http.open_timeout = @timeout
697
1047
  http.read_timeout = @timeout
1048
+
1049
+ # set http request method
1050
+ case method
1051
+ when 'GET'
1052
+ request = Net::HTTP::Get.new(url, headers.to_hash)
1053
+ when 'HEAD'
1054
+ request = Net::HTTP::Head.new(url, headers.to_hash)
1055
+ when 'DELETE'
1056
+ request = Net::HTTP::Delete.new(url, headers.to_hash)
1057
+ when 'POST'
1058
+ request = Net::HTTP::Post.new(url, headers.to_hash)
1059
+ when 'PUT'
1060
+ request = Net::HTTP::Put.new(url, headers.to_hash)
1061
+ when 'OPTIONS'
1062
+ request = Net::HTTP::Options.new(url, headers.to_hash)
1063
+ else
1064
+ logger.info("Request method #{method.inspect} not implemented")
1065
+ raise SSRFProxy::HTTP::Error::InvalidClientRequest,
1066
+ "Request method #{method.inspect} not implemented"
1067
+ end
1068
+
1069
+ # set http request credentials
1070
+ request.basic_auth(@user, @pass) unless @user.eql?('') && @pass.eql?('')
1071
+
698
1072
  # send http request
699
1073
  response = {}
700
- logger.info("Sending request: #{url}")
1074
+ logger.info('Sending request: ' \
1075
+ "#{@url.scheme}://#{@url.host}:#{@url.port}#{url}")
701
1076
  begin
702
- if method == 'GET'
703
- response = http.request Net::HTTP::Get.new(url, headers.to_hash)
704
- elsif method == 'HEAD'
705
- response = http.request Net::HTTP::Head.new(url, headers.to_hash)
706
- elsif method == 'DELETE'
707
- response = http.request Net::HTTP::Delete.new(url, headers.to_hash)
708
- elsif method == 'POST'
709
- request = Net::HTTP::Post.new(url, headers.to_hash)
1077
+ unless body.eql?('')
710
1078
  request.body = body
711
- response = http.request(request)
712
- elsif method == 'PUT'
713
- request = Net::HTTP::Put.new(url, headers.to_hash)
714
- request.body = body
715
- response = http.request(request)
716
- else
717
- logger.info("SSRF request method not implemented -- Method[#{method}]")
718
- raise SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod,
719
- "Request method not implemented -- Method[#{method}]"
1079
+ logger.info("Using request body: #{request.body.inspect}")
720
1080
  end
1081
+ response = http.request(request)
1082
+ rescue Net::HTTPBadResponse, EOFError
1083
+ logger.info('Server returned an invalid HTTP response')
1084
+ raise SSRFProxy::HTTP::Error::InvalidResponse,
1085
+ 'Server returned an invalid HTTP response'
1086
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET
1087
+ logger.info('Connection failed')
1088
+ raise SSRFProxy::HTTP::Error::ConnectionFailed,
1089
+ 'Connection failed'
721
1090
  rescue Timeout::Error, Errno::ETIMEDOUT
722
1091
  logger.info("Connection timed out [#{@timeout}]")
723
1092
  raise SSRFProxy::HTTP::Error::ConnectionTimeout,
@@ -726,6 +1095,15 @@ module SSRFProxy
726
1095
  logger.error("Unhandled exception: #{e}")
727
1096
  raise e
728
1097
  end
1098
+
1099
+ if response.code.eql?('401')
1100
+ if @user.eql?('') && @pass.eql?('')
1101
+ logger.warn('Authentication required')
1102
+ else
1103
+ logger.warn('Authentication failed')
1104
+ end
1105
+ end
1106
+
729
1107
  response
730
1108
  end
731
1109
 
@@ -740,8 +1118,18 @@ module SSRFProxy
740
1118
  #
741
1119
  def guess_status(response)
742
1120
  result = {}
1121
+ # response status code returned by php-simple-proxy and php-json-proxy
1122
+ if response =~ /"status":{"http_code":([\d]+)}/
1123
+ result['code'] = $1
1124
+ result['message'] = ''
743
1125
  # generic page titles containing HTTP status
744
- if response =~ />400 Bad Request</
1126
+ elsif response =~ />301 Moved</ || response =~ />Document Moved</ || response =~ />Object Moved</ || response =~ />301 Moved Permanently</
1127
+ result['code'] = 301
1128
+ result['message'] = 'Document Moved'
1129
+ elsif response =~ />302 Found</ || response =~ />302 Moved Temporarily</
1130
+ result['code'] = 302
1131
+ result['message'] = 'Found'
1132
+ elsif response =~ />400 Bad Request</
745
1133
  result['code'] = 400
746
1134
  result['message'] = 'Bad Request'
747
1135
  elsif response =~ />401 Unauthorized</
@@ -753,6 +1141,12 @@ module SSRFProxy
753
1141
  elsif response =~ />404 Not Found</
754
1142
  result['code'] = 404
755
1143
  result['message'] = 'Not Found'
1144
+ elsif response =~ />The page is not found</
1145
+ result['code'] = 404
1146
+ result['message'] = 'Not Found'
1147
+ elsif response =~ />413 Request Entity Too Large</
1148
+ result['code'] = 413
1149
+ result['message'] = 'Request Entity Too Large'
756
1150
  elsif response =~ />500 Internal Server Error</
757
1151
  result['code'] = 500
758
1152
  result['message'] = 'Internal Server Error'
@@ -823,7 +1217,7 @@ module SSRFProxy
823
1217
  result['message'] = 'Timeout'
824
1218
  end
825
1219
  # C errno
826
- elsif response =~ /\[Errno -?[\d]{1,3}\]/
1220
+ elsif response =~ /\[Errno -?[\d]{1,5}\]/
827
1221
  if response =~ /\[Errno -2\] Name or service not known/
828
1222
  result['code'] = 502
829
1223
  result['message'] = 'Bad Gateway'
@@ -842,6 +1236,24 @@ module SSRFProxy
842
1236
  elsif response =~ /\[Errno 113\] No route to host/
843
1237
  result['code'] = 502
844
1238
  result['message'] = 'Bad Gateway'
1239
+ elsif response =~ /\[Errno 11004\] getaddrinfo failed/
1240
+ result['code'] = 502
1241
+ result['message'] = 'Bad Gateway'
1242
+ elsif response =~ /\[Errno 10053\] An established connection was aborted/
1243
+ result['code'] = 502
1244
+ result['message'] = 'Bad Gateway'
1245
+ elsif response =~ /\[Errno 10054\] An existing connection was forcibly closed/
1246
+ result['code'] = 502
1247
+ result['message'] = 'Bad Gateway'
1248
+ elsif response =~ /\[Errno 10055\] An operation on a socket could not be performed/
1249
+ result['code'] = 502
1250
+ result['message'] = 'Bad Gateway'
1251
+ elsif response =~ /\[Errno 10060\] A connection attempt failed/
1252
+ result['code'] = 502
1253
+ result['message'] = 'Bad Gateway'
1254
+ elsif response =~ /\[Errno 10061\] No connection could be made/
1255
+ result['code'] = 502
1256
+ result['message'] = 'Bad Gateway'
845
1257
  end
846
1258
  # Python urllib errors
847
1259
  elsif response =~ /HTTPError: HTTP Error \d+/
@@ -944,47 +1356,56 @@ module SSRFProxy
944
1356
  end
945
1357
 
946
1358
  #
947
- # Detect WAF and SSRF protection libraries based on common strings in the response body
1359
+ # Guess content type based on file extension
948
1360
  #
949
- # @param [String] response HTTP response
1361
+ # @param [String] ext File extension including dots
1362
+ #
1363
+ # @example Return mime type for extension '.png'
1364
+ # guess_mime('favicon.png')
950
1365
  #
951
- # @return [Boolean] true if WAF detected
1366
+ # @return [String] content-type value
952
1367
  #
953
- def detect_waf(response)
954
- detected = false
955
- # SafeCurl (safe_curl) InvalidURLException
956
- if response =~ /fin1te\\SafeCurl\\Exception\\InvalidURLException/
957
- logger.info('SafeCurl protection mechanism appears to be in use')
958
- detected = true
1368
+ def guess_mime(ext)
1369
+ content_types = WEBrick::HTTPUtils::DefaultMimeTypes
1370
+ common_content_types = { 'ico' => 'image/x-icon' }
1371
+ content_types.merge!(common_content_types)
1372
+ content_types.each do |k, v|
1373
+ return v.to_s if ext.eql?(".#{k}")
959
1374
  end
960
- detected
1375
+ nil
961
1376
  end
962
1377
 
963
1378
  #
964
- # Guess content type based on file extension
1379
+ # Guess content type based on magic bytes
965
1380
  #
966
- # @param [String] ext File extension [with dots] (Example: '.png')
1381
+ # @param [String] content File contents
967
1382
  #
968
1383
  # @return [String] content-type value
969
1384
  #
970
- def guess_mime(ext)
971
- content_types = WEBrick::HTTPUtils::DefaultMimeTypes
972
- common_content_types = {
973
- 'ico' => 'image/x-icon' }
974
- content_types.merge!(common_content_types)
975
- content_types.each do |k, v|
976
- return v.to_s if ext == ".#{k}"
1385
+ def sniff_mime(content)
1386
+ m = MimeMagic.by_magic(content)
1387
+ return if m.nil?
1388
+
1389
+ # Overwrite incorrect mime types
1390
+ case m.type.to_s
1391
+ when 'application/xhtml+xml'
1392
+ return 'text/html'
1393
+ when 'text/x-csrc'
1394
+ return 'text/css'
977
1395
  end
1396
+
1397
+ m.type
1398
+ rescue
978
1399
  nil
979
1400
  end
980
1401
 
981
1402
  # private methods
982
- private :parse_options,
1403
+ private :parse_http_request,
983
1404
  :send_http_request,
984
1405
  :run_rules,
985
1406
  :encode_ip,
986
1407
  :guess_mime,
987
- :guess_status,
988
- :detect_waf
1408
+ :sniff_mime,
1409
+ :guess_status
989
1410
  end
990
1411
  end