mirror of
https://github.com/HoneyryderChuck/httpx.git
synced 2025-10-05 00:02:38 -04:00
a plugin which allows for requests to fail when requests are crafted to use IPs considered internal or reserved for specific usages. these SSRF vulnerabilities happen when one allows requests with urls input by an external user. This plugin is inspired, and heavily makes use of routines existing in the ssrf_filter gem: https://github.com/arkadiyt/ssrf_filter/ .
244 lines
8.5 KiB
Ruby
244 lines
8.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "support/http_helpers"
|
|
|
|
class HTTPSTest < Minitest::Test
|
|
include HTTPHelpers
|
|
include Requests
|
|
include Get
|
|
include Compression
|
|
include Head
|
|
include WithBody
|
|
include Multipart
|
|
include Headers
|
|
include ResponseBody
|
|
include IO
|
|
include Callbacks
|
|
include Errors if RUBY_ENGINE == "ruby"
|
|
include Resolvers if ENV.key?("HTTPX_RESOLVER_URI")
|
|
# TODO: uncomment as soon as nghttpx supports altsvc for HTTP/2
|
|
# include AltSvc if ENV.key?("HTTPBIN_ALTSVC_HOST")
|
|
|
|
include Plugins::Proxy unless ENV.key?("HTTPX_NO_PROXY")
|
|
include Plugins::Authentication
|
|
include Plugins::OAuth
|
|
include Plugins::FollowRedirects
|
|
include Plugins::Cookies
|
|
include Plugins::PushPromise if OpenSSL::SSL::SSLContext.instance_methods.include?(:alpn_protocols)
|
|
include Plugins::Retries
|
|
include Plugins::Expect
|
|
include Plugins::RateLimiter
|
|
include Plugins::Persistent
|
|
include Plugins::Stream
|
|
include Plugins::AWSAuthentication
|
|
include Plugins::Upgrade
|
|
include Plugins::GRPC if RUBY_ENGINE == "ruby"
|
|
include Plugins::ResponseCache
|
|
include Plugins::CircuitBreaker
|
|
include Plugins::WebDav
|
|
include Plugins::Brotli if RUBY_ENGINE == "ruby"
|
|
include Plugins::SsrfFilter
|
|
|
|
def test_ssl_session_resumption
|
|
uri = build_uri("/get")
|
|
HTTPX.with(ssl: { ssl_version: :TLSv1_2, alpn_protocols: %w[http1.1] }).plugin(SessionWithPool).wrap do |http|
|
|
http.get(uri)
|
|
conn1 = http.pool.conn_store.last
|
|
|
|
http.get(uri)
|
|
conn2 = http.pool.conn_store.last
|
|
|
|
# because there's reconnection
|
|
assert conn1 == conn2
|
|
|
|
assert conn2.io.instance_variable_get(:@io).session_reused?
|
|
end
|
|
end unless RUBY_ENGINE == "jruby"
|
|
|
|
def test_connection_coalescing
|
|
coalesced_origin = "https://#{ENV["HTTPBIN_COALESCING_HOST"]}"
|
|
HTTPX.plugin(SessionWithPool).wrap do |http|
|
|
response1 = http.get(origin)
|
|
verify_status(response1, 200)
|
|
response2 = http.get(coalesced_origin)
|
|
verify_status(response2, 200)
|
|
# introspection time
|
|
pool = http.pool
|
|
connections = pool.connections
|
|
origins = connections.map(&:origins)
|
|
assert origins.any? { |orgs| orgs.sort == [origin, coalesced_origin].sort },
|
|
"connections for #{[origin, coalesced_origin]} didn't coalesce (expected connection with both origins (#{origins}))"
|
|
|
|
unsafe_origin = URI(origin)
|
|
unsafe_origin.scheme = "http"
|
|
response3 = http.get(unsafe_origin)
|
|
verify_status(response3, 200)
|
|
|
|
# introspection time
|
|
pool = http.pool
|
|
connections = pool.connections
|
|
origins = connections.map(&:origins)
|
|
refute origins.any?([origin]),
|
|
"connection coalesced inexpectedly (expected connection with both origins (#{origins}))"
|
|
end
|
|
end if ENV.key?("HTTPBIN_COALESCING_HOST")
|
|
|
|
def test_verbose_log
|
|
log = StringIO.new
|
|
uri = build_uri("/get")
|
|
response = HTTPX.get(uri, debug: log, debug_level: 3)
|
|
verify_status(response, 200)
|
|
log_output = log.string
|
|
# assert tls output
|
|
assert log_output.include?("SSL connection using TLSv")
|
|
assert log_output.include?("ALPN, server accepted to use h2")
|
|
assert log_output.include?("Server certificate:")
|
|
assert log_output.include?(" subject: ")
|
|
assert log_output.include?(" start date: ")
|
|
assert log_output.include?(" expire date: ")
|
|
assert log_output.include?(" issuer: ")
|
|
assert log_output.include?(" SSL certificate verify ok")
|
|
|
|
# assert request headers
|
|
assert log_output.include?("HEADER: :scheme: https")
|
|
assert log_output.include?("HEADER: :method: GET")
|
|
assert log_output.include?("HEADER: :path: ")
|
|
assert log_output.include?("HEADER: :authority: ")
|
|
assert log_output.include?("HEADER: accept: */*")
|
|
# assert response headers
|
|
assert log_output.include?("HEADER: :status: 200")
|
|
assert log_output.include?("HEADER: content-type: ")
|
|
assert log_output.include?("HEADER: content-length: ")
|
|
end
|
|
|
|
# HTTP/2-specific tests
|
|
|
|
{
|
|
# TODO: turn this on when CI does keep-alive on HTTP/1.1
|
|
# http1: { uri: "https://httpbin.org/get", ssl: { alpn_protocols: %w[http/1.1] }, max_concurrent_requests: 1 },
|
|
http2: {},
|
|
}.each do |proto, proto_options|
|
|
define_method :"test_multiple_get_max_requests_#{proto}" do
|
|
uri = proto_options.delete(:uri) || URI(build_uri("/get"))
|
|
options = { max_requests: 2, **proto_options }
|
|
|
|
HTTPX.plugin(SessionWithPool).with(options).wrap do |http|
|
|
response1, response2, response3 = http.get(uri, uri, uri)
|
|
verify_status(response1, 200)
|
|
verify_body_length(response1)
|
|
verify_status(response2, 200)
|
|
verify_body_length(response2)
|
|
verify_status(response3, 200)
|
|
verify_body_length(response3)
|
|
connection_count = http.pool.connection_count
|
|
assert connection_count == 2, "expected to have 2 connections, instead have #{connection_count}"
|
|
assert http.connection_exausted, "expected 1 connnection to have exhausted"
|
|
|
|
# ssl session ought to be reused
|
|
conn = http.pool.connections.first
|
|
assert conn.io.instance_variable_get(:@io).session_reused? unless RUBY_ENGINE == "jruby"
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_http2_uncoalesce_on_misdirected
|
|
uri = build_uri("/status/421")
|
|
HTTPX.plugin(SessionWithPool).wrap do |http|
|
|
response = http.get(uri)
|
|
verify_status(response, 421)
|
|
connection_count = http.pool.connection_count
|
|
assert connection_count == 2, "expected to have 2 connections, instead have #{connection_count}"
|
|
assert response.version == "1.1", "request should have been retried with HTTP/1.1"
|
|
end
|
|
end
|
|
|
|
def test_http2_settings_timeout
|
|
uri = build_uri("/get")
|
|
HTTPX.plugin(SessionWithPool).plugin(SessionWithFrameDelay).wrap do |http|
|
|
response = http.get(uri)
|
|
verify_error_response(response, /settings_timeout/)
|
|
end
|
|
end unless RUBY_ENGINE == "jruby"
|
|
|
|
def test_http2_request_trailers
|
|
uri = build_uri("/post")
|
|
log = StringIO.new
|
|
|
|
HTTPX.wrap do |http|
|
|
total_time = start_time = nil
|
|
trailered = false
|
|
request = http.build_request("POST", uri, body: %w[this is chunked], debug: log, debug_level: 3)
|
|
request.on(:headers) do |_written_request|
|
|
start_time = HTTPX::Utils.now
|
|
end
|
|
request.on(:trailers) do |written_request|
|
|
total_time = HTTPX::Utils.elapsed_time(start_time)
|
|
written_request.trailers["x-time-spent"] = total_time
|
|
trailered = true
|
|
end
|
|
response = http.request(request)
|
|
verify_status(response, 200)
|
|
body = json_body(response)
|
|
# verify_header(body["headers"], "x-time-spent", total_time.to_s)
|
|
assert body.key?("data")
|
|
assert trailered, "trailer callback wasn't called"
|
|
|
|
# assert response headers
|
|
log_output = log.string
|
|
assert log_output.include?("HEADER: x-time-spent: #{total_time}")
|
|
end
|
|
end
|
|
|
|
def test_http2_client_sends_settings_timeout
|
|
test_server = nil
|
|
start_test_servlet(SettingsTimeoutServer) do |server|
|
|
test_server = server
|
|
uri = "#{server.origin}/"
|
|
http = HTTPX.plugin(SessionWithPool).with(timeout: { settings_timeout: 3 }, ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
|
|
response = http.get(uri)
|
|
verify_error_response(response, HTTPX::SettingsTimeoutError)
|
|
end
|
|
last_frame = test_server.frames.last
|
|
assert last_frame[:error] == :settings_timeout
|
|
end
|
|
|
|
def test_http2_client_goaway_with_no_response
|
|
start_test_servlet(KeepAlivePongServer) do |server|
|
|
uri = "#{server.origin}/"
|
|
HTTPX.plugin(SessionWithPool).with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE }) do |http|
|
|
response = http.get(uri)
|
|
verify_status(response, 200)
|
|
response = http.get(uri)
|
|
verify_error_response(response, HTTPX::Connection::HTTP2::GoawayError)
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_ssl_wrong_hostname
|
|
uri = build_uri("/get")
|
|
response = HTTPX.with(ssl: { hostname: "great-gatsby.com" }).get(uri)
|
|
verify_error_response(response, /certificate verify failed|does not match the server certificate/)
|
|
end
|
|
|
|
def test_https_request_with_ip_not_set_sni
|
|
# # server conf
|
|
ca_store = OpenSSL::X509::Store.new
|
|
ca_store.set_default_paths
|
|
ca_store.add_file(File.join(ByIpCertServer::CERTS_DIR, "ca-bundle.crt"))
|
|
|
|
start_test_servlet(ByIpCertServer) do |server|
|
|
uri = "#{server.origin}/"
|
|
HTTPX.plugin(SessionWithPool).with(ssl: { cert_store: ca_store }) do |http|
|
|
response = http.get(uri)
|
|
verify_status(response, 200)
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def scheme
|
|
"https://"
|
|
end
|
|
end
|