ssrf_proxy 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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