ritm 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/ritm/certs/ca.rb +32 -0
- data/lib/ritm/certs/certificate.rb +41 -0
- data/lib/ritm/configuration.rb +77 -0
- data/lib/ritm/dispatcher.rb +37 -0
- data/lib/ritm/helpers/encodings.rb +65 -0
- data/lib/ritm/helpers/patches.rb +53 -0
- data/lib/ritm/helpers/utils.rb +15 -0
- data/lib/ritm/interception/handlers.rb +10 -0
- data/lib/ritm/interception/http_forwarder.rb +78 -0
- data/lib/ritm/interception/intercept_utils.rb +50 -0
- data/lib/ritm/interception/request_interceptor_servlet.rb +16 -0
- data/lib/ritm/main.rb +70 -0
- data/lib/ritm/proxy/cert_signing_https_server.rb +50 -0
- data/lib/ritm/proxy/launcher.rb +80 -0
- data/lib/ritm/proxy/proxy_server.rb +49 -0
- data/lib/ritm/proxy/ssl_reverse_proxy.rb +59 -0
- data/lib/ritm/version.rb +4 -0
- data/lib/ritm.rb +5 -0
- metadata +187 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2eeab06c6fd5db111b87a1ff18694f4206f571c9
|
4
|
+
data.tar.gz: b1537590beb540d93cd9161b7a7fee6d4090f3cb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a2e1a3bbb3b8c0b9ac87339152c9a3b726ea2295d3c14c48e67952e89a5b40ea5314446e445078b5e98556550faf8de6609af42d6e438509ae4b87604f98fd1a
|
7
|
+
data.tar.gz: 9ebf2c4991de1026d05cfd9e6226424f9feb0631b1d22a001819195d9d3e215f779a72feb0b65dc1f653c317d6dab62dac49ed45c29f592e37ed38ac0cc8897d
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'certificate_authority'
|
2
|
+
require 'ritm/certs/certificate'
|
3
|
+
|
4
|
+
module Ritm
|
5
|
+
# Wrapper on a Certificate Authority with ability of signing certificates
|
6
|
+
class CA < Ritm::Certificate
|
7
|
+
def self.create(common_name: 'RubyInTheMiddle')
|
8
|
+
super(common_name, serial_number: 1) do |cert|
|
9
|
+
cert.signing_entity = true
|
10
|
+
cert.sign!(signing_profile)
|
11
|
+
yield cert if block_given?
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.load(crt, private_key)
|
16
|
+
super(crt, private_key) do |cert|
|
17
|
+
cert.signing_entity = true
|
18
|
+
cert.sign!(signing_profile)
|
19
|
+
yield cert if block_given?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def sign(certificate)
|
24
|
+
certificate.cert.parent = @cert
|
25
|
+
certificate.cert.sign!
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.signing_profile
|
29
|
+
{ 'extensions' => { 'keyUsage' => { 'usage' => %w(critical keyCertSign keyEncipherment digitalSignature) } } }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'certificate_authority'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# Wraps a SSL Certificate via on-the-fly creation or loading from files
|
5
|
+
class Certificate
|
6
|
+
attr_accessor :cert
|
7
|
+
|
8
|
+
def self.load(crt, private_key)
|
9
|
+
x509 = OpenSSL::X509::Certificate.new(crt)
|
10
|
+
cert = CertificateAuthority::Certificate.from_openssl(x509)
|
11
|
+
cert.key_material.private_key = OpenSSL::PKey::RSA.new(private_key)
|
12
|
+
yield cert if block_given?
|
13
|
+
new cert
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.create(common_name, serial_number: nil)
|
17
|
+
cert = CertificateAuthority::Certificate.new
|
18
|
+
cert.subject.common_name = common_name
|
19
|
+
cert.serial_number.number = serial_number || common_name.hash.abs
|
20
|
+
cert.key_material.generate_key
|
21
|
+
yield cert if block_given?
|
22
|
+
new cert
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(cert)
|
26
|
+
@cert = cert
|
27
|
+
end
|
28
|
+
|
29
|
+
def private_key
|
30
|
+
@cert.key_material.private_key
|
31
|
+
end
|
32
|
+
|
33
|
+
def public_key
|
34
|
+
@cert.key_material.public_key
|
35
|
+
end
|
36
|
+
|
37
|
+
def pem
|
38
|
+
@cert.to_pem
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'dot_hash'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Ritm
|
5
|
+
# Global Ritm settings
|
6
|
+
class Configuration
|
7
|
+
DEFAULT_SETTINGS = {
|
8
|
+
proxy: {
|
9
|
+
bind_address: '127.0.0.1',
|
10
|
+
bind_port: 8080
|
11
|
+
},
|
12
|
+
|
13
|
+
ssl_reverse_proxy: {
|
14
|
+
bind_address: '127.0.0.1',
|
15
|
+
bind_port: 8081,
|
16
|
+
ca: {
|
17
|
+
pem: nil,
|
18
|
+
key: nil
|
19
|
+
}
|
20
|
+
},
|
21
|
+
|
22
|
+
intercept: {
|
23
|
+
# Is interception enabled
|
24
|
+
enabled: true,
|
25
|
+
|
26
|
+
# Do not intercept requests whose URLs match/start with the given regex/strings (blacklist)
|
27
|
+
skip_urls: [],
|
28
|
+
|
29
|
+
# Intercepts requests whose URLs match/start with the given regex/strings (whitelist)
|
30
|
+
# By default everything will be intercepted.
|
31
|
+
intercept_urls: []
|
32
|
+
},
|
33
|
+
|
34
|
+
misc: {
|
35
|
+
add_request_headers: {},
|
36
|
+
add_response_headers: { 'connection' => 'clone' },
|
37
|
+
|
38
|
+
strip_request_headers: [/proxy-*/],
|
39
|
+
strip_response_headers: ['strict-transport-security', 'transfer-encoding'],
|
40
|
+
|
41
|
+
unpack_gzip_deflate_in_requests: true,
|
42
|
+
unpack_gzip_deflate_in_responses: true,
|
43
|
+
process_chunked_encoded_transfer: true
|
44
|
+
}
|
45
|
+
}.freeze
|
46
|
+
|
47
|
+
def initialize(settings = {})
|
48
|
+
settings = DEFAULT_SETTINGS.merge(settings)
|
49
|
+
@values = {
|
50
|
+
dispatcher: Dispatcher.new,
|
51
|
+
|
52
|
+
# Is interception enabled
|
53
|
+
enabled: true
|
54
|
+
}
|
55
|
+
|
56
|
+
@settings = settings.to_properties
|
57
|
+
end
|
58
|
+
|
59
|
+
def method_missing(m, *args, &block)
|
60
|
+
@settings.send(m, *args, &block)
|
61
|
+
end
|
62
|
+
|
63
|
+
def [](setting)
|
64
|
+
@values[setting]
|
65
|
+
end
|
66
|
+
|
67
|
+
# Re-enable interception
|
68
|
+
def enable
|
69
|
+
@values[:enabled] = true
|
70
|
+
end
|
71
|
+
|
72
|
+
# Disable interception
|
73
|
+
def disable
|
74
|
+
@values[:enabled] = false
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Ritm
|
2
|
+
# Keeps a list of subscribers and notifies them when requests/responses are intercepted
|
3
|
+
class Dispatcher
|
4
|
+
def initialize
|
5
|
+
@handlers = { on_request: [], on_response: [] }
|
6
|
+
end
|
7
|
+
|
8
|
+
def add_handler(handler)
|
9
|
+
on_request { |*args| handler.on_request(*args) } if handler.respond_to? :on_request
|
10
|
+
on_response { |*args| handler.on_response(*args) } if handler.respond_to? :on_response
|
11
|
+
end
|
12
|
+
|
13
|
+
def on_request(&block)
|
14
|
+
@handlers[:on_request] << block
|
15
|
+
end
|
16
|
+
|
17
|
+
def on_response(&block)
|
18
|
+
@handlers[:on_response] << block
|
19
|
+
end
|
20
|
+
|
21
|
+
def notify_request(request)
|
22
|
+
notify(:on_request, request)
|
23
|
+
end
|
24
|
+
|
25
|
+
def notify_response(request, response)
|
26
|
+
notify(:on_response, request, response)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def notify(event, *args)
|
32
|
+
@handlers[event].each do |handler|
|
33
|
+
handler.call(*args)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'zlib'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# ENCODER/DECODER of HTTP content
|
5
|
+
module Encodings
|
6
|
+
ENCODINGS = [:identity, :gzip, :deflate].freeze
|
7
|
+
|
8
|
+
def self.encode(encoding, data)
|
9
|
+
case encoding
|
10
|
+
when :gzip
|
11
|
+
encode_gzip(data)
|
12
|
+
when :deflate
|
13
|
+
encode_deflate(data)
|
14
|
+
when :identity
|
15
|
+
identity(data)
|
16
|
+
else
|
17
|
+
raise "Unsupported encoding #{encoding}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.decode(encoding, data)
|
22
|
+
case encoding
|
23
|
+
when :gzip
|
24
|
+
decode_gzip(data)
|
25
|
+
when :deflate
|
26
|
+
decode_deflate(data)
|
27
|
+
when :identity
|
28
|
+
identity(data)
|
29
|
+
else
|
30
|
+
raise "Unsupported encoding #{encoding}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class << self
|
35
|
+
private
|
36
|
+
|
37
|
+
# Returns data unchanged. Identity is the default value of Accept-Encoding headers.
|
38
|
+
def identity(data)
|
39
|
+
data
|
40
|
+
end
|
41
|
+
|
42
|
+
def encode_gzip(data)
|
43
|
+
wio = StringIO.new('wb')
|
44
|
+
w_gz = Zlib::GzipWriter.new(wio)
|
45
|
+
w_gz.write(data)
|
46
|
+
w_gz.close
|
47
|
+
wio.string
|
48
|
+
end
|
49
|
+
|
50
|
+
def decode_gzip(data)
|
51
|
+
io = StringIO.new(data, 'rb')
|
52
|
+
gz = Zlib::GzipReader.new(io)
|
53
|
+
gz.read
|
54
|
+
end
|
55
|
+
|
56
|
+
def encode_deflate(data)
|
57
|
+
Zlib::Deflate.deflate(data)
|
58
|
+
end
|
59
|
+
|
60
|
+
def decode_deflate(data)
|
61
|
+
Zlib::Inflate.inflate(data)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'webrick/httpproxy'
|
3
|
+
require 'ritm/helpers/utils'
|
4
|
+
|
5
|
+
# Patch WEBrick too short max uri length
|
6
|
+
Ritm::Utils.silence_warnings { WEBrick::HTTPRequest::MAX_URI_LENGTH = 4000 }
|
7
|
+
|
8
|
+
# Make request method writable
|
9
|
+
WEBrick::HTTPRequest.instance_eval { attr_accessor :request_method, :unparsed_uri }
|
10
|
+
|
11
|
+
module WEBrick
|
12
|
+
# Other patches to WEBrick::HTTPRequest
|
13
|
+
class HTTPRequest
|
14
|
+
def []=(header_name, *values)
|
15
|
+
@header[header_name.downcase] = values.map(&:to_s)
|
16
|
+
end
|
17
|
+
|
18
|
+
def body=(str)
|
19
|
+
body # Read current body
|
20
|
+
@body = str
|
21
|
+
end
|
22
|
+
|
23
|
+
def request_uri=(uri)
|
24
|
+
@request_uri = parse_uri(uri.to_s)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Support other methods in HTTPServer
|
29
|
+
class HTTPServer
|
30
|
+
def do_DELETE(req, res)
|
31
|
+
perform_proxy_request(req, res) do |http, path, header|
|
32
|
+
http.delete(path, header)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def do_PUT(req, res)
|
37
|
+
perform_proxy_request(req, res) do |http, path, header|
|
38
|
+
http.put(path, req.body || '', header)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def do_PATCH(req, res)
|
43
|
+
perform_proxy_request(req, res) do |http, path, header|
|
44
|
+
http.patch(path, req.body || '', header)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# TODO: make sure options gets proxied too (so trace)
|
49
|
+
def do_OPTIONS(_req, res)
|
50
|
+
res['allow'] = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,CONNECT'
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
module Ritm
|
3
|
+
# Shortcuts and other utilities to be included
|
4
|
+
# in modules/classes
|
5
|
+
module Utils
|
6
|
+
# Runs a block of code without warnings.
|
7
|
+
def self.silence_warnings
|
8
|
+
warn_level = $VERBOSE
|
9
|
+
$VERBOSE = nil
|
10
|
+
result = yield
|
11
|
+
$VERBOSE = warn_level
|
12
|
+
result
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
|
3
|
+
require 'ritm/interception/intercept_utils'
|
4
|
+
|
5
|
+
module Ritm
|
6
|
+
# Forwarder that acts as a WEBrick <-> Faraday adaptor: Works this way:
|
7
|
+
# 1. A WEBrick request objects is received
|
8
|
+
# 2. The WEBrick request object is sent to the request interceptor
|
9
|
+
# 3. The (maybe modified) WEBrick request object is transformed into a Faraday request and sent to destination server
|
10
|
+
# 4. The Faraday response obtained from the server is transformed into a WEBrick response
|
11
|
+
# 5. The WEBrick response object is sent to the response interceptor
|
12
|
+
# 6. The (maybe modified) WEBrick response object is sent back to the client
|
13
|
+
#
|
14
|
+
# Besides the possible modifications to be done by interceptors there might be automated globally configured
|
15
|
+
# transformations like header stripping/adding.
|
16
|
+
class HTTPForwarder
|
17
|
+
include InterceptUtils
|
18
|
+
|
19
|
+
def initialize(request_interceptor, response_interceptor)
|
20
|
+
@request_interceptor = request_interceptor
|
21
|
+
@response_interceptor = response_interceptor
|
22
|
+
# TODO: make SSL verification a configuration setting
|
23
|
+
@client = Faraday.new(ssl: { verify: false }) do |conn|
|
24
|
+
conn.adapter :net_http
|
25
|
+
# TODO: support customizations (e.g. upstream proxies or different adapters)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def forward(request, response)
|
30
|
+
intercept_request(@request_interceptor, request)
|
31
|
+
|
32
|
+
faraday_response = faraday_forward request
|
33
|
+
to_webrick_response faraday_response, response
|
34
|
+
intercept_response(@response_interceptor, request, response)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def faraday_forward(request)
|
40
|
+
req_method = request.request_method.downcase
|
41
|
+
@client.send req_method do |req|
|
42
|
+
req.url request.request_uri
|
43
|
+
req.body = request.body
|
44
|
+
add_request_headers(req, request)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def add_request_headers(faraday_request, webrick_request)
|
49
|
+
webrick_request.header.each do |name, value|
|
50
|
+
faraday_request.headers[name] = value unless strip?(name, Ritm.conf.misc.strip_request_headers)
|
51
|
+
end
|
52
|
+
Ritm.conf.misc.add_request_headers.each { |k, v| faraday_request.headers[k] = v }
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_webrick_response(faraday_response, webrick_response)
|
56
|
+
webrick_response.status = faraday_response.status
|
57
|
+
webrick_response.body = faraday_response.body
|
58
|
+
faraday_response.headers.each do |name, value|
|
59
|
+
webrick_response[name] = value unless strip?(name, Ritm.conf.misc.strip_response_headers)
|
60
|
+
end
|
61
|
+
Ritm.conf.misc.add_response_headers.each { |k, v| webrick_response[k] = v }
|
62
|
+
webrick_response
|
63
|
+
end
|
64
|
+
|
65
|
+
def strip?(header, rules)
|
66
|
+
header = header.to_s.downcase
|
67
|
+
rules.each do |rule|
|
68
|
+
case rule
|
69
|
+
when String
|
70
|
+
return true if header == rule
|
71
|
+
when Regexp
|
72
|
+
return true if header =~ rule
|
73
|
+
end
|
74
|
+
end
|
75
|
+
false
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'ritm/helpers/encodings'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# Interceptor callbacks calling logic shared by the HTTP Proxy Server and the SSL Reverse Proxy Server
|
5
|
+
# Passes request
|
6
|
+
module InterceptUtils
|
7
|
+
def intercept_request(handler, request)
|
8
|
+
return if handler.nil?
|
9
|
+
handler.call(request)
|
10
|
+
end
|
11
|
+
|
12
|
+
def intercept_response(handler, request, response)
|
13
|
+
return if handler.nil?
|
14
|
+
# TODO: Disable the automated decoding from config
|
15
|
+
encoding = content_encoding(response)
|
16
|
+
decoded(encoding, response) do |decoded_response|
|
17
|
+
handler.call(request, decoded_response)
|
18
|
+
end
|
19
|
+
|
20
|
+
response.header.delete('content-length') if chunked?(response)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def chunked?(response)
|
26
|
+
response.header.fetch('transfer-encoding', '').casecmp 'chunked'
|
27
|
+
end
|
28
|
+
|
29
|
+
def content_encoding(response)
|
30
|
+
case response.header.fetch('content-encoding', '').downcase
|
31
|
+
when 'gzip', 'x-gzip'
|
32
|
+
:gzip
|
33
|
+
when 'deflate'
|
34
|
+
:deflate
|
35
|
+
else
|
36
|
+
:identity
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def decoded(encoding, res)
|
41
|
+
res.body = Encodings.decode(encoding, res.body)
|
42
|
+
_content_encoding = res.header.delete('content-encoding')
|
43
|
+
yield res
|
44
|
+
# TODO: should it be re-encoded?
|
45
|
+
# res.body = Encodings.encode(encoding, res.body)
|
46
|
+
# res.header['content-encoding'] = content_encoding
|
47
|
+
res.header['content-length'] = res.body.size.to_s
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'ritm/interception/http_forwarder'
|
3
|
+
|
4
|
+
module Ritm
|
5
|
+
# Actual implementation of the SSL Reverse Proxy service (decoupled from the certificate handling)
|
6
|
+
class RequestInterceptorServlet < WEBrick::HTTPServlet::AbstractServlet
|
7
|
+
def initialize(server, request_interceptor, response_interceptor)
|
8
|
+
super server
|
9
|
+
@forwarder = HTTPForwarder.new(request_interceptor, response_interceptor)
|
10
|
+
end
|
11
|
+
|
12
|
+
def service(request, response)
|
13
|
+
@forwarder.forward(request, response)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/ritm/main.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# Main module
|
2
|
+
module Ritm
|
3
|
+
# Define global settings
|
4
|
+
def self.configure(&block)
|
5
|
+
conf.instance_eval(&block)
|
6
|
+
end
|
7
|
+
|
8
|
+
# Re-enable fuzzing (if it was disabled)
|
9
|
+
def self.enable
|
10
|
+
conf.enable
|
11
|
+
end
|
12
|
+
|
13
|
+
# Disable fuzzing (if it was enabled)
|
14
|
+
def self.disable
|
15
|
+
conf.disable
|
16
|
+
end
|
17
|
+
|
18
|
+
# Access the current config settings
|
19
|
+
def self.conf
|
20
|
+
@configuration ||= Configuration.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.dispatcher
|
24
|
+
conf[:dispatcher]
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.add_handler(handler)
|
28
|
+
dispatcher.add_handler(handler)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.on_request(&block)
|
32
|
+
dispatcher.on_request(&block)
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.on_response(&block)
|
36
|
+
dispatcher.on_response(&block)
|
37
|
+
end
|
38
|
+
|
39
|
+
# private class methods
|
40
|
+
|
41
|
+
def self.intercept?(request)
|
42
|
+
return false unless conf[:enabled]
|
43
|
+
url = request.url.to_s
|
44
|
+
whitelisted?(url) && !blacklisted?(url)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.whitelisted?(url)
|
48
|
+
conf[:attack_urls].empty? || url_matches_any?(url, conf[:attack_urls])
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.blacklisted?(url)
|
52
|
+
url_matches_any? url, conf[:skip_urls]
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.url_matches_any?(url, matchers)
|
56
|
+
matchers.each do |matcher|
|
57
|
+
case matcher
|
58
|
+
when Regexp
|
59
|
+
return true if url =~ matcher
|
60
|
+
when String
|
61
|
+
return true if url.include? matcher
|
62
|
+
else
|
63
|
+
raise 'URL matcher should be a String or Regexp'
|
64
|
+
end
|
65
|
+
end
|
66
|
+
false
|
67
|
+
end
|
68
|
+
|
69
|
+
private_class_method :intercept?, :whitelisted?, :blacklisted?, :url_matches_any?
|
70
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'webrick/https'
|
3
|
+
require 'ritm/certs/certificate'
|
4
|
+
|
5
|
+
module Ritm
|
6
|
+
module Proxy
|
7
|
+
# Patches WEBrick::HTTPServer SSL context creation to get
|
8
|
+
# a callback on the 'Client Helo' step of the SSL-Handshake if SNI is specified
|
9
|
+
# So we can create self-signed certificates on the fly
|
10
|
+
class CertSigningHTTPSServer < WEBrick::HTTPServer
|
11
|
+
# Override
|
12
|
+
def setup_ssl_context(config)
|
13
|
+
ctx = super(config)
|
14
|
+
prepare_sni_callback(ctx, config[:ca])
|
15
|
+
ctx
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Keeps track of the created self-signed certificates
|
21
|
+
# TODO: this can grow a lot and take up memory, fix by either:
|
22
|
+
# 1. implementing wildcard certificates generation (so there's one certificate per top level domain)
|
23
|
+
# 2. Use the same key material (private/public keys) for all the server names and just do the signing on-the-fly
|
24
|
+
# 3. both of the above
|
25
|
+
def prepare_sni_callback(ctx, ca)
|
26
|
+
contexts = {}
|
27
|
+
mutex = Mutex.new
|
28
|
+
|
29
|
+
# Sets the SNI callback on the SSLTCPSocket
|
30
|
+
ctx.servername_cb = proc do |sock, servername|
|
31
|
+
mutex.synchronize do
|
32
|
+
unless contexts.include? servername
|
33
|
+
cert = Ritm::Certificate.create(servername)
|
34
|
+
ca.sign(cert)
|
35
|
+
contexts[servername] = context_with_cert(sock.context, cert)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
contexts[servername]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def context_with_cert(original_ctx, cert)
|
43
|
+
ctx = original_ctx.dup
|
44
|
+
ctx.key = OpenSSL::PKey::RSA.new(cert.private_key)
|
45
|
+
ctx.cert = OpenSSL::X509::Certificate.new(cert.pem)
|
46
|
+
ctx
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'ritm/proxy/ssl_reverse_proxy'
|
2
|
+
require 'ritm/proxy/proxy_server'
|
3
|
+
require 'ritm/certs/ca'
|
4
|
+
require 'ritm/interception/handlers'
|
5
|
+
|
6
|
+
module Ritm
|
7
|
+
module Proxy
|
8
|
+
# Launches the Proxy server and the SSL Reverse Proxy with the given settings
|
9
|
+
class Launcher
|
10
|
+
# By default settings are read from Ritm::Configuration but you can override some via these named arguments:
|
11
|
+
# proxy_port [Fixnum]: the port where the main proxy listens (the one to be configured in the client)
|
12
|
+
# ssl_reverse_proxy_port [Fixnum]: the port where the reverse proxy for ssl traffic interception listens
|
13
|
+
# interface [String]: the host/address to bind the main proxy
|
14
|
+
# ca_crt_path [String]: the path to the certification authority certificate
|
15
|
+
# ca_key_path [String]: the path to the certification authority private key
|
16
|
+
# request_interceptor [Proc |request|]: the handler for request interception
|
17
|
+
# response_interceptor [Proc |request, response|]: the handler for response interception
|
18
|
+
def initialize(**args)
|
19
|
+
build_settings(**args)
|
20
|
+
|
21
|
+
build_reverse_proxy(@ssl_proxy_host, @ssl_proxy_port, @request_interceptor, @response_interceptor)
|
22
|
+
build_proxy(@proxy_host, @proxy_port, @https_forward, @request_interceptor, @response_interceptor)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Starts the service (non blocking)
|
26
|
+
def start
|
27
|
+
@https.start_async
|
28
|
+
@http.start_async
|
29
|
+
end
|
30
|
+
|
31
|
+
# Stops the service
|
32
|
+
def shutdown
|
33
|
+
@https.shutdown
|
34
|
+
@http.shutdown
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def build_settings(**args)
|
40
|
+
c = Ritm.conf
|
41
|
+
@proxy_host = args.fetch(:interface, c.proxy.bind_address)
|
42
|
+
@proxy_port = args.fetch(:proxy_port, c.proxy.bind_port)
|
43
|
+
@ssl_proxy_host = c.ssl_reverse_proxy.bind_address
|
44
|
+
@ssl_proxy_port = args.fetch(:ssl_reverse_proxy_port, c.ssl_reverse_proxy.bind_port)
|
45
|
+
@https_forward = "#{@ssl_proxy_host}:#{@ssl_proxy_port}"
|
46
|
+
@request_interceptor = args[:request_interceptor] || DEFAULT_REQUEST_HANDLER
|
47
|
+
@response_interceptor = args[:response_interceptor] || DEFAULT_RESPONSE_HANDLER
|
48
|
+
|
49
|
+
crt_path = args.fetch(:ca_crt_path, c.ssl_reverse_proxy.ca.pem)
|
50
|
+
key_path = args.fetch(:ca_key_path, c.ssl_reverse_proxy.ca.key)
|
51
|
+
@certificate = ca_certificate(crt_path, key_path)
|
52
|
+
end
|
53
|
+
|
54
|
+
def build_proxy(host, port, https_forward_to, req_intercept, res_intercept)
|
55
|
+
@http = Ritm::Proxy::ProxyServer.new(Port: port,
|
56
|
+
AccessLog: [],
|
57
|
+
BindAddress: host,
|
58
|
+
Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
|
59
|
+
https_forward: https_forward_to,
|
60
|
+
ProxyVia: nil,
|
61
|
+
request_interceptor: req_intercept,
|
62
|
+
response_interceptor: res_intercept)
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_reverse_proxy(_host, port, req_intercept, res_intercept)
|
66
|
+
@https = Ritm::Proxy::SSLReverseProxy.new(port, @certificate,
|
67
|
+
request_interceptor: req_intercept,
|
68
|
+
response_interceptor: res_intercept)
|
69
|
+
end
|
70
|
+
|
71
|
+
def ca_certificate(pem, key)
|
72
|
+
if pem.nil? || key.nil?
|
73
|
+
Ritm::CA.create
|
74
|
+
else
|
75
|
+
Ritm::CA.load(File.read(pem), File.read(key))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'webrick/httpproxy'
|
3
|
+
require 'ritm/helpers/patches'
|
4
|
+
require 'ritm/interception/intercept_utils'
|
5
|
+
|
6
|
+
module Ritm
|
7
|
+
module Proxy
|
8
|
+
# Proxy server that accepts request and response intercept handlers for HTTP traffic
|
9
|
+
# HTTPS traffic is redirected to the SSLReverseProxy for interception
|
10
|
+
class ProxyServer < WEBrick::HTTPProxyServer
|
11
|
+
include InterceptUtils
|
12
|
+
|
13
|
+
def start_async
|
14
|
+
trap(:TERM) { shutdown }
|
15
|
+
trap(:INT) { shutdown }
|
16
|
+
Thread.new { start }
|
17
|
+
end
|
18
|
+
|
19
|
+
# Override
|
20
|
+
# Patches the destination address on HTTPS connections to go via the HTTPS Reverse Proxy
|
21
|
+
def do_CONNECT(req, res)
|
22
|
+
req.class.send(:attr_accessor, :unparsed_uri)
|
23
|
+
req.unparsed_uri = @config[:https_forward]
|
24
|
+
super
|
25
|
+
end
|
26
|
+
|
27
|
+
# Override
|
28
|
+
# Handles HTTP (no SSL) traffic interception
|
29
|
+
def proxy_service(req, res)
|
30
|
+
# Proxy Authentication
|
31
|
+
proxy_auth(req, res)
|
32
|
+
|
33
|
+
# Request modifier handler
|
34
|
+
intercept_request(@config[:request_interceptor], req)
|
35
|
+
|
36
|
+
begin
|
37
|
+
send("do_#{req.request_method}", req, res)
|
38
|
+
rescue NoMethodError
|
39
|
+
raise WEBrick::HTTPStatus::MethodNotAllowed, "unsupported method `#{req.request_method}'."
|
40
|
+
rescue => err
|
41
|
+
raise WEBrick::HTTPStatus::ServiceUnavailable, err.message
|
42
|
+
end
|
43
|
+
|
44
|
+
# Response modifier handler
|
45
|
+
intercept_response(@config[:response_interceptor], req, res)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'ritm/helpers/patches'
|
2
|
+
require 'ritm/interception/request_interceptor_servlet'
|
3
|
+
require 'ritm/proxy/cert_signing_https_server'
|
4
|
+
require 'ritm/certs/certificate'
|
5
|
+
|
6
|
+
module Ritm
|
7
|
+
module Proxy
|
8
|
+
# SSL Intercept reverse proxy server. Supports interception of https request and responses
|
9
|
+
# It does man-in-the-middle with on-the-fly certificate signing using the given CA
|
10
|
+
class SSLReverseProxy
|
11
|
+
# Creates a HTTPS server with the given settings
|
12
|
+
# @param port [Fixnum]: TCP port to bind the service
|
13
|
+
# @param ca [Ritm::CA]: The certificate authority used to sign fake server certificates
|
14
|
+
# @param request_interceptor [Proc]: If given, it will be invoked before proxying the request
|
15
|
+
# @param response_interceptor [Proc]: If give, it will be invoked before sending back the response
|
16
|
+
def initialize(port, ca, request_interceptor: nil, response_interceptor: nil)
|
17
|
+
@ca = ca
|
18
|
+
default_vhost = 'localhost'
|
19
|
+
@server = CertSigningHTTPSServer.new(Port: port,
|
20
|
+
AccessLog: [],
|
21
|
+
Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
|
22
|
+
ca: ca,
|
23
|
+
**vhost_settings(default_vhost))
|
24
|
+
|
25
|
+
@server.mount '/', RequestInterceptorServlet, request_interceptor, response_interceptor
|
26
|
+
end
|
27
|
+
|
28
|
+
def start_async
|
29
|
+
trap(:TERM) { shutdown }
|
30
|
+
trap(:INT) { shutdown }
|
31
|
+
Thread.new { @server.start }
|
32
|
+
end
|
33
|
+
|
34
|
+
def shutdown
|
35
|
+
@server.shutdown
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def gen_signed_cert(common_name)
|
41
|
+
cert = Ritm::Certificate.create(common_name)
|
42
|
+
@ca.sign(cert)
|
43
|
+
cert
|
44
|
+
end
|
45
|
+
|
46
|
+
def vhost_settings(hostname)
|
47
|
+
cert = gen_signed_cert(hostname)
|
48
|
+
{
|
49
|
+
ServerName: hostname,
|
50
|
+
SSLEnable: true,
|
51
|
+
SSLVerifyClient: OpenSSL::SSL::VERIFY_NONE,
|
52
|
+
SSLPrivateKey: OpenSSL::PKey::RSA.new(cert.private_key),
|
53
|
+
SSLCertificate: OpenSSL::X509::Certificate.new(cert.pem),
|
54
|
+
SSLCertName: [['CN', hostname]]
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/ritm/version.rb
ADDED
data/lib/ritm.rb
ADDED
metadata
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ritm
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sebastián Tello
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-05-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: faraday
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.9'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.9'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: webrick
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: certificate_authority
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.1.6
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.1.6
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: dot_hash
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.5'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.5'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.4'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.4'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0.40'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0.40'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: sinatra
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.4'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.4'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: thin
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '1.6'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '1.6'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rake
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '11.1'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '11.1'
|
139
|
+
description: HTTP(S) Intercept Proxy
|
140
|
+
email: argos83+ritm@gmail.com
|
141
|
+
executables: []
|
142
|
+
extensions: []
|
143
|
+
extra_rdoc_files: []
|
144
|
+
files:
|
145
|
+
- lib/ritm.rb
|
146
|
+
- lib/ritm/certs/ca.rb
|
147
|
+
- lib/ritm/certs/certificate.rb
|
148
|
+
- lib/ritm/configuration.rb
|
149
|
+
- lib/ritm/dispatcher.rb
|
150
|
+
- lib/ritm/helpers/encodings.rb
|
151
|
+
- lib/ritm/helpers/patches.rb
|
152
|
+
- lib/ritm/helpers/utils.rb
|
153
|
+
- lib/ritm/interception/handlers.rb
|
154
|
+
- lib/ritm/interception/http_forwarder.rb
|
155
|
+
- lib/ritm/interception/intercept_utils.rb
|
156
|
+
- lib/ritm/interception/request_interceptor_servlet.rb
|
157
|
+
- lib/ritm/main.rb
|
158
|
+
- lib/ritm/proxy/cert_signing_https_server.rb
|
159
|
+
- lib/ritm/proxy/launcher.rb
|
160
|
+
- lib/ritm/proxy/proxy_server.rb
|
161
|
+
- lib/ritm/proxy/ssl_reverse_proxy.rb
|
162
|
+
- lib/ritm/version.rb
|
163
|
+
homepage: https://github.com/argos83/ritm
|
164
|
+
licenses:
|
165
|
+
- Apache License, v2.0
|
166
|
+
metadata: {}
|
167
|
+
post_install_message:
|
168
|
+
rdoc_options: []
|
169
|
+
require_paths:
|
170
|
+
- lib
|
171
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
172
|
+
requirements:
|
173
|
+
- - ">="
|
174
|
+
- !ruby/object:Gem::Version
|
175
|
+
version: '0'
|
176
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
requirements: []
|
182
|
+
rubyforge_project:
|
183
|
+
rubygems_version: 2.4.5.1
|
184
|
+
signing_key:
|
185
|
+
specification_version: 4
|
186
|
+
summary: Ruby In The Middle
|
187
|
+
test_files: []
|