Landing #10884 - Add JSON-RPC Client

This commit is contained in:
christopher lee 2018-11-13 08:31:55 -06:00
commit 97ee965c6e
No known key found for this signature in database
GPG Key ID: DFC302A157BC2607
8 changed files with 458 additions and 1 deletions

View File

@ -12,6 +12,7 @@ PATH
concurrent-ruby (= 1.0.5)
dnsruby
ed25519
em-http-request
faker
filesize
jsobfu
@ -119,6 +120,7 @@ GEM
builder (3.2.3)
coderay (1.1.2)
concurrent-ruby (1.0.5)
cookiejar (0.3.3)
crass (1.0.4)
daemons (1.2.6)
diff-lcs (1.3)
@ -126,6 +128,14 @@ GEM
addressable (~> 2.5)
docile (1.3.1)
ed25519 (1.2.4)
em-http-request (1.1.5)
addressable (>= 2.3.4)
cookiejar (!= 0.3.1)
em-socksify (>= 0.3)
eventmachine (>= 1.0.3)
http_parser.rb (>= 0.6.0)
em-socksify (0.3.2)
eventmachine (>= 1.0.0.beta.4)
erubis (2.7.0)
eventmachine (1.2.7)
factory_bot (4.11.1)
@ -140,6 +150,7 @@ GEM
filesize (0.2.0)
fivemat (1.3.7)
hashery (2.1.2)
http_parser.rb (0.6.0)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
jsobfu (0.4.2)

View File

@ -17,12 +17,16 @@ module Msf::RPC
module JSON
autoload :Client, 'msf/core/rpc/json/client'
autoload :Dispatcher, 'msf/core/rpc/json/dispatcher'
autoload :DispatcherHelper, 'msf/core/rpc/json/dispatcher_helper'
autoload :Request, 'msf/core/rpc/json/request'
autoload :Response, 'msf/core/rpc/json/response'
autoload :RpcCommand, 'msf/core/rpc/json/rpc_command'
autoload :RpcCommandFactory, 'msf/core/rpc/json/rpc_command_factory'
# exception classes
# server
autoload :Error, 'msf/core/rpc/json/error'
autoload :ParseError, 'msf/core/rpc/json/error'
autoload :InvalidRequest, 'msf/core/rpc/json/error'
@ -31,5 +35,11 @@ module Msf::RPC
autoload :InternalError, 'msf/core/rpc/json/error'
autoload :ServerError, 'msf/core/rpc/json/error'
autoload :ApplicationServerError, 'msf/core/rpc/json/error'
# client
autoload :ClientError, 'msf/core/rpc/json/error'
autoload :InvalidResponse, 'msf/core/rpc/json/error'
autoload :JSONParseError, 'msf/core/rpc/json/error'
autoload :ErrorResponse, 'msf/core/rpc/json/error'
end
end

View File

@ -0,0 +1,80 @@
require 'json'
require 'uri'
require 'msf/core/rpc'
module Msf::RPC::JSON
# JSON-RPC Client
# All client method call requests must be dispatched from within an
# EventMachine (reactor) run loop.
class Client
attr_reader :uri
attr_reader :api_token
attr_reader :symbolize_names
attr_accessor :namespace
# Instantiate a Client.
# @param uri [String] the JSON-RPC service URI
# @param api_token [String] the API token. Default: nil
# @param namespace [String] the namespace for the JSON-RPC method. The namespace will
# be prepended to the method name with a period separator. Default: nil
# @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when
# processing JSON objects; otherwise, strings are used. Default: true
# @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil
# @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil
# @param verify_peer [Boolean] indicates whether a server should request a certificate
# from a peer, to be verified by user code. Default: nil
def initialize(uri, api_token: nil, namespace: nil, symbolize_names: true,
private_key_file: nil, cert_chain_file: nil, verify_peer: nil)
@uri = URI.parse(uri)
@api_token = api_token
@namespace = namespace
@symbolize_names = symbolize_names
@private_key_file = private_key_file
@cert_chain_file = cert_chain_file
@verify_peer = verify_peer
end
private
# Invoked by Ruby when obj is sent a message it cannot handle, then processes
# the call as an RPC method invocation.
# @param symbol [Symbol] the symbol for the method called
# @param args [Array] any positional arguments passed to the method
# @param keyword_args [Hash] any keyword arguments passed to the method
# @returns [Msf::RPC::JSON::Request] an EM::Deferrable for the RPC method invocation.
def method_missing(symbol, *args, **keyword_args, &block)
# assemble method parameters
if !args.empty? && !keyword_args.empty?
params = args << keyword_args
elsif !args.empty?
params = args
elsif !keyword_args.empty?
params = keyword_args
else
params = nil
end
process_call_async(symbol, params)
end
# Asynchronously processes the RPC method invocation.
# @param method [Symbol] the method
# @param params [Array, Hash] any arguments passed to the method
# @returns [Msf::RPC::JSON::Request] an EM::Deferrable for the RPC method invocation.
def process_call_async(method, params)
req = Request.new(@uri,
api_token: @api_token,
method: method,
params: params,
namespace: @namespace,
symbolize_names: @symbolize_names,
private_key_file: @private_key_file,
cert_chain_file: @cert_chain_file,
verify_peer: @verify_peer)
req.send
req
end
end
end

