Skip to content
Merged
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
14 changes: 8 additions & 6 deletions docs/fork-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions examples/tls_info.rb
Original file line number Diff line number Diff line change
@@ -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}"
1 change: 1 addition & 0 deletions lib/wreq.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions lib/wreq_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions lib/wreq_ruby/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions lib/wreq_ruby/tls.rb
Original file line number Diff line number Diff line change
@@ -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<String>, 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
# # => "#<Wreq::TlsInfo peer_certificate=781B peer_certificate_chain=1>"
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
2 changes: 1 addition & 1 deletion src/arch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(ManuallyDrop<T>);

Expand Down
3 changes: 3 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ struct Builder {
// ========= TLS options =========
/// Whether to verify TLS certificates.
verify: Option<bool>,
/// Whether to retain peer certificate data on responses.
tls_info: Option<bool>,

// ========= Network options =========
/// Whether to disable the proxy for the client.
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/client/resp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::{
header::Headers,
http::{StatusCode, Version},
rt,
tls::TlsInfo,
};

/// A response from a request.
Expand Down Expand Up @@ -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<TlsInfo> {
self.state
.as_ref()
.extensions
.get::<wreq::tls::TlsInfo>()
.cloned()
.map(TlsInfo)
}

/// 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 @@ -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(())
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod http;
mod options;
mod rt;
mod serde;
mod tls;

use magnus::{Error, Module, Ruby, Value};

Expand Down Expand Up @@ -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)]
Expand Down
51 changes: 51 additions & 0 deletions src/tls.rs
Original file line number Diff line number Diff line change
@@ -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<RString> {
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<RArray> {
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(())
}
95 changes: 95 additions & 0 deletions test/support/tls_server.rb
Original file line number Diff line number Diff line change
@@ -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
Loading