From 3f8743b30c80fe65996d55efc0aeea46d36bdc3e Mon Sep 17 00:00:00 2001 From: Nick Knipe Date: Thu, 3 Oct 2024 13:23:05 -0700 Subject: [PATCH] Option to allow localhost and allowlist The `disable_net_connect!` will block any outgoing requests. However, some development environments include core infrastructure which requires gRPC communication. For example, projects utilizing a Bigtable emulator that need to keep this dependency functioning as part of the local system while simultanesouly needing disable system-external gRPC calls. This commit provides an option to allow localhost-like connections, and specify an allowlist of hosts for which gRPC requests should not be disabled. It shadows the pattern used by the Webmock Ruby library. --- lib/grpc_mock/api.rb | 4 +- lib/grpc_mock/configuration.rb | 4 +- lib/grpc_mock/grpc_stub_adapter.rb | 44 ++++- spec/examples/hello/hello_client.rb | 17 +- spec/rspec_spec.rb | 294 ++++++++++++++++++++++++++++ 5 files changed, 350 insertions(+), 13 deletions(-) diff --git a/lib/grpc_mock/api.rb b/lib/grpc_mock/api.rb index 2e40384..f385e15 100644 --- a/lib/grpc_mock/api.rb +++ b/lib/grpc_mock/api.rb @@ -15,8 +15,10 @@ def request_including(values) GrpcMock::Matchers::RequestIncludingMatcher.new(values) end - def disable_net_connect! + def disable_net_connect!(allow_localhost: false, allow: nil) GrpcMock.config.allow_net_connect = false + GrpcMock.config.allow_localhost = allow_localhost + GrpcMock.config.allow = allow end def allow_net_connect! diff --git a/lib/grpc_mock/configuration.rb b/lib/grpc_mock/configuration.rb index 1db88a8..34ba4f7 100644 --- a/lib/grpc_mock/configuration.rb +++ b/lib/grpc_mock/configuration.rb @@ -2,10 +2,12 @@ module GrpcMock class Configuration - attr_accessor :allow_net_connect + attr_accessor :allow_net_connect, :allow_localhost, :allow def initialize @allow_net_connect = true + @allow_localhost = false + @allow = nil end end end diff --git a/lib/grpc_mock/grpc_stub_adapter.rb b/lib/grpc_mock/grpc_stub_adapter.rb index 05bb528..079462b 100644 --- a/lib/grpc_mock/grpc_stub_adapter.rb +++ b/lib/grpc_mock/grpc_stub_adapter.rb @@ -26,7 +26,7 @@ def request_response(method, request, *args, metadata: {}, return_op: false, **k else mock.evaluate(request, call.single_req_view) end - elsif GrpcMock.config.allow_net_connect + elsif connection_allowed? super else raise NetConnectNotAllowedError, method @@ -52,7 +52,7 @@ def client_streamer(method, requests, *args, metadata: {}, return_op: false, **k else mock.evaluate(r, call.multi_req_view) end - elsif GrpcMock.config.allow_net_connect + elsif connection_allowed? super else raise NetConnectNotAllowedError, method @@ -76,7 +76,7 @@ def server_streamer(method, request, *args, metadata: {}, return_op: false, **kw else mock.evaluate(request, call.single_req_view) end - elsif GrpcMock.config.allow_net_connect + elsif connection_allowed? super else raise NetConnectNotAllowedError, method @@ -100,12 +100,48 @@ def bidi_streamer(method, requests, *args, metadata: {}, return_op: false, **kwa else mock.evaluate(r, nil) # FIXME: provide BidiCall equivalent end - elsif GrpcMock.config.allow_net_connect + elsif connection_allowed? super else raise NetConnectNotAllowedError, method end end + + def connection_allowed? + return true if GrpcMock.config.allow_net_connect + + uri = @host.include?("://") ? URI.parse(@host) : URI.parse("http://#{@host}") + return true if GrpcMock.config.allow_localhost && localhost_allowed?(uri) + + net_connection_explicitly_allowed?(GrpcMock.config.allow, uri) + end + + def localhost_allowed?(uri) + return false unless GrpcMock.config.allow_localhost + %w(localhost 127.0.0.1 0.0.0.0 [::1]).include?(uri.host) + end + + def net_connection_explicitly_allowed?(allowed, uri) + return false unless GrpcMock.config.allow + + case allowed + when Array + allowed.any? { |allowed_item| net_connection_explicitly_allowed?(allowed_item, uri) } + when Regexp + (uri.to_s =~ allowed) != nil || + ("#{uri.scheme}://#{uri.host}" =~ allowed) != nil && uri.port == uri.default_port + when String + allowed == uri.to_s || + allowed == uri.host || + allowed == "#{uri.host}:#{uri.port}" || + allowed == "#{uri.scheme}://#{uri.host}:#{uri.port}" || + allowed == "#{uri.scheme}://#{uri.host}" && uri.port == uri.default_port + else + if allowed.respond_to?(:call) + allowed.call(uri) + end + end + end end def self.disable! diff --git a/spec/examples/hello/hello_client.rb b/spec/examples/hello/hello_client.rb index 786028a..9817943 100644 --- a/spec/examples/hello/hello_client.rb +++ b/spec/examples/hello/hello_client.rb @@ -1,23 +1,26 @@ require_relative './hello_services_pb' class HelloClient - def initialize - @client = Hello::Hello::Stub.new('localhost:8000', :this_channel_is_insecure) + def initialize(host = 'localhost:8000') + @client = Hello::Hello::Stub.new(host, :this_channel_is_insecure) end - def send_message(msg, client_stream: false, server_stream: false, metadata: {}) + def send_message(msg, client_stream: false, server_stream: false, metadata: {}, timeout: 0.1) + options = { metadata: metadata } + options[:deadline] = Time.now + timeout if timeout + if client_stream && server_stream m = Hello::HelloStreamRequest.new(msg: msg) - @client.hello_stream([m].to_enum, metadata: metadata) + @client.hello_stream([m].to_enum, **options) elsif client_stream m = Hello::HelloStreamRequest.new(msg: msg) - @client.hello_client_stream([m].to_enum, metadata: metadata) + @client.hello_client_stream([m].to_enum, **options) elsif server_stream m = Hello::HelloRequest.new(msg: msg) - @client.hello_server_stream(m, metadata: metadata) + @client.hello_server_stream(m, **options) else m = Hello::HelloRequest.new(msg: msg) - @client.hello(m, metadata: metadata) + @client.hello(m, **options) end end end diff --git a/spec/rspec_spec.rb b/spec/rspec_spec.rb index 1438407..556edc8 100644 --- a/spec/rspec_spec.rb +++ b/spec/rspec_spec.rb @@ -97,4 +97,298 @@ end end end + + context 'disable_net_connect with exception for localhost' do + before do + GrpcMock.disable_net_connect!(allow_localhost: true) + end + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'but client does not reference localhost' do + let(:client) do + HelloClient.new('example.com:8000') + end + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + end + + context 'disable_net_connect with allow specified' do + before do + GrpcMock.disable_net_connect!(allow: allow_list) + end + + let(:client) do + HelloClient.new('example.com:8000') + end + + context 'as nil' do + let(:allow_list) { nil } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + + context 'as empty array' do + let(:allow_list) { [] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + + context 'as string' do + let(:allow_list) { 'example.com' } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'with a port' do + let(:allow_list) { 'example.com:8000' } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + end + + context 'with a non-matching port' do + let(:allow_list) { 'example.com:8888' } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + + context 'that does not match' do + let(:allow_list) { 'exmple.com'} + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + end + + context 'as array of strings' do + let(:allow_list) { ['http://example.com', 'foo.com'] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'that does not match' do + let(:allow_list) { ['exmple.com', 'foo.com'] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + end + + context 'as array of procs' do + let(:allow_list) { [proc { |uri| uri.host.starts_with?('ex') }] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'that does not match' do + let(:allow_list) { [proc { |uri| uri.host.starts_with?('ax') }] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + end + + context 'as array of regex' do + let(:allow_list) { [/ex/, /foo/] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GRPC::Unavailable) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GRPC::Unavailable) } + end + + context 'that does not match' do + let(:allow_list) { [/ax/, /foo/] } + + context 'when request_response' do + it { expect { client.send_message('hello!') } .to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when server_stream' do + it { expect { client.send_message('hello!', server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when client_stream' do + it { expect { client.send_message('hello!', client_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + + context 'when bidi_stream' do + it { expect { client.send_message('hello!', client_stream: true, server_stream: true) }.to raise_error(GrpcMock::NetConnectNotAllowedError) } + end + end + end + end end