Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/wreq_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions lib/wreq_ruby/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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
# # => "#<Wreq::TlsInfo peer_certificate=(1467 bytes) peer_certificate_chain=(3 certs)>"
def inspect
end

# Returns the same representation as {#inspect}.
#
# @return [String]
def to_s
end
end
end
end
Expand Down
3 changes: 3 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ struct Builder {
// ========= TLS options =========
/// Whether to verify TLS certificates.
verify: Option<bool>,
/// Whether to collect TLS information on responses.
tls_info: Option<bool>,

// ========= Network options =========
/// Whether to disable the proxy for the client.
Expand Down Expand Up @@ -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);
Expand Down
67 changes: 66 additions & 1 deletion src/client/resp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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<Vec<u8>>,
peer_certificate_chain: Option<Vec<Vec<u8>>>,
}

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<RString> {
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<RArray> {
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<Value, Error> = 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!("#<Wreq::TlsInfo {cert_info} {chain_info}>")
}
}

impl Response {
/// Create a new [`Response`] instance.
pub fn new(response: wreq::Response) -> Self {
Expand Down Expand Up @@ -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<TlsInfo> {
self.extensions.get::<WreqTlsInfo>().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<Bytes, Error> {
let response = rb_self.response(ruby, false)?;
Expand Down Expand Up @@ -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(())
}
144 changes: 144 additions & 0 deletions test/tls_info_test.rb
Original file line number Diff line number Diff line change
@@ -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#<Wreq::TlsInfo /, inspection)
end

def test_to_s_matches_inspect
client = Wreq::Client.new(tls_info: true)
response = client.get("#{HTTPBIN_URL}/get")
tls = response.tls_info

assert_equal tls.inspect, tls.to_s
end

# ---- Connection reuse ----

def test_tls_info_on_reused_connection
client = Wreq::Client.new(tls_info: true)

resp1 = client.get("#{HTTPBIN_URL}/get")
resp2 = client.get("#{HTTPBIN_URL}/get")

tls1 = resp1.tls_info
tls2 = resp2.tls_info

refute_nil tls1
refute_nil tls2
assert tls1.peer_certificate.bytesize > 0
assert tls2.peer_certificate.bytesize > 0
end
end