diff --git a/.github/workflows/qa-tests.yml b/.github/workflows/qa-tests.yml index 4a333bf9..d236434f 100644 --- a/.github/workflows/qa-tests.yml +++ b/.github/workflows/qa-tests.yml @@ -63,11 +63,11 @@ jobs: cat Dockerfile - name: Run Firewall QA Tests - uses: AikidoSec/firewall-tester-action@v1.0.9 + uses: AikidoSec/firewall-tester-action@v1.0.11 with: dockerfile_path: ./zen-demo-ruby/Dockerfile app_port: 3000 sleep_before_test: 30 extra_args: "-e RAILS_ENV=test -e AIKIDO_CLIENT_IP_HEADER=HTTP_X_FORWARDED_FOR" max_parallel_tests: 15 - skip_tests: test_outbound_domain_blocking,test_rate_limiting_group_id_1_minute,test_user_rate_limiting_1_minute_enable_disable + skip_tests: test_rate_limiting_group_id_1_minute,test_user_rate_limiting_1_minute_enable_disable diff --git a/lib/aikido/zen.rb b/lib/aikido/zen.rb index 153f7bfc..2ac6bf50 100644 --- a/lib/aikido/zen.rb +++ b/lib/aikido/zen.rb @@ -22,7 +22,6 @@ require_relative "zen/middleware/attack_wave_protector" require_relative "zen/middleware/request_tracker" require_relative "zen/outbound_connection" -require_relative "zen/outbound_connection_monitor" require_relative "zen/runtime_settings" require_relative "zen/rate_limiter" require_relative "zen/attack_wave" @@ -213,6 +212,19 @@ class << self alias_method :set_user, :track_user end + def self.block_outbound?(connection) + context = current_context + settings = runtime_settings + + unless context.nil? + request = context.request + + return false if settings.bypassed_ips.include?(request.client_ip) + end + + settings.block_outbound?(connection) + end + # Marks that the Zen middleware was installed properly # @return void def self.middleware_installed! diff --git a/lib/aikido/zen/config.rb b/lib/aikido/zen/config.rb index 5b332a01..84f67a3c 100644 --- a/lib/aikido/zen/config.rb +++ b/lib/aikido/zen/config.rb @@ -95,7 +95,7 @@ class Config # the oldest seen users. attr_accessor :max_users_tracked - # @return [Proc{(Aikido::Zen::Request, Symbol) => Array(Integer, Hash, #each)}] + # @return [Proc{(Aikido::Zen::Request, Symbol, reason: String=nil) => Array(Integer, Hash, #each)}] # Rack handler used to respond to requests from IPs, users or others blocked in the Aikido # dashboard. attr_accessor :blocked_responder diff --git a/lib/aikido/zen/errors.rb b/lib/aikido/zen/errors.rb index 7b4d1b5a..318532be 100644 --- a/lib/aikido/zen/errors.rb +++ b/lib/aikido/zen/errors.rb @@ -114,5 +114,11 @@ def initialize(msg) super end end + + class OutboundConnectionBlockedError < StandardError + def initialize(connection) + super("Zen blocked an outbound connection to #{connection.host}.") + end + end end end diff --git a/lib/aikido/zen/outbound_connection_monitor.rb b/lib/aikido/zen/outbound_connection_monitor.rb deleted file mode 100644 index baebcb4c..00000000 --- a/lib/aikido/zen/outbound_connection_monitor.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Aikido::Zen - # This simple callable follows the Scanner API so that it can be injected into - # any Sink that wraps an HTTP library, and lets us keep track of any hosts to - # which the app communicates over HTTP. - module OutboundConnectionMonitor - def self.skips_on_nil_context? - false - end - - # This simply reports the connection to the Agent, and always returns +nil+ - # as it's not scanning for any particular attack. - # - # @param connection [Aikido::Zen::OutboundConnection] - # @return [nil] - def self.call(connection:, **) - Aikido::Zen.track_outbound(connection) - - nil - end - end -end diff --git a/lib/aikido/zen/runtime_settings.rb b/lib/aikido/zen/runtime_settings.rb index b39ac75f..50c7e804 100644 --- a/lib/aikido/zen/runtime_settings.rb +++ b/lib/aikido/zen/runtime_settings.rb @@ -11,7 +11,7 @@ module Aikido::Zen # # You can subscribe to changes with +#add_observer(object, func_name)+, which # will call the function passing the settings as an argument - RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :bypassed_ips, :received_any_stats, :blocking_mode, :blocked_user_agent_regexp, :monitored_user_agent_regexp, :user_agent_details, :blocked_ip_lists, :allowed_ip_lists, :monitored_ip_lists) do + RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :bypassed_ips, :received_any_stats, :blocking_mode, :blocked_user_agent_regexp, :monitored_user_agent_regexp, :user_agent_details, :blocked_ip_lists, :allowed_ip_lists, :monitored_ip_lists, :block_new, :domains) do def initialize(*) super self.endpoints ||= RuntimeSettings::Endpoints.new @@ -19,6 +19,7 @@ def initialize(*) self.blocked_ip_lists ||= [] self.allowed_ip_lists ||= [] self.monitored_ip_lists ||= [] + self.domains ||= [] end # @!attribute [rw] updated_at @@ -62,6 +63,12 @@ def initialize(*) # @!attribute [rw] user_agent_details # @return [Regexp] + # @!attribute [rw] block_new + # @return [Boolean] + + # @!attribute [rw] domains + # @return [Array] + # Parse and interpret the JSON response from the core API with updated # runtime settings, and apply the changes. # @@ -81,6 +88,9 @@ def update_from_runtime_config_json(data) self.received_any_stats = data["receivedAnyStats"] self.blocking_mode = data["block"] + self.block_new = data["blockNewOutgoingRequests"] + self.domains = RuntimeSettings::Domains.from_json(data["domains"]) + updated_at != last_updated_at end @@ -186,9 +196,16 @@ def monitored_ip_list_keys(ip) monitored_ip_lists.filter_map { |ip_list| ip_list.key if ip_list.include?(ip) } end + + def block_outbound?(connection) + return true if domains.include?(connection.host) && domains[connection.host].block? + + block_new && domains[connection.host].block? + end end end require_relative "runtime_settings/ip_set" require_relative "runtime_settings/ip_list" require_relative "runtime_settings/endpoints" +require_relative "runtime_settings/domains" diff --git a/lib/aikido/zen/runtime_settings/domain_settings.rb b/lib/aikido/zen/runtime_settings/domain_settings.rb new file mode 100644 index 00000000..70534152 --- /dev/null +++ b/lib/aikido/zen/runtime_settings/domain_settings.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Aikido::Zen + class RuntimeSettings::DomainSettings + def self.none + @no_settings ||= new(mode: :block) + end + + def self.from_json(data) + new( + mode: data["mode"]&.to_sym + ) + end + + attr_reader :mode + + def initialize(mode:) + raise ArgumentError, "mode must be either :block or :allow" unless [:block, :allow].include?(mode) + + @mode = mode + end + + def block? + @mode == :block + end + + def allow? + @mode == :allow + end + end +end diff --git a/lib/aikido/zen/runtime_settings/domains.rb b/lib/aikido/zen/runtime_settings/domains.rb new file mode 100644 index 00000000..1be2256b --- /dev/null +++ b/lib/aikido/zen/runtime_settings/domains.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "domain_settings" + +module Aikido::Zen + class RuntimeSettings::Domains + def self.from_json(data) + domain_pairs = Array(data).map do |value| + hostname = value["hostname"].downcase + settings = RuntimeSettings::DomainSettings.from_json(value) + [hostname, settings] + end + + new(domain_pairs.to_h) + end + + def initialize(domains = {}) + @domains = domains + @domains.default = RuntimeSettings::DomainSettings.none + end + + def [](hostname) + @domains[hostname.downcase] + end + + def include?(hostname) + @domains.key?(hostname.downcase) + end + + def size + @domains.size + end + end +end diff --git a/lib/aikido/zen/sinks.rb b/lib/aikido/zen/sinks.rb index 120d9f4f..3ff307ca 100644 --- a/lib/aikido/zen/sinks.rb +++ b/lib/aikido/zen/sinks.rb @@ -14,6 +14,9 @@ require_relative "sinks/file" require_relative "sinks/socket" require_relative "sinks/resolv" + +# HTTP clients + require_relative "sinks/net_http" # http.rb aims to support and is tested against Ruby 3.0+: @@ -29,6 +32,8 @@ require_relative "sinks/async_http" require_relative "sinks/em_http" +# Database drivers + require_relative "sinks/mysql2" require_relative "sinks/pg" require_relative "sinks/sqlite3" diff --git a/lib/aikido/zen/sinks/async_http.rb b/lib/aikido/zen/sinks/async_http.rb index e3be397b..56aedf4d 100644 --- a/lib/aikido/zen/sinks/async_http.rb +++ b/lib/aikido/zen/sinks/async_http.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Async module HTTP SINK = Sinks.add("async-http", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -52,8 +50,16 @@ def self.load_sinks! connection = OutboundConnection.from_uri(uri) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/curb.rb b/lib/aikido/zen/sinks/curb.rb index a7fe8a22..17419b93 100644 --- a/lib/aikido/zen/sinks/curb.rb +++ b/lib/aikido/zen/sinks/curb.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Curl SINK = Sinks.add("curb", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -64,8 +62,16 @@ def self.load_sinks! connection = OutboundConnection.from_uri(URI(url)) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/em_http.rb b/lib/aikido/zen/sinks/em_http.rb index 588b4ec2..68873267 100644 --- a/lib/aikido/zen/sinks/em_http.rb +++ b/lib/aikido/zen/sinks/em_http.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module EventMachine module HttpRequest SINK = Sinks.add("em-http-request", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -50,7 +48,15 @@ def self.load_sinks! port: req.port ) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + + Aikido::Zen.track_outbound(connection) end end end diff --git a/lib/aikido/zen/sinks/excon.rb b/lib/aikido/zen/sinks/excon.rb index 9d26bbfb..2874379c 100644 --- a/lib/aikido/zen/sinks/excon.rb +++ b/lib/aikido/zen/sinks/excon.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Excon SINK = Sinks.add("excon", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -56,8 +54,16 @@ def self.load_sinks! connection = OutboundConnection.from_uri(request.uri) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/http.rb b/lib/aikido/zen/sinks/http.rb index 5647df52..6163123c 100644 --- a/lib/aikido/zen/sinks/http.rb +++ b/lib/aikido/zen/sinks/http.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module HTTP SINK = Sinks.add("http", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -70,8 +68,16 @@ def self.load_sinks! connection = Helpers.build_outbound(req) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/httpclient.rb b/lib/aikido/zen/sinks/httpclient.rb index a84faef1..5d37dff0 100644 --- a/lib/aikido/zen/sinks/httpclient.rb +++ b/lib/aikido/zen/sinks/httpclient.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module HTTPClient SINK = Sinks.add("httpclient", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -50,8 +48,16 @@ def self.sink(req, &block) context["ssrf.request"] = wrapped_request end + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + yield ensure context["ssrf.request"] = prev_request if context diff --git a/lib/aikido/zen/sinks/httpx.rb b/lib/aikido/zen/sinks/httpx.rb index 4a3269a7..3f22d79c 100644 --- a/lib/aikido/zen/sinks/httpx.rb +++ b/lib/aikido/zen/sinks/httpx.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module HTTPX SINK = Sinks.add("httpx", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -55,8 +53,16 @@ def self.load_sinks! connection = OutboundConnection.from_uri(request.uri) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + request.on(:response) do |response| Scanners::SSRFScanner.track_redirects( request: wrapped_request, diff --git a/lib/aikido/zen/sinks/net_http.rb b/lib/aikido/zen/sinks/net_http.rb index f5d31f49..40f196c2 100644 --- a/lib/aikido/zen/sinks/net_http.rb +++ b/lib/aikido/zen/sinks/net_http.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Net module HTTP SINK = Sinks.add("net-http", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -86,8 +84,16 @@ def self.load_sinks! connection = Helpers.build_outbound(self) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/patron.rb b/lib/aikido/zen/sinks/patron.rb index 07507973..5fdb4adc 100644 --- a/lib/aikido/zen/sinks/patron.rb +++ b/lib/aikido/zen/sinks/patron.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../scanners/ssrf_scanner" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Patron SINK = Sinks.add("patron", scanners: [ - Scanners::SSRFScanner, - OutboundConnectionMonitor + Scanners::SSRFScanner ]) module Helpers @@ -59,8 +57,16 @@ def self.load_sinks! connection = OutboundConnection.from_uri(URI(request.url)) + if Aikido::Zen.block_outbound?(connection) + Sinks::DSL.presafe do + raise OutboundConnectionBlockedError.new(connection) + end + end + Helpers.scan(wrapped_request, connection, "request") + Aikido::Zen.track_outbound(connection) + response = original_call.call Scanners::SSRFScanner.track_redirects( diff --git a/lib/aikido/zen/sinks/typhoeus.rb b/lib/aikido/zen/sinks/typhoeus.rb index 2b10fc8c..4f01e3de 100644 --- a/lib/aikido/zen/sinks/typhoeus.rb +++ b/lib/aikido/zen/sinks/typhoeus.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true require_relative "../sink" -require_relative "../outbound_connection_monitor" module Aikido::Zen module Sinks module Typhoeus SINK = Sinks.add("typhoeus", scanners: [ - Aikido::Zen::Scanners::SSRFScanner, - Aikido::Zen::OutboundConnectionMonitor + Aikido::Zen::Scanners::SSRFScanner ]) before_callback = ->(request) { @@ -24,12 +22,20 @@ module Typhoeus context["ssrf.request"] = wrapped_request end + connection = Aikido::Zen::OutboundConnection.from_uri(URI(request.base_url)) + + if Aikido::Zen.block_outbound?(connection) + raise OutboundConnectionBlockedError.new(connection) + end + SINK.scan( - connection: Aikido::Zen::OutboundConnection.from_uri(URI(request.base_url)), + connection: connection, request: wrapped_request, operation: "request" ) + Aikido::Zen.track_outbound(connection) + request.on_headers do |response| context["ssrf.request"] = prev_request if context @@ -56,12 +62,14 @@ module Typhoeus ) context["ssrf.request"] = last_effective_request if context + connection = Aikido::Zen::OutboundConnection.from_uri(URI(response.effective_url)) + # In this case, we can't actually stop the request from happening, but # we can scan again (now that we know another request happened), to # stop the response from being exposed to the user. This downgrades # the SSRF into a blind SSRF, which is better than doing nothing. SINK.scan( - connection: Aikido::Zen::OutboundConnection.from_uri(URI(response.effective_url)), + connection: connection, request: last_effective_request, operation: "request" ) diff --git a/test/aikido/zen/outbound_connection_monitor_test.rb b/test/aikido/zen/outbound_connection_monitor_test.rb deleted file mode 100644 index 9d80326c..00000000 --- a/test/aikido/zen/outbound_connection_monitor_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class Aikido::Zen::OutboundConnectionMonitorTest < ActiveSupport::TestCase - setup do - @monitor = Aikido::Zen::OutboundConnectionMonitor - end - - test "tells the agent to track the connection" do - conn = Aikido::Zen::OutboundConnection.new(host: "example.com", port: 443) - - agent = Minitest::Mock.new - agent.expect :track_outbound, nil, [conn] - - Aikido.stub_const(:Zen, agent) do - @monitor.call(connection: conn) - - assert_mock agent - end - end - - test "returns nil" do - conn = Aikido::Zen::OutboundConnection.new(host: "example.com", port: 443) - assert_nil @monitor.call(connection: conn) - end -end diff --git a/test/aikido/zen/runtime_settings/domain_settings_tests.rb b/test/aikido/zen/runtime_settings/domain_settings_tests.rb new file mode 100644 index 00000000..d550b991 --- /dev/null +++ b/test/aikido/zen/runtime_settings/domain_settings_tests.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "test_helper" + +class Aikido::Zen::RuntimeSettings::DomainSettingsTest < ActiveSupport::TestCase +end diff --git a/test/aikido/zen/runtime_settings_test.rb b/test/aikido/zen/runtime_settings_test.rb index 41222336..38250ecc 100644 --- a/test/aikido/zen/runtime_settings_test.rb +++ b/test/aikido/zen/runtime_settings_test.rb @@ -17,7 +17,18 @@ class Aikido::Zen::RuntimeSettingsTest < ActiveSupport::TestCase "blockedUserIds" => [], "allowedIPAddresses" => [], "receivedAnyStats" => false, - "block" => true + "block" => true, + "blockNewOutgoingRequests" => true, + "domains" => [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] }) assert_equal Time.utc(2024, 5, 31, 16, 8, 37), @settings.updated_at @@ -27,6 +38,31 @@ class Aikido::Zen::RuntimeSettingsTest < ActiveSupport::TestCase assert_equal Aikido::Zen::RuntimeSettings::IPSet.new, @settings.bypassed_ips assert_equal false, @settings.received_any_stats assert_equal true, @settings.blocking_mode + assert_equal true, @settings.block_new + + assert_equal 2, @settings.domains.size + assert_includes @settings.domains, "safe.example.com" + assert_includes @settings.domains, "evil.example.com" + + safe_domain = @settings.domains["safe.example.com"] + assert_kind_of Aikido::Zen::RuntimeSettings::DomainSettings, safe_domain + assert_equal :allow, safe_domain.mode + + # TODO: move + refute safe_domain.block? + + evil_domain = @settings.domains["evil.example.com"] + assert_kind_of Aikido::Zen::RuntimeSettings::DomainSettings, evil_domain + assert_equal :block, evil_domain.mode + + # TODO: move + assert evil_domain.block? + + # TODO: move + new_domain = @settings.domains["new.example.com"] + assert_kind_of Aikido::Zen::RuntimeSettings::DomainSettings, new_domain + assert_equal :block, new_domain.mode + assert new_domain.block? end test "#update_from_runtime_config_json from a JSON response without the block key" do diff --git a/test/aikido/zen/sinks/async_http_test.rb b/test/aikido/zen/sinks/async_http_test.rb index 80bbb927..24453276 100644 --- a/test/aikido/zen/sinks/async_http_test.rb +++ b/test/aikido/zen/sinks/async_http_test.rb @@ -287,4 +287,218 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + Sync do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + Sync do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + Sync do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + Sync do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + Sync do + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + skip "requests may be executed on another thread without access to the current context" + + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + Sync do + client = Async::HTTP::Internet.new + client.get(@safe_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@evil_uri) do |response| + assert_equal "OK (80)", response.body.read + end + + client = Async::HTTP::Internet.new + client.get(@new_uri) do |response| + assert_equal "OK (80)", response.body.read + end + end + end + end end diff --git a/test/aikido/zen/sinks/curb_test.rb b/test/aikido/zen/sinks/curb_test.rb index 10a762b0..a2a70111 100644 --- a/test/aikido/zen/sinks/curb_test.rb +++ b/test/aikido/zen/sinks/curb_test.rb @@ -238,4 +238,168 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = Curl.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = Curl.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end end diff --git a/test/aikido/zen/sinks/em_http_test.rb b/test/aikido/zen/sinks/em_http_test.rb index ef2c8acc..488449b6 100644 --- a/test/aikido/zen/sinks/em_http_test.rb +++ b/test/aikido/zen/sinks/em_http_test.rb @@ -255,4 +255,180 @@ def within_reactor(&block) end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + http = make_request(:get, @safe_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @evil_uri) + assert_equal "OK (80)", http.response + + http = make_request(:get, @new_uri) + assert_equal "OK (80)", http.response + end + + private + + # Makes a request within the EM reactor loop and returns the EM::HTTP object + def make_request(verb, uri, **options) + http = nil + EventMachine.run do + http = EventMachine::HttpRequest.new(uri).public_send(verb, **options) + http.callback { EventMachine.stop } + end + http + end + end end diff --git a/test/aikido/zen/sinks/excon_test.rb b/test/aikido/zen/sinks/excon_test.rb index 4261edff..9ec138ba 100644 --- a/test/aikido/zen/sinks/excon_test.rb +++ b/test/aikido/zen/sinks/excon_test.rb @@ -567,4 +567,168 @@ class PassingOptionsTest < self end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = Excon.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Excon.get(@new_uri) + assert_equal "OK (80)", response.body + end + end end diff --git a/test/aikido/zen/sinks/http_test.rb b/test/aikido/zen/sinks/http_test.rb index b3464f1b..951c5e33 100644 --- a/test/aikido/zen/sinks/http_test.rb +++ b/test/aikido/zen/sinks/http_test.rb @@ -246,4 +246,168 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = HTTP.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTP.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end end diff --git a/test/aikido/zen/sinks/httpclient_test.rb b/test/aikido/zen/sinks/httpclient_test.rb index 118d897b..20f432f7 100644 --- a/test/aikido/zen/sinks/httpclient_test.rb +++ b/test/aikido/zen/sinks/httpclient_test.rb @@ -582,4 +582,168 @@ class AsyncMethodsTest < self end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = HTTPClient.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = HTTPClient.get(@new_uri) + assert_equal "OK (80)", response.body + end + end end diff --git a/test/aikido/zen/sinks/httpx_test.rb b/test/aikido/zen/sinks/httpx_test.rb index ec7f390d..7e123f7d 100644 --- a/test/aikido/zen/sinks/httpx_test.rb +++ b/test/aikido/zen/sinks/httpx_test.rb @@ -242,4 +242,168 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = HTTPX.get(@safe_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@evil_uri) + assert_equal "OK (80)", response.body.to_s + + response = HTTPX.get(@new_uri) + assert_equal "OK (80)", response.body.to_s + end + end end diff --git a/test/aikido/zen/sinks/net_http_test.rb b/test/aikido/zen/sinks/net_http_test.rb index 94637e4d..6aa4f6d4 100644 --- a/test/aikido/zen/sinks/net_http_test.rb +++ b/test/aikido/zen/sinks/net_http_test.rb @@ -10,6 +10,8 @@ class SSRFDetectionTest < ActiveSupport::TestCase setup do stub_request(:get, "https://localhost/safe") .to_return(status: 200, body: "OK") + + @settings = Aikido::Zen.runtime_settings end test "allows normal requests" do @@ -463,4 +465,160 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + + test "does not fail if a context is not set" do + Aikido::Zen.current_context = nil + + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + assert_equal "OK (80)", Net::HTTP.get(@safe_uri) + + assert_equal "OK (80)", Net::HTTP.get(@evil_uri) + + assert_equal "OK (80)", Net::HTTP.get(@new_uri) + end + end end diff --git a/test/aikido/zen/sinks/patron_test.rb b/test/aikido/zen/sinks/patron_test.rb index ece73263..6600fe2b 100644 --- a/test/aikido/zen/sinks/patron_test.rb +++ b/test/aikido/zen/sinks/patron_test.rb @@ -224,4 +224,186 @@ class ConnectionTrackingTest < ActiveSupport::TestCase end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + session = Patron::Session.new(base_url: @safe_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @evil_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + + session = Patron::Session.new(base_url: @new_uri) + response = session.get("/") + assert_equal "OK (80)", response.body.to_s + end + end end diff --git a/test/aikido/zen/sinks/typhoeus_test.rb b/test/aikido/zen/sinks/typhoeus_test.rb index 9091c1bd..8a61b2f8 100644 --- a/test/aikido/zen/sinks/typhoeus_test.rb +++ b/test/aikido/zen/sinks/typhoeus_test.rb @@ -430,4 +430,168 @@ class HydraTest < self end end end + + class ConnectionBlockingTest < ActiveSupport::TestCase + include StubsCurrentContext + include HTTPConnectionTrackingAssertions + + # Override StubCurrentContext#current_context to provide a request with an IP + # necessary for testing bypassed IPs. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + setup do + @settings = Aikido::Zen.runtime_settings + + @safe_uri = URI("http://safe.example.com/") + @evil_uri = URI("http://evil.example.com/") + @new_uri = URI("http://new.example.com/") + + stub_request(:any, @safe_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @evil_uri).to_return(status: 200, body: "OK (80)") + stub_request(:any, @new_uri).to_return(status: 200, body: "OK (80)") + end + + DEFAULT_RUNTIME_CONFIG = { + "success" => true, + "serviceId" => 1234, + "configUpdatedAt" => 1717171717000, + "heartbeatIntervalInMS" => 60000, + "endpoints" => [], + "blockedUserIds" => [], + "allowedIPAddresses" => [], + "receivedAnyStats" => false, + "block" => true + } + + DEFAULT_DOMAINS = [ + { + "hostname" => "safe.example.com", + "mode" => "allow" + }, + { + "hostname" => "evil.example.com", + "mode" => "block" + } + ] + + def configure_domains(block_new: nil, domains: nil, bypassed_ips: []) + data = DEFAULT_RUNTIME_CONFIG.merge( + { + "allowedIPAddresses" => bypassed_ips, + "blockNewOutgoingRequests" => block_new, + "domains" => domains + }.compact + ) + + @settings.update_from_runtime_config_json(data) + end + + test "all requests are allowed by default" do + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when blockNewOutgoingRequests is false and the domain list is empty" do + configure_domains(block_new: false, domains: []) + + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are blocked when blockNewOutgoingRequests is true and the domain list is empty" do + configure_domains(block_new: true, domains: []) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + end + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are blocked when blockNewOutgoingRequests is true" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to allowed domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + end + + test "requests to blocked domains are blocked when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + assert_raises(Aikido::Zen::OutboundConnectionBlockedError) do + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + end + end + + test "requests to unknown domains are allowed when blockNewOutgoingRequests is false" do + configure_domains(block_new: false, domains: DEFAULT_DOMAINS) + + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + + test "all requests are allowed when the client IP is in the bypassed IPs list" do + configure_domains(block_new: true, domains: DEFAULT_DOMAINS, bypassed_ips: ["1.2.3.4"]) + + response = Typhoeus.get(@safe_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@evil_uri) + assert_equal "OK (80)", response.body + + response = Typhoeus.get(@new_uri) + assert_equal "OK (80)", response.body + end + end end