diff --git a/server/repeater.go b/server/repeater.go index 623f724..3f157d9 100644 --- a/server/repeater.go +++ b/server/repeater.go @@ -167,6 +167,9 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { return nil }, } + // Each send owns its transport. Registered before Body.Close so idle + // connections are released after the response body, including on errors. + defer client.CloseIdleConnections() started := time.Now() resp, err := client.Do(req) @@ -235,6 +238,9 @@ func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundT // Keep response bytes faithful and make body_bytes match what was received; // transparent gzip decompression would otherwise obscure both values. transport.DisableCompression = true + // Use the request's timeout_ms budget instead of inherited phase deadlines. + transport.TLSHandshakeTimeout = 0 + transport.ExpectContinueTimeout = 0 if !proxied { // Clear a custom TLS dialer before installing the guarded dialer; when // DialTLSContext is set, net/http bypasses DialContext entirely. diff --git a/server/repeater_test.go b/server/repeater_test.go index c6ef2fd..608f04d 100644 --- a/server/repeater_test.go +++ b/server/repeater_test.go @@ -161,6 +161,78 @@ func TestRepeaterDirectHTTP2(t *testing.T) { } } +func TestRepeaterTLSHandshakeUsesRequestTimeout(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + // Scale down the inherited timeout so a slow handshake can be tested + // without waiting for the default transport's ten-second deadline. + base := http.DefaultTransport + short := base.(*http.Transport).Clone() + short.TLSHandshakeTimeout = 10 * time.Millisecond + http.DefaultTransport = short + defer func() { http.DefaultTransport = base }() + defer short.CloseIdleConnections() + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "handshake completed") + })) + srv.TLS = &tls.Config{GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { + time.Sleep(100 * time.Millisecond) + return nil, nil + }} + srv.StartTLS() + defer srv.Close() + + code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{ + "url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true, "timeout_ms": 1000, + }) + if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "handshake completed" { + t.Fatalf("handshake within request budget failed: status=%d raw=%s", code, raw) + } +} + +func TestRepeaterClosesIdleConnections(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + for _, mode := range []string{"direct", "http2", "global proxy", "recording proxy fallback"} { + t.Run(mode, func(t *testing.T) { + closed := make(chan struct{}, 1) + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "response") + })) + srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateClosed { + closed <- struct{}{} + } + } + if mode == "http2" { + srv.EnableHTTP2 = true + srv.StartTLS() + } else { + srv.Start() + } + defer srv.Close() + + s := &Server{} + payload := map[string]any{"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true} + if mode == "global proxy" || mode == "recording proxy fallback" { + s.m = &Manager{globalProxy: srv.URL} + payload["url"] = "http://target.example/" + if mode == "recording proxy fallback" { + delete(payload, "use_recording_proxy") + } + } + code, out, raw := repeaterTestSend(t, s, payload) + if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "response" { + t.Fatalf("status=%d raw=%s", code, raw) + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("connection still open after sendRepeater returned") + } + }) + } +} + func TestRepeaterRoundTripHeadersAndBody(t *testing.T) { t.Setenv(repeaterAllowLocalEnv, "1") var got http.Request diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx index 041b87e..56078a6 100644 --- a/web/src/components/finding-repeater-dialog.tsx +++ b/web/src/components/finding-repeater-dialog.tsx @@ -67,7 +67,7 @@ export function formatHeaders(headers: Record | Record