From 820053c94340d62c8a6503777aaa9c628aefc3b2 Mon Sep 17 00:00:00 2001 From: Ben Ubois Date: Fri, 31 Jul 2026 15:28:34 +0200 Subject: [PATCH] Add HTTP.blocklist for denying requests by IP/host Adds an opt-in blocklist that raises HTTP::BlockedHostError if the request resolves to a host or IP address which is on the list. --- .mutant.yml | 8 ++ CHANGELOG.md | 19 ++++ README.md | 44 ++++++++ lib/http.rb | 1 + lib/http/blocklist.rb | 136 +++++++++++++++++++++++ lib/http/chainable.rb | 17 +++ lib/http/client.rb | 6 +- lib/http/connection.rb | 2 +- lib/http/connection/internals.rb | 18 ++++ lib/http/errors.rb | 3 + lib/http/options.rb | 3 +- lib/http/options/definitions.rb | 19 ++++ lib/http/session.rb | 6 +- sig/http.rbs | 38 +++++++ test/http/blocklist_test.rb | 160 ++++++++++++++++++++++++++++ test/http/connection_test.rb | 48 +++++++++ test/http/options/blocklist_test.rb | 46 ++++++++ test/http/options/merge_test.rb | 1 + test/http_test.rb | 47 ++++++++ 19 files changed, 616 insertions(+), 6 deletions(-) create mode 100644 lib/http/blocklist.rb create mode 100644 test/http/blocklist_test.rb create mode 100644 test/http/options/blocklist_test.rb diff --git a/.mutant.yml b/.mutant.yml index b8f3653b..ceabbc6d 100644 --- a/.mutant.yml +++ b/.mutant.yml @@ -16,6 +16,10 @@ requires: mutation: operators: full timeout: 10.0 + ignore_patterns: + # getaddrinfo resolves the same addresses regardless of the socktype and + # protocol arguments, so mutating them cannot change behavior. + - send{selector=getaddrinfo} matcher: subjects: @@ -44,6 +48,10 @@ matcher: - "HTTP::Response#init_body" - "HTTP::Response#init_request" # Equivalent mutations — semantically identical code that mutant cannot distinguish + # `is_a?` -> `instance_of?`; Blocklist is never subclassed + - "HTTP::Blocklist.new" + # `downcase` -> `upcase`; case folding is applied to both rules and queries + - "HTTP::Blocklist#normalize_host" - "HTTP::Response::Body#loggable?" - "HTTP::Response::Body#read_contents" - "HTTP::Response::Body#to_s" diff --git a/CHANGELOG.md b/CHANGELOG.md index 954aa1ae..93b98e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `HTTP.blocklist` denies requests to hostnames and IP addresses you don't want + reachable, raising `HTTP::BlockedHostError`. `IPAddr` entries are matched + against every address the request host resolves to, and the socket connects to + the address that was validated, so DNS cannot answer differently between the + check and the connect. `String` entries are hostnames, matching the request + host and its subdomains. An optional `deny:` callable receives each resolved + address as an `IPAddr` and blocks it by returning true, e.g. + `HTTP.blocklist(deny: ->(address) { address.loopback? || address.private? })`. + Every redirect hop is checked. Also available as a per-request and constructor + option, either as a list of rules or as a Hash: + `HTTP.get(url, blocklist: [IPAddr.new("127.0.0.0/8")])`, + `HTTP.get(url, blocklist: { entries: [...], deny: ->(a) { a.loopback? } })`. + Note that requests using a blocklist connect to a single validated address, so + dual-stack fallback (Happy Eyeballs) does not apply to them. A blocklist + cannot be enforced through a proxy — the proxy resolves the target and makes + the connection — so combining the two warns once and the checks are advisory. + ### Fixed - Building a default `Host` header now raises `HTTP::RequestError` when the diff --git a/README.md b/README.md index 3218b83c..b9402f1e 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,50 @@ HTTP.base_uri("https://api.example.com/v1").persistent do |http| end ``` +### Blocklist + +When fetching user-supplied URLs, block hosts you don't want reachable: + +```ruby +http = HTTP.blocklist( + IPAddr.new("169.254.0.0/16"), # link-local, incl. cloud metadata + "internal.example.com", # by hostname + deny: ->(address) { address.loopback? || address.private? } +) + +http.get("http://127.0.0.1/") # raises HTTP::BlockedHostError +http.get("https://example.com") # allowed +``` + +Entries are classified by type, and the block decides anything they can't: + +- **`IPAddr`** entries are checked against *every* address the request host + resolves to, so a hostname pointing at `127.0.0.1` is blocked too. The socket + then connects to the address that was validated, so DNS cannot return a + different answer between the check and the connect. +- **`String`** entries are hostnames, matched against the request host and its + subdomains, case-insensitively: `internal.example.com` also blocks + `db.internal.example.com`. A String is *always* a hostname — write + `IPAddr.new("127.0.0.0/8")`, not `"127.0.0.0/8"`. +- **`deny:`** receives each resolved address as an `IPAddr` and blocks it by + returning true, like `retriable`'s `should_retry:`. It composes with the + entries: an address is blocked if either matches. In the `blocklist:` option + form, pass a Hash — `blocklist: { entries: [...], deny: ->(a) { ... } }`. + +Every redirect hop is checked, so a redirect into a blocked host raises rather +than being followed. `HTTP::BlockedHostError` is an `HTTP::RequestError`, not a +`ConnectionError`, so `retriable` will not retry it. + +**A blocklist cannot be enforced through a proxy**, and warns once when the two +are combined. The proxy resolves the target and makes the connection, so the +addresses checked here are not necessarily the ones reached, and a target that +only resolves from the proxy's network will fail to resolve locally. The rules +are still applied on a best-effort basis; treat the result as advisory. + +Note that with a blocklist configured http.rb connects to a single validated +address rather than letting the OS try each one, so dual-stack fallback +(Happy Eyeballs) does not apply to those requests. + ### Thread Safety Configured sessions are safe to share across threads: diff --git a/lib/http.rb b/lib/http.rb index 63e91ddf..368cce9c 100644 --- a/lib/http.rb +++ b/lib/http.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "http/errors" +require "http/blocklist" require "http/timeout/null" require "http/timeout/per_operation" require "http/timeout/global" diff --git a/lib/http/blocklist.rb b/lib/http/blocklist.rb new file mode 100644 index 00000000..3a4b7727 --- /dev/null +++ b/lib/http/blocklist.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "ipaddr" +require "socket" + +module HTTP + # Denies requests to hostnames and IP addresses matching configured rules + # + # @example + # HTTP::Blocklist.new([IPAddr.new("127.0.0.0/8"), "internal.example.com"]) + # HTTP::Blocklist.new(deny: ->(address) { address.loopback? || address.private? }) + class Blocklist + # Returns existing Blocklist or creates a new one + # + # @example + # HTTP::Blocklist.new([IPAddr.new("127.0.0.0/8"), "localhost"]) + # + # @param [HTTP::Blocklist, Array, String] entries + # @param [#call, nil] deny predicate called with each resolved address + # @return [HTTP::Blocklist] + # @api public + def self.new(entries = [], deny: nil) + return entries if entries.is_a?(Blocklist) + + super + end + + # Initializes a blocklist from address rules, hostname rules, and a predicate + # + # @example + # HTTP::Blocklist.new([IPAddr.new("169.254.0.0/16")], deny: ->(ip) { ip.loopback? }) + # + # @param [Array, String] entries address and hostname rules + # @param [#call, nil] deny called with each resolved address, truthy blocks it + # @return [HTTP::Blocklist] + # @api public + def initialize(entries, deny:) + rules = Array(entries) #: Array[IPAddr | String] + hosts = rules.grep(String) #: Array[String] + + @addresses = rules.grep(IPAddr) + @hosts = hosts.map { |entry| ".#{normalize_host(entry)}" } + @deny = deny + end + + # Whether a hostname matches any hostname rule + # + # @example + # blocklist.blocked_host?("api.example.com") + # + # @param [String] host hostname to check + # @return [Boolean] + # @api public + def blocked_host?(host) + name = ".#{normalize_host(host)}" + @hosts.any? { |rule| name.end_with?(rule) } + end + + # Whether an address matches any address rule or is denied by the predicate + # + # IPv4-mapped IPv6 addresses are reduced to their native IPv4 form first, + # so `::ffff:127.0.0.1` cannot slip past a `127.0.0.0/8` rule, and `deny` + # sees the same normalized address. + # + # @example + # blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + # + # @param [IPAddr] address address to check + # @return [Boolean] + # @api public + def blocked_address?(address) + ip = address.native + deny = @deny + + return true if @addresses.any? { |rule| rule.include?(ip) } + return false unless deny + + deny.call(ip) ? true : false + end + + # Resolves a hostname, denying it if the name or any address is blocked + # + # Hostname rules are checked before resolving, so a blocked name never + # generates DNS traffic. Every resolved address is checked, and the first + # one is returned so the caller can connect to a validated address rather + # than resolving a second time. + # + # @example + # blocklist.validate!("example.com") # => "93.184.216.34" + # + # @param [String] host hostname or IP address to resolve + # @return [String] the validated address to connect to + # @raise [HTTP::BlockedHostError] when the host or an address is blocked + # @api public + def validate!(host) + raise BlockedHostError, "blocked host: #{host}" if blocked_host?(host) + + addresses = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map(&:ip_address) + + addresses.each do |address| + next unless blocked_address?(IPAddr.new(address)) + + raise BlockedHostError, "#{host} resolves to blocked address: #{address}" + end + + addresses.first + end + + # Warns once that a blocklist cannot be enforced through a proxy + # + # @example + # blocklist.warn_proxy_incompatible + # + # @return [void] + # @api private + def warn_proxy_incompatible + return if @warned + + @warned = true + warn "HTTP::Blocklist: a blocklist cannot be enforced through a proxy, because the proxy " \ + "resolves the target and makes the connection; the addresses checked here are not " \ + "necessarily the ones reached" + end + + private + + # Normalizes a hostname for comparison + # + # @param [String] host hostname to normalize + # @return [String] downcased hostname without a trailing dot + # @api private + def normalize_host(host) + host.downcase.chomp(".") + end + end +end diff --git a/lib/http/chainable.rb b/lib/http/chainable.rb index d46cf973..701389be 100644 --- a/lib/http/chainable.rb +++ b/lib/http/chainable.rb @@ -166,6 +166,23 @@ def via(*proxy) end alias through via + # Deny requests to blocked hostnames and IP addresses + # + # @example + # HTTP.blocklist(IPAddr.new("127.0.0.0/8"), "localhost").get("http://example.com") + # + # @example Deciding per address + # HTTP.blocklist(deny: ->(address) { address.loopback? || address.private? }) + # + # @param [Array] entries address and hostname rules + # @param [#call, nil] deny called with each resolved address, truthy blocks it + # @return [HTTP::Session] + # @see HTTP::Blocklist + # @api public + def blocklist(*entries, deny: nil) + branch default_options.with_blocklist(entries: entries.flatten, deny: deny) + end + # Make client follow redirects # # @example diff --git a/lib/http/client.rb b/lib/http/client.rb index d000be7d..ba396891 100644 --- a/lib/http/client.rb +++ b/lib/http/client.rb @@ -47,13 +47,15 @@ def request(verb, uri, response: nil, encoding: nil, follow: nil, ssl: nil, ssl_context: nil, proxy: nil, nodelay: nil, features: nil, retriable: nil, socket_class: nil, ssl_socket_class: nil, timeout_class: nil, - timeout_options: nil, keep_alive_timeout: nil, base_uri: nil, persistent: nil) + timeout_options: nil, keep_alive_timeout: nil, base_uri: nil, persistent: nil, + blocklist: nil) opts = { headers: headers, params: params, form: form, json: json, body: body, response: response, encoding: encoding, follow: follow, ssl: ssl, ssl_context: ssl_context, proxy: proxy, nodelay: nodelay, features: features, retriable: retriable, socket_class: socket_class, ssl_socket_class: ssl_socket_class, timeout_class: timeout_class, timeout_options: timeout_options, - keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent }.compact + keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent, + blocklist: blocklist }.compact opts = @default_options.merge(opts) builder = Request::Builder.new(opts) req = builder.build(verb, uri) diff --git a/lib/http/connection.rb b/lib/http/connection.rb index e550a4b6..40bc33c1 100644 --- a/lib/http/connection.rb +++ b/lib/http/connection.rb @@ -255,7 +255,7 @@ def check_premature_eof(eof) # @api private def connect_socket(req, options) @socket = options.timeout_class.new(**options.timeout_options) - @socket.connect(options.socket_class, req.socket_host, req.socket_port, nodelay: options.nodelay) + @socket.connect(options.socket_class, connect_address(req, options), req.socket_port, nodelay: options.nodelay) send_proxy_connect_request(req) start_tls(req, options) diff --git a/lib/http/connection/internals.rb b/lib/http/connection/internals.rb index 3cb662eb..8e84e096 100644 --- a/lib/http/connection/internals.rb +++ b/lib/http/connection/internals.rb @@ -34,6 +34,24 @@ def flush_or_close_response(response) end end + # Resolve the address to connect to, enforcing the blocklist + # + # @param [HTTP::Request] req + # @param [HTTP::Options] options + # @return [String] host or validated address to connect to + # @raise [HTTP::BlockedHostError] when the request target is blocked + # @api private + def connect_address(req, options) + blocklist = options.blocklist + return req.socket_host unless blocklist + + proxied = req.using_proxy? + blocklist.warn_proxy_incompatible if proxied + address = blocklist.validate!(req.host) + + proxied ? req.socket_host : address + end + # Sets up SSL context and starts TLS if needed # @param (see Connection#initialize) # @return [void] diff --git a/lib/http/errors.rb b/lib/http/errors.rb index 5ff410df..d337e15f 100644 --- a/lib/http/errors.rb +++ b/lib/http/errors.rb @@ -17,6 +17,9 @@ class SocketWriteError < ConnectionError; end # Generic Request error class RequestError < Error; end + # Error raised when host matches a blocked host/ip + class BlockedHostError < RequestError; end + # Generic Response error class ResponseError < Error; end diff --git a/lib/http/options.rb b/lib/http/options.rb index ff13359d..61704a02 100644 --- a/lib/http/options.rb +++ b/lib/http/options.rb @@ -152,7 +152,8 @@ def initialize( retriable: nil, base_uri: nil, persistent: nil, - ssl_context: nil + ssl_context: nil, + blocklist: nil ) assign_options(binding) end diff --git a/lib/http/options/definitions.rb b/lib/http/options/definitions.rb index ecea56a3..0d990635 100644 --- a/lib/http/options/definitions.rb +++ b/lib/http/options/definitions.rb @@ -90,6 +90,25 @@ def retriable=(value) end end + def_option :blocklist, reader_only: true + + # Sets the blocklist of denied hostnames and addresses + # + # Accepts a {Blocklist}, a list of rules, or a Hash of `entries` and `deny`. + # + # @param [HTTP::Blocklist, Array, String, Hash, nil] value + # @api private + # @return [HTTP::Blocklist, nil] + def blocklist=(value) + @blocklist = + if value.respond_to?(:to_hash) + hash = value #: Hash[Symbol, untyped] + Blocklist.new(hash[:entries], **hash.except(:entries)) + elsif value + Blocklist.new(value) + end + end + def_option :base_uri, reader_only: true # Sets the base URI for resolving relative request paths diff --git a/lib/http/session.rb b/lib/http/session.rb index 4ada7238..e128d61d 100644 --- a/lib/http/session.rb +++ b/lib/http/session.rb @@ -108,14 +108,16 @@ def request(verb, uri, response: nil, encoding: nil, follow: nil, ssl: nil, ssl_context: nil, proxy: nil, nodelay: nil, features: nil, retriable: nil, socket_class: nil, ssl_socket_class: nil, timeout_class: nil, - timeout_options: nil, keep_alive_timeout: nil, base_uri: nil, persistent: nil, &block) + timeout_options: nil, keep_alive_timeout: nil, base_uri: nil, persistent: nil, + blocklist: nil, &block) merged = default_options.merge( { headers: headers, params: params, form: form, json: json, body: body, response: response, encoding: encoding, follow: follow, ssl: ssl, ssl_context: ssl_context, proxy: proxy, nodelay: nodelay, features: features, retriable: retriable, socket_class: socket_class, ssl_socket_class: ssl_socket_class, timeout_class: timeout_class, timeout_options: timeout_options, - keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent }.compact + keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent, + blocklist: blocklist }.compact ) client = persistent? ? nil : make_client(default_options) res = perform_request(client, verb, uri, merged) diff --git a/sig/http.rbs b/sig/http.rbs index 9b1c4a69..ee32e6f6 100644 --- a/sig/http.rbs +++ b/sig/http.rbs @@ -5,6 +5,30 @@ module HTTP extend Chainable + type blocklist_rule = IPAddr | String + + type blocklist_deny = ^(IPAddr) -> boolish + + type blocklist_entries = Blocklist | Array[blocklist_rule] | String | Hash[Symbol, untyped] + + class Blocklist + @addresses: Array[IPAddr] + @hosts: Array[String] + @deny: blocklist_deny? + @warned: bool? + + def self.new: (?blocklist_entries entries, ?deny: blocklist_deny?) -> Blocklist + def initialize: (Array[blocklist_rule] | String | nil entries, deny: blocklist_deny?) -> void + def blocked_host?: (String host) -> bool + def blocked_address?: (IPAddr address) -> bool + def validate!: (String host) -> String + def warn_proxy_incompatible: () -> void + + private + + def normalize_host: (String host) -> String + end + def self.[]: (Hash[String | Symbol, String] headers) -> Session module Base64 @@ -68,6 +92,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) -> Response | [T] ( @@ -93,6 +118,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) { (Response) -> T } -> T def timeout: (Numeric | Hash[Symbol, Numeric] | :null options) -> Session @@ -102,6 +128,7 @@ module HTTP def via: (*(String | Integer | Hash[String, String]) proxy) -> Session alias through via def follow: (?strict: bool, ?max_hops: Integer, ?on_redirect: (^(Response, Request) -> void)?) -> Session + def blocklist: (*blocklist_rule entries, ?deny: blocklist_deny?) -> Session def headers: (Hash[String | Symbol, untyped] headers) -> Session def cookies: (Hash[String | Symbol, String] | Array[Cookie] cookies) -> Session def encoding: (String | Encoding encoding) -> Session @@ -156,6 +183,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) -> Response def persistent?: () -> bool @@ -207,6 +235,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) -> Response | [T] ( @@ -232,6 +261,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) { (Response) -> T } -> T def persistent?: () -> bool @@ -646,6 +676,7 @@ module HTTP ?retriable: bool | Hash[Symbol, untyped], ?base_uri: String | URI?, ?persistent: String?, + ?blocklist: blocklist_entries?, ?ssl_context: OpenSSL::SSL::SSLContext? ) -> void def features=: (Hash[Symbol, untyped] features) -> Hash[Symbol, Feature] @@ -658,6 +689,7 @@ module HTTP def base_uri?: () -> bool def persistent=: (String? value) -> String? def persistent?: () -> bool + def blocklist=: (blocklist_entries? value) -> Blocklist? def merge: (Hash[Symbol, untyped] | Options other) -> Options def to_hash: () -> Hash[Symbol, untyped] def dup: () ?{ (Options) -> void } -> Options @@ -684,6 +716,7 @@ module HTTP attr_reader retriable: Hash[Symbol, untyped]? attr_reader base_uri: URI? attr_reader persistent: String? + attr_reader blocklist: Blocklist? def with_headers: (Hash[String | Symbol, untyped] | Headers value) -> Options def with_encoding: (String | Encoding value) -> Options @@ -692,6 +725,7 @@ module HTTP def with_retriable: (Hash[Symbol, untyped] | bool value) -> Options def with_base_uri: (String | URI value) -> Options def with_persistent: (String value) -> Options + def with_blocklist: (blocklist_entries value) -> Options def with_proxy: (Hash[Symbol, untyped] value) -> Options def with_params: (Hash[String | Symbol, untyped] value) -> Options def with_form: (untyped value) -> Options @@ -1174,6 +1208,7 @@ module HTTP def flush_pending_response: () -> void def flush_or_close_response: (Response response) -> void + def connect_address: (Request req, Options options) -> String def start_tls: (Request req, Options options) -> void def send_proxy_connect_request: (Request req) -> void def handle_proxy_connect_response: () -> void @@ -1463,6 +1498,9 @@ module HTTP class RequestError < Error end + class BlockedHostError < RequestError + end + class ResponseError < Error end diff --git a/test/http/blocklist_test.rb b/test/http/blocklist_test.rb new file mode 100644 index 00000000..3924942b --- /dev/null +++ b/test/http/blocklist_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "test_helper" + +class HTTPBlocklistTest < Minitest::Test + cover "HTTP::Blocklist*" + + LOOPBACK_V4 = IPAddr.new("127.0.0.0/8") + + DENY_LOOPBACK = lambda(&:loopback?) + + # .new + + def test_new_returns_a_given_blocklist_unchanged + blocklist = HTTP::Blocklist.new(["localhost"]) + + assert_same blocklist, HTTP::Blocklist.new(blocklist) + end + + def test_new_accepts_entries_that_are_not_arrays + assert HTTP::Blocklist.new("localhost").blocked_host?("localhost") + assert HTTP::Blocklist.new(Set["localhost"]).blocked_host?("localhost") + end + + # #blocked_host? + + def test_blocked_host_matches_the_host_and_its_subdomains + blocklist = HTTP::Blocklist.new(["example.com"]) + + assert blocklist.blocked_host?("example.com") + assert blocklist.blocked_host?("api.example.com") + refute blocklist.blocked_host?("notexample.com") + refute blocklist.blocked_host?("example.org") + end + + def test_blocked_host_ignores_case_and_a_trailing_dot + blocklist = HTTP::Blocklist.new(["Example.COM"]) + + assert blocklist.blocked_host?("API.example.com.") + end + + def test_blocked_host_only_considers_string_entries + blocklist = HTTP::Blocklist.new([IPAddr.new("127.0.0.1"), "127.0.0.2"]) + + refute blocklist.blocked_host?("127.0.0.1") + assert blocklist.blocked_host?("127.0.0.2") + end + + # #blocked_address? + + def test_blocked_address_matches_addresses_and_ranges + blocklist = HTTP::Blocklist.new([IPAddr.new("169.254.169.254"), LOOPBACK_V4]) + + assert blocklist.blocked_address?(IPAddr.new("169.254.169.254")) + assert blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + refute blocklist.blocked_address?(IPAddr.new("93.184.216.34")) + end + + def test_blocked_address_compares_within_an_address_family + blocklist = HTTP::Blocklist.new([LOOPBACK_V4, IPAddr.new("::1")]) + + assert blocklist.blocked_address?(IPAddr.new("::1")) + assert blocklist.blocked_address?(IPAddr.new("::ffff:127.0.0.1")) + refute blocklist.blocked_address?(IPAddr.new("::2")) + end + + def test_blocked_address_only_considers_ipaddr_entries + blocklist = HTTP::Blocklist.new(["localhost"]) + + refute blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + end + + def test_blocked_address_consults_deny_and_returns_a_boolean + blocklist = HTTP::Blocklist.new(deny: DENY_LOOPBACK) + + assert blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + assert_same false, blocklist.blocked_address?(IPAddr.new("93.184.216.34")) + assert_same false, HTTP::Blocklist.new([]).blocked_address?(IPAddr.new("127.0.0.1")) + end + + def test_blocked_address_blocks_when_either_entries_or_deny_match + entries_only = HTTP::Blocklist.new([LOOPBACK_V4], deny: ->(_address) { false }) + deny_only = HTTP::Blocklist.new([IPAddr.new("10.0.0.0/8")], deny: DENY_LOOPBACK) + + assert entries_only.blocked_address?(IPAddr.new("127.0.0.1")) + assert deny_only.blocked_address?(IPAddr.new("127.0.0.1")) + end + + def test_blocked_address_gives_deny_the_native_form_of_a_mapped_address + seen = [] + blocklist = HTTP::Blocklist.new(deny: ->(address) { seen << address.to_s and false }) + + blocklist.blocked_address?(IPAddr.new("::ffff:127.0.0.1")) + + assert_equal ["127.0.0.1"], seen + end + + # #validate! + + def test_validate_returns_the_first_resolved_address + blocklist = HTTP::Blocklist.new([IPAddr.new("10.0.0.0/8")]) + resolved = [Addrinfo.ip("93.184.216.34"), Addrinfo.ip("93.184.216.35")] + + assert_equal "127.0.0.1", blocklist.validate!("127.0.0.1") + + Addrinfo.stub(:getaddrinfo, resolved) do + assert_equal "93.184.216.34", blocklist.validate!("example.com") + end + end + + def test_validate_raises_for_a_blocked_hostname_without_resolving_it + blocklist = HTTP::Blocklist.new(["blocked.invalid"]) + + err = assert_raises(HTTP::BlockedHostError) { blocklist.validate!("api.blocked.invalid") } + assert_includes err.message, "api.blocked.invalid" + end + + def test_validate_raises_when_any_resolved_address_is_blocked + blocklist = HTTP::Blocklist.new([LOOPBACK_V4]) + resolved = [Addrinfo.ip("93.184.216.34"), Addrinfo.ip("127.0.0.1")] + + Addrinfo.stub(:getaddrinfo, resolved) do + err = assert_raises(HTTP::BlockedHostError) { blocklist.validate!("example.com") } + assert_includes err.message, "example.com" + assert_includes err.message, "127.0.0.1" + end + end + + def test_validate_raises_when_deny_blocks_a_resolved_address + blocklist = HTTP::Blocklist.new(deny: DENY_LOOPBACK) + + assert_raises(HTTP::BlockedHostError) { blocklist.validate!("127.0.0.1") } + end + + def test_validate_does_not_warn_and_raises_a_non_retriable_error + blocklist = HTTP::Blocklist.new([LOOPBACK_V4]) + err = nil + + warning = capture_warning do + err = assert_raises(HTTP::BlockedHostError) { blocklist.validate!("127.0.0.1") } + end + + assert_empty warning + refute_kind_of HTTP::ConnectionError, err + end + + # #warn_proxy_incompatible + + def test_warn_proxy_incompatible_warns_once + blocklist = HTTP::Blocklist.new([LOOPBACK_V4]) + + warning = capture_warning do + blocklist.warn_proxy_incompatible + blocklist.warn_proxy_incompatible + end + + assert_includes warning, "proxy" + assert_equal 1, warning.lines.count + end +end diff --git a/test/http/connection_test.rb b/test/http/connection_test.rb index 8aae7f61..5982aebb 100644 --- a/test/http/connection_test.rb +++ b/test/http/connection_test.rb @@ -1530,4 +1530,52 @@ def test_init_state_initializes_parser assert_instance_of HTTP::Response::Parser, parser end + + # --------------------------------------------------------------------------- + # blocklist enforcement + # --------------------------------------------------------------------------- + + LOOPBACK_RULES = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128")].freeze + + LOOPBACK_ADDRESSES = ["127.0.0.1", "::1"].freeze + + PROXY = { proxy_address: "proxy.example", proxy_port: 8080 }.freeze + + # Returns the host the socket was actually opened against. + def connect_to(uri: "http://localhost/", proxy: nil, **) + connected = [] + socket = fake(connect: ->(_klass, host, _port, **) { connected << host }, close: nil) + timeout_class = fake(new: socket) + req = build_req(uri: uri, proxy: proxy) + + HTTP::Connection.new(req, HTTP::Options.new(timeout_class: timeout_class, **)) + + connected.first + end + + def test_connect_uses_the_validated_address_only_when_a_blocklist_is_configured + assert_equal "localhost", connect_to + assert_includes LOOPBACK_ADDRESSES, connect_to(blocklist: [IPAddr.new("10.0.0.0/8")]) + end + + def test_connect_raises_when_the_request_host_is_blocked + assert_raises(HTTP::BlockedHostError) { connect_to(blocklist: LOOPBACK_RULES) } + end + + def test_connect_still_dials_the_proxy_but_checks_the_target + allowed = HTTP::Blocklist.new([IPAddr.new("10.0.0.0/8")]) + + capture_warning do + assert_equal "proxy.example", connect_to(proxy: PROXY, blocklist: allowed) + + assert_raises(HTTP::BlockedHostError) { connect_to(proxy: PROXY, blocklist: LOOPBACK_RULES) } + end + end + + def test_connect_warns_only_when_a_blocklist_is_combined_with_a_proxy + blocklist = HTTP::Blocklist.new([IPAddr.new("10.0.0.0/8")]) + + assert_empty(capture_warning { connect_to(blocklist: blocklist) }) + assert_includes capture_warning { connect_to(proxy: PROXY, blocklist: blocklist) }, "proxy" + end end diff --git a/test/http/options/blocklist_test.rb b/test/http/options/blocklist_test.rb new file mode 100644 index 00000000..c48ee485 --- /dev/null +++ b/test/http/options/blocklist_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +class HTTPOptionsBlocklistTest < Minitest::Test + cover "HTTP::Options*" + + def test_blocklist_coerces_option_values + existing = HTTP::Blocklist.new(["localhost"]) + + assert_nil HTTP::Options.new.blocklist + assert_kind_of HTTP::Blocklist, HTTP::Options.new(blocklist: ["localhost"]).blocklist + assert_same existing, HTTP::Options.new(blocklist: existing).blocklist + end + + def test_blocklist_accepts_a_hash_of_entries_and_deny + opts = HTTP::Options.new( + blocklist: { entries: ["localhost"], deny: lambda(&:loopback?) } + ) + + deny_only = HTTP::Options.new(blocklist: { deny: lambda(&:loopback?) }) + + assert opts.blocklist.blocked_host?("localhost") + assert opts.blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + assert deny_only.blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + end + + def test_blocklist_raises_for_an_unknown_hash_key + assert_raises(ArgumentError) { HTTP::Options.new(blocklist: { entries: [], allow: :nope }) } + end + + def test_with_blocklist_replaces_without_modifying_the_original + opts = HTTP::Options.new + result = opts.with_blocklist(["example.com"]).with_blocklist(["example.org"]) + + assert_nil opts.blocklist + assert result.blocklist.blocked_host?("example.org") + refute result.blocklist.blocked_host?("example.com") + end + + def test_blocklist_survives_a_merge_with_per_request_options + opts = HTTP::Options.new(blocklist: ["localhost"]).merge(follow: true) + + assert opts.blocklist.blocked_host?("localhost") + end +end diff --git a/test/http/options/merge_test.rb b/test/http/options/merge_test.rb index 49c611d2..181f46cc 100644 --- a/test/http/options/merge_test.rb +++ b/test/http/options/merge_test.rb @@ -64,6 +64,7 @@ def test_merges_as_expected_in_complex_cases proxy: { proxy_address: "127.0.0.1", proxy_port: 8080 }, follow: nil, retriable: nil, + blocklist: nil, base_uri: nil, socket_class: HTTP::Options.default_socket_class, nodelay: false, diff --git a/test/http_test.rb b/test/http_test.rb index 7cea9c9e..f02d0930 100644 --- a/test/http_test.rb +++ b/test/http_test.rb @@ -316,6 +316,53 @@ def test_base_uri_raises_when_persistent_host_not_given_and_no_base_uri assert_raises(ArgumentError) { HTTP.persistent } end + # .blocklist + + def test_blocklist_collects_entries_from_a_splat_or_a_single_array + splatted = HTTP.blocklist("localhost", IPAddr.new("127.0.0.0/8")).default_options.blocklist + arrayed = HTTP.blocklist([IPAddr.new("127.0.0.1"), "localhost"]).default_options.blocklist + + [splatted, arrayed].each do |blocklist| + assert blocklist.blocked_host?("localhost") + assert blocklist.blocked_address?(IPAddr.new("127.0.0.1")) + end + end + + def test_blocklist_blocks_matching_requests_and_allows_the_rest + assert_raises(HTTP::BlockedHostError) do + HTTP.blocklist(IPAddr.new("127.0.0.0/8")).get(dummy.endpoint) + end + + assert_equal 200, HTTP.blocklist(IPAddr.new("10.0.0.0/8")).get(dummy.endpoint).code + end + + def test_blocklist_deny_decides_per_address + assert_raises(HTTP::BlockedHostError) do + HTTP.blocklist(deny: lambda(&:loopback?)).get(dummy.endpoint) + end + + assert_equal 200, HTTP.blocklist(deny: lambda(&:private?)).get(dummy.endpoint).code + end + + def test_blocklist_can_be_given_per_request + assert_raises(HTTP::BlockedHostError) do + HTTP.get(dummy.endpoint, blocklist: [IPAddr.new("127.0.0.0/8")]) + end + + assert_raises(HTTP::BlockedHostError) do + HTTP.blocklist(IPAddr.new("10.0.0.0/8")).get(dummy.endpoint, blocklist: { deny: lambda(&:loopback?) }) + end + end + + def test_blocklist_raises_when_a_redirect_targets_a_blocked_host + target = "http://localhost:#{dummy.port}/" + + assert_raises(HTTP::BlockedHostError) do + HTTP.blocklist("localhost").follow + .get("#{dummy.endpoint}/cross-origin-redirect?target=#{target}") + end + end + # .persistent def test_persistent_with_host_returns_http_session