View File

@ -120,7 +120,7 @@ module Msf::RPC::JSON
# Validate the JSON-RPC request.
# @param request [Hash] the JSON-RPC request
# @returns [Boolean] true if the JSON-RPC request is a valid; otherwise, false.
# @returns [Boolean] true if the JSON-RPC request is valid; otherwise, false.
def validate_rpc_request(request)
# validate request is an object
return false unless request.is_a?(Hash)

View File

@ -133,4 +133,94 @@ module Msf::RPC::JSON
super(APPLICATION_SERVER_ERROR, ERROR_MESSAGES[APPLICATION_SERVER_ERROR] % {msg: message}, data: data)
end
end
# Base class for all Msf::RPC::JSON client exceptions.
class ClientError < StandardError
attr_reader :response
# Instantiate a ClientError object.
#
# @param message [String] A String providing a short description of the error.
# @param response [Hash] A response hash. The default value is nil.
def initialize(message = nil, response: nil)
super(message)
@response = response
end
end
class InvalidResponse < ClientError
# Instantiate an InvalidResponse object.
#
# @param message [String] A String providing a short description of the error.
# @param response [Hash] A response hash. The default value is nil.
def initialize(message = 'Invalid response from server', response: nil)
super(message, response: response)
end
end
class JSONParseError < ClientError
# Instantiate an JSONParseError object.
#
# @param message [String] A String providing a short description of the error.
# @param response [Hash] A response hash. The default value is nil.
def initialize(message = 'Invalid JSON was received from the server', response: nil)
super(message, response: response)
end
end
class ErrorResponse < ClientError
attr_reader :id
attr_reader :code
attr_reader :message
attr_reader :data
# Parse response and return a new ErrorResponse instance.
# @param response [Hash] A response hash.
# @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when
# processing JSON objects; otherwise, strings are used. Default: true
# @returns [ErrorResponse] ErrorResponse object that represents the response hash.
def self.parse(response, symbolize_names: true)
id_key = symbolize_names ? :id : :id.to_s
error_key = symbolize_names ? :error : :error.to_s
code_key = symbolize_names ? :code : :code.to_s
message_key = symbolize_names ? :message : :message.to_s
data_key = symbolize_names ? :data : :data.to_s
id = response[id_key]
error = response[error_key]
if !error.nil?
code = error[code_key]
message = error[message_key]
data = error[data_key]
else
code = nil
message = nil
data = nil
end
ErrorResponse.new(id: id, code: code, message: message, data: data, response: response)
end
# Instantiate an ErrorResponse object.
#
# @param id [Integer, String, NilClass] It MUST be the same as the value of the
# id member in the Request Object. If there was an error in detecting the id
# in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.
# @param code [Integer] A Number that indicates the error type that occurred.
# @param message [String] A String providing a short description of the error.
# The message SHOULD be limited to a concise single sentence.
# @param data [Object] A Primitive or Structured value that contains additional
# information about the error. This may be omitted. The value of this member is
# defined by the Server (e.g. detailed error information, nested errors etc.).
# The default value is nil.
# @param response [Hash] A response hash. The default value is nil.
def initialize(id:, code:, message:, data: nil, response: nil)
super(message, response: response)
@id = id
@code = code
@message = message
@data = data
end
end
end

View File

