ritm 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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: []
|