From 95c06855d97c5fcbd01af26e29abd9caeaf73d23 Mon Sep 17 00:00:00 2001 From: Nakul Bharti Date: Sat, 5 Sep 2026 23:26:42 +0530 Subject: [PATCH] fix: do not re-attempt https after the scheme fallback The HTTP 400 upgrade did not check whether https had already been tried. When the scheme heuristic probes https first, that attempt fails, the error fallback switches to http, and the plaintext service answers 400, the upgrade retried the same https endpoint a second time - costing up to a full -timeout against a host that accepts the TLS connection and never replies. Claude-Session: https://claude.ai/code/session_015AKXsNzjd3HjGMq4B8JCNH Claude-Local-Session: claude --resume 0fe5b680-5ba5-43e6-b354-43d4388aaf9c --- runner/runner.go | 2 +- runner/runner_test.go | 87 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/runner/runner.go b/runner/runner.go index ea0cac3f..d58625ef 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -1948,7 +1948,7 @@ retry: // Attempt HTTPS through the normal client path, which starts with the same // handshake: a port that does not speak TLS fails there and the response // kept above is restored. Unsafe mode bypasses the scheme retry entirely. - if err == nil && !tlsUpgraded && !scanopts.Unsafe && origProtocol == httpx.HTTPorHTTPS && + if err == nil && !tlsUpgraded && !retried && !scanopts.Unsafe && origProtocol == httpx.HTTPorHTTPS && protocol == httpx.HTTP && resp != nil && resp.StatusCode == http.StatusBadRequest { keptResp, keptReq, keptURL, keptProtocol = resp, req, URL.Clone(), protocol protocol = httpx.HTTPS diff --git a/runner/runner_test.go b/runner/runner_test.go index 3f9d5e55..b18e4b4b 100644 --- a/runner/runner_test.go +++ b/runner/runner_test.go @@ -1166,3 +1166,90 @@ func TestHandshakeThenCloseKeepsPlainResult(t *testing.T) { require.EqualValues(t, 1, atomic.LoadInt64(&tlsHandshakes), "the failed HTTPS request must complete its TLS handshake first") } + +// TestHTTPSNotRetriedAfterSchemeFallback covers a target the heuristic probes +// as HTTPS first: the HTTPS attempt fails, the scheme fallback switches to +// plaintext, and the plaintext answer is 400. The 400 upgrade must not send the +// request back to the HTTPS endpoint that has already failed. +// +// A proxy carries the requests so the target can use a privileged port without +// binding one, and so both attempt kinds can be counted: CONNECT is the HTTPS +// attempt, an absolute-URI GET is the plaintext one. +func TestHTTPSNotRetriedAfterSchemeFallback(t *testing.T) { + var connects, plainRequests int64 + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer func() { _ = conn.Close() }() + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReader(conn) + requestLine, err := reader.ReadString('\n') + if err != nil { + return + } + for { + line, err := reader.ReadString('\n') + if err != nil || strings.TrimSpace(line) == "" { + break + } + } + if strings.HasPrefix(requestLine, http.MethodConnect+" ") { + atomic.AddInt64(&connects, 1) + // Refuse the tunnel: the HTTPS attempt cannot succeed. + _, _ = io.WriteString(conn, "HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n") + return + } + atomic.AddInt64(&plainRequests, 1) + _, _ = io.WriteString(conn, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n"+ + "Content-Length: 11\r\n\r\nBad Request") + }(conn) + } + }() + + var ( + mu sync.Mutex + results []Result + ) + options := &Options{ + Threads: 1, RateLimit: 10, Retries: 0, Timeout: 5, + Methods: http.MethodGet, Delay: -1, + HTTPProxy: "http://" + listener.Addr().String(), + // Port 1023 keeps the scheme heuristic on HTTPS first without needing + // to bind a privileged port locally. + InputTargetHost: []string{"probe-target.invalid:1023"}, + OnResult: func(r Result) { + if r.Err != nil || r.URL == "" { + return + } + mu.Lock() + results = append(results, r) + mu.Unlock() + }, + } + + require.Equal(t, "https", determineMostLikelySchemeOrder("probe-target.invalid:1023"), + "the target must be probed as https first or this test proves nothing") + + runner, err := New(options) + require.NoError(t, err) + defer runner.Close() + runner.RunEnumeration() + + mu.Lock() + defer mu.Unlock() + + require.EqualValues(t, 1, atomic.LoadInt64(&connects), + "the same https endpoint must not be attempted twice") + require.Len(t, results, 1, "the plaintext 400 must still be reported") + require.Equal(t, "http", results[0].Scheme) + require.Equal(t, http.StatusBadRequest, results[0].StatusCode) +}