fix(handler/http): resolve origin-form requests via Host header (#679)

Origin-form requests ("GET / HTTP/1.1" with only a Host header, as
sent by nginx proxy_pass to an upstream proxy) failed RoundTrip with
"http: no Host in request URL": normalizeRequest infers URL.Scheme
but leaves URL.Host empty, and http.Transport requires a non-empty
URL.Host. Also affected every keep-alive request after the first,
which bypass normalizeRequest entirely.

Fill scheme+Host from req.Host in the shared proxyRoundTrip path,
covering both initial and subsequent requests.

e2e-verified: raw origin-form request proxied by Host header
(gost/tests/e2e TestHTTPProxyOriginForm).
This commit is contained in:
ginuerzh
2026-08-27 00:02:13 +08:00
parent 93bd2ea760
commit 2e9befc39c
2 changed files with 54 additions and 0 deletions
+12
View File
@@ -98,6 +98,18 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
}
req.Header.Del("Gost-Record")
// Origin-form requests ("GET / HTTP/1.1" + Host header, e.g. sent by
// nginx proxy_pass) carry their target only in req.Host, which
// normalizeRequest doesn't mirror into the URL (it fills Scheme only,
// leaving URL.Host empty). Subsequent keep-alive requests parsed in
// handleProxy never pass through normalizeRequest at all. The
// http.Transport requires a non-empty URL.Host, otherwise it fails
// with "http: no Host in request URL" (#679).
if req.URL.Host == "" {
req.URL.Scheme = "http"
req.URL.Host = req.Host
}
host := normalizeHostPort(req.Host, "80")
ro.Host = host
ro.Time = time.Now()
+42
View File
@@ -245,6 +245,48 @@ func TestProxyRoundTrip_HeaderCleanup(t *testing.T) {
}
}
func TestProxyRoundTrip_OriginFormHost(t *testing.T) {
// Origin-form request (GET / + Host header), as sent by nginx
// proxy_pass. Mirrors go-gost/gost#679: the transport must not fail
// with "http: no Host in request URL".
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "myoniondomain.onion" {
t.Errorf("unexpected Host header: %q", r.Host)
}
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
// Transport must dial the test server regardless of the target host,
// like the handler's real transport does via the proxy chain.
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial(network, ts.Listener.Addr().String())
},
},
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", "/", nil)
req.Host = "myoniondomain.onion"
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err != nil {
t.Fatalf("proxyRoundTrip error: %v", err)
}
}
// testBypass implements bypass.Bypass for testing.
type testBypass struct {
contains bool