diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index c9bef7c..399e070 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -129,6 +129,11 @@ class Client # verification. When false, the client will accept any certificate, # including self-signed or expired ones. Should only be disabled # for testing purposes. + # + # @param tls_info [Boolean, nil] Enable collection of TLS certificate + # information on responses. When true, {Response#tls_info} will return + # a {Wreq::TlsInfo} object for HTTPS responses. Collection is opt-in + # because retaining certificate-chain bytes has a cost. Defaults to false. # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index fc77eac..6779492 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -169,6 +169,78 @@ def chunks # response.close def close end + + # Get TLS certificate information from the response. + # + # Returns a {Wreq::TlsInfo} object when TLS information collection was + # enabled on the client via +tls_info: true+ and the response was received + # over HTTPS. Returns +nil+ when collection was not enabled, the response + # did not use TLS, or the native transport has no TLS information. + # + # @return [Wreq::TlsInfo, nil] TLS certificate information, or nil + # @example + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # tls = response.tls_info + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + def tls_info + end + end + + # TLS certificate information extracted from a response. + # + # This is an immutable value object returned by {Response#tls_info} when + # TLS information collection is enabled on the client. Certificate data + # is DER-encoded and independent of response-body consumption and + # connection-pool reuse. + # + # Callers can pass DER bytes to +OpenSSL::X509::Certificate.new+ for + # parsing, subject/issuer inspection, or fingerprint formatting. + # + # @example Inspect TLS info + # tls = response.tls_info + # tls.peer_certificate # => "\x30\x82..." (DER binary String) + # tls.peer_certificate_chain # => ["\x30\x82...", ...] (frozen Array) + # + # @example Parse with OpenSSL + # cert = OpenSSL::X509::Certificate.new(tls.peer_certificate) + # puts cert.subject + class TlsInfo + # Get the DER-encoded leaf certificate of the peer. + # + # @return [String, nil] DER-encoded certificate as a binary String + # (+Encoding::BINARY+), or +nil+ if unavailable + def peer_certificate + end + + # Get the full peer certificate chain. + # + # The returned array is frozen and contains DER-encoded binary Strings. + # It includes the leaf certificate when the native transport supplies it. + # + # @return [Array, nil] frozen Array of DER-encoded binary Strings, + # or +nil+ if unavailable + def peer_certificate_chain + end + + # Returns a compact string representation for debugging. + # + # Only shows byte counts and certificate counts; no raw certificate + # bytes are included. + # + # @return [String] human-readable representation + # @example + # tls.inspect + # # => "#" + def inspect + end + + # Returns the same representation as {#inspect}. + # + # @return [String] + def to_s + end end end end diff --git a/src/client.rs b/src/client.rs index 3bdec58..4afa9f8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -94,6 +94,8 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, + /// Whether to collect TLS information on responses. + tls_info: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. @@ -349,6 +351,7 @@ impl Client { // TLS options. apply_option!(set_if_some, builder, params.verify, tls_cert_verification); + apply_option!(set_if_some, builder, params.tls_info, tls_info); // Network options. apply_option!(set_if_some, builder, params.proxy, proxy); diff --git a/src/client/resp.rs b/src/client/resp.rs index 9fbc08c..d548d36 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,8 +5,10 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; +use magnus::value::ReprValue; +use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; use wreq::Uri; +use wreq::tls::TlsInfo as WreqTlsInfo; use crate::{ client::body::{json::Json, stream::BodyReceiver}, @@ -40,6 +42,46 @@ enum Body { Reusable(Bytes), } +/// TLS certificate information extracted from a response. +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +struct TlsInfo { + peer_certificate: Option>, + peer_certificate_chain: Option>>, +} + +impl TlsInfo { + /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self + .peer_certificate + .as_ref() + .map(|der| ruby.str_from_slice(der)) + } + /// Get the full certificate chain as a frozen Array of binary Ruby Strings. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.peer_certificate_chain.as_ref().map(|chain| { + let ary = ruby.ary_new_capa(chain.len()); + for cert in chain { + let _ = ary.push(ruby.str_from_slice(cert)); + } + let _: Result = ary.funcall("freeze", ()); + ary + }) + } + + fn inspect(&self) -> String { + let cert_info = match &self.peer_certificate { + Some(der) => format!("peer_certificate=({} bytes)", der.len()), + None => "peer_certificate=nil".to_owned(), + }; + let chain_info = match &self.peer_certificate_chain { + Some(chain) => format!("peer_certificate_chain=({} certs)", chain.len()), + None => "peer_certificate_chain=nil".to_owned(), + }; + format!("#") + } +} + impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -167,6 +209,16 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } + /// Get TLS certificate information, if available. + fn tls_info(&self) -> Option { + self.extensions.get::().map(|info| TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + }) + } + /// Get the response body as bytes. pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { let response = rb_self.response(ruby, false)?; @@ -243,5 +295,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("json", magnus::method!(Response::json, 0))?; response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; + response.define_method("tls_info", magnus::method!(Response::tls_info, 0))?; + + let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; + tls_info_class.define_method( + "peer_certificate", + magnus::method!(TlsInfo::peer_certificate, 0), + )?; + tls_info_class.define_method( + "peer_certificate_chain", + magnus::method!(TlsInfo::peer_certificate_chain, 0), + )?; + tls_info_class.define_method("inspect", magnus::method!(TlsInfo::inspect, 0))?; + tls_info_class.define_method("to_s", magnus::method!(TlsInfo::inspect, 0))?; Ok(()) } diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb new file mode 100644 index 0000000..fc25e00 --- /dev/null +++ b/test/tls_info_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "test_helper" + +class TlsInfoTest < Minitest::Test + # ---- Opt-in behavior ---- + + def test_tls_info_nil_when_not_enabled + response = Wreq.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_nil_on_default_client + client = Wreq::Client.new + response = client.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_present_when_enabled + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + refute_nil response.tls_info + assert_instance_of Wreq::TlsInfo, response.tls_info + end + + # ---- Plain HTTP returns nil ---- + + def test_tls_info_nil_for_plain_http + client = Wreq::Client.new(tls_info: true) + response = client.get("http://httpbin.io/get") + assert_nil response.tls_info + end + + # ---- Peer certificate ---- + + def test_peer_certificate_is_binary_string + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + cert = tls.peer_certificate + refute_nil cert + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + + # ---- Peer certificate chain ---- + + def test_peer_certificate_chain_is_frozen_array + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + chain = tls.peer_certificate_chain + refute_nil chain + assert_instance_of Array, chain + assert chain.frozen?, "certificate chain array must be frozen" + assert chain.length > 0 + end + + def test_peer_certificate_chain_contains_binary_strings + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + chain.each do |cert| + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + end + + def test_peer_certificate_chain_immutable + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + assert_raises(FrozenError) { chain.push("test") } + end + + # ---- Data survives body consumption ---- + + def test_tls_info_available_after_body_read + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + _body = response.text + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + assert tls.peer_certificate.bytesize > 0 + end + + def test_tls_info_available_after_close + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + response.close + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + end + + # ---- Inspect does not leak certificate bytes ---- + + def test_inspect_shows_byte_counts_only + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + inspection = tls.inspect + assert_match(/peer_certificate=\(\d+ bytes\)/, inspection) + assert_match(/peer_certificate_chain=\(\d+ certs\)/, inspection) + assert_match(/\A# 0 + assert tls2.peer_certificate.bytesize > 0 + end +end \ No newline at end of file