From 10ca025e1b76eb2bd7992bc30576cc68097d931c Mon Sep 17 00:00:00 2001 From: Adam S Date: Thu, 6 Aug 2026 12:32:20 -0700 Subject: [PATCH 1/2] [DOC] List set_debug_output under Security The Security section indexes the TLS accessors but omits the one method whose own documentation calls it "a serious security hole". A reader scanning that section for anything affecting the safety of a connection would not find it; it is listed only under Debugging. --- lib/net/http.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/net/http.rb b/lib/net/http.rb index 6c43e62..2ea536f 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -688,6 +688,9 @@ class HTTPHeaderSyntaxError < StandardError; end # Returns the flags for server the certification verification at the beginning of the SSL/TLS session. # - {:verify_mode=}[rdoc-ref:Net::HTTP#verify_mode=]: # Sets the flags for server the certification verification at the beginning of the SSL/TLS session. + # - {#set_debug_output}[rdoc-ref:Net::HTTP#set_debug_output]: + # Sets the output stream for debugging, which logs credentials in plain text; + # never use it in production code. # # === Addresses and Ports # From 809fee5ce492e47a9db9397c1267b1b9c06fe0db Mon Sep 17 00:00:00 2001 From: Adam S Date: Thu, 6 Aug 2026 12:32:22 -0700 Subject: [PATCH 2/2] Warn when Net::HTTP#set_debug_output enables debug output Debug output writes every request and response to the given stream in plain text, including Authorization and Cookie headers and message bodies. The documentation has said "This method opens a serious security hole. Never use this method in production code." for as long as the method has existed, but nothing tells the caller when that has happened: the method behaves identically in a console and in a production worker, so output committed by accident, or enabled during an incident and never reverted, keeps writing credentials to whatever stream it was given. Write a warning to $stderr naming what is being logged. Ruby has no concept of an application environment, so there is nothing to consult to decide whether this process is in production -- reading RAILS_ENV or RACK_ENV would put knowledge of specific frameworks into the standard library, and no file in lib/ does that today. Warn unconditionally instead, and rely on $DEBUG to recognise the case where debugging is deliberate: that is the flag Ruby already has for it, and net/http's own test harness treats it the same way in test/net/http/utils.rb. Warn at most once per thread. set_debug_output is a configuration setter that net/http never calls itself, so a connection configured once and reused pays nothing per request, but wrappers that build a Net::HTTP per request and configure it each time would otherwise repeat an identical message on every request and flood the log it is meant to draw attention to. The flag lives in thread-local storage rather than on Net::HTTP because assigning a class-level instance variable from a non-main Ractor raises Ractor::IsolationError, and Thread#thread_variable_set has been available since 2.0, so it needs no version guard. The warning is plain Kernel#warn rather than a Warning category. Of the categories Ruby 4.0 defines, only :experimental is enabled by default, and none of them describes this; a guardrail nobody has enabled does not guard anything. It can still be intercepted by overriding Warning.warn. Implements [Feature #22233] --- lib/net/http.rb | 14 ++++++ test/net/http/test_http.rb | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/lib/net/http.rb b/lib/net/http.rb index 2ea536f..5ab4cf9 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1263,8 +1263,22 @@ def inspect # read 2 bytes # Conn keep-alive # + # Debug output contains credentials, so enabling it writes a warning to + # $stderr. The warning is written at most once per thread, and is + # not written when $DEBUG is set, which already declares that the + # process is being debugged, or when +output+ is +nil+, which disables debug + # output. Like any other Ruby warning it can be intercepted by overriding + # Warning.warn. + # def set_debug_output(output) warn 'Net::HTTP#set_debug_output called after HTTP started', uplevel: 1 if started? + if output and !$DEBUG and !Thread.current.thread_variable_get(:net_http_debug_output_warned) + Thread.current.thread_variable_set(:net_http_debug_output_warned, true) + warn 'Net::HTTP#set_debug_output: every request and response, including ' + + 'Authorization and Cookie headers and message bodies, will be written ' + + 'to the given stream in plain text; never enable this in production', + uplevel: 1 + end @debug_output = output end diff --git a/test/net/http/test_http.rb b/test/net/http/test_http.rb index e5028d4..a1bd6a8 100644 --- a/test/net/http/test_http.rb +++ b/test/net/http/test_http.rb @@ -7,6 +7,10 @@ class TestNetHTTP < Test::Unit::TestCase + def setup + Thread.current.thread_variable_set(:net_http_debug_output_warned, nil) + end + def test_class_Proxy no_proxy_class = Net::HTTP.Proxy nil @@ -266,6 +270,76 @@ def test_default_configuration Net::HTTP.default_configuration = nil end + def test_set_debug_output_warns + http = Net::HTTP.new 'hostname.example' + + assert_warning(/#{__FILE__}:#{__LINE__+1}: warning: Net::HTTP#set_debug_output: /) do + http.set_debug_output StringIO.new + end + end + + def test_set_debug_output_names_what_is_logged + http = Net::HTTP.new 'hostname.example' + + assert_warning(/Authorization and Cookie headers and message bodies/) do + http.set_debug_output StringIO.new + end + end + + def test_set_debug_output_nil_does_not_warn + http = Net::HTTP.new 'hostname.example' + + assert_warning('') do + http.set_debug_output nil + end + end + + def test_set_debug_output_DEBUG_does_not_warn + debug, $DEBUG = $DEBUG, true + + http = Net::HTTP.new 'hostname.example' + + assert_warning('') do + http.set_debug_output StringIO.new + end + ensure + $DEBUG = debug + end + + def test_set_debug_output_warns_once_per_thread + http = Net::HTTP.new 'hostname.example' + + assert_warning(/Net::HTTP#set_debug_output: /) do + http.set_debug_output StringIO.new + end + + assert_warning('') do + http.set_debug_output StringIO.new + end + end + + def test_set_debug_output_warns_once_across_objects + assert_warning(/Net::HTTP#set_debug_output: /) do + Net::HTTP.new('hostname.example').set_debug_output StringIO.new + end + + assert_warning('') do + Net::HTTP.new('hostname.example').set_debug_output StringIO.new + end + end + + def test_set_debug_output_warns_once_in_subclasses + assert_warning(/Net::HTTP#set_debug_output: /) do + Net::HTTP.new('hostname.example').set_debug_output StringIO.new + end + + proxy = Net::HTTP::Proxy('proxy.example', 8000) + + assert_warning('') do + proxy.new('hostname.example').set_debug_output StringIO.new + end + end + end module TestNetHTTP_version_1_1_methods @@ -1470,4 +1544,18 @@ def test_get assert_equal expected, ret RUBY end + + # The once-per-thread flag is kept in thread-local storage rather than on + # Net::HTTP, because non-main Ractors cannot set instance variables of + # classes. Recording it there would raise Ractor::IsolationError. + def test_set_debug_output + assert_ractor(<<~RUBY, require: 'net/http', ignore_stderr: true) + require 'stringio' + ret = Ractor.new { + Net::HTTP.new('hostname.example').set_debug_output(StringIO.new) + :ok + }.value + assert_equal :ok, ret + RUBY + end end if defined?(Ractor) && Ractor.method_defined?(:value)