@ -0,0 +1,227 @@
require 'em-http-request'
require 'json'
require 'msf/core/rpc'
module Msf::RPC::JSON
# Represents a JSON-RPC request. This is an EM::Deferrable class and instances
# respond to #callback and #errback to store callback actions.
class Request
include EM::Deferrable
JSON_MEDIA_TYPE = 'application/json'
JSON_RPC_VERSION = '2.0'
JSON_RPC_RESPONSE_REQUIRED_MEMBERS = %i(jsonrpc id)
JSON_RPC_RESPONSE_MEMBER_TYPES = {
# A String specifying the version of the JSON-RPC protocol.
jsonrpc: [String],
# An identifier established by the Client that MUST contain a String,
# Number, or NULL value if included. If it is not included it is assumed
# to be a notification. The value SHOULD normally not be Null [1] and
# Numbers SHOULD NOT contain fractional parts [2]
id: [Integer, String, NilClass],
}
JSON_RPC_ERROR_RESPONSE_REQUIRED_MEMBERS = %i(code message)
JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES = {
# A Number that indicates the error type that occurred.
# This MUST be an integer.
code: [Integer],
# A String providing a short description of the error.
# The message SHOULD be limited to a concise single sentence.
message: [String]
}
# Instantiate a Request.
# @param uri [URI::HTTP] the JSON-RPC service URI
# @param api_token [String] the API token. Default: nil
# @param method [String] the JSON-RPC method name.
# @param params [Array, Hash] the JSON-RPC method parameters. Default: nil
# @param namespace [String] the namespace for the JSON-RPC method. The namespace will
# be prepended to the method name with a period separator. Default: nil
# @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when
# processing JSON objects; otherwise, strings are used. Default: true
# @param is_notification [Boolean] If true, the request is created as a notification;
# otherwise, a standard request. Default: false
# @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil
# @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil
# @param verify_peer [Boolean] indicates whether a server should request a certificate
# from a peer, to be verified by user code. Default: nil
def initialize(uri, api_token: nil, method:, params: nil, namespace: nil,
symbolize_names: true, is_notification: false,
private_key_file: nil, cert_chain_file: nil, verify_peer: nil)
@uri = uri
@api_token = api_token
@namespace = namespace
@symbolize_names = symbolize_names
@is_notification = is_notification
@headers = {
'Accept': JSON_MEDIA_TYPE,
'Content-Type': JSON_MEDIA_TYPE,
'Authorization': "Bearer #{@api_token}"
}
absolute_method_name = @namespace.nil? ? method : "#{@namespace}.#{method}"
request_msg = {
jsonrpc: JSON_RPC_VERSION,
method: absolute_method_name
}
request_msg[:id] = Request.generate_id unless is_notification
request_msg[:params] = params unless params.nil?
@request_options = {
head: @headers,
body: request_msg.to_json
}
# add SSL options if specified
if !private_key_file.nil? || !cert_chain_file.nil? || verify_peer.is_a?(TrueClass) ||
verify_peer.is_a?(FalseClass)
ssl_options = {}
ssl_options[:private_key_file] = private_key_file unless private_key_file.nil?
ssl_options[:cert_chain_file] = cert_chain_file unless cert_chain_file.nil?
ssl_options[:verify_peer] = verify_peer if verify_peer.is_a?(TrueClass) || verify_peer.is_a?(FalseClass)
@request_options[:ssl] = ssl_options
end
end
# Sends the JSON-RPC request using an EM::HttpRequest object, then validates and processes
# the JSON-RPC response.
def send
http = EM::HttpRequest.new(@uri).post(@request_options)
http.callback do
process(http.response)
end
http.errback do
fail(http.error)
end
end
private
# Process the JSON-RPC response.
# @param source [String] the JSON-RPC response
def process(source)
begin
response = JSON.parse(source, symbolize_names: @symbolize_names)
if response.is_a?(Array)
# process batch response
# TODO: implement batch response processing
fail("#{self.class.name}##{__method__} is not implemented for batch response")
else
process_response(response)
end
rescue JSON::ParserError
fail(JSONParseError.new(response: source))
end
end
# Validate and process the JSON-RPC response.
# @param response [Hash] the JSON-RPC response
def process_response(response)
if !valid_rpc_response?(response)
fail(InvalidResponse.new(response: response))
return
end
error_key = @symbolize_names ? :error : :error.to_s
if response.key?(error_key)
# process error response
fail(ErrorResponse.parse(response, symbolize_names: @symbolize_names))
else
# process successful response
succeed(Response.parse(response, symbolize_names: @symbolize_names))
end
end
# Validate the JSON-RPC response.
# @param response [Hash] the JSON-RPC response
# @returns [Boolean] true if the JSON-RPC response is valid; otherwise, false.
def valid_rpc_response?(response)
# validate response is an object
return false unless response.is_a?(Hash)
JSON_RPC_RESPONSE_REQUIRED_MEMBERS.each do |member|
tmp_member = @symbolize_names ? member : member.to_s
return false unless response.key?(tmp_member)
end
# validate response members are correct types
response.each do |member, value|
tmp_member = @symbolize_names ? member : member.to_sym
return false if JSON_RPC_RESPONSE_MEMBER_TYPES.key?(tmp_member) &&
!JSON_RPC_RESPONSE_MEMBER_TYPES[tmp_member].one? { |type| value.is_a?(type) }
end
return false if response[:jsonrpc] != JSON_RPC_VERSION
result_key = @symbolize_names ? :result : :result.to_s
error_key = @symbolize_names ? :error : :error.to_s
return false if response.key?(result_key) && response.key?(error_key)
if response.key?(error_key)
error_response = response[error_key]
# validate error response is an object
return false unless error_response.is_a?(Hash)
JSON_RPC_ERROR_RESPONSE_REQUIRED_MEMBERS.each do |member|
tmp_member = @symbolize_names ? member : member.to_s
return false unless error_response.key?(tmp_member)
end
# validate error response members are correct types
error_response.each do |member, value|
tmp_member = @symbolize_names ? member : member.to_sym
return false if JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES.key?(tmp_member) &&
!JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES[tmp_member].one? { |type| value.is_a?(type) }
end
end
true
end
# Generates a random id.
# @param n [Integer] Upper boundary for the random id.
# @return [Integer] A random id. If a positive integer is given for n,
# returns an integer: 0 <= id < n.
def self.generate_id(n = (2**(0.size * 8 - 1))-1)
SecureRandom.random_number(n)
end
end
# Represents a JSON-RPC Notification. This is an EM::Deferrable class and
# instances respond to #callback and #errback to store callback actions.
class Notification < Request
# Instantiate a Notification.
# @param uri [URI::HTTP] the JSON-RPC service URI
# @param api_token [String] the API token. Default: nil
# @param method [String] the JSON-RPC method name.
# @param params [Array, Hash] the JSON-RPC method parameters. Default: nil
# @param namespace [String] the namespace for the JSON-RPC method. The namespace will
# be prepended to the method name with a period separator. Default: nil
# @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when
# processing JSON objects; otherwise, strings are used. Default: true
# @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil
# @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil
# @param verify_peer [Boolean] indicates whether a server should request a certificate
# from a peer, to be verified by user code. Default: nil
def initialize(uri, api_token: nil, method:, params: nil, namespace: nil,
symbolize_names: true, private_key_file: nil,
cert_chain_file: nil, verify_peer: nil)
super(uri,
api_token: api_token,
method: method,
params: params,
namespace: namespace,
symbolize_names: symbolize_names,
is_notification: true,
private_key_file: private_key_file,
cert_chain_file: cert_chain_file,
verify_peer: verify_peer)
end
end
end

View File

@ -0,0 +1,37 @@
module Msf::RPC::JSON
# Represents a JSON-RPC response.
class Response
attr_reader :response
attr_reader :id
attr_reader :result
# Parse response and return a new Response instance.
# @param response [Hash] A response hash.
# @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when
# processing JSON objects; otherwise, strings are used. Default: true
# @returns [Response] Response object that represents the response hash.
def self.parse(response, symbolize_names: true)
id_key = symbolize_names ? :id : :id.to_s
result_key = symbolize_names ? :result : :result.to_s
id = response[id_key]
result = response[result_key]
Response.new(id: id, result: result, response: response)
end
# Instantiate a Response object.
#
# @param id [Integer, String, NilClass] It MUST be the same as the value of the
# id member in the Request Object. If there was an error in detecting the id
# in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.
# @param result [Integer, String, Array, Hash, NilClass] Result of the method.
# @param response [Hash] A response hash. The default value is nil.
def initialize(id:, result:, response: nil)
@id = id
@result = result
@response = response
end
end
end

View File

@ -105,6 +105,8 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency 'sinatra'
spec.add_runtime_dependency 'sysrandom'
spec.add_runtime_dependency 'warden'
# Required for JSON-RPC client
spec.add_runtime_dependency 'em-http-request'
# TimeZone info
spec.add_runtime_dependency 'tzinfo-data'
# Gem for dealing with SSHKeys