Skip to content

Stop reusing connections after a proxy refuses CONNECT - #858

Draft
ilyazub wants to merge 1 commit into
httprb:mainfrom
serpapi:refused-connect-not-kept-alive
Draft

ilyazub wants to merge 1 commit into
httprb:mainfrom
serpapi:refused-connect-not-kept-alive

Conversation

@ilyazub

@ilyazub ilyazub commented Oct 3, 2026

Copy link
Copy Markdown

A proxy can refuse a CONNECT tunnel with a keep-alive response, like a 407 with a Content-Length body. The connection stays marked keep-alive and @failed_proxy_connect is never cleared, so the next request on a persistent client skips sending and reads whatever is left on that socket without contacting the proxy.

Local proxy that answers every CONNECT with a keep-alive 407, HTTP.timeout(3).via(proxy).persistent("https://example.com"), two GETs:

2nd request proxy connections
main, 1st body left unread the 1st 407 again, body "deny" 1
main, 1st body read status 0, then HTTP::TimeoutError after 3 s reading its body 1
this branch, either way a new 407 from the proxy 2

The fix is one line in handle_proxy_connect_response: a refused tunnel sets @keep_alive = false. The client then closes the connection before the next request, and finish_response closes it once the refusal's body is read.

AuthProxyServer closes its socket after the 407, so no test kept a refused tunnel open. The new RefusingProxyServer keeps it open and counts connections. The 3 new tests fail on main.

  • MRI 3.4.8: 2335 runs, 0 failures, 100% line and branch coverage. rubocop and yardstick pass, steep shows the same 4 warnings as main
  • JRuby 10.1.2.0: 2334 runs, 0 failures
Repro script
require "socket"
require "http"
server = TCPServer.new("127.0.0.1", 0)
port = server.addr[1]
accepts = 0
Thread.new do
  loop do
    s = server.accept
    accepts += 1
    Thread.new(s) do |c|
      loop do
        head = +""
        while (line = c.gets) && line != "\r\n"
          head << line
        end
        break if head.empty?
        c.write "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\nContent-Length: 4\r\n\r\ndeny"
      end
    rescue IOError, SystemCallError
    ensure
      c.close rescue nil
    end
  end
end
mode = ARGV[0]
client = HTTP.timeout(3).via("127.0.0.1", port).persistent("https://example.com")
r1 = client.get("https://example.com/")
begin; r1.to_s if mode == "read"; rescue => e; puts "#{mode}: r1.to_s raised #{e.class}: #{e.message[0,80]}"; end
begin
  r2 = client.get("https://example.com/")
  puts "#{mode}: r1=#{r1.status.to_i} r2=#{r2.status.to_i} body2=#{r2.to_s.inspect} accepts=#{accepts}"
rescue => e
  puts "#{mode}: r1=#{r1.status.to_i} r2 raised #{e.class}: #{e.message[0, 80]} accepts=#{accepts}"
end

ruby -Ilib proxy407_run.rb unread and ... read:

main   unread: r1=407 r2=407 body2="deny" accepts=1
main   read: r1=407 r2 raised HTTP::TimeoutError: Timed out after using the allocated 3 seconds accepts=1
branch unread: r1=407 r2=407 body2="deny" accepts=2
branch read: r1=407 r2=407 body2="deny" accepts=2

A proxy can refuse a CONNECT tunnel with a keep-alive response, such as
a 407 with a Content-Length body. Connection records the failed tunnel
and the client skips sending the request, but the connection stayed
keep-alive, so a persistent client reused it. The flag is never cleared,
so the next request skipped sending again and returned a response read
from the same socket without contacting the proxy: the old refusal if
its body was still unread, or a response with status 0 once it had been
read, whose body read then blocks until the read timeout.

The socket can't carry another request, since the proxy never opened
the tunnel. Mark the connection as not keep-alive when CONNECT fails.
The client then closes it before the next request, and finish_response
closes it once the refusal's body has been read, so every request asks
the proxy again on a new connection.

AuthProxyServer closes the socket after its 407, so no test kept a
refused tunnel open. RefusingProxyServer answers every CONNECT with a
keep-alive 407 and counts connections.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant