diff --git a/docs/fork-safety.md b/docs/fork-safety.md index 2824be3..0dfce5c 100644 --- a/docs/fork-safety.md +++ b/docs/fork-safety.md @@ -9,18 +9,20 @@ connections are not safe to reuse. If the parent has already loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. This applies to new and existing clients, module -request methods, streaming request bodies, and response body methods. Retrying -the operation in the same child raises the same error. +request methods, streaming request bodies, and response methods backed by native +state. Retrying the operation in the same child raises the same error. Read-only +response metadata such as status, headers, and captured TLS information remains +available. The parent can continue using its clients. When inherited Ruby objects are collected in the child, their native runtime state is left for the operating system to reclaim when the process exits. -## Child processes are unsupported +## HTTP work in forked children is unsupported -A process created with `fork` must not use wreq-ruby, even when it first loads -the extension after the fork. If the parent loaded wreq-ruby, native operations -in the child raise `Wreq::ForkError`. +A process created with `fork` must not start or continue HTTP work through +wreq-ruby, even when it first loads the extension after the fork. If the parent +loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. When the extension was not present in the parent, no wreq-ruby state or fork marker reaches the child. The extension cannot reliably distinguish that child diff --git a/examples/tls_info.rb b/examples/tls_info.rb new file mode 100644 index 0000000..38b25ff --- /dev/null +++ b/examples/tls_info.rb @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "openssl" +require_relative "../lib/wreq" + +url = ARGV.fetch(0, "https://example.com") +client = Wreq::Client.new(tls_info: true) +response = client.get(url) +tls_info = response.tls_info +response.close + +abort "TLS information is unavailable for #{url}" unless tls_info + +p tls_info + +if (der = tls_info.peer_certificate) + certificate = OpenSSL::X509::Certificate.new(der) + puts "Subject: #{certificate.subject}" + puts "Issuer: #{certificate.issuer}" + puts "Valid from: #{certificate.not_before}" + puts "Valid until: #{certificate.not_after}" +end + +chain = tls_info.peer_certificate_chain +chain_size = chain ? chain.length : "unavailable" +puts "Certificate chain: #{chain_size}" diff --git a/lib/wreq.rb b/lib/wreq.rb index 18ccb36..8fca103 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -12,6 +12,7 @@ require_relative "wreq_ruby/emulate" require_relative "wreq_ruby/client" require_relative "wreq_ruby/response" +require_relative "wreq_ruby/tls" require_relative "wreq_ruby/body" require_relative "wreq_ruby/header" require_relative "wreq_ruby/error" diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index e2ff2a3..31cbeb1 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -134,6 +134,11 @@ class Client # including self-signed or expired ones. Should only be disabled # for testing purposes. # + # @param tls_info [Boolean, nil] Retain peer certificate data for HTTPS + # responses. When true, {Wreq::Response#tls_info} may return a + # {Wreq::TlsInfo} object. Disabled by default because retaining + # certificate data uses additional memory. + # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the # environment. diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8a497db..4985495 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -177,6 +177,26 @@ def chunks # response.close def close end + + # Return TLS information captured for this response. + # + # Returns +nil+ when +tls_info: true+ was not enabled, the response used + # plain HTTP, or the transport supplied no TLS information. Reading or + # closing the response body does not discard captured TLS data. + # + # @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+ + # when unavailable + # @example + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # tls = response.tls_info + # + # if tls + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + # end + def tls_info + end end end end diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb new file mode 100644 index 0000000..65b4682 --- /dev/null +++ b/lib/wreq_ruby/tls.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +unless defined?(Wreq) + module Wreq + # Peer certificate data captured for one HTTPS response. + # + # Instances are returned by {Wreq::Response#tls_info}. Certificate bytes + # remain available after the response body is read or closed, even if the + # connection is later reused. + # + # The returned certificate Strings are Ruby-owned copies. Changing one does + # not alter the stored TLS data or values returned by later calls. The chain + # Array is frozen, but its String elements remain mutable. + # + # Certificates use the DER encoding described by the X.509 profile in + # RFC 5280. + # + # @example Parse the peer certificate with OpenSSL + # require "openssl" + # + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # der = response.tls_info&.peer_certificate + # + # if der + # certificate = OpenSSL::X509::Certificate.new(der) + # puts certificate.subject + # end + # @see https://www.rfc-editor.org/rfc/rfc5280#section-4.1 X.509 certificate format + class TlsInfo + # Return the peer's leaf certificate. + # + # @return [String, nil] a new DER-encoded String with + # +Encoding::BINARY+, or +nil+ when the transport did not provide one + def peer_certificate + end + + # Return the peer certificate chain. + # + # The Array is frozen. Each element is a new DER-encoded binary String. + # The chain includes the leaf certificate when the transport supplies it. + # + # @return [Array, nil] a frozen Array of certificate copies, or + # +nil+ when the transport did not provide a chain + def peer_certificate_chain + end + end + end +end + +# ======================== Ruby API Extensions ======================== + +module Wreq + class TlsInfo + # Return a compact summary for debugging. + # + # The summary reports the leaf certificate size and the number of + # certificates in the chain without printing the DER data. + # + # @return [String] TLS certificate metadata + # @example + # tls_info.inspect + # # => "#" + def inspect + certificate = peer_certificate + chain = peer_certificate_chain + certificate_size = certificate ? "#{certificate.bytesize}B" : "nil" + chain_size = chain ? chain.length : "nil" + + "#<#{self.class} peer_certificate=#{certificate_size} peer_certificate_chain=#{chain_size}>" + end + end +end diff --git a/src/arch.rs b/src/arch.rs index 91d542b..afbbc81 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -16,7 +16,7 @@ use std::mem::ManuallyDrop; /// system reclaim it when the process exits. /// /// This wrapper only controls destruction. Call [`crate::rt::ensure_current`] -/// before accessing the inner value. +/// before using process-bound state stored inside it. #[derive(Clone)] pub(crate) struct ProcessLocal(ManuallyDrop); diff --git a/src/client.rs b/src/client.rs index 6f8bf85..9a2b5c4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,6 +95,8 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, + /// Whether to retain peer certificate data on responses. + tls_info: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. @@ -358,6 +360,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 9cc2f98..a2f25d4 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -17,6 +17,7 @@ use crate::{ header::Headers, http::{StatusCode, Version}, rt, + tls::TlsInfo, }; /// A response from a request. @@ -180,6 +181,16 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } + /// Return peer certificate data retained for this response. + fn tls_info(&self) -> Option { + self.state + .as_ref() + .extensions + .get::() + .cloned() + .map(TlsInfo) + } + /// Get the response body as bytes. pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { let response = rb_self.response(ruby, false)?; @@ -258,5 +269,6 @@ 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))?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 82d852d..549a1f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod tls; use magnus::{Error, Module, Ruby, Value}; @@ -98,6 +99,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { http::include(ruby, &gem_module)?; header::include(ruby, &gem_module)?; cookie::include(ruby, &gem_module)?; + tls::include(ruby, &gem_module)?; client::include(ruby, &gem_module)?; emulate::include(ruby, &gem_module)?; #[cfg(unix)] diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..98f7e22 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,51 @@ +//! Ruby wrappers for TLS metadata attached to a response. +//! +//! Certificates use the DER encoding described by the X.509 profile in +//! [RFC 5280 section 4.1](https://www.rfc-editor.org/rfc/rfc5280#section-4.1). + +use magnus::{Error, Module, RArray, RModule, RString, Ruby, value::ReprValue}; + +/// Read-only Ruby wrapper around [`wreq::tls::TlsInfo`]. +/// +/// The native value keeps certificate bytes alive independently of the response +/// body. Its `Bytes` buffers are cheap to clone, while accessors copy the data +/// into Ruby-owned Strings so callers cannot mutate the stored metadata. +#[derive(Clone)] +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +pub(crate) struct TlsInfo(pub(crate) wreq::tls::TlsInfo); + +impl TlsInfo { + /// Copy the DER-encoded leaf certificate into a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self + .0 + .peer_certificate() + .map(|der| ruby.str_from_slice(der)) + } + + /// Copy the certificate chain into a frozen Array of binary Ruby Strings. + /// + /// Only the Array is frozen. Its Strings are independent copies and remain + /// mutable in Ruby. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.0.peer_certificate_chain().map(|chain| { + let certificates = ruby.ary_from_iter(chain.map(|cert| ruby.str_from_slice(cert))); + certificates.freeze(); + certificates + }) + } +} + +/// Define the `Wreq::TlsInfo` Ruby class and its readers. +pub(crate) fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { + 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), + )?; + Ok(()) +} diff --git a/test/support/tls_server.rb b/test/support/tls_server.rb new file mode 100644 index 0000000..a8ce995 --- /dev/null +++ b/test/support/tls_server.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "openssl" +require "socket" +require "timeout" + +# A small HTTPS server that serves every expected request on one TLS connection. +module TlsTestServer + RESPONSE_BODY = "ok" + + module_function + + def with_connection(request_count:) + tcp_server = TCPServer.new("127.0.0.1", 0) + context, certificate_der = server_context + ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context) + outcome = Queue.new + server_thread = Thread.new do + socket = ssl_server.accept + request_lines = [] + + request_count.times do |index| + request_lines << read_request(socket) + connection = (index == request_count - 1) ? "close" : "keep-alive" + socket.write(response(connection)) + socket.flush + end + + outcome << {connections: 1, requests: request_lines} + rescue => error + outcome << error + ensure + socket&.close + end + server_thread.report_on_exception = false + + yield "https://127.0.0.1:#{tcp_server.addr[1]}/", certificate_der + + result = Timeout.timeout(5) { outcome.pop } + raise result if result.is_a?(StandardError) + + result + ensure + tcp_server&.close + server_thread&.join(5) + if server_thread&.alive? + server_thread.kill + server_thread.join + end + end + + def read_request(socket) + request_line = socket.gets + raise EOFError, "client closed before sending a request" unless request_line + + loop do + line = socket.gets + raise EOFError, "client closed while sending headers" unless line + break if line == "\r\n" + end + + request_line + end + private_class_method :read_request + + def response(connection) + [ + "HTTP/1.1 200 OK", + "Content-Length: #{RESPONSE_BODY.bytesize}", + "Connection: #{connection}", + "", + RESPONSE_BODY + ].join("\r\n") + end + private_class_method :response + + def server_context + key = OpenSSL::PKey::RSA.new(2048) + certificate = OpenSSL::X509::Certificate.new + certificate.version = 2 + certificate.serial = 1 + certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=127.0.0.1") + certificate.public_key = key.public_key + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + 3600 + certificate.sign(key, OpenSSL::Digest.new("SHA256")) + + context = OpenSSL::SSL::SSLContext.new.tap do |ssl_context| + ssl_context.cert = certificate + ssl_context.key = key + end + [context, certificate.to_der] + end + private_class_method :server_context +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb new file mode 100644 index 0000000..a92b8ff --- /dev/null +++ b/test/tls_info_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "support/tls_server" + +class TlsInfoTest < Minitest::Test + HTTPBIN_HTTP_URL = ENV.fetch("HTTPBIN_HTTP_URL", HTTPBIN_URL.sub(/\Ahttps:/, "http:")) + + def test_tls_info_is_nil_when_disabled_or_request_is_plain_http + default_response = Wreq::Client.new.get("#{HTTPBIN_URL}/get") + plain_response = Wreq::Client.new(tls_info: true).get("#{HTTPBIN_HTTP_URL}/get") + + assert_nil default_response.tls_info + assert_nil plain_response.tls_info + end + + def test_certificate_data_survives_body_lifecycle_on_a_reused_connection + fixture = TlsTestServer.with_connection(request_count: 2) do |base_url, certificate_der| + client = Wreq::Client.new( + tls_info: true, + verify: false, + http1_only: true, + no_proxy: true, + timeout: 5 + ) + + read_response = client.get("#{base_url}read") + assert_equal "ok", read_response.text + read_tls = read_response.tls_info + + closed_response = client.get("#{base_url}close") + closed_response.close + closed_tls = closed_response.tls_info + + assert_instance_of Wreq::TlsInfo, read_tls + certificate = read_tls.peer_certificate + chain = read_tls.peer_certificate_chain + assert_equal certificate_der, certificate + assert_equal Encoding::BINARY, certificate.encoding + assert_equal [certificate_der], chain + assert_equal Encoding::BINARY, chain.first.encoding + assert_predicate chain, :frozen? + assert_equal( + "#", + read_tls.inspect + ) + assert_empty Wreq::TlsInfo.instance_methods(false) & %i[to_h to_s] + + certificate.clear + assert_equal certificate_der, read_tls.peer_certificate + assert_equal certificate_der, closed_tls.peer_certificate + end + + assert_equal( + {connections: 1, requests: ["GET /read HTTP/1.1\r\n", "GET /close HTTP/1.1\r\n"]}, + fixture + ) + end